跳到正文

应用层协议设计:帧格式的头字节开销、序号回绕、幂等与心跳,全部本机算过一遍

环境:Windows 11 x64,GCC 13.1.0(MinGW-w64,全路径 D:/Qt/Tools/mingw1310_64/bin/gcc.exe),-O2 编译本机自写的小探针。本节的所有数字都来自这三个探针(p1.c 编码与头开销、p2.c 序号/幂等/心跳、p3.c 兼容性与测试向量)的 stdout 原文,属于确定性算术而非计时测量,因此不需要中位数与区间——同一输入跑两次逐字节一致(第三节实测过)。跨栏引用第 8 节《socket 编程基础》的粘包取证、第 5 节《滑动窗口》的抓包口径,没有重测。凡是需要真服务器/真设备的(MQTT 互通、QUIC、跨版本灰度、真实 RTT 下的心跳抖动),第七节逐条标”未在本机实测”。

一、头字节是固定税,载荷越小税越重

同一个载荷长度,三种帧形状的实测头开销:

帧形状头组成payload=116100102465535
A 固定头magic(2)+ver(1)+len(u16 大端)+crc(u16)7 B → 87.50%30.43%6.54%0.68%0.010%
B 固定头+变长magic(2)+ver(1) + varint 长度6 B → 85.71%27.27%5.66%7 B → 0.68%8 B → 0.012%
C 最小头ver(1) + varint 长度2 B → 66.67%11.11%1.96%3 B → 0.29%4 B → 0.006%

读法:

  1. 小报文才是协议设计的主战场。 载荷 16 字节时,A 形状每 1 字节数据要背 3 字节头;等到载荷 1 KB,头开销已经跌到 1% 以下,再为省头字节做设计是无效的。所以”要不要 varint”取决于你的典型载荷落在哪一行,而不是抽象的”更紧凑”。
  2. 变长编码不是单调省钱。 B 形状在 payload≥1024 时头反而从 6 变 7 字节、65535 时到 8 字节——varint 的长度本身也要按数值大小付字节。实测 varint 的档位:
值:                     0 / 1 / 127 / 128 / 300 / 16383 / 16384 / 2^20 / 2^27 / 2^34 / 2^56
varint 字节数:          1 / 1 /   1 /   2 /   2 /     2 /     3 /    3 /    4 /    5 /    9
  1. CRC 是”要不要”而不是”多少字节”:A 与 B 的差就是那 2 字节 CRC。控制类报文(一问一答、走 TCP)不需要帧级 CRC,因为 TCP 已有校验;数据报类(UDP)才有意义——第 6 节实测过 UDP 侧接收缓冲不足会一次作废两个数据报,那时帧级校验能帮你判断”丢的是哪一个”。

二、“半包”必须由长度或分隔符判定,不能靠编码自描述

一个很容易写错的判断:既然 varint 有”续接位”,那是不是读完一个不带续接位的字节就够了、剩下的一定是载荷?实测的反例:

varint(300)=2 字节, 读回=300, 消耗 2 字节, 首字节=0xAC 次字节=0x02
截断样本 0x80 0x01 -> 值=128 消耗=2(第二个字节没有续接位,所以它是完整编码)

0x80 0x01 这两字节看起来像”一个被截断的多字节数”,实际是完全合法的 varint(值 128)。也就是说流里剩下的字节恰好能拼出一个合法长度时,你根本分不出这是”完整的帧头”还是”半包 + 下一帧的开头”。这正是第 8 节那个观察的机制面:

3 次 send(10) → 线上仍是 3 个段(本机抓包核对 98 条报文、载荷 121 字节 = 应用侧 121 字节)
但接收侧一次 recv 真的可能只给 1 个字节

结论:帧边界要么写成”固定头 + 长度字段”(然后按长度收满),要么用不可能出现在载荷里的分隔符(并规定转义规则)。 别指望”解析到不认识的字节”来自愈——它一定会被后续数据凑成合法值。

三、版本与兼容:允许尾部扩展,别让”长度正好”成为断言

一个只有 3 个已知字段的小协议,新版本追加了 2 个尾部字节:

