DDIA(六):编码格式的取舍与JSON战报体积优化方案

DDIA(六):编码格式的取舍与JSON战报体积优化方案

好家伙,

最近在研究战报的数据结构和体积压缩:于是去翻 DDIA 第 4 章,正好整章讲的就是这件事:

数据要跨进程、跨服务、落盘存档,
就得从内存结构体变成字节序列——这就是编码.

编码格式怎么选,直接影响包体大小、解析开销,以及未来改字段时会不会炸.

这篇拿三种最常见的格式,对着我这份战报真刀真枪比一遍:JSON、Protobuf、Avro.

0.背景:一份战报要出门,得先"打包"

先把标本摆上桌.下面是一份最小可用战报——从一场真实战斗的战报里抽出来的,只保留胜负、回合数,和"阿尔德一刀砍中豺狼人"这一次伤害计算的 4 条过程日志:

{
  "winner": "player",
  "rounds": 6,
  "entries": [
    {"seq": 413, "depth": 7, "kind": "start",   "process": "calc.damage", "text": "目标:gnoll#1 单位:alde#1 技能:strike"},
    {"seq": 414, "depth": 8, "kind": "trigger", "process": "calc.damage", "text": "触发器[warcry.buff](来源:alde#1)在 on:calc.damage 生效"},
    {"seq": 415, "depth": 8, "kind": "note",    "process": "calc.damage", "text": "乘区 panel=0 item=0 status=原值[0.3]→衰减后0.3 固定=0"},
    {"seq": 416, "depth": 7, "kind": "end",     "process": "calc.damage", "text": "结果=31.2"}
  ]
}

这份小战报要出门三趟:发给客户端展示、写进存储、丢给 worker 做验算.每趟都得先打包:

内存里: BattleReport 结构体
  -> 编码 -> 字节序列(发给客户端 / 写进存储 / 丢给 worker)
  -> 解码 -> 另一台机器上的结构体

这篇要做的事很具体:把这份小战报分别用 JSON、Protobuf、Avro 真的编一遍,一个字节一个字节数,看钱花在哪.

心里再挂个数:它来自的那份完整战报有 1648 条日志,JSON 落盘 562.8KB.小样本上看清了,完整版的账自然对得上.

1.JSON 的三个问题

第一个:体积.小战报压成一行(去掉缩进换行)是 508 字节.但里面真正的信息量——text 里那些字加几个小整数——远没有 508 字节.钱花哪了:

"seq":"depth":"kind":"process":"text":
                  五个字段名, 原样重复了 4 遍
"calc.damage":    同一个值, 重复了 4 遍
"start"/"end"...: kind 明明只有 4 种取值, 却按完整字符串存
seq 413~416:      值恒等于"它是第几条", 纯冗余

放大到 1648 条的完整战报,这笔账变成:

缩进和换行: 压成一行, 562.8KB -> 286.6KB, 一半是空白字符
字段名:     五个字段名重复 1648 遍, 共 62KB
枚举值:     kind 4 种、process 43 种取值, 按字符串存了 1648 遍
真正的信息: 所有 text 加起来 29.1KB

为 29.1KB 的内容付了 562.8KB 的运费.

第二个:类型含糊.

JSON 只有 number, 不分 int 和 float.
战报里 initiative 结果 124.85 和 hp 100 落盘后是同一种东西,
读回来全靠解析方猜; player_id 这类 int64 一旦超过 2^53,
JS 解析直接丢精度: 72057594037927937 读出来变成 72057594037927936.
没有二进制类型, 想内嵌一段压缩快照还得 base64, 体积再涨约 1/3.

第三个:解析贵.

文本 -> 结构体, 要逐字符扫描、转数字、处理转义.
完整战报解析一次要过 57 万个字符;
战斗服高峰期每秒几千条协议, 这部分 CPU 占比会难看到
你抓一次 profile 就能一眼认出来.

小结:JSON 的可读性是给人看的,但体积和解析的代价是机器在付.

顺带一提:"先 gzip 一下"确实立竿见影——完整战报 minify+gzip 后只剩 20.4KB.但压缩解决的是传输和存储,解压回来还是那 28 万字符要逐个解析,CPU 的账一分没少.MessagePack、BSON 这类"二进制 JSON"同理,字段名还在每条记录里,治标不治本.

2.Protobuf:用 tag 号代替字段名

Protobuf 的思路:先写 schema,编码时只传字段编号(tag),不传字段名.给小战报写 schema:

message ReportEntry {
  uint32 seq      = 1;
  uint32 depth    = 2;
  Kind   kind     = 3;   // enum: start=0 note=1 end=2 trigger=3
  string process  = 4;
  string text     = 5;
}
message BattleReport {
  string winner           = 1;
  uint32 rounds           = 2;
  repeated ReportEntry entries = 3;
}

按这份 schema 把小战报编出来:508 字节的 JSON 变成 289 字节.省在哪,拿最短的那条日志(seq=416,"结果=31.2")看字节:

JSON 版, 79 字节:
{"seq":416,"depth":7,"kind":"end","process":"calc.damage","text":"结果=31.2"}

Protobuf 版, 33 字节:
08 a0 03        tag=1(seq),   值 416
10 07           tag=2(depth), 值 7
18 02           tag=3(kind),  值 2(end 在枚举里排第 2)
22 0b calc.damage   tag=4(process), 长度 11, 内容
2a 0b 结果=31.2     tag=5(text),    长度 11, 内容

逐条看省钱的三招:

字段名没了:   "seq" 五个字节 -> 08 一个字节(tag 号)
枚举字符串没了: "end" 连引号 5 字节 -> 02 一个字节
数字不再是字符: 416 三个字符 -> varint 两字节 a0 03

(08 和 a0 03 这些字节具体是怎么算出来的,文末附录有逐位的计算过程,这里先记住"字段名变编号、数字变二进制"这两件事即可.)

字段名只存在于 schema 里,通信双方各存一份.第 1 节算过完整战报字段名占 62KB——在这里直接归零.

兼容演进全靠 tag 号的纪律.拿一个真实的改需求节奏说:上线第 3 周,策划要求日志条目里加"耗时毫秒"字段:

1. 新加字段 = 用新 tag 号(cost_ms = 6).
   旧代码读到不认识的 tag 直接跳过 -> 旧代码能读新数据
2. 新代码读旧数据: 新字段必须 optional 或有默认值
3. tag 号一旦用过, 永远不能复用.
   实习生把删掉的 tag=2 复用给新字段,
   老数据里所有 depth 都会被解读成新字段.
   所以删字段也要把号保留成 reserved
4. 字段类型不是随便改, 只有部分变更安全(比如 int32 -> int64)

小结:Protobuf 用"字段名换成编号 + schema 纪律",换来小体积和兼容演进.

3.Avro:连 tag 号也省了,字节里只剩值

Avro 走得更彻底.先看结果再讲原理——还是那条 seq=416 的日志:

Protobuf 版, 33 字节:
08 a0 03  10 07  18 02  22 0b calc.damage  2a 0b 结果=31.2
↑ 每个值前面都有个 tag 字节, 说明"我是几号字段"

Avro 版, 28 字节:
c0 06  0e  04  16 calc.damage  16 结果=31.2
↑ 没有任何 tag, 五个值按顺序光屁股排队

Avro 编码里只有值:第 1 个是 seq,第 2 个是 depth,第 3 个是 kind……仅此而已.

这时候自然的疑问来了:解码的人拿到 c0 06 0e 04... 这串字节,凭什么知道第 1 个是 seq、第 4 个是 process?

答案:凭 schema——写这份数据时用的那份 schema,必须跟数据一起保存.Avro 的 schema 就是一个 JSON,描述"字段按什么顺序、各是什么类型":

