gRPC 不是银弹:为内网极致性能,如何设计自己的 RPC 协议?
自研 RPC 协议:为性能而生的赛道利器
尽管 gRPC 凭借其标准化、跨语言和基于 HTTP/2 的强大特性,在公网和云原生环境中大放异彩,但在某些特定的内网环境中,对性能、延迟和资源占用的要求可能更为苛刻。HTTP/2 虽然高效,但其帧结构和头部处理机制相较于专为内网设计的极简协议,仍可能引入不必要的开销。相比之下,内网环境的网络特性包括更短的请求链路、更低的丢包率和更可靠的硬件环境。在实际生产环境中,公网环境通常会通过 nginx 等反向代理服务进行优化。经过反向代理后,服务间的请求链路实际上已经转移到了内网环境。对于内网环境,自行实现的 RPC 协议相较于 gRPC 协议具有以下优势。1)灵活性:根据业务需求和技术栈定制协议特性,如支持特定的调用模式、元数据传递、流控策略等。2)轻量级:协议头部和消息结构可以做到极致精简,仅包含必要字段,减少网络传输的字节数和解析开销。3)性能优化:可以选择或定制最高效的序列化/反序列化方案;可以实现更激进的内存管理和对象复用策略;可以针对特定的硬件特性进行微调。
TCP 拆包粘包
RPC 协议是建立在传输层协议之上的应用层协议,其中传输层协议包括 TCP、UDP 等。TCP 协议因其高可靠性和全双工的特点,成为许多应用层协议的选择,包括 gRPC 所使用的 HTTP/2 协议。然而,TCP 协议传输的是一串无边界的二进制流。由于底层网络并不了解应用层数据的具体含义,它会根据 TCP 缓冲区(Buffer Cache)的情况进行数据包的划分。这就可能导致一个完整的应用层数据包被 TCP 拆分为多个小包进行发送,或者将多个小包封装成一个大的数据包进行发送。这种现象通常被称为 TCP 拆包(Packet splitting)和粘包(Packet sticking)问题。

TCP 拆包和粘包问题可能会导致接收端无法正确解析和处理数据,从而影响应用层的正常运行。为了解决这个问题,通常需要在应用层进行数据的边界划分和处理。常见有如下的解决方案。1)固定长度(Fixed-Length):每个消息包长度固定。简单但可能浪费空间(若数据小于固定长度)或无法处理大数据(若数据大于固定长度)。2)分隔符(Delimiter-Based):在消息末尾添加特殊字符序列(如 \r\n)。适用于文本协议,但处理二进制数据或数据本身包含分隔符时较麻烦。3)长度前缀(Length-Prefixed):在每个消息包前附加一个字段(通常是 2 或 4 字节整数)来指明该消息包的长度。接收方先读取长度字段,再根据长度读取完整的消息数据。这是 RPC 框架(包括 HTTP/2 的 DATA 帧内的消息和 gRPC 的消息封装)最常用的方式,因为它精确、高效且适用于任何类型的数据。对于 RPC 框架,每个数据包都不应该传输多余的数据(所以补齐的方式不可取),因此长度前缀更适合这样的场景。
帧头设计
一个典型的自研 RPC 协议通常包含一个固定长度的帧头 (Frame Header) 和一个可变长度的协议体 (Protocol Body)。协议体又可以进一步划分为包头 (Message Header / Metadata) 和包体 (Message Body / Payload)。首先,一个最简单的协议包含两部份,比如用 4 字节的帧头来保存协议体的大小,这样接收端首先读取帧头的里面的值,接着再根据值的大小来读取协议体的数据。

然而接收端接收到协议体是一串二进制数据,需知道序列化编码方式。因此在帧头增加 1 字节来保存当前数据的序列化编码方式。

接下来在帧头增加 1 字节,用来保存当前数据类型。比如请求、响应、单向调用、流式调用等。这样接收端,可以根据数据类型,来处理不同的逻辑。

如上实现一个简单的数据接收和解析功能,但这样不足以完整描述一个 RPC 协议。以 gRPC 为例,一次 Request 请求包括请求头,请求体和 EOS。请求头和请求体都属于不固定长度的数据,这些数据无法放到帧头中。因为帧头是固定长度,一旦对帧头增加新的功能,将会导致协议解析失败引发线上故障。为了能够平滑地升级改造前后的协议,有必要设计一种支持可扩展的协议。其关键在于让协议体支持可扩展,对于协议体的数据主要包括四部份:1)当前 RPC 远程调用的信息,如服务名、接口名、方法名、版本。2)RPC 框架定义的透传元数据,如 rpc-version、rpc-env。3)业务自定义的透传元数据,如 usr-traceid、usr-logid。4)客户端和服务端发送的数据,如请求参数、返回值。前三部分认为是协议体的扩展部分,用于保存当前 RPC 远程调用的上下文,称为包头。第四部分用于保存当前 RPC 发送的数据,称为包体。将协议体拆分成包头和包体以后,需在帧头再增加 2 字节来保存包头的长度,这样接收端可根据协议体总长度和包头长度来合理读取包头和包体数据。

一个完整的 RPC 协议设计如上,帧头一共 19 字节。1)魔数(Magic):2 字节,用于快速识别协议类型和版本。2)消息类型(Data Type):1 字节,消息的类型(如 0x01=Request, 0x02=Response, 0x03=Heartbeat)。2)整体长度(Total Length):4 字节,协议体(包头 + 包体)的总长度。3)包头长度(Body Head Length):2 字节,包体长度 = TotalLength - HeaderLength。5)序列化 ID(Serialization ID):1 字节,序列化类型(如 0x1=Protobuf, 0x2=JSON, 0x3=Kryo)。6)压缩算法 ID(Compress ID):1 字节,压缩算法类型(如 0x1=Gzip, 0x2=Snappy)。包头通常不压缩或使用轻量压缩。7)消息 ID(Request ID):4 字节,唯一标识一次 RPC 调用,用于异步请求响应的匹配。8)预留字段(Reserved):4 字节,预留字段,用于未来协议扩展,增加兼容性。
协议体设计
协议体的包头用于承载 RPC 调用的元信息,分为请求包头和响应包头,会被特定的序列化类型序列化(由序列化 ID 标识),比如使用 Protobuf 进行序列化。下面用.proto 对包头进行定义。
编码解码
以上定义了一个 RPC 协议的帧头、包头和包体。下面简单用 Java Netty 框架演示如何编码解码 RPC 协议体数据。
自研 RPC 协议的主要优势在于其设计的紧凑性,这使得它能够满足特定高并发场景下的数据传输性能需求。由于协议体的数据格式统一,将包头和包体序列化为特定的二进制数据,这使得代码的实现过程变得更为简单。然而,自研 RPC 协议也面临着一些挑战,其中最主要的是兼容性问题。如果协议仅支持特定的编程语言或平台,那么在其他环境中的应用就可能会遇到困难。此外,开发和维护成本、生态系统支持、安全性和稳定性等因素也需要开发人员在设计阶段进行深入考虑。
未完待续
很高兴与你相遇!如果你喜欢本文内容,记得关注哦!
版权声明: 本文为 InfoQ 作者【poemyang】的原创文章。
原文链接:【http://xie.infoq.cn/article/fef3c93fcb94adbd8e859ebd9】。文章转载请联系作者。
评论