带未知尾部字段的帧 9 字节: 01 02 03 DE AD BE EF 99 88
旧版解码器(只会读 7 字节)used=7 ver=1 type=0x0203 req_id=0xDEADBEEF -> 字段一致=是
严格模式(要求长度恰为 7)在同一条帧上 = 拒绝

两种解码策略在同一条报文上一个成功一个拒绝。这就是前向兼容的全部技术含义:

  • 头部字段要”顺序固定 + 未知尾部忽略”,解码器只保证读到 min(认识的长度, 缓冲长度),绝不校验总长度。
  • 反过来,新增中间字段是破坏性的(后面所有字段的偏移都会挪),要扩展只能往尾部加,或者引入 TLV(type-length-value)列表——后者让”不认识的类型直接跳过长度字节”成为结构性质而不是纪律。
  • 必须有显式版本协商:本例把 ver 放在第 0 字节(1 字节固定),这样即使后面的布局整体换掉,旧解析器仍能先读出版本再决定走哪条分支。“看长度猜版本”是禁手。
  • 兼容性要写成断言进 CI:第三节实测的”同一输入两次编码逐字节相同”(两次编码长度 9 vs 9,逐字节相同=1)就是最简形态的回归测试——把 golden bytes 存成测试向量:
测试向量(带扩展): 010203deadbeef9988  (共 9 字节)

任何改动让这串字节变了,就说明它破坏了这个协议的字节级契约。

四、序号必须按”回绕”来比,本机四个反例

16 位序号,判断 a 是否比 b 新,两种写法:

a      b      正确(带符号差)  天真(a>b)  是否分歧
    5      3      1              1
    3      5      0              0
    0  65535      1              0      <= 分歧
65535      0      0              1      <= 分歧
  200  65400      1              0      <= 分歧
65400    200      0              1      <= 分歧

**四条分歧全部落在回绕点两侧。**正确写法只有一行:int16_t d = (int16_t)(a - b); return d > 0;——利用补码减法把”环形距离”变回”有符号大小”。规矩:

  • 序号位宽要大到”最长可能的往返 × 峰值速率”不会绕一圈,同时在文档里把回绕解释写清楚(第 4 节《TCP 连接管理》里 TCP 用 32 位序号 + 相对序处理,正是同一件事的标准答案)。
  • 绝对不要写 seq_a > seq_b。这条 bug 的特点是平时永远不出错,只在跑很多天或流量很大时出现一次,本机四个反例就是它能有多隐蔽的证据。

五、心跳、超时与幂等:三个数要一起定

心跳参数是”检测延迟”与”空帧成本”的直接兑换(每帧按 24 字节估):

心跳间隔 × 容忍丢拍最坏发现断线每小时帧数每连接每小时字节
1000 ms × 33.0 s360086400 B
5000 ms × 315.0 s72017280 B
30000 ms × 260.0 s1202880 B

从第一行到第三行,字节成本降 30 倍、检测延迟涨 20 倍——所以”心跳该设多久”只能由业务能容忍多久发现掉线反推,没有普适值。(另外:Sleep/定时器类等待在本机的精度受 ~15.6ms 进程默认 tick 支配,见并发专栏第 1 节与调试与性能第 9 节——心跳间隔设到几百毫秒级时,这个抖动才开始有影响。)

**幂等靠”唯一 id + 去重表”,而不是靠客户端不重发。**实测一个最小形态:投递 8 条消息、id 序列 1,1,2,1,3,2,3,3(3 个唯一 id):

投递 8 条(3 个唯一 id)-> 实际执行 3 次,去重表占 8*sizeof(int)=32 字节

设计要点跟着三条:

  1. id 必须来自发送方且在重试间不变(同一次业务动作 = 同一个 id);服务端只在”没见过”时执行。
  2. 去重表要有窗口:永久记会涨内存,所以工程上按时间/序号划窗——窗口太短就会让”迟到的重发”被执行两次,这与第四节序号回绕是同一类”边界条件”问题。
  3. 执行结果要能被 id 反查(“重复请求返回第一次的结果”),否则客户端拿到错误也不知道自己该不该重试——第 8 节里非阻塞 connect 失败要从异常集里读 SO_ERROR=10061、而裸 send 会阻塞到被 timeout 强杀(exit=124),这类”失败形态多样”的现实就是幂等必须存在的理由。

六、把协议写成能查的文档

