手写 RPC 协议帧:18 字节头 + CRC32 + protobuf

协议版本:v1.1 完整代码:github.com/FSSLI/rpc_framework - docs/protocol.md

RPC 协议设计,新人最容易踩的坑是:“protobuf 已经有 RPC 语法了,我直接用不就行了?”

可以用,但你失去了:

  1. 自定义传输层——gRPC 强绑 HTTP/2,不能上 UDP/QUIC
  2. 自定义扩展——trace_id 怎么塞?心跳怎么发?
  3. 二进制紧凑度——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


二、统一包格式

1
2
3
4
┌─────────────────┬─────────────────┬─────────────────┐
│  Fixed Header   │  Variable Body  │    Checksum     │
│     18 Bytes    │   body_len B    │     4 Bytes     │
└─────────────────┴─────────────────┴─────────────────┘

总开销:22 字节,加 body。


三、18 字节 Fixed Header 详解

字段类型长度用途
magicuint32_t4B0x52504346 = “RPCF”,识别协议
versionuint8_t1B= 1,留升级空间
msg_typeuint8_t1B0=REQUEST, 1=RESPONSE, 2=HEARTBEAT
body_lenuint32_t4B网络字节序,TCP 粘包解决
req_iduint64_t8B异步/同步匹配

3.1 magic 字段:0x52504346 = “RPCF”

4 字节,直接写 “RPCF” 的 ASCII 码。作用:

  1. 快速识别协议——读完前 4 字节就能 reject 错连的 HTTP/SSH 流量
  2. 流式重新同步——decode 失败时,跳 1 字节重找 magic,不丢整条流
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// codec.h
class Codec {
  static constexpr uint32_t MAGIC = 0x52504346;
  static constexpr size_t HEADER_LEN = 18;
  static constexpr size_t CHECKSUM_LEN = 4;
};

// codec.cc 解码
bool Codec::decode(Buffer& buf, DecodedPacket& pkt) {
  if (buf.readableBytes() < HEADER_LEN + CHECKSUM_LEN) return false;
  
  // 1. 校验 magic
  uint32_t magic = buf.peekUint32();
  if (magic != MAGIC) {
    // 跳过 1 字节重找
    buf.retrieve(1);
    return false;
  }
  
  // 2. 读 header
  buf.retrieve(4);  // magic
  pkt.version = buf.readUint8();
  pkt.msg_type = static_cast<MsgType>(buf.readUint8());
  pkt.body_len = buf.readUint32();
  pkt.req_id = buf.readUint64();
  
  // 3. 读 body
  if (buf.readableBytes() < pkt.body_len + CHECKSUM_LEN) return false;
  pkt.body = buf.retrieveAsString(pkt.body_len);
  
  // 4. 校验 CRC32
  uint32_t expected = buf.readUint32();
  // ... CRC32 计算
  return crc_ok;
}

3.2 body_len:粘包终结者

TCP 是流,没"包"的概念。我读 100 字节,可能拿到第 1 条 80 字节 + 第 2 条 20 字节,也可能只拿到第 1 条的前 30 字节。

body_len 直接告诉"读完这 N 字节就是一条完整消息"——这就是 length-field framing。

1
2
3
4
5
// Buffer: 一次 readFd 拿到 N 字节,可能含 1-多条消息
// Codec::decode 在循环里调,直到拿不到完整消息
while (codec.decode(buf, pkt)) {
  handler(pkt);
}

3.3 req_id:8 字节全局唯一

异步调用时,用 req_id 把 request 和 response 配对:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// client
uint64_t req_id = next_id_.fetch_add(1);
RpcRequest req; req.set_req_id(req_id);
send(req);

// 收到 response 时
RpcResponse resp; recv(resp);
if (resp.req_id() == req_id) {
  promise.set_value(resp);
}

四、Body 格式:protobuf payload

Header 解决"怎么分帧",Body 解决"传什么数据"。

4.1 Request Body

字段长度说明
service_len2B服务名长度 (uint16)
serviceN B“EchoService”
method_len2B方法名长度
methodN B“Echo”
payload_len4B参数长度 (uint32)
payloadN BRpcRequest.service() / .method() / .args() (protobuf)

注意:service + method 名字符串在 body 里,不像 gRPC 走 HTTP/2 :path——我不依赖外部 schema,decoder 自己读出"要调谁、调哪个方法"。

4.2 RpcRequest/Response protobuf

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
message RpcRequest {
  string service = 1;
  string method = 2;
  bytes payload = 3;        // 业务参数(反序列化后给业务)
  map<string, string> meta = 4;  // trace_id, deadline 等
}

message RpcResponse {
  int32 status = 1;   // 0=SUCCESS, 1=FAILED, 2=TIMEOUT
  bytes payload = 2;
  string error_msg = 3;
}

五、CRC32:4 字节保证完整性

为什么不是更短的校验和 / MD5?

方案长度检错能力性能
单字节 XOR1B50% (奇数错位)极快
CRC324B100% (随机错)查表 1GB/s
MD516B100% + 抗故意改~500MB/s

我选 CRC32 (IEEE 802.3):4 字节足够,查表实现达到内存带宽,工程最成熟。

覆盖范围:Header + Body,不包括 Checksum 自身

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 查表法
static uint32_t crc32_table[256] = { /* 256 项,启动时一次性生成 */ };

uint32_t crc32(const void* data, size_t len) {
  uint32_t crc = 0xFFFFFFFF;
  for (size_t i = 0; i < len; i++) {
    crc = (crc >> 8) ^ crc32_table[(crc ^ ((uint8_t*)data)[i]) & 0xFF];
  }
  return crc ^ 0xFFFFFFFF;
}

六、心跳消息:msg_type = 2

1
2
3
4
┌─────────────────┬─────────────────┬─────────────────┐
│  Fixed Header   │  service=N B    │    Checksum     │
│  msg_type=2     │  node_id + ts   │     4 Bytes     │
└─────────────────┴─────────────────┴─────────────────┘

为什么需要心跳? 服务发现场景下,服务节点要"告诉 registry 自己还活着"。5s 一次心跳,超时 15s 剔除。

为什么走自己的协议不直接 TCP keepalive? TCP keepalive 默认 7200s,改系统参数不优雅;自己发心跳还能顺便报 load / qps 等元数据


七、踩过的坑

7.1 网络字节序:一定别用主机字节序

body_lenreq_id 必须网络字节序——A 端 big-endian 设备, B 端 little-endian,不一致就死

1
2
3
4
5
6
// 错 (主机字节序, x86 上 OK, ARM 大端会错)
buf.append((char*)&body_len, 4);

// 对 (显式网络字节序)
uint32_t body_len_be = htonl(body_len);
buf.append((char*)&body_len_be, 4);

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 payloadreq.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 时启用:

1
2
// v1: 18 字节 header
// v2: 26 字节 header (含 trace_id)

这样老 client 发的 v1 包,v2 server 也能读——前向兼容。


九、与 gRPC 对比:何时应该上自研协议

场景用 gRPC自研
普通业务❌ 杀鸡用牛刀
强性能 / 低延迟
移动端弱网✅ 可上 QUIC
多语言✅ auto-gen❌ 手动
内部 1 个团队用
跨公司 / 开源

我的结论:99% 的项目直接用 gRPC。自研协议 = 学协议设计 + 学性能优化,但生产里没有维护性收益


相关阅读: