02 | 协议:怎么设计可扩展且向后兼容的协议?

简介: 本讲深入讲解RPC协议设计原理,从HTTP协议类比引入,剖析协议在解决网络传输“断句”问题中的关键作用。重点探讨如何通过消息边界、协议头与体的设计实现高效通信,并强调可扩展性对升级兼容的重要性,最终揭示私有RPC协议为何优于HTTP。

上一讲我分享了 RPC 原理,其核心是让我们像调用本地一样调用远程,帮助我们的应用层屏蔽远程调用的复杂性,使得我们可以更加方便地构建分布式系统。总结起来,其实就一个关键字:透明化。
接着上一讲的内容,我们再来聊聊 RPC 协议。
一提到协议,你最先想到的可能是 TCP 协议、UDP 协议等等,这些网络传输协议的实现在我看来有点晦涩难懂。虽然在 RPC 中我们也会用到这些协议,但这些协议更多的是对我们上层应用是透明的,我们 RPC 在使用过程中并不太需要关注他们的细节。那我今天要讲的 RPC 协议到底是什么呢?
可能我举个例子,你立马就明白了。HTTP 协议是不是很熟悉(本讲里面所说的 HTTP 默认都是 1.X)? 这应该是我们日常工作中用得最频繁的协议了,每天打开浏览器浏览的网页就是使用的 HTTP 协议。那 HTTP 协议跟 RPC 协议又有什么关系呢?看起来他俩好像不搭边,但他们有一个共性就是都属于 应用层协议。
所以 我们今天要讲的 RPC 协议就是围绕应用层协议展开的。我们可以先了解下 HTTP 协议,我们先看看它的协议格式是什么样子的。回想一下我们在浏览器里面输入一个 URL 会发生什么?抛开 DNS 解析暂且不谈,浏览器收到命令后会封装一个请求,并把请求发送到 DNS 解析出来的 IP 上,通过抓包工具我们可以抓到请求的数据包,如下图所示:

协议的作用
看完 HTTP 协议之后,你可能会有一个疑问,我们为什么需要协议这个东西呢?没有协议就不能通信吗?
我们知道只有二进制才能在网络中传输,所以 RPC 请求在发送到网络中之前,他需要把方法调用的请求参数转成二进制;转成二进制后,写入本地 Socket 中,然后被网卡发送到网络设备中。
但在传输过程中,RPC 并不会把请求参数的所有二进制数据整体一下子发送到对端机器上,中间可能会拆分成好几个数据包,也可能会合并其他请求的数据包(合并的前提是同一个 TCP 连接上的数据),至于怎么拆分合并,这其中的细节会涉及到系统参数配置和 TCP 窗口大小。对于服务提供方应用来说,他会从 TCP 通道里面收到很多的二进制数据,那这时候怎么识别出哪些二进制是第一个请求的呢?
这就好比让你读一篇没有标点符号的文章,你要怎么识别出每一句话到哪里结束呢?很简单啊,我们加上标点,完成断句就好了。
同理在 RPC 传输数据的时候,为了能准确地「断句」,我们也必须在应用发送请求的数据包里面加入「句号」,这样才能帮我们的接收方应用从数据流里面分割出正确的数据。这个数据包里面的句号就是消息的边界,用于标示请求数据的结束位置。举个具体例子,调用方发送 AB、CD、EF 3 个消息,如果没有边界的话,接收端就可能收到 ABCDEF 或者 ABC、DEF 这样的消息,这就会导致接收的语义跟发送的时候不一致了。
所以呢,为了避免语义不一致的事情发生,我们就需要在发送请求的时候设定一个边界 ,然后在收到请求的时候按照这个设定的边界进行数据分割。这个边界语义的表达,就是我们所说的协议。
如何设计协议?
理解了协议的作用,我们再来看看在 RPC 里面是怎么设计协议的。可能你会问:前面你不是说了 HTTP 协议跟 RPC 都属于应用层协议,那有了现成的 HTTP 协议,为啥不直接用,还要为 RPC 设计私有协议呢?
这还要从 RPC 的作用说起,相对于 HTTP 的用处,RPC 更多的是负责应用间的通信,所以性能要求相对更高。但 HTTP 协议的数据包大小相对请求数据本身要大很多,又需要加入很多无用的内容,比如换行符号、回车符等;还有一个更重要的原因是,HTTP 协议属于无状态协议,客户端无法对请求和响应进行关联,每次请求都需要重新建立连接,响应完成后再关闭连接。因此,对于要求高性能的 RPC 来说,HTTP 协议基本很难满足需求,所以 RPC 会选择设计更紧凑的私有协议。那怎么设计一个私有 RPC 协议呢?
在设计协议前,我们先梳理下要完成 RPC 通信的时候,在协议里面需要放哪些内容。
首先要想到的就是我们前面说的 消息边界 了,但 RPC 每次发请求发的大小都是不固定的,所以我们的协议必须能让接收方正确地读出不定长的内容。我们可以先固定一个长度(比如 4 个字节)用来保存整个请求数据大小,这样收到数据的时候,我们先读取固定长度的位置里面的值,值的大小就代表协议体的长度,接着再根据值的大小来读取协议体的数据,整个协议(不定长协议)可以设计成这样:

但上面这种协议,只实现了正确的断句效果,在 RPC 里面还行不通。因为对于服务提供方来说,他是不知道这个协议体里面的二进制数据是通过哪种序列化方式生成的。如果不能知道调用方用的序列化方式,即使服务提供方还原出了正确的语义,也并不能把二进制还原成对象,那服务提供方收到这个数据后也就不能完成调用了。因此我们需要把序列化方式单独拿出来,类似协议长度一样用固定的长度存放,这些需要固定长度存放的参数我们可以统称为「协议头」,这样整个协议就会拆分成两部分:协议头和协议体。
在协议头里面,我们除了会放协议长度、序列化方式,还会放一些像协议标示、消息 ID、消息类型这样的参数,而协议体一般只放请求接口方法、请求的业务参数值和一些扩展属性。这样一个完整的 RPC 协议大概就出来了,协议头是由一堆固定的长度参数组成,而协议体是根据请求接口和参数构造的,长度属于可变的,具体协议如下图所示:

可扩展的协议
刚才讲的协议属于 定长协议头,那也就是说往后就不能再往协议头里加新参数了,如果加参数就会导致线上兼容问题。举个具体例子,假设你设计了一个 88Bit 的协议头,其中协议长度占用 32bit,然后你为了加入新功能,在协议头里面加了 2bit,并且放到协议头的最后。升级后的应用,会用新的协议发出请求,然而没有升级的应用收到的请求后,还是按照 88bit 读取协议头,新加的 2 个 bit 会当作协议体前 2 个 bit 数据读出来,但原本的协议体最后 2 个 bit 会被丢弃了,这样就会导致协议体的数据是错的。
可能你会想:那我把参数加在不定长的协议体里面行不行?而且刚才你也说了,协议体里面会放一些扩展属性。没错,协议体里面是可以加新的参数,但这里有一个关键点,就是协议体里面的内容都是经过序列化出来的,也就是说你要获取到你参数的值,就必须把整个协议体里面的数据经过反序列化出来。但在某些场景下,这样做的代价有点高啊!
比如说,服务提供方收到一个过期请求,这个过期是说服务提供方收到的这个请求的时间大于调用方发送的时间和配置的超时时间,既然已经过期,就没有必要接着处理,直接返回一个超时就好了。那要实现这个功能,就要在协议里面传递这个配置的超时时间,那如果之前协议里面没有加超时时间参数的话,我们现在把这个超时时间加到协议体里面是不是就有点重了呢?显然,会加重 CPU 的消耗。
所以为了保证能平滑地升级改造前后的协议,我们有必要设计一种 支持可扩展的协议。其关键在于让协议头支持可扩展,扩展后协议头的长度就不能定长了。那要实现读取不定长的协议头里面的内容,在这之前肯定需要一个固定的地方读取长度,所以我们需要一个固定的写入协议头的长度。整体协议就变成了三部分内容:固定部分、协议头内容、协议体内容,前两部分我们还是可以统称为「协议头」,具体协议如下:

最后,我想说,设计一个简单的 RPC 协议并不难,难的就是怎么去设计一个可「升级」的协议。不仅要让我们在扩展新特性的时候能做到向下兼容,而且要尽可能地减少资源损耗,所以我们协议的结构不仅要支持协议体的扩展,还要做到协议头也能扩展。上述这种设计方法来源于我多年的线上经验,可以说做好扩展性是至关重要的,期待这个协议模版能帮你避掉一些坑。
总结
我们人类区别于其他动物的一个很大原因,就是我们能够通过语言去沟通,用文字去沉淀文明,从而让我们能站在巨人的肩膀上成长,但为了保证我们记录的文字能够被其他人理解,我们必须通过符号去实现断句,否则就可能导致文字的意义被曲解,甚至闹出笑话。
在 RPC 里面,协议的作用就类似于文字中的符号,作为应用拆解请求消息的边界,保证二进制数据经过网络传输后,还能被正确地还原语义,避免调用方跟被调用方之间的「鸡同鸭讲」。
但我们在设计协议的时候,也不能只单纯考虑满足目前功能,还应该从更高的层次出发。就好比我们设计系统架构一样,我们需要保证设计出来的系统能够能很好地扩展,支持新增功能。
课后思考
好了,今天的内容就到这里,最后留一道思考题。今天我们讨论过 RPC 不直接用 HTTP 协议的一个原因是无法实现请求跟响应关联,每次请求都需要重新建立连接,响应完成后再关闭连接,所以我们要设计私有协议。那么在 RPC 里面,我们是怎么实现请求跟响应关联的呢?
笔者认为:请求与响应关联最主要的原因是因为 RPC 为了高吞吐量,使用了 NIO 类似的一个线程管理多个 socket 来异步发送接受消息,协议头中定义了消息 ID,调用方可以通过消息 ID 来关联上响应的请求,响应的时候将请求中的消息 ID 响应回来

相关文章
|
1天前
|
开发者
业务架构图
本文系统介绍了业务架构图的核心概念与绘制方法,涵盖业务定义、架构分层、模块与功能划分,并强调以业务为中心、淡化技术细节,提升客户理解与开发效率。
|
1天前
|
存储 数据可视化 Java
用拉链法实现哈希表
本文详解哈希表中拉链法的实现原理,通过简化版与完整版Java代码,介绍如何用链表解决哈希冲突,支持泛型、动态扩容及增删查改操作,帮助深入理解哈希表底层机制。
|
1天前
|
存储 Serverless
哈希冲突
哈希冲突可通过优化哈希函数或采用冲突解决策略应对。开放寻址法通过线性、二次探查或双散列寻找空位,但易导致聚集,影响效率;链表法则在冲突位置构建链表,避免抢占,更适应动态数据,是常用方案之一。
|
1天前
|
缓存 负载均衡 安全
第十章 常用组件1、nginx相关
正向代理是客户端通过代理访问外部服务器,隐藏客户端身份,用于访问受限资源或保护隐私;反向代理则是服务器前的代理,接收客户端请求并转发至内部服务器,隐藏真实服务器,实现负载均衡、安全防护与缓存加速,提升系统性能与安全性。
|
1天前
|
搜索推荐 算法 UED
15 | 最近邻检索(上):如何用局部敏感哈希快速过滤相似文章?
在搜索引擎与推荐系统中,相似文章去重至关重要。通过向量空间模型将文档转为高维向量,利用SimHash等局部敏感哈希技术生成紧凑指纹,结合海明距离与抽屉原理分段索引,可高效近似检索相似内容,避免重复展示,提升用户体验。该方法广泛应用于网页去重、图像识别等领域。
|
1天前
|
Java
方法重载
本示例演示Java中方法重载的用法:在同一个类中,允许方法名相同但参数列表不同(参数个数、类型或顺序不同),与返回值无关。通过重载可简化方法命名,提升代码可读性和复用性。
|
1天前
|
关系型数据库 MySQL Shell
nexus搭建
本文介绍如何使用Nexus搭建Docker私有仓库。包括Nexus中Docker仓库的启用、Blob Store创建、docker-hosted仓库配置(HTTP/HTTPS端口、匿名拉取等),以及防火墙和Docker客户端的insecure-registry配置。详细说明镜像打标、登录认证及推送至私仓的完整流程,适用于本地化镜像管理与内网部署需求。
|
1天前
|
前端开发 搜索推荐 测试技术
背景图变换
本文介绍如何基于若依(RuoYi)框架定制化项目:更换浏览器标签logo、系统页面logo与标题、登录页名称及背景图,去除官网标识,并调整主题风格。通过替换静态资源、修改配置文件及全局搜索删除冗余链接,实现项目个性化展示,提升品牌辨识度,适用于前端界面定制开发场景。(238字)
|
1天前
|
人工智能 缓存 前端开发
如何实现行业圈子内的内容更新和消息交流?搭建一个圈子论坛软件如何做
明确核心定位:聚焦垂直行业、兴趣社群或商务资源对接,打造精准交流平台。设计用户系统、内容互动、消息通信与社区管理四大功能模块,采用PHP+Vue+React Native技术栈,结合WebSocket实时通信与云存储,构建高效稳定的技术架构,支持多端部署与扩展。
22 0
|
1天前
|
存储 NoSQL Linux
Redis集群部署指南
本教程基于CentOS7详解Redis集群部署,涵盖单机安装、主从复制、哨兵高可用及分片集群搭建。通过多实例模拟真实环境,深入讲解配置、启动、主从切换与数据读写测试,助你掌握Redis分布式架构核心技能。
11 0