协议版本:v1.1 完整代码:github.com/FSSLI/rpc_framework - docs/protocol.md
RPC 协议设计,新人最容易踩的坑是:“protobuf 已经有 RPC 语法了,我直接用不就行了?”
可以用,但你失去了:
- 自定义传输层——gRPC 强绑 HTTP/2,不能上 UDP/QUIC
- 自定义扩展——trace_id 怎么塞?心跳怎么发?
- 二进制紧凑度——HTTP/2 头部 + framing 开销在 30-50%
我花了两天设计自己的协议,18 字节头 + 变长 body + 4 字节 CRC。记录下来,顺便说说踩的坑。
一、为什么不用 gRPC
先把"为什么不用"说清楚,避免被认为是重复造轮子。
| 维度 | gRPC (HTTP/2 + protobuf) | 自研协议 |
|---|---|---|
| 性能 | 30-50% framing 开销 | 0% framing |
| 协议升级 | 跟着 HTTP/2 走 | 自主可控 |
| 学习成本 | 文档齐全 | 自己写 |
| 跨语言 | auto-gen, 20+ 语言 | 手动写 client (但 C++ 端能复用 codec) |
| 心跳 / 扩展 | 走 metadata | 自己的 msg_type |
| 链路追踪 | 用 metadata 传 trace_id | 自己的扩展字段 |
我选自研,纯粹为了学习 + 性能——生产环境99% 的情况都应该直接用 gRPC。
二、统一包格式
| |
总开销:22 字节,加 body。
三、18 字节 Fixed Header 详解
| 字段 | 类型 | 长度 | 用途 |
|---|---|---|---|
| magic | uint32_t | 4B | 0x52504346 = “RPCF”,识别协议 |
| version | uint8_t | 1B | = 1,留升级空间 |
| msg_type | uint8_t | 1B | 0=REQUEST, 1=RESPONSE, 2=HEARTBEAT |
| body_len | uint32_t | 4B | 网络字节序,TCP 粘包解决 |
| req_id | uint64_t | 8B | 异步/同步匹配 |
3.1 magic 字段:0x52504346 = “RPCF”
4 字节,直接写 “RPCF” 的 ASCII 码。作用:
- 快速识别协议——读完前 4 字节就能 reject 错连的 HTTP/SSH 流量
- 流式重新同步——decode 失败时,跳 1 字节重找 magic,不丢整条流
| |
3.2 body_len:粘包终结者
TCP 是流,没"包"的概念。我读 100 字节,可能拿到第 1 条 80 字节 + 第 2 条 20 字节,也可能只拿到第 1 条的前 30 字节。
body_len 直接告诉"读完这 N 字节就是一条完整消息"——这就是 length-field framing。
| |
3.3 req_id:8 字节全局唯一
异步调用时,用 req_id 把 request 和 response 配对:
| |
四、Body 格式:protobuf payload
Header 解决"怎么分帧",Body 解决"传什么数据"。
4.1 Request Body
| 字段 | 长度 | 说明 |
|---|---|---|
| service_len | 2B | 服务名长度 (uint16) |
| service | N B | “EchoService” |
| method_len | 2B | 方法名长度 |
| method | N B | “Echo” |
| payload_len | 4B | 参数长度 (uint32) |
| payload | N B | RpcRequest.service() / .method() / .args() (protobuf) |
注意:service + method 名字符串在 body 里,不像 gRPC 走 HTTP/2
:path——我不依赖外部 schema,decoder 自己读出"要调谁、调哪个方法"。
4.2 RpcRequest/Response protobuf
| |
五、CRC32:4 字节保证完整性
为什么不是更短的校验和 / MD5?
| 方案 | 长度 | 检错能力 | 性能 |
|---|---|---|---|
| 单字节 XOR | 1B | 50% (奇数错位) | 极快 |
| CRC32 | 4B | 100% (随机错) | 查表 1GB/s |
| MD5 | 16B | 100% + 抗故意改 | ~500MB/s |
我选 CRC32 (IEEE 802.3):4 字节足够,查表实现达到内存带宽,工程最成熟。
覆盖范围:Header + Body,不包括 Checksum 自身。
| |
六、心跳消息:msg_type = 2
| |
为什么需要心跳? 服务发现场景下,服务节点要"告诉 registry 自己还活着"。5s 一次心跳,超时 15s 剔除。
为什么走自己的协议不直接 TCP keepalive? TCP keepalive 默认 7200s,改系统参数不优雅;自己发心跳还能顺便报 load / qps 等元数据。
七、踩过的坑
7.1 网络字节序:一定别用主机字节序
body_len 和 req_id 必须网络字节序——A 端 big-endian 设备, B 端 little-endian,不一致就死。
| |
7.2 CRC32 多项式选错:0xEDB88320 vs 0x04C11DB7
两者数学上等价 (前者的反射多项式),但代码里混淆会客户端发 0xEDB…, 服务端用 0x04… 校验永远不过。
约定:全用 0xEDB88320 (反射)——和 zlib/PNG 兼容。
7.3 流式重新同步:跳 1 字节 vs 跳 4 字节
magic 4 字节,理论应"读 4 字节然后比对"。但错位 1 字节时也会落到"前 3 字节是 magic, 第 4 字节是 version"——你不知道。
正解:跳 1 字节重找 magic——这能保证重新同步。
7.4 protobuf:多一次 string 拷贝 vs ParseFromArray
decode 路径如果先把网络 buffer 拷进 std::string payload 再 req.ParseFromString(payload),多一次内存拷贝。
MessageLite::ParseFromArray(const void*, int) 可以直接指着网络 buffer 解析,省掉这次拷贝(压测文第三节)。
(详见压测文)
八、扩展性:未来加 trace_id 怎么加?
两种方式:
A. 加到 Header 末尾:让 Header 变长 8 字节——破坏 v1 兼容性 B. 走 Body 的 meta map:灵活,但每条消息多 30 字节
我选 A 但加 8B 扩展字段到 Header 末尾, version = 1 时忽略,version = 2 时启用:
| |
这样老 client 发的 v1 包,v2 server 也能读——前向兼容。
九、与 gRPC 对比:何时应该上自研协议
| 场景 | 用 gRPC | 自研 |
|---|---|---|
| 普通业务 | ✅ | ❌ 杀鸡用牛刀 |
| 强性能 / 低延迟 | ❌ | ✅ |
| 移动端弱网 | ❌ | ✅ 可上 QUIC |
| 多语言 | ✅ auto-gen | ❌ 手动 |
| 内部 1 个团队用 | ❌ | ✅ |
| 跨公司 / 开源 | ✅ | ❌ |
我的结论:99% 的项目直接用 gRPC。自研协议 = 学协议设计 + 学性能优化,但生产里没有维护性收益。
相关阅读: