上篇验证过用 int64 存玩家 ID(234231233)只需要 5 个字节,感觉 Protobuf 挺能省的。结果最近处理战斗协议时发现一件让我疑惑的事:把 hp_delta = -1 用 int32 编码,居然要 10 个字节。
JSON 写 {"hp_delta":-1} 才 15 字节,Protobuf 存个 -1 就花了 10 个字节,比 JSON 没省多少,这是怎么回事?
负数在 VARINT 里是个大数
VARINT 的编码思路是数越小字节越少,每个字节用 7 位存数据,最高位标记后面是否还有字节。所以 1 只需 1 字节,300 只需 2 字节。
但负数在计算机里是补码,-1 的 32 位补码是 0xFFFFFFFF,本质上是个很大的正整数。更麻烦的是,Protobuf 对 int32 的负值编码前会先符号扩展成 64 位:
scss
int32(-1) → 0xFFFFFFFF
int64(-1) → 0xFFFFFFFFFFFFFFFF
VARINT → FF FF FF FF FF FF FF FF FF 01
10 个字节,而且不管你的负数是 -1 还是 -9999,只要是 int32 负值,一律 10 字节,没有例外。
ZigZag 的思路
官方解法是 sint32 / sint64,背后是 ZigZag 编码。思路是把有符号整数重新映射成无符号整数,让绝对值小的数映射结果也小:
| 原始值 | ZigZag 结果 |
|---|---|
| 0 | 0 |
| -1 | 1 |
| 1 | 2 |
| -2 | 3 |
| 2 | 4 |
| -128 | 255 |
| 128 | 256 |
就是把整数轴从中间折叠,正负交错排列。公式是:
scss
ZigZag(n) = (n << 1) ^ (n >> 31)
>> 这里是算术右移,负数右移后全是 1。拿 -1 算一下:
ini
n = -1,二进制:1111 1111 1111 1111 1111 1111 1111 1111
n << 1 = 0xFFFFFFFE
n >> 31 = 0xFFFFFFFF
XOR:0xFFFFFFFE ^ 0xFFFFFFFF = 1
-1 变成了 1,VARINT 只要 1 字节。再算个 -300:
ini
n = -300 → 0xFFFFFED4
n << 1 = 0xFFFFFDA8
n >> 31 = 0xFFFFFFFF
XOR:0xFFFFFDA8 ^ 0xFFFFFFFF = 0x257 = 599
599 的 VARINT 按 7 位分组:
scss
低 7 位:101 0111 → 有后续 → 0xD7
高 7 位:000 0100 → 最后 → 0x04
sint32(-300) = D7 04 2 字节
int32(-300) = D4 FD FF FF FF FF FF FF FF 01 10 字节
差了 5 倍。解码反过来:(ZigZag >>> 1) ^ -(ZigZag & 1),>>> 是无符号右移。
在游戏协议里的体感
把上篇的 BattleEvent 改成 sint32 之后,字节数变化很明显。拿三个常见的负数字段举例(hp_delta=-50, dx=-3, score=-100):
| 字段 | ZigZag 结果 | sint32 字节 | int32 字节 |
|---|---|---|---|
| hp_delta = -50 | 99 | 1 | 10 |
| dx = -3 | 5 | 1 | 10 |
| score = -100 | 199 | 2 | 10 |
加上每个字段 1 字节的 tag,int32 版本是 33 字节,sint32 版本是 9 字节,压到原来 27%。帧同步每秒推 20 帧,这个差距乘上在线人数就比较可观了。
顺便说一下选型,三种整数类型差异挺大,选错了挺亏的:
| 类型 | 编码 | 用在哪 |
|---|---|---|
int32 |
VARINT,负数固定 10 字节 | 始终非负:技能 ID、物品数量、等级 |
sint32 |
ZigZag + VARINT | 会出现负数:血量变化、位移增量、坐标差值 |
fixed32 |
固定 4 字节 | 值普遍超过 2^28:时间戳、大范围随机种子 |
有个坑值得单独说:坐标用 int32 存绝对值没问题,始终是正数。但有人为了省带宽改成存增量,增量有正有负,这时候必须同步把类型改成 sint32,否则玩家往左走反而更费带宽,相当于优化了个负数。
感兴趣的话可以去 tools.ioirb.cn/proto/parse... 把上面计算出来的十六进制丢进去反向解析,把 sint32 改回 int32 对比一下字节数,差距很直观。
用到了AI排版,好看多了。