{
  "type": "record",
  "name": "ReportEntry",
  "fields": [
    {"name": "seq",     "type": "int"},
    {"name": "depth",   "type": "int"},
    {"name": "kind",    "type": {"type": "enum", "symbols": ["start","note","end","trigger"]}},
    {"name": "process", "type": "string"},
    {"name": "text",    "type": "string"}
  ]
}

说白了,它就是一张座位表:第 1 个座位坐 seq(int),第 4 个座位坐 process(string).解码方拿着座位表按顺序读字节,才知道每个值是谁.

所以 Avro 的完整工作流程是:

写入方: 按自己手里的 schema(写时 schema)编码,
        把 schema 原文跟数据存在一起(归档文件的文件头),
        或者存进一个公共的 schema 仓库(schema registry)

读取方: 先拿到写时 schema(从文件头或仓库里取),
        再拿自己手里的 schema(读时 schema)跟它对齐:
        字段按名字配对, 我没有的字段跳过, 我多出的字段用默认值

注意"读时对齐"这步是 Avro 独有的.对比一下两者处理"新旧版本"的方式:

Protobuf: 兼容性编码在字节里(tag 号), 写的那一刻就定死了
Avro:     兼容性在读的那一刻现场协商(两份 schema 按字段名对齐)

这换来的好处:百万场战报归档,每条日志再省 5 个 tag 字节,而且整个文件只存一份 schema,摊到每条记录上约等于零.

代价也直白:

字节里没有任何自描述信息.
schema 丢了, 数据文件就是一堆无法解读的死字节——
连"这里有几个字段"都推不出来.
schema 管理从可选项变成硬依赖.

小结:三份编码摆在一起,小战报 JSON 508 字节、Protobuf 289 字节、Avro 263 字节.省的每一步都有来路:JSON 什么都带,Protobuf 把字段名换成 tag,Avro 连 tag 都交给了 schema.

4.三种格式怎么选

维度 JSON Protobuf Avro
小战报实测 508 字节 289 字节 263 字节
体积 最小
需要 schema 不需要 需要 需要, 且读时解析
可读性 人可直接读 不可读 不可读
典型场景 对外 API、配置、调试 服务间 RPC、客户端协议 数据归档、数仓文件

放到游戏场景里,我现在的理解是:

客户端协议、服务间调用 -> Protobuf(高频, 体积和解析都敏感)
运营配置、调试接口     -> JSON(低频, 可读性值钱)
战报/日志长期归档      -> Avro 类(海量, 每个字节都要钱)

有一个例外值得单独说:对外开放的 API.第三方没有你的 schema,JSON 的自描述就成了优点,这种场合别强求二进制.

5.容易踩的坑

第一个坑:

"二进制编码不可读, 排查问题怎么办."

保留一条 JSON 调试通道即可,主链路不必为可读性天天付费.

第二个坑:

以为 Protobuf 改字段是自由的.

加字段自由,改类型和复用 tag 号不自由.schema 纪律破了,兼容就没了.

第三个坑:

选了 Avro 但不建 schema 管理.

没有 schema registry 或文件内嵌 schema,半年后没人解得开旧数据.

第四个坑:

拿 Avro 存异构数据.

Avro 敢连 tag 都不要,靠的是一个前提:每条记录长得一样,字段按座位表顺序必然出现.结构一乱,前提就塌了.

完整战报里就有现成的反例:1648 条日志大部分是 5 个字段,但 128 条 snapshot 额外带一个大 data 字段(整个单位列表).两种 schema 对付它的方式:

Protobuf: optional SnapshotData data = 6;
          没有 data 的 1520 条, 线上 0 字节, tag 干脆不出现.
          "字段可以缺席"是 tag 机制白送的.

Avro:     字段必须写成 union ["null", "SnapshotData"],
          每条记录都要为"选了哪个分支"付一个标记字节,
          且 schema 必须把所有形态提前枚举干净.

