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

// 请求包头
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协议体数据。

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

未完待续

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

gRPC不是银弹:为内网极致性能,如何设计自己的RPC协议?的更多相关文章

  1. 使用Holer外网SSH访问内网(局域网)Linux系统

    1. Holer工具简介 Holer exposes local servers behind NATs and firewalls to the public internet over secur ...

  2. 使用Holer远程桌面登录家里电脑和公司内网电脑

    1. Holer工具简介 Holer exposes local servers behind NATs and firewalls to the public internet over secur ...

  3. Vue实战041:获取当前客户端IP地址详解(内网和外网)

    前言 我们经常会有需求,希望能获取的到当前用户的IP地址,而IP又分为公网ip(也称外网)和私网IP(也称内网IP),IP地址是IP协议提供的一种统一的地址格式,每台设备都设定了一个唯一的IP地址”, ...

  4. 使用内网映射工具Holer将本地的Web应用映射到公网上访问

    Holer exposes local servers behind NATs and firewalls to the public internet over secure tunnels. Su ...

  5. Holer一款局域网服务器代理到公网的内网映射工具

    Holer简介 Holer是一个将局域网服务器代理到公网的内网映射工具,支持转发基于TCP协议的报文. 相关链接 开源地址:https://github.com/Wisdom-Projects/hol ...

  6. 内网安全---隐藏通信隧道基础&&网络通信隧道之一ICMP隧道

    一,隐藏通信隧道基础知识 在完成信息收集之后,我们要判断流量是否出的去.进的来.隐藏通信隧道技术常用于在受限的网络环境中追踪数据流向和在非受信任的网络中实现安全的数据传输. 1.常见的隧道: .网络层 ...

  7. Windows用户如何安装cpolar内网穿透

    概述 本教程适合于Windows用户,安装并使用cpolar工具. 什么是cpolar? cpolar是一个非常强大的内网穿透工具,开发调试的必备利器 它可以将本地内网服务器的HTTP.HTTPS.T ...

  8. 用SSH访问内网主机的方法

    如今的互联网公司通常不会直接自己直接配主机搭建服务器了,而是采用了类似阿里云的这种云主机,当应用变得越来越大了之后,就不可避免地增加主机,而出于成本考虑,不可能给每一台主机都分配公网带宽,所以实际的情 ...

  9. 内网穿透神器ngrok

    相信做Web开发的同学们,经常会遇到需要将本地部署的Web应用能够让公网环境直接访问到的情况,例如微信应用调试.支付宝接口调试等.这个时候,一个叫ngrok的神器可能会帮到你,它提供了一个能够在公网安 ...

  10. 内网劫持渗透新姿势:MITMf简要指南

    声明:本文具有一定攻击性,仅作为技术交流和安全教学之用,不要用在除了搭建环境之外的环境. 0×01 题记 又是一年十月一,想到小伙伴们都纷纷出门旅游,皆有美酒佳人相伴,想到这里,不禁潸然泪下.子曰:& ...

随机推荐

  1. Java集合框架性能特征与使用场景深度解析

    Java 集合框架的性能优化与场景适配是高级程序员面试的核心考点.本文聚焦线性集合.集合.映射等核心组件的性能指标(时间复杂度.空间开销)与适用场景,结合 JDK 演进特性与工程实践,构建系统化知识体 ...

  2. 四、Linux系统调用跟踪工具strace

    4.1.strace(系统调用跟踪) ​ strace 是 Linux 下的系统调用跟踪工具,可以监视进程执行时与内核的交互,包括文件操作.进程管理.网络通信.内存分配等.它主要用于调试.性能优化.问 ...

  3. Python学习:PocketFlow中的RAG例子

    运行效果 例子地址:https://github.com/The-Pocket/PocketFlow/tree/main/cookbook/pocketflow-rag 什么是RAG(用PocketF ...

  4. .NET应用中的高效分布式同步解决方案

    前言 在分布式系统中,多个线程.进程或服务之间常常需要并发访问共享资源,这就带来了数据同步与一致性的挑战.今天大姚给大家分享一个.NET应用中的高效分布式同步解决方案:DistributedLock. ...

  5. PLY 模型文件简析

    PLY 模型文件简析 参考链接 wiki需要FFFQQQ TIPS 主要是一些英文的简析,但是一句话,网上讲的不清楚,特此说明 property list uchar int vertex_indic ...

  6. java 生产者消费者模式 使用标志位进行判断

    简介 RT code package com.kuang; /** * Created by lee on 2021/3/30. */ public class TestPC2 { public st ...

  7. java 接口sort comparable

    简介 java 没有对于多继承的实现,为了间接实现多继承,采用了接口的概念 code package cn; import java.util.Arrays; public class Employe ...

  8. 3.1k star!推荐一款开源基于AI实现的浏览器自动化插件工具 !

    大家好!今天,我要给大家介绍一款超实用的开源工具--Chrome MCP Server!这款工具不仅能大幅提升我们的工作效率,还能让AI助手(如Claude)直接操控浏览器,实现自动化操作.内容分析等 ...

  9. Golang基础笔记十二之defer、panic、error

    本文首发于公众号:Hunter后端 原文链接:Golang基础笔记十二之defer.panic.error 本篇笔记介绍一下 Golang 里 defer.panic 和 error 的相关概念和操作 ...

  10. 产品更新丨谷云科技 iPaaS 集成平台 V7.6 版本发布

    六月,谷云科技iPaaS集成平台更新了V7.6版本.这次更新中我们着重对API网关.API编排.组织管理权限.API监控等功能进行了增强以及优化,一起来看看有什么新变化吧! 网关.监控.编排.组织权限 ...