一份可执行的协议文档,最小要素(本节每条都能在上面的探针里找到对应实现):

  • 字节序写死(大端/网络序;第 8 节实测过 inet_pton 内存是 7f 00 00 01、误填 0x7f000001 得到 1.0.0.127、htons(80) 字段值 0x5000——字节序不是概念题,是填错就出鬼值的题)。
  • 每个字段:宽度、编码、缺省、是否可省;变长字段给出档位表(第一节那张 varint 表)。
  • 帧边界的判定方法(长度前缀 / 分隔符 + 转义),并明确”不允许靠解析失败来自愈”。
  • 扩展规则:只往尾部加 / 用 TLV;禁止中间插字段;版本号在固定位置。
  • 错误与超时的语义:哪些错误可重试、重试是否必须复用同一个幂等 id、服务端对重复 id 返回什么。
  • 测试向量:一组 golden hex(如 010203deadbeef9988)+ 期望解码结果,直接进 CI。
  • 兼容矩阵:新服务端对旧客户端、旧服务端对新客户端、灰度期同时在线两种版本——这三行每行都要有一个向量,不是文字描述。

七、本节未在本机实测

  • MQTT / CoAP 等现成协议的互通:本机没有 broker,也没有 -lmosquitto 之类客户端库,所以”MQTT 固定头 + remaining length 的实际字节数""QoS 1 重发在 broker 侧的去重行为""遗嘱消息的触发时机”全部未验证。本文的帧格式与 varint 是我自己实现的小协议,它的数字不能当成 MQTT 的规范数字引用。
  • 真实网络下的心跳代价:本机是回环/单进程,测的是算术(帧数 × 字节数),没有测过 NAT 超时、无线链路抖动、TCP keepalive 与应用心跳叠加时的真实断线判定时间。
  • QUIC / 0-RTT:本机不存在 QUIC 组件(第 6 节已证 msquic.dll、quic.dll 均不存在),0-RTT 的重放与幂等关系只作机制说明,无实测数据。
  • 跨版本灰度与滚动升级:需要两套服务端实例与流量入口,本机没有;第三节的兼容矩阵只证明了单条报文层面的两种解码策略差异。
  • 压缩与头部字典(HPACK/QPACK 式头压缩的收益曲线):本机无实现,未测。
  • 协议级加密与签名的字节开销:与第 9 节《TLS 与证书链》重叠,该节尚未发布,本节不引任何数字。

八、小结

  • 头字节是固定税,小载荷最贵:7 字节固定头在载荷 1/16/100/1024/65535 时分别是 87.50% / 30.43% / 6.54% / 0.68% / 0.01%;换成 2 字节最小头,载荷 16 时降到 11.11%。载荷上 KB 之后别再为头字节做设计。
  • varint 不是单调省钱:127/128/16384/2^20/2^56 分别用 1/2/3/3/9 字节;B 形状在 65535 载荷时头反而涨到 8 字节。
  • 半包不能靠编码自描述:0x80 0x01 看着像截断其实是完整 varint(值 128);配合第 8 节”3 次 send 线上 3 段、接收侧可能只给 1 字节”的事实,帧边界只能由长度字段或分隔符+转义决定。
  • 兼容性 = 尾部扩展 + 忽略未知 + 固定位置版本号:同一条 9 字节帧 01 02 03 DE AD BE EF 99 88,宽松解码器取回全部已知字段,严格解码器(要求恰好 7 字节)拒绝。“长度正好”不能当断言。
  • 序号一律用带符号差:16 位在 (0,65535)、(65535,0)、(200,65400)、(65400,200) 四个点上与 a>b 分歧——平时永不出错、绕回时必错。
  • 心跳是延迟换字节:1000ms×3 = 3.0 s 发现断线 / 86400 B 每小时,30000ms×2 = 60 s / 2880 B(成本 30 倍、延迟 20 倍),必须由业务容忍度反推。
  • 幂等靠发送方不变的唯一 id:8 投 3 唯一 id 实测执行 3 次;去重要划窗,重复请求要能返回第一次的结果。
  • 协议文档的最低可执行形态是测试向量:010203deadbeef9988 + 期望解码结果 + 兼容矩阵三行,同一输入两次编码逐字节一致(实测 逐字节相同=1)才能进 CI。