一个可选字段还好.但要是 43 种 process 各带一套自己形状的载荷,Avro 就得写一个 43 分支的巨型 union——省下的 tag 字节从 union 标记里加倍吐回去.更疼的是演化:

加第 44 种载荷:
Avro:     改写时 schema 的 union 分支 -> 所有读方 schema 跟着对齐
Protobuf: 发个新 tag 号, 旧代码按类型码跳过, 完事

所以选型表里给 Avro 的场景才只有归档和数仓:记录同构、批量写入、schema 集中管理.真要归档这份战报,现实做法也不是硬上 union,而是归档前先把结构压平成同构记录(比如 data 拆到单独的表)——让数据去凑 Avro 的前提,而不是让 schema 去迁就混乱.

6.总结

所以这篇先记住一句话:

编码格式的取舍, 本质是"把多少信息放进编码里":
JSON 全带, Protobuf 带编号, Avro 什么都不带、全靠 schema.

出题

  1. 本文的小战报 JSON 508 字节、Protobuf 289 字节.省下的 219 字节主要来自哪三招?
  2. 实习生删了 Protobuf 里一个废弃字段,顺手把它的 tag 号给了新字段.上线后会出什么事?
  3. Avro 编码里连 tag 都没有,解码方拿到一串光字节,靠什么知道第 4 个值是 process?这带来什么硬依赖?
  4. 你项目里"客户端协议、运营配置、战报归档"三类数据,各自该选哪种格式?

解答

  1. 字段名换 tag 号("seq" 5 字节 -> 08 一个字节)、枚举字符串换编号("end" 5 字节 -> 02 一个字节)、数字从字符变 varint(416 三字符 -> a0 03 两字节).外加引号冒号花括号这些结构字符全部消失.(对应第 1、2 节)
  2. 老数据里原字段的值会被当成新字段解读,数据全乱.tag 号一旦用过就永远不能复用,删字段要把号保留成 reserved.(对应第 2 节)
  3. 靠写时 schema——那张"座位表"记录了字段顺序和类型,解码方先取到它(从文件头或 schema registry),再和自己的读时 schema 按字段名对齐.代价是 schema 丢了数据就是死字节,schema 管理成为硬依赖.(对应第 3 节)
  4. 客户端协议选 Protobuf(高频,体积和解析敏感),运营配置选 JSON(低频,可读性值钱),战报归档选 Avro 类(海量,编码最小).(对应第 4 节)

下一篇把这套知识用到一个实战案例上:我们项目的战报,是怎么从 774KB 压到 32KB 的.

参考资料

  • Designing Data-Intensive Applications 第 4 章
    JSON/Protobuf/Avro 的三层对比框架来自这一章. 引用它是为了支撑"编码里放多少信息"这条主线,以及 Avro 读时解析 schema 的机制.
  • Protobuf 官方编码文档
    官方对 tag 号编码和兼容规则的权威说明. 引用它是为了支撑"tag 号不复用、类型变更受限"这些具体纪律,建议改 schema 前都翻一遍.
  • Apache Avro 官方文档
    Avro schema resolution 的权威定义在这里. 引用它是为了支撑"写时 schema + 读时 schema 在读方解析"这个核心机制.

附录:08 和 a0 03 是怎么算出来的

正文第 2 节说 "seq":416 编码后是 08 a0 03 三个字节.这里把两部分的计算过程摆开.

tag 字节:08

Protobuf 编码时,每个值前面放一个 tag 字节,同时装两样信息——字段编号和值的类型:

tag 字节 = (字段编号 << 3) | 类型码

常用类型码就两个:
0 = varint(整数)
2 = 带长度前缀的字节串(字符串、嵌套消息)

seq 在 schema 里是 1 号字段,值是整数:

(1 << 3) | 0 = 8 = 0x08

按二进制看:
0000 1___    前 5 位: 字段编号 1
_____ 000    后 3 位: 类型码 0(varint)

拿这个公式验证正文里其余四个 tag,全都对得上:

