阿里高级专家剖析 | 应用层 Protocol 的标准设计
封面人物:《只狼/影逝二度》苇名一心
本文难度:★★★★☆
前言
在正式阅读本文之前,请大家首先思考下,对于 protocol 的定义和理解,自己究竟掌握了多少?我们都知道,在网络通信中,protocol 约定了客户侧/服务侧之间的数据传输格式,比如:典型的传输层 TCP/UDP 协议,应用层 HTTP、FTP、SSH、XMPP 和 Telnet 协议等。可是目前市面上几乎所有的 RPC 框架、中间件,以及存储系统却都自定义有私有的应用层 protocol,为何要不停的造轮子?事实上,通用 protocol 几乎无法同时满足我们对传输效率、安全性,以及扩展性方面的硬性要求,因此在实际的开发过程中,我们往往会选择自定义 protocol 来解决 2 个主要矛盾,首先是约定双端的数据传输格式,以及通信(会话)语义;其次是为了解决 TCP 连接场景下必然存在的粘包/拆包问题。如果要想彻底弄明白自定义 protocol 的本质,我们就必须深入其底层,找出导致 TCP 协议产生粘包/拆包问题的根本性原因,以及仔细分析成熟开源项目中关于 protocol 部分的设计原理,追本溯源,才能知其然并知其所以然。
本文主要会分为两 Part 来进行讲解。第一 Part,我会深入底层细节找出导致 TCP 协议出现粘包/拆包问题的根本性原因;第二 Part,我会快速切换到高层领域,深入剖析 dubbo 的源码(v3.0.8),为大家讲解应该如何设计出一套符合业界标准的应用层 protocol,相信大家在仔细阅读后一定能够有所裨益。
追本溯源
在正式讨论 TCP 协议是如何导致数据在传输过程中发生粘包/拆包问题之前,我们先对 TCP 的基础知识进行一次简单的回顾。TCP(传输控制协议,Transmission Control Protocol)是一种非常复杂、面向连接、基于字节流服务模型、单播,以及具备高可靠性的传输层协议。TCP 协议的可靠性主要是体现在它完善的生命周期上,客户侧/服务侧在进行数据交互之前,必须先提前建立网络通信通道,确保 2 侧均可以进行正常的数据收/发工作;其次,在数据的传输期间,TCP 提供有消息 ACK 确认、超时重传、MSS/MTU、Nagle 发包率控制、滑窗流控、拥塞控制等相关机制来共同确保数据传输的可靠性。TCP 连接的生命周期,如图 1 所示:
TCP 连接的生命周期主要分为连接建立(TCP 三次握手)、数据传输,以及关闭连接(TCP 四次挥手)等 3 个阶段。在连接建立阶段,客户侧/服务侧期间总共需要经历 3 次报文交互过程(两次SYN
报文,一次ACK
报文),因此我们习惯将其称之为 TCP 三次握手。
当客户侧作为连接的主动方时,则会由客户侧发起一个
SYN
报文,报文内容大致包括:目标端口号、ISN(c)、以及滑动窗口大小(CWS,Calculated Window size),并进入 SYN_SEND 状态,同时等待对端确认;服务侧收到对端
SYN
报文后,会响应一个SYN+ACK
报文,报文内容大致包括:ISN(s),CWS、以及 ISN(c)+1 作为确认号的 ACK 值(回想下,在工作群中,当领导布置完相关任务后,大家是否都习惯性回复“+1”)等,并进入 SYN_RECV 状态,同时等待对端确认;最后由客户侧确认对端
SYN
报文,并将 ISN(s)+1 作为确认号 ACK 值发送给服务侧。
在经历SYN->SYN+ACK->ACK
后,就表示 TCP 连接已建立,此时双端都顺利进入 ESTABLISHED 状态。TCP 三次握手的抓包过程,如图 2 所示:
当 TCP 连接建立后,就表示客户侧/服务侧之间可以进行正常的数据传输了,而一次完整的数据传输交互过程则由一次PSH+ACK
报文和一次ACK
报文构成。假设由客户侧主动向对端发送一个 6bytes 的数据包,服务侧在收到PSH+ACK
的报文后会回复一个ACK
报文,其中ACK=7
表示序列号 7 之前的数据都已经收到,服务侧下次期望从序列号为 7 的偏移量位置开始接收数据。TCP 数据传输的抓包过程,如图 3 所示:
而在 TCP 连接生命周期的最后一个阶段,客户侧/服务侧之间总共需要经历 4 次报文交互过程(两次FIN+ACK
报文,两次ACK
报文),因此我们习惯上将其称之为 TCP 四次挥手。
当客户侧作为断开连接的主动方时,则会由客户侧发起一个
FIN+ACK
报文,报文内容大致包括:当前 ISN(c),以及对端最近一次发来的 ISN(s)作为确认号 ACK 值,并进入 FIN_WAIT_1 状态,同时等待对端确认;服务侧收到
FIN+ACK
报文后,会回复一个ACK
报文,报文内容大致包括:当前 ISN(s),ISN(c)+1 作为确认号 ACK 值,并进入 CLOSE_WAIT 状态;此时 TCP 处于半关闭状态(即:断开 c2s 连接),客户侧待收到ACK
报文后则会进入 FIN_WAIT_2 状态;如果服务侧没有任何数据可供发送,便会主动向对端发起一个
FIN+ACK
报文来断开 s2c 的连接,并进入 LAST_ACK 状态,同时等待对端确认;客户侧收到
FIN+ACK
报文后,最终会回复一个ACK
报文,报文内容包括:ISN(c)+1,ISN(s)+1 作为确认号 ACK 值,并进入 TIME_WAIT 状态。
TCP 四次挥手的抓包过程,如图 4 所示:
在此大家需要注意,服务侧向对端发送FIN+ACK
报文后并不会立即就断开 s2c 的连接,因为服务侧需要等待接收最后的 ACK 报文。在某些特殊情况下,如果出现 ACK 丢包,服务侧就会向客户侧再次发起FIN
报文,直至服务侧成功收到ACK
后才会断开 s2c 的连接。当双端都成功断开连接后,TCP 的连接状态就会进入 CLOSE 状态。
滑动窗口导致的粘包/拆包问题
TCP 协议是安全可靠的,在双端成功建立连接后,客户侧/服务侧就可以进行正常的数据交互。如图 1 所示,在“交互式通信”模式下,整体交互过程看起来非常像是严格采用了确认应答模式(即:接收端在收到对端报文后立即发送 ACK),但实际上 TCP 的数据处理方式却并非采用这样的形式。在此大家需要注意,在一些网络延迟较大的场景下,数据包的往返时间越长,其通信效率就越低。比如:跨洲延迟≈200ms
,假设我们从 DE 机房向 SG 机房发送数据,然后由 SG 机房跨洲 ACK,那么发送 10 段报文产生的理论耗时就为20(200ms)
。那是否有办法可以提升数据的传输效率?仔细观察每次双端的交互过程不难发现,报文中都会携带参数win
,这表示接收端还能接收的最大数据长度,简而言之,TCP 是基于一种叫做“滑动窗口”的概念来提升数据的传输效率,只要对端的接收窗口足够大,发送端就可以持续向接收端发送数据,从而无需实时等待对端的 ACK 应答。在 TCP 滑动窗口模式下,采用同样的跨洲调用理论耗时仅为,其中表示 ACK 次数,假设,那么 RT 仅为原来的,大幅提升了数据的传输效率。
滑动窗口不仅能够提升数据的传输效率,还能实现传输层流量控制。由于客户侧/服务侧可以同时收/发数据(双工通信),因此双端都会独立负责维护一个提供窗口和一个接收窗口,窗口大小来源于操作系统和相关硬件。当客户侧/服务侧进行 TCP 三次握手时,双端就会开始交换自己的 SO_RCVBUF 大小,并且在通信期间,接收端还会根据自己的实际处理能力来和发送端动态协商 SO_RCVBUF 的大小;总之,发送端的数据传输速率必须≤接收端的数据处理速率,反之发送端就必须停止,直至接收端有足够的数据处理能力时再尝试继续发送。如图 5 所示,滑动窗口在操作系统中实际上是一个基于环形队列的实现,其中提供窗口的大小等价于接收窗口的大小。简单来说,当接收窗口的左边界索引向右移动 6 位指向序列号 11 时,右边界索引同时也会向右移动 6 位,序列号 6~10 对应的数据表示已确认,接收端希望下次从序列号为 11 的索引位开始接收数据。当然,对于<左边界索引位的数据会被认为是重复数据而丢弃,超出右边界索引位的数据也会因为超出其处理范围而被丢弃,但对于那些未按序到达的数据,TCP 却并不会直接丢弃它们,而是选择将其缓存起来,待缺失序列号对应的数据被全部接收完成后再统一交付给上层应用。对应发送端的提供窗口,左边 2 个索引会发生重叠,共同指向序列号为 11 的索引位,同时右边界索引向右移动 6 位,其中序列号 6~7 对应的数据已被接收端确认,不存在超时重发的可能性,因此可以在未来执行“清除”操作。
尽管基于滑动窗口的设计可以有效降低网络传输延迟和实现流量控制,但所带来的问题也非常明显,由于 TCP 不存在消息保护边界(即:不清楚一个完整消息的具体数据区间范围),那么就会导致在数据的收/发过程中出现粘包/拆包问题。如图 6 所示,当接收端的 SO_RCVBUF 空间中没有足够的可用空间来接收发送端的数据时,发送端就会被迫将一个完整的数据包拆分成 n 份,并分多次进行发送,从而产生拆包问题。但如果因为接收方的数据处理速率较慢,导致 SO_RCVBUF 空间中积压了多段报文时,便会产生粘包问题。在此大家需要注意,无论是发生拆包还是粘包问题,接收端在面对不完整的数据包时都必须提供正确处理逻辑,否则将会直接影响上层业务。然而除了滑动窗口外,MSS 和 TCP 的发包率控制算法 Nagle 同样也会导致数据在网络的传输过程中产生粘包/拆包问题。
MSS 导致的拆包问题
MTU 代表着链路层对网络中发送一次数据包的最大长度限制,而 MSS 则代表着传输层对网络中发送一次数据包的最大长度限制,MSS 的值来源于 MTU,我们可以理解为MSS=MTU-(IP_HEADER+TCP_HEADER)
。那么 MTU 的限制到底是多少呢?一般来说,本地回环地址的 MTU 值会远大于通信网卡的 MTU 值,我本机的本地回环地址 MTU 值为 16384bytes,而通信网卡的 MTU 值仅为 1500bytes。以本地回环地址为例,如果一次发送的数据包长度>MTU,那么发送端就会对数据包进行拆包处理,如图 7 所示:
上述示例中,我发送的数据包长度为 20000bytes,发送端在向对端发送数据时便将原数据包拆包成 2 次发送。相信细心的同学已经发现了,2 次数据的包长之和>20000bytes,这是因为 20000bytes 并是非完整的数据包长度,仅仅只是 TCP 报文体的长度,而一个完整的 TCP 报文还需要追加TCP_HEADER
,然而在网络的传输过程中,TCP 报文还需要持续追加网络层的IP_HEADER
、链路层的DATA_LINK_HEADER
和CRC
,以及物理层的SMAC
、DMAC
和TYPE
等。
Nagle 算法导致的粘包问题
上一小节中我们已经知道发送端将消息发送到对端前,TCP 会在应用数据前面追加TCP_HEADER
,并且在网络的传输过程中还会持续追加IP_HEADER
、DATA_LINK_HEADER
、CRCSMAC
、DMAC
和TYPE
等。这就意味着无论应用数据的长度是多少,哪怕只有 1bytes,都会在前面追加定长内容,所带来的直接问题就是,如果发送端持续性发送小报文,在高频的 I/O 密集型场景下将会给网络通道带来极大的负载压力。因此为了尽可能提高传输效率,节省网络带宽,TCP 引入了 Nagle 算法来有效控制发包率,尽可能减少小报文的发送。
如图 8 所示,在开启 Nagle 算法的情况下,我分 5 次发送大小为 1bytes 的数据包,结果在发送端被强制合并为 1 个 5bytes 的数据包后才发给接收端。Nagle 算法的本质就是,只要 TCP 连接中还有在传数据,长度小于 MSS 的数据就不能被发送,直到所有在传数据都收到 ACK,并且就算收到 ACK 后也并不代表这些数据就会被立即发送,而是将其合并,待数据长度达到 MSS,或者出现超时后才允许发送。当大家清楚 Nagle 算法的基本原 jis 理后,不难发现,Nagle 算法似乎并不一定适用于所有场景,尤其是那些对延迟极其敏感的业务,并且数据的合并发送必然会导致数据在接收端的 SO_RCVBUF 空间中产生粘包。
应用层 protocol 设计
前言曾经提及过,通用 protocol 几乎无法同时满足我们对传输效率、安全性,以及扩展性方面的硬性要求,因此在某些特殊情况下(排除无缘无故造轮子的场景),我们需要自研应用层 protocol 来更好的适配自身业务,以及解决 TCP 连接场景下必然存在的粘包/拆包问题。那么接下来我们就先来看看市面上常见的几种 protocol 模式,如下所示:
定长 protocol;
特殊分隔符 protocol;
定长 protocol header+可变 protocol body;
可变 protocol header+可变 protocol body;
定长 protocol 的解码处理非常简单,但缺点也相对明显,因为在“交互式通信”模式下,我们几乎不可能猜测到具体的报文长度,如果所设置的长度>实际报文长度将会直接影响网络的传输效率;特殊分隔符 protocol,使得开发人员可以根据"\r\n"
等特殊符号来实现解码操作,但需要提前与业务研发同学进行协商,避免与业务分隔符产生碰撞;定长 protocol header+可变 protocol body 模式消除了上述 2 种 protocol 模式存在的弊端,但扩展性较差;而可变 protocol header+可变 protocol body 模式,由于其 header 部分可变,因此具备更好的扩展性和灵活性,比如:更好的应对协议版本升级所带来的变更影响,或者允许用户携带一定的业务元素,以便于支持灰度路由。在此大家需要注意,具体使用哪一种 protocol 模式还需要根据具体的业务场景而定。
大家思考下,protocol 中大致应该包含哪些内容呢?如图 9 所示,以定长 protocol header+可变 protocol body 模式为例,除去 body 中必须的 request/response 数据外,header 中至少应该包含 protocol 的 magic、version、消息类型(用于判断 request/response/heartbeat)、加密类型、序列化类型,以及 body 长度等字段。当然,如果希望 protocol 具备一定的扩展性,则建议预留一个 ext 字段,并且出于对传输效率的考虑,header 应该采用偏紧凑的设计。
dubbo-protocol
dubbo3.x 版本最大的变化之一就是引入了一套全新设计的 RPC 协议-traple,力求在为开发人员带来更好的 protocol 扩展性的同时,也在以更加坚定的决心朝着 cloud-native 大步迈进。虽然 traple 目前是 dubbo 官方的主推协议,但就当下的市场认可度和熟知度而言,dubbo2-protocol 仍是主流,因此本文选择以后者进行讲解。
如图 10 所示,dubbo2-protocol 是一种结构相对紧凑,且基于定长 header+可变 body 模式的应用层 protocol。header 中索引位为 1~2 的 byte 用于存放 hig/low-magic;索引位为 3 的 byte 用于存放消息请求标识、heartbeat、event,以及序列化类型等信息;而索引位为 5~12 的 byte 用来存储 reqid;最后索引位为 13~16 的 byte 则用来存储数据包长度。具体代码位置位于ExchangeCodec#encodeRequest
中,如下所示:
之所以说 dubbo2-protocol 是一种结构紧凑的协议,是因为自始至终都秉承着“够用原则”,绝不浪费任何一点可用资源。上述程序示例中,代码FLAG_REQUEST | serialization.getContentTypeId()
表示用 1byte 来分别存储消息请求标识和序列化类型,其中消息请求标识的二进制位数为0b10000000
,序列化类型 hessian2 的二进制位数为0b00000010
,进行“|”
运算后,最终结果为0b10000010
。在此大家需要注意,body 完成序列化后会优先写入到 ByteBuf 中,然后再将数据包长度追加到 header 中索引位为 12~15 的 byte 上,最后更新 ByteBuf 的 writeIndex 后写入 16byte 的 header 数据就完成了一次 protocol 的编码操作。
dubbo 如何处理拆包问题
dubbo 的编/解码处理器是基于 Netty 的 MessageToByteEncoder 和 ByteToMessageDecoder,其内部真正处理编/解码操作的 Handler 为内部类InternalEncoder#encode
和InternalDecoder#decode
。dubbo 粘包/拆包的整体处理流程,如图 11 所示:
dubbo 处理 TCP 拆包的思路是什么呢?简单来说,解码器会首先根据 protocol 的结构依次解析出 header 中的所有数据信息,如果发现 ByteBuf 中的可读字节数<heade 中所指定的数据包长度,就说明发送端一定产生了拆包问题,解码器便会立即返回一个枚举常量NEED_MORE_INPUT
给上游,并重设 readerindex,然后继续等待上游回调InternalDecoder#decode
方法后再尝试解码,直至最终 ByteBuf 中所积累的可读字节数>header 中指定的数据包长度时解码器才会进行解码处理。粘包的处理逻辑位于ExchangeCodec#decode
中,如下所示:
准确来说,如果解码器在解析 header 的过程中发现 ByteBuf 的可读字节数<魔术长度和<header len,以及<header 中完整的数据包长度时都会返回NEED_MORE_INPUT
。而当解码器确认 ByteBuf 中的可读字节数为一个完整的数据包长度时,便会由 ExchangeCodec 的派生类 DubboCodec 来负责调用DecodeableRpcInvocation#decode
完成对 body 的反序列化操作。在DubboCodec
#decodeBody
方法中,解码器会先从 header 中解析出具体的序列化类型,以便于后续使用相同的序列化工具进行反序列化操作,如下所示:
DecodeableRpcInvocation#decode
缺省不会由 netty 的 worker 线程来负责执行,而是交给 dubbo 的业务线程去执行这类耗时处理。
dubbo 如何处理粘包问题
当大家清楚 dubbo 是如何处理 TCP 拆包问题后,接下来我们再来看看 dubbo 关于 TCP 粘包问题的解决思路。如图 11 所示,解码器会在DubboCountCodec#decode
中采用自旋的方式来依次解析一个个完整的数据包,直至 ByteBuf 中没有更多的可读数据为止。在此大家需要注意,当解码器读取完一个完整的数据包后,如果接收端的 SO_RCVBUF 空间中存在不完整的数据包或者没有数据时,便会由ExchangeCodec#decode
返回一个枚举常量NEED_MORE_INPUT
给上游,并按照和之前相同的处理逻辑尝试解码。粘包的处理逻辑位于DubboCountCodec#decode
中,如下所示:
解码器自旋解析粘包时得到的结果集会被存储到MultiMessage#addMessage
中,然后会交付给上游InternalDecoder#decode
的方法入参 List,最终会被传递给 pipeline 上的其它 ChannelHandler 来完成一次 RPC 调用。
后记
关于 Protocol 的标准设计就讲到这里,感兴趣的同学可以自行阅读 dubbo3.x 的源码,或者参考其他相关文献资料。如果在阅读过程中有任何疑问,欢迎在评论区留言参与讨论。
推荐文章:
版权声明: 本文为 InfoQ 作者【高翔龙】的原创文章。
原文链接:【http://xie.infoq.cn/article/ad938fbe0c2bf94a015b43366】。文章转载请联系作者。
评论 (1 条评论)