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对包头进行定义。

bash 复制代码
// 请求包头
message RequestBodyHead {
  // 协议版本
  uint32 version = 1;
  // 主调服务的名称
  bytes caller = 2;
  // 被调服务的名称
  bytes callee = 3;
  // 调用服务的方法名
  bytes func = 4;
  // 框架透传的信息key-value对,目前分两部分
  // 1是框架层要透传的信息,key的名字要以rpc-开头
  // 2是业务层要透传的信息,业务可以自行设置
  map<string, bytes> trans_info = 5;
  // 其他信息
  ......
}

// 响应包头
message ResponseBodyHead {
  // 协议版本
  uint32 version = 1;
  // 请求在框架层的错误状态码
  int32 status = 2;
  // 调用结果信息描述
  // 失败的时候用
  bytes status_msg = 3;
  // 框架透传回来的信息key-value对,
  // 目前分两部分
  // 1是框架层透传回来的信息,key的名字要以rpc-开头
  // 2是业务层透传回来的信息,业务可以自行设置
  map<string, bytes> trans_info = 4;
  // 其他信息
  ......
}

编码解码

以上定义了一个RPC协议的帧头、包头和包体。下面简单用Java Netty框架演示如何编码解码RPC协议体数据。

java 复制代码
// 编码数据
encode(ChannelBuffer in) {
    // 写入魔数
    in.writeShort(FRAME_MAGIC);
    // 写入数据类型
    in.writeByte(dataType);
    // 写入协议体长度
    in.writeInt(totalSize);
    // 写入协议体包头长度
    in.writeUnsignedShort(headSize);
    // 写入序列化ID
    in.writeByte(serializationID);
    // 写入压缩算法ID
    in.writeByte(compressID);
    // 写入消息ID
    in.writeInt(requestID);
    // 写入预留字段
    in.writeBytes(reserved);
    // 写入包头数据 
    // 根据serializationID和compressID 进行序列化和压缩
    in.writeBytes(headBytes);
    // 写入包体数据
    // 根据serializationID和compressID 进行序列化和压缩
    in.writeBytes(bodyBytes);
}

 // 解码数据
  decode(ChannelBuffer in) {
    // 检查帧头长度是否有18个字节
    if (in.readableBytes() < FRAME_SIZE) {
        return NOT_ENOUGH_DATA;
    }
    // 判断是否是合法的RPC协议
    if (in.readShort() != FRAME_MAGIC) {
        // 不是抛出RPC异常信息
        throw RpcException;
    }
    // 获取数据类型
    byte dataType = in.readByte();
    // 获取协议体长度
    int totalSize = in.readInt();
    // 获取协议体包头长度
    int headSize = in.readUnsignedShort();
    // 获取序列化ID
    byte serializationID = in.readByte();
    // 获取压缩算法ID
    byte compressID = in.readByte();
    // 获取消息ID
    int requestID = in.readInt();
    // 获取预留字段
    in.readBytes(reserved);
    // 获取包头数据
    // 根据serializationID和compressID 进行解压和反序列化
    in.readBytes(headBytes);
    // 获取包体长度
    int bodySize = totalSize - headSize;
    // 获取包体数据
    // 根据serializationID和compressID 进行解压和反序列化
    in.readBytes(bodyBytes);
}

自研RPC协议的主要优势在于其设计的紧凑性,这使得它能够满足特定高并发场景下的数据传输性能需求。由于协议体的数据格式统一,将包头和包体序列化为特定的二进制数据,这使得代码的实现过程变得更为简单。

然而,自研RPC协议也面临着一些挑战,其中最主要的是兼容性问题。如果协议仅支持特定的编程语言或平台,那么在其他环境中的应用就可能会遇到困难。此外,开发和维护成本、生态系统支持、安全性和稳定性等因素也需要开发人员在设计阶段进行深入考虑。

未完待续

很高兴与你相遇!如果你喜欢本文内容,记得关注哦!

相关推荐
蒜鸟~蒜鸟10 小时前
手把手教你搭建 UDP 多人聊天室(附完整源码)
网络·网络协议·udp
oraen10 小时前
深入理解Kafka事务
分布式·kafka·linq
三十_A10 小时前
【NestJS】HTTP 接口传参的 5 种方式(含前端调用与后端接收)
前端·网络协议·http
一行•坚书11 小时前
怎么用redis lua脚本实现各分布式锁?Redisson各分布式锁怎么实现的?
redis·分布式·lua
君不见,青丝成雪11 小时前
atomic常用类方法
分布式
程序员老舅11 小时前
‌NAT穿透技术原理:P2P通信中的打洞机制解析‌
服务器·c++·网络协议·网络编程·p2p·nat·网络穿透
now_cn12 小时前
构建线上门户的核心三要素:域名、DNS与IP 全面解析
网络·网络协议·tcp/ip
武子康12 小时前
Java-114 深入浅出 MySQL 开源分布式中间件 ShardingSphere 深度解读
java·数据库·分布式·mysql·中间件·性能优化·开源
徐子元竟然被占了!!13 小时前
针对 “TCP 会话维持与身份验证” 的攻击
网络·网络协议·tcp/ip