10 = (2<<3)|0  -> 2 号字段 depth,   varint
18 = (3<<3)|0  -> 3 号字段 kind,    varint
22 = (4<<3)|2  -> 4 号字段 process, 字节串(后面跟长度 0b=11)
2a = (5<<3)|2  -> 5 号字段 text,    字节串(后面跟长度 0b=11)

解码方读到 08,右移 3 位得到字段编号 1,查 schema:"1 号是 seq"——字段名就是这么从线上消失的.

varint 字节:a0 03

先回答一个自然的疑问:416 的十六进制就是 01 a0,两个字节装得下,为什么不直接写进字节流?

因为解码方不知道这个数占几个字节.假设直接写:

字节流: ... 08 01 a0 10 07 ...

解码方读完 tag 08, 知道"接下来是 seq 的值". 然后读几个字节?
读 1 个: seq = 0x01 = 1          x
读 2 个: seq = 0x01a0 = 416      对
读 4 个: seq = 0x01a01007        x 把后面 depth 的字节都吞了

数字不像字符串有长度前缀,字节流里没有任何标记说"这个数到哪结束".要让边界可知,只有三条路:

路 1: 定长.     所有整数一律 4 字节. 边界永远清楚,
                但 depth=7 这种小数字也要花 4 字节.
路 2: 长度前缀. 像字符串那样先写"占 2 字节". 每个数字多付 1 字节.
路 3: varint.   从每个字节抽 1 位当"后面还有没有"的路标,
                数字自己宣告自己的结束位置. 小数字 1 字节, 零额外开销.

Protobuf 选了路 3:牺牲每字节 1 位的容量(8 位只有 7 位装数据),换来"数字自带边界".这就是为什么 416 要重新切成 7 位一组——第 8 位被征用当路标了.规则:

每个字节低 7 位装数据,最高位是续传标志:
1 = 后面还有字节, 0 = 到此为止.
低位组在前.

对 416 编码走一遍:

416 的二进制:  1 1010 0000   (9 位, 一个字节装不下)

从低位切 7 位一组:
低 7 位:  010 0000  = 0x20
剩余高位: 000 0011  = 0x03

低位组在前, 第一组不是最后一组, 最高位置 1:
0x20 | 0x80 = 0xa0
第二组是最后一组, 最高位保持 0:
0x03

所以 416 -> a0 03

再从解码方视角走一遍,看"路标"怎么起作用:

读第 1 个字节 a0 = 1010 0000
  最高位是 1 -> 后面还有, 收下数据位 010 0000
读第 2 个字节 03 = 0000 0011
  最高位是 0 -> 到此为止, 收下数据位 000 0011

拼回去(低位组在前, 后读的放高位):
000 0011 ++ 010 0000 = 1 1010 0000 = 416

全程不需要 schema、不需要长度,读到最高位为 0 自然停下——数字的边界写在数字自己身上.

varint 的妙处是小数字只花一个字节:depth=7 就是 07,kind=2 就是 02,不用像定长 int32 那样永远占 4 个字节.战报里绝大多数数字都很小,这一招把"数字按字符存"的浪费全收了回来.

全部 33 个字节逐字段算一遍

工具备齐(tag 公式 + varint 规则),把正文那条日志的五个字段全部推出来.

字段 1:seq = 416

tag:  (1 << 3) | 0 = 0x08          (1 号字段, 类型码 0 = varint)
值:   varint(416) = a0 03          (上一节刚算的)
产出:  08 a0 03                     (3 字节)

字段 2:depth = 7

tag:  (2 << 3) | 0 = 0x10
值:   7 < 128, varint 一个字节搞定 -> 07
产出:  10 07                        (2 字节)

字段 3:kind = "end"

schema 里枚举定了编号: start=0 note=1 end=2 trigger=3
tag:  (3 << 3) | 0 = 0x18           (enum 线上就是个 varint, 类型码 0)
值:   end -> 2 -> 02
产出:  18 02                        (2 字节)

字段 4:process = "calc.damage"

tag:  (4 << 3) | 2 = 0x22           (字符串, 类型码 2 = 长度+内容)
长度:  "calc.damage" 共 11 字节 -> varint(11) = 0b
内容:  ASCII 原样上线:
       63 61 6c 63 2e 64 61 6d 61 67 65
       c  a  l  c  .  d  a  m  a  g  e
产出:  22 0b 63 61 6c 63 2e 64 61 6d 61 67 65    (13 字节)

字段 5:text = "结果=31.2"

tag:  (5 << 3) | 2 = 0x2a
内容:  UTF-8: 结=e7 bb 93  果=e6 9e 9c  "=31.2"=3d 33 31 2e 32
       共 3+3+5 = 11 字节
长度:  varint(11) = 0b
产出:  2a 0b e7 bb 93 e6 9e 9c 3d 33 31 2e 32    (13 字节)

合计 3+2+2+13+13 = 33 字节,正文的数就是这么来的.

顺带注意类型码的分工:seq 是 uint32、kind 是 enum,schema 类型不同,线上类型码却都是 0——类型码不是字段的数据类型,只负责说清"这个值到哪结束"(varint 读到最高位为 0 为止,字节串按长度跳).值到底解读成数字还是枚举,查 schema.这也是旧代码能跳过陌生新字段的底气:不认识 tag 没关系,按类型码就知道跳几个字节.

追问:kind 不映射成 2,直接按字符串传行不行

行.schema 里把 kind 声明成 string,它就走类型码 2:

方案 A(enum):   Kind kind = 3;     ->  18 02              (2 字节)
方案 B(string): string kind = 3;   ->  1a 03 65 6e 64     (5 字节)
                                        ↑(3<<3)|2=0x1a, 长度3, "end"原文

(小心一个巧合:方案 A 里的 02 是枚举值 2,方案 B 里 1a 的低 3 位是类型码 2,两个 2 毫无关系.)

两种都是合法的 Protobuf.但正文选 enum,三笔账:

体积账: 5 字节 vs 2 字节, kind 在完整战报里出现 1648 次.
        取值只有 4 种却按完整字符串传, 正是第 1 节骂 JSON 的原罪,
        string 版等于把这毛病原样搬进 Protobuf.
约束账: enum 编译期钉死取值, 代码里拼错编不过;
        string 版 "End"、"ennd" 都能塞进去, 坏数据到消费端才炸.
解析账: enum 直接 switch 整数; string 还得做一次比较或查表.

什么时候该用 string?取值集合不固定、schema 管不住的时候——正文里 process 有 43 种还在随版本增加,它就是 string;kind 的 4 种是引擎写死的生命周期状态,enum 合适.

这个选择其实是全文主线缩小到单个字段上的变奏:enum vs string,就是"'end'->2 这张映射表放 schema 里还是放数据里"——和 JSON vs Protobuf 的分野一模一样.

对账:79 字节和 33 字节的差在哪

两个字符串的内容(11+11=22 字节)在 JSON 和 Protobuf 里一模一样,一个字节都省不了.省的全是"包装":

JSON:     79 = 22(字符串内容) + 57(字段名/引号/冒号/逗号/花括号/数字字符)
Protobuf: 33 = 22(字符串内容) + 11(5 个 tag + 2 个长度 + 4 个数字值字节)

包装从 57 字节缩到 11 字节.放大到 1648 条日志,就是正文里那笔"562.8KB 只装着 29.1KB 内容"的账.

顺带一提:正文第 3 节 Avro 版里 416 编码成 c0 06,和这里的 a0 03 不一样,是因为 Avro 的整数多做了一步 ZigZag 变换(把 416 映射成 832 再走 varint),让负数也能编得短.感兴趣可以拿 832 用上面的规则验算一遍 c0 06.

posted @ 2026-09-01 23:30  养肥胖虎  阅读(23)  评论(0)    收藏  举报