[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"consumer-news-detail-859":3,"consumer-news-interaction-859":38,"consumer-news-related-859":41},{"detail":4,"item":34},{"card":5,"schemaVersion":21,"fields":22,"content":28},{"id":6,"kind":7,"targetType":8,"targetId":9,"subtype":7,"typeLabel":10,"title":11,"subtitle":12,"summary":13,"coverUrl":14,"badgeText":14,"href":15,"sourceName":12,"meta":16,"metrics":18,"tags":19,"resolved":20},"NEWS_ARTICLE:859","news","NEWS_ARTICLE",859,"资讯","DDIA(六):编码格式的取舍与JSON战报体积优化方案","博客园","DDIA(六):编码格式的取舍与JSON战报体积优化方案 好家伙, 最近在研究战报的数据结构和体积压缩:于是去翻 DDIA 第 4 章,正好整章讲的就是这件事: 数据要跨进程、跨服务、落盘存档, 就得从内存结构体变成字节序列——这就是编码. 编码格式怎么选,直接影响包体大小、解析开销,以及未来改字段","","\u002Fnews\u002F859",[17],"2026",{},[],true,"consumer-content-detail-v1",{"sourceName":12,"authorName":23,"summary":13,"description":13,"publishTime":24,"updateTime":25,"sourceUrl":26,"language":27},"养肥胖虎","2026-09-01T23:30","2026-09-02T20:37:50","https:\u002F\u002Fwww.cnblogs.com\u002FFatTiger4399\u002Fp\u002F22798096","中文",{"format":29,"policy":30,"normalized":20,"html":31,"text":32,"wordCount":33,"hasBody":20},"HTML","NEWS_CONTENT_V1","DDIA(六):编码格式的取舍与JSON战报体积优化方案\n\u003Cp>好家伙,\u003C\u002Fp>\n\u003Cp>最近在研究战报的数据结构和体积压缩:于是去翻 DDIA 第 4 章,正好整章讲的就是这件事:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>数据要跨进程、跨服务、落盘存档,\n就得从内存结构体变成字节序列——这就是编码.\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>编码格式怎么选,直接影响包体大小、解析开销,以及未来改字段时会不会炸.\u003C\u002Fp>\n\u003Cp>这篇拿三种最常见的格式,对着我这份战报真刀真枪比一遍:JSON、Protobuf、Avro.\u003C\u002Fp>\n\u003Ch2>0.背景:一份战报要出门,得先\"打包\"\u003C\u002Fh2>\n\u003Cp>先把标本摆上桌.下面是一份最小可用战报——从一场真实战斗的战报里抽出来的,只保留胜负、回合数,和\"阿尔德一刀砍中豺狼人\"这一次伤害计算的 4 条过程日志:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>{\n  \"winner\": \"player\",\n  \"rounds\": 6,\n  \"entries\": [\n    {\"seq\": 413, \"depth\": 7, \"kind\": \"start\",   \"process\": \"calc.damage\", \"text\": \"目标:gnoll#1 单位:alde#1 技能:strike\"},\n    {\"seq\": 414, \"depth\": 8, \"kind\": \"trigger\", \"process\": \"calc.damage\", \"text\": \"触发器[warcry.buff]（来源:alde#1）在 on:calc.damage 生效\"},\n    {\"seq\": 415, \"depth\": 8, \"kind\": \"note\",    \"process\": \"calc.damage\", \"text\": \"乘区 panel=0 item=0 status=原值[0.3]→衰减后0.3 固定=0\"},\n    {\"seq\": 416, \"depth\": 7, \"kind\": \"end\",     \"process\": \"calc.damage\", \"text\": \"结果=31.2\"}\n  ]\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>这份小战报要出门三趟:发给客户端展示、写进存储、丢给 worker 做验算.每趟都得先打包:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>内存里: BattleReport 结构体\n  -&gt; 编码 -&gt; 字节序列(发给客户端 \u002F 写进存储 \u002F 丢给 worker)\n  -&gt; 解码 -&gt; 另一台机器上的结构体\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>这篇要做的事很具体:把这份小战报分别用 JSON、Protobuf、Avro 真的编一遍,一个字节一个字节数,看钱花在哪.\u003C\u002Fp>\n\u003Cp>心里再挂个数:它来自的那份完整战报有 1648 条日志,JSON 落盘 562.8KB.小样本上看清了,完整版的账自然对得上.\u003C\u002Fp>\n\u003Ch2>1.JSON 的三个问题\u003C\u002Fh2>\n\u003Cp>第一个:体积.小战报压成一行(去掉缩进换行)是 508 字节.但里面真正的信息量——text 里那些字加几个小整数——远没有 508 字节.钱花哪了:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>\"seq\":\"depth\":\"kind\":\"process\":\"text\":\n                  五个字段名, 原样重复了 4 遍\n\"calc.damage\":    同一个值, 重复了 4 遍\n\"start\"\u002F\"end\"...: kind 明明只有 4 种取值, 却按完整字符串存\nseq 413~416:      值恒等于\"它是第几条\", 纯冗余\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>放大到 1648 条的完整战报,这笔账变成:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>缩进和换行: 压成一行, 562.8KB -&gt; 286.6KB, 一半是空白字符\n字段名:     五个字段名重复 1648 遍, 共 62KB\n枚举值:     kind 4 种、process 43 种取值, 按字符串存了 1648 遍\n真正的信息: 所有 text 加起来 29.1KB\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>为 29.1KB 的内容付了 562.8KB 的运费.\u003C\u002Fp>\n\u003Cp>第二个:类型含糊.\u003C\u002Fp>\n\u003Cpre>\u003Ccode>JSON 只有 number, 不分 int 和 float.\n战报里 initiative 结果 124.85 和 hp 100 落盘后是同一种东西,\n读回来全靠解析方猜; player_id 这类 int64 一旦超过 2^53,\nJS 解析直接丢精度: 72057594037927937 读出来变成 72057594037927936.\n没有二进制类型, 想内嵌一段压缩快照还得 base64, 体积再涨约 1\u002F3.\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>第三个:解析贵.\u003C\u002Fp>\n\u003Cpre>\u003Ccode>文本 -&gt; 结构体, 要逐字符扫描、转数字、处理转义.\n完整战报解析一次要过 57 万个字符;\n战斗服高峰期每秒几千条协议, 这部分 CPU 占比会难看到\n你抓一次 profile 就能一眼认出来.\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>小结:JSON 的可读性是给人看的,但体积和解析的代价是机器在付.\u003C\u002Fp>\n\u003Cp>顺带一提:\"先 gzip 一下\"确实立竿见影——完整战报 minify+gzip 后只剩 20.4KB.但压缩解决的是传输和存储,解压回来还是那 28 万字符要逐个解析,CPU 的账一分没少.MessagePack、BSON 这类\"二进制 JSON\"同理,字段名还在每条记录里,治标不治本.\u003C\u002Fp>\n\u003Ch2>2.Protobuf:用 tag 号代替字段名\u003C\u002Fh2>\n\u003Cp>Protobuf 的思路:先写 schema,编码时只传字段编号(tag),不传字段名.给小战报写 schema:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>message ReportEntry {\n  uint32 seq      = 1;\n  uint32 depth    = 2;\n  Kind   kind     = 3;   \u002F\u002F enum: start=0 note=1 end=2 trigger=3\n  string process  = 4;\n  string text     = 5;\n}\nmessage BattleReport {\n  string winner           = 1;\n  uint32 rounds           = 2;\n  repeated ReportEntry entries = 3;\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>按这份 schema 把小战报编出来:508 字节的 JSON 变成 289 字节.省在哪,拿最短的那条日志(seq=416,\"结果=31.2\")看字节:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>JSON 版, 79 字节:\n{\"seq\":416,\"depth\":7,\"kind\":\"end\",\"process\":\"calc.damage\",\"text\":\"结果=31.2\"}\n\nProtobuf 版, 33 字节:\n08 a0 03        tag=1(seq),   值 416\n10 07           tag=2(depth), 值 7\n18 02           tag=3(kind),  值 2(end 在枚举里排第 2)\n22 0b calc.damage   tag=4(process), 长度 11, 内容\n2a 0b 结果=31.2     tag=5(text),    长度 11, 内容\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>逐条看省钱的三招:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>字段名没了:   \"seq\" 五个字节 -&gt; 08 一个字节(tag 号)\n枚举字符串没了: \"end\" 连引号 5 字节 -&gt; 02 一个字节\n数字不再是字符: 416 三个字符 -&gt; varint 两字节 a0 03\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>(08 和 a0 03 这些字节具体是怎么算出来的,文末附录有逐位的计算过程,这里先记住\"字段名变编号、数字变二进制\"这两件事即可.)\u003C\u002Fp>\n\u003Cp>字段名只存在于 schema 里,通信双方各存一份.第 1 节算过完整战报字段名占 62KB——在这里直接归零.\u003C\u002Fp>\n\u003Cp>兼容演进全靠 tag 号的纪律.拿一个真实的改需求节奏说:上线第 3 周,策划要求日志条目里加\"耗时毫秒\"字段:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>1. 新加字段 = 用新 tag 号(cost_ms = 6).\n   旧代码读到不认识的 tag 直接跳过 -&gt; 旧代码能读新数据\n2. 新代码读旧数据: 新字段必须 optional 或有默认值\n3. tag 号一旦用过, 永远不能复用.\n   实习生把删掉的 tag=2 复用给新字段,\n   老数据里所有 depth 都会被解读成新字段.\n   所以删字段也要把号保留成 reserved\n4. 字段类型不是随便改, 只有部分变更安全(比如 int32 -&gt; int64)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>小结:Protobuf 用\"字段名换成编号 + schema 纪律\",换来小体积和兼容演进.\u003C\u002Fp>\n\u003Ch2>3.Avro:连 tag 号也省了,字节里只剩值\u003C\u002Fh2>\n\u003Cp>Avro 走得更彻底.先看结果再讲原理——还是那条 seq=416 的日志:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>Protobuf 版, 33 字节:\n08 a0 03  10 07  18 02  22 0b calc.damage  2a 0b 结果=31.2\n↑ 每个值前面都有个 tag 字节, 说明\"我是几号字段\"\n\nAvro 版, 28 字节:\nc0 06  0e  04  16 calc.damage  16 结果=31.2\n↑ 没有任何 tag, 五个值按顺序光屁股排队\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Avro 编码里只有值:第 1 个是 seq,第 2 个是 depth,第 3 个是 kind……仅此而已.\u003C\u002Fp>\n\u003Cp>这时候自然的疑问来了:解码的人拿到 \u003Ccode>c0 06 0e 04...\u003C\u002Fcode> 这串字节,凭什么知道第 1 个是 seq、第 4 个是 process?\u003C\u002Fp>\n\u003Cp>答案:凭 schema——写这份数据时用的那份 schema,必须跟数据一起保存.Avro 的 schema 就是一个 JSON,描述\"字段按什么顺序、各是什么类型\":\u003C\u002Fp>\n\u003Cpre>\u003Ccode>{\n  \"type\": \"record\",\n  \"name\": \"ReportEntry\",\n  \"fields\": [\n    {\"name\": \"seq\",     \"type\": \"int\"},\n    {\"name\": \"depth\",   \"type\": \"int\"},\n    {\"name\": \"kind\",    \"type\": {\"type\": \"enum\", \"symbols\": [\"start\",\"note\",\"end\",\"trigger\"]}},\n    {\"name\": \"process\", \"type\": \"string\"},\n    {\"name\": \"text\",    \"type\": \"string\"}\n  ]\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>说白了,它就是一张座位表:第 1 个座位坐 seq(int),第 4 个座位坐 process(string).解码方拿着座位表按顺序读字节,才知道每个值是谁.\u003C\u002Fp>\n\u003Cp>所以 Avro 的完整工作流程是:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>写入方: 按自己手里的 schema(写时 schema)编码,\n        把 schema 原文跟数据存在一起(归档文件的文件头),\n        或者存进一个公共的 schema 仓库(schema registry)\n\n读取方: 先拿到写时 schema(从文件头或仓库里取),\n        再拿自己手里的 schema(读时 schema)跟它对齐:\n        字段按名字配对, 我没有的字段跳过, 我多出的字段用默认值\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>注意\"读时对齐\"这步是 Avro 独有的.对比一下两者处理\"新旧版本\"的方式:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>Protobuf: 兼容性编码在字节里(tag 号), 写的那一刻就定死了\nAvro:     兼容性在读的那一刻现场协商(两份 schema 按字段名对齐)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>这换来的好处:百万场战报归档,每条日志再省 5 个 tag 字节,而且整个文件只存一份 schema,摊到每条记录上约等于零.\u003C\u002Fp>\n\u003Cp>代价也直白:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>字节里没有任何自描述信息.\nschema 丢了, 数据文件就是一堆无法解读的死字节——\n连\"这里有几个字段\"都推不出来.\nschema 管理从可选项变成硬依赖.\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>小结:三份编码摆在一起,小战报 JSON 508 字节、Protobuf 289 字节、Avro 263 字节.省的每一步都有来路:JSON 什么都带,Protobuf 把字段名换成 tag,Avro 连 tag 都交给了 schema.\u003C\u002Fp>\n\u003Ch2>4.三种格式怎么选\u003C\u002Fh2>\n\u003Ctable>\n \u003Cthead>\n  \u003Ctr>\n   \u003Cth>维度\u003C\u002Fth>\n   \u003Cth>JSON\u003C\u002Fth>\n   \u003Cth>Protobuf\u003C\u002Fth>\n   \u003Cth>Avro\u003C\u002Fth>\n  \u003C\u002Ftr>\n \u003C\u002Fthead>\n \u003Ctbody>\n  \u003Ctr>\n   \u003Ctd>小战报实测\u003C\u002Ftd>\n   \u003Ctd>508 字节\u003C\u002Ftd>\n   \u003Ctd>289 字节\u003C\u002Ftd>\n   \u003Ctd>263 字节\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>体积\u003C\u002Ftd>\n   \u003Ctd>大\u003C\u002Ftd>\n   \u003Ctd>小\u003C\u002Ftd>\n   \u003Ctd>最小\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>需要 schema\u003C\u002Ftd>\n   \u003Ctd>不需要\u003C\u002Ftd>\n   \u003Ctd>需要\u003C\u002Ftd>\n   \u003Ctd>需要, 且读时解析\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>可读性\u003C\u002Ftd>\n   \u003Ctd>人可直接读\u003C\u002Ftd>\n   \u003Ctd>不可读\u003C\u002Ftd>\n   \u003Ctd>不可读\u003C\u002Ftd>\n  \u003C\u002Ftr>\n  \u003Ctr>\n   \u003Ctd>典型场景\u003C\u002Ftd>\n   \u003Ctd>对外 API、配置、调试\u003C\u002Ftd>\n   \u003Ctd>服务间 RPC、客户端协议\u003C\u002Ftd>\n   \u003Ctd>数据归档、数仓文件\u003C\u002Ftd>\n  \u003C\u002Ftr>\n \u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>放到游戏场景里,我现在的理解是:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>客户端协议、服务间调用 -&gt; Protobuf(高频, 体积和解析都敏感)\n运营配置、调试接口     -&gt; JSON(低频, 可读性值钱)\n战报\u002F日志长期归档      -&gt; Avro 类(海量, 每个字节都要钱)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>有一个例外值得单独说:对外开放的 API.第三方没有你的 schema,JSON 的自描述就成了优点,这种场合别强求二进制.\u003C\u002Fp>\n\u003Ch2>5.容易踩的坑\u003C\u002Fh2>\n\u003Cp>第一个坑:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>\"二进制编码不可读, 排查问题怎么办.\"\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>保留一条 JSON 调试通道即可,主链路不必为可读性天天付费.\u003C\u002Fp>\n\u003Cp>第二个坑:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>以为 Protobuf 改字段是自由的.\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>加字段自由,改类型和复用 tag 号不自由.schema 纪律破了,兼容就没了.\u003C\u002Fp>\n\u003Cp>第三个坑:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>选了 Avro 但不建 schema 管理.\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>没有 schema registry 或文件内嵌 schema,半年后没人解得开旧数据.\u003C\u002Fp>\n\u003Cp>第四个坑:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>拿 Avro 存异构数据.\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Avro 敢连 tag 都不要,靠的是一个前提:每条记录长得一样,字段按座位表顺序必然出现.结构一乱,前提就塌了.\u003C\u002Fp>\n\u003Cp>完整战报里就有现成的反例:1648 条日志大部分是 5 个字段,但 128 条 snapshot 额外带一个大 data 字段(整个单位列表).两种 schema 对付它的方式:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>Protobuf: optional SnapshotData data = 6;\n          没有 data 的 1520 条, 线上 0 字节, tag 干脆不出现.\n          \"字段可以缺席\"是 tag 机制白送的.\n\nAvro:     字段必须写成 union [\"null\", \"SnapshotData\"],\n          每条记录都要为\"选了哪个分支\"付一个标记字节,\n          且 schema 必须把所有形态提前枚举干净.\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>一个可选字段还好.但要是 43 种 process 各带一套自己形状的载荷,Avro 就得写一个 43 分支的巨型 union——省下的 tag 字节从 union 标记里加倍吐回去.更疼的是演化:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>加第 44 种载荷:\nAvro:     改写时 schema 的 union 分支 -&gt; 所有读方 schema 跟着对齐\nProtobuf: 发个新 tag 号, 旧代码按类型码跳过, 完事\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>所以选型表里给 Avro 的场景才只有归档和数仓:记录同构、批量写入、schema 集中管理.真要归档这份战报,现实做法也不是硬上 union,而是归档前先把结构压平成同构记录(比如 data 拆到单独的表)——让数据去凑 Avro 的前提,而不是让 schema 去迁就混乱.\u003C\u002Fp>\n\u003Ch2>6.总结\u003C\u002Fh2>\n\u003Cp>所以这篇先记住一句话:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>编码格式的取舍, 本质是\"把多少信息放进编码里\":\nJSON 全带, Protobuf 带编号, Avro 什么都不带、全靠 schema.\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2>出题\u003C\u002Fh2>\n\u003Col>\n \u003Cli>本文的小战报 JSON 508 字节、Protobuf 289 字节.省下的 219 字节主要来自哪三招?\u003C\u002Fli>\n \u003Cli>实习生删了 Protobuf 里一个废弃字段,顺手把它的 tag 号给了新字段.上线后会出什么事?\u003C\u002Fli>\n \u003Cli>Avro 编码里连 tag 都没有,解码方拿到一串光字节,靠什么知道第 4 个值是 process?这带来什么硬依赖?\u003C\u002Fli>\n \u003Cli>你项目里\"客户端协议、运营配置、战报归档\"三类数据,各自该选哪种格式?\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch2>解答\u003C\u002Fh2>\n\u003Col>\n \u003Cli>字段名换 tag 号(\"seq\" 5 字节 -&gt; 08 一个字节)、枚举字符串换编号(\"end\" 5 字节 -&gt; 02 一个字节)、数字从字符变 varint(416 三字符 -&gt; a0 03 两字节).外加引号冒号花括号这些结构字符全部消失.(对应第 1、2 节)\u003C\u002Fli>\n \u003Cli>老数据里原字段的值会被当成新字段解读,数据全乱.tag 号一旦用过就永远不能复用,删字段要把号保留成 reserved.(对应第 2 节)\u003C\u002Fli>\n \u003Cli>靠写时 schema——那张\"座位表\"记录了字段顺序和类型,解码方先取到它(从文件头或 schema registry),再和自己的读时 schema 按字段名对齐.代价是 schema 丢了数据就是死字节,schema 管理成为硬依赖.(对应第 3 节)\u003C\u002Fli>\n \u003Cli>客户端协议选 Protobuf(高频,体积和解析敏感),运营配置选 JSON(低频,可读性值钱),战报归档选 Avro 类(海量,编码最小).(对应第 4 节)\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>下一篇把这套知识用到一个实战案例上:我们项目的战报,是怎么从 774KB 压到 32KB 的.\u003C\u002Fp>\n\u003Ch2>参考资料\u003C\u002Fh2>\n\u003Cul>\n \u003Cli>\u003Ca href=\"https:\u002F\u002Fdataintensive.net\u002F\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">Designing Data-Intensive Applications\u003C\u002Fa> 第 4 章\u003Cbr>\n   JSON\u002FProtobuf\u002FAvro 的三层对比框架来自这一章. 引用它是为了支撑\"编码里放多少信息\"这条主线,以及 Avro 读时解析 schema 的机制.\u003C\u002Fli>\n \u003Cli>\u003Ca href=\"https:\u002F\u002Fprotobuf.dev\u002Fprogramming-guides\u002Fencoding\u002F\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">Protobuf 官方编码文档\u003C\u002Fa>\u003Cbr>\n   官方对 tag 号编码和兼容规则的权威说明. 引用它是为了支撑\"tag 号不复用、类型变更受限\"这些具体纪律,建议改 schema 前都翻一遍.\u003C\u002Fli>\n \u003Cli>\u003Ca href=\"https:\u002F\u002Favro.apache.org\u002Fdocs\u002F\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">Apache Avro 官方文档\u003C\u002Fa>\u003Cbr>\n   Avro schema resolution 的权威定义在这里. 引用它是为了支撑\"写时 schema + 读时 schema 在读方解析\"这个核心机制.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>附录:08 和 a0 03 是怎么算出来的\u003C\u002Fh2>\n\u003Cp>正文第 2 节说 \u003Ccode>\"seq\":416\u003C\u002Fcode> 编码后是 \u003Ccode>08 a0 03\u003C\u002Fcode> 三个字节.这里把两部分的计算过程摆开.\u003C\u002Fp>\n\u003Ch3>tag 字节:08\u003C\u002Fh3>\n\u003Cp>Protobuf 编码时,每个值前面放一个 tag 字节,同时装两样信息——字段编号和值的类型:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>tag 字节 = (字段编号 &lt;&lt; 3) | 类型码\n\n常用类型码就两个:\n0 = varint(整数)\n2 = 带长度前缀的字节串(字符串、嵌套消息)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>seq 在 schema 里是 1 号字段,值是整数:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>(1 &lt;&lt; 3) | 0 = 8 = 0x08\n\n按二进制看:\n0000 1___    前 5 位: 字段编号 1\n_____ 000    后 3 位: 类型码 0(varint)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>拿这个公式验证正文里其余四个 tag,全都对得上:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>10 = (2&lt;&lt;3)|0  -&gt; 2 号字段 depth,   varint\n18 = (3&lt;&lt;3)|0  -&gt; 3 号字段 kind,    varint\n22 = (4&lt;&lt;3)|2  -&gt; 4 号字段 process, 字节串(后面跟长度 0b=11)\n2a = (5&lt;&lt;3)|2  -&gt; 5 号字段 text,    字节串(后面跟长度 0b=11)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>解码方读到 \u003Ccode>08\u003C\u002Fcode>,右移 3 位得到字段编号 1,查 schema:\"1 号是 seq\"——字段名就是这么从线上消失的.\u003C\u002Fp>\n\u003Ch3>varint 字节:a0 03\u003C\u002Fh3>\n\u003Cp>先回答一个自然的疑问:416 的十六进制就是 \u003Ccode>01 a0\u003C\u002Fcode>,两个字节装得下,为什么不直接写进字节流?\u003C\u002Fp>\n\u003Cp>因为解码方不知道这个数占几个字节.假设直接写:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>字节流: ... 08 01 a0 10 07 ...\n\n解码方读完 tag 08, 知道\"接下来是 seq 的值\". 然后读几个字节?\n读 1 个: seq = 0x01 = 1          x\n读 2 个: seq = 0x01a0 = 416      对\n读 4 个: seq = 0x01a01007        x 把后面 depth 的字节都吞了\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>数字不像字符串有长度前缀,字节流里没有任何标记说\"这个数到哪结束\".要让边界可知,只有三条路:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>路 1: 定长.     所有整数一律 4 字节. 边界永远清楚,\n                但 depth=7 这种小数字也要花 4 字节.\n路 2: 长度前缀. 像字符串那样先写\"占 2 字节\". 每个数字多付 1 字节.\n路 3: varint.   从每个字节抽 1 位当\"后面还有没有\"的路标,\n                数字自己宣告自己的结束位置. 小数字 1 字节, 零额外开销.\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Protobuf 选了路 3:牺牲每字节 1 位的容量(8 位只有 7 位装数据),换来\"数字自带边界\".这就是为什么 416 要重新切成 7 位一组——第 8 位被征用当路标了.规则:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>每个字节低 7 位装数据,最高位是续传标志:\n1 = 后面还有字节, 0 = 到此为止.\n低位组在前.\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>对 416 编码走一遍:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>416 的二进制:  1 1010 0000   (9 位, 一个字节装不下)\n\n从低位切 7 位一组:\n低 7 位:  010 0000  = 0x20\n剩余高位: 000 0011  = 0x03\n\n低位组在前, 第一组不是最后一组, 最高位置 1:\n0x20 | 0x80 = 0xa0\n第二组是最后一组, 最高位保持 0:\n0x03\n\n所以 416 -&gt; a0 03\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>再从解码方视角走一遍,看\"路标\"怎么起作用:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>读第 1 个字节 a0 = 1010 0000\n  最高位是 1 -&gt; 后面还有, 收下数据位 010 0000\n读第 2 个字节 03 = 0000 0011\n  最高位是 0 -&gt; 到此为止, 收下数据位 000 0011\n\n拼回去(低位组在前, 后读的放高位):\n000 0011 ++ 010 0000 = 1 1010 0000 = 416\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>全程不需要 schema、不需要长度,读到最高位为 0 自然停下——数字的边界写在数字自己身上.\u003C\u002Fp>\n\u003Cp>varint 的妙处是小数字只花一个字节:depth=7 就是 \u003Ccode>07\u003C\u002Fcode>,kind=2 就是 \u003Ccode>02\u003C\u002Fcode>,不用像定长 int32 那样永远占 4 个字节.战报里绝大多数数字都很小,这一招把\"数字按字符存\"的浪费全收了回来.\u003C\u002Fp>\n\u003Ch3>全部 33 个字节逐字段算一遍\u003C\u002Fh3>\n\u003Cp>工具备齐(tag 公式 + varint 规则),把正文那条日志的五个字段全部推出来.\u003C\u002Fp>\n\u003Cp>字段 1:seq = 416\u003C\u002Fp>\n\u003Cpre>\u003Ccode>tag:  (1 &lt;&lt; 3) | 0 = 0x08          (1 号字段, 类型码 0 = varint)\n值:   varint(416) = a0 03          (上一节刚算的)\n产出:  08 a0 03                     (3 字节)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>字段 2:depth = 7\u003C\u002Fp>\n\u003Cpre>\u003Ccode>tag:  (2 &lt;&lt; 3) | 0 = 0x10\n值:   7 &lt; 128, varint 一个字节搞定 -&gt; 07\n产出:  10 07                        (2 字节)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>字段 3:kind = \"end\"\u003C\u002Fp>\n\u003Cpre>\u003Ccode>schema 里枚举定了编号: start=0 note=1 end=2 trigger=3\ntag:  (3 &lt;&lt; 3) | 0 = 0x18           (enum 线上就是个 varint, 类型码 0)\n值:   end -&gt; 2 -&gt; 02\n产出:  18 02                        (2 字节)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>字段 4:process = \"calc.damage\"\u003C\u002Fp>\n\u003Cpre>\u003Ccode>tag:  (4 &lt;&lt; 3) | 2 = 0x22           (字符串, 类型码 2 = 长度+内容)\n长度:  \"calc.damage\" 共 11 字节 -&gt; varint(11) = 0b\n内容:  ASCII 原样上线:\n       63 61 6c 63 2e 64 61 6d 61 67 65\n       c  a  l  c  .  d  a  m  a  g  e\n产出:  22 0b 63 61 6c 63 2e 64 61 6d 61 67 65    (13 字节)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>字段 5:text = \"结果=31.2\"\u003C\u002Fp>\n\u003Cpre>\u003Ccode>tag:  (5 &lt;&lt; 3) | 2 = 0x2a\n内容:  UTF-8: 结=e7 bb 93  果=e6 9e 9c  \"=31.2\"=3d 33 31 2e 32\n       共 3+3+5 = 11 字节\n长度:  varint(11) = 0b\n产出:  2a 0b e7 bb 93 e6 9e 9c 3d 33 31 2e 32    (13 字节)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>合计 3+2+2+13+13 = 33 字节,正文的数就是这么来的.\u003C\u002Fp>\n\u003Cp>顺带注意类型码的分工:seq 是 uint32、kind 是 enum,schema 类型不同,线上类型码却都是 0——类型码不是字段的数据类型,只负责说清\"这个值到哪结束\"(varint 读到最高位为 0 为止,字节串按长度跳).值到底解读成数字还是枚举,查 schema.这也是旧代码能跳过陌生新字段的底气:不认识 tag 没关系,按类型码就知道跳几个字节.\u003C\u002Fp>\n\u003Ch3>追问:kind 不映射成 2,直接按字符串传行不行\u003C\u002Fh3>\n\u003Cp>行.schema 里把 kind 声明成 string,它就走类型码 2:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>方案 A(enum):   Kind kind = 3;     -&gt;  18 02              (2 字节)\n方案 B(string): string kind = 3;   -&gt;  1a 03 65 6e 64     (5 字节)\n                                        ↑(3&lt;&lt;3)|2=0x1a, 长度3, \"end\"原文\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>(小心一个巧合:方案 A 里的 02 是枚举值 2,方案 B 里 1a 的低 3 位是类型码 2,两个 2 毫无关系.)\u003C\u002Fp>\n\u003Cp>两种都是合法的 Protobuf.但正文选 enum,三笔账:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>体积账: 5 字节 vs 2 字节, kind 在完整战报里出现 1648 次.\n        取值只有 4 种却按完整字符串传, 正是第 1 节骂 JSON 的原罪,\n        string 版等于把这毛病原样搬进 Protobuf.\n约束账: enum 编译期钉死取值, 代码里拼错编不过;\n        string 版 \"End\"、\"ennd\" 都能塞进去, 坏数据到消费端才炸.\n解析账: enum 直接 switch 整数; string 还得做一次比较或查表.\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>什么时候该用 string?取值集合不固定、schema 管不住的时候——正文里 process 有 43 种还在随版本增加,它就是 string;kind 的 4 种是引擎写死的生命周期状态,enum 合适.\u003C\u002Fp>\n\u003Cp>这个选择其实是全文主线缩小到单个字段上的变奏:enum vs string,就是\"'end'-&gt;2 这张映射表放 schema 里还是放数据里\"——和 JSON vs Protobuf 的分野一模一样.\u003C\u002Fp>\n\u003Ch3>对账:79 字节和 33 字节的差在哪\u003C\u002Fh3>\n\u003Cp>两个字符串的内容(11+11=22 字节)在 JSON 和 Protobuf 里一模一样,一个字节都省不了.省的全是\"包装\":\u003C\u002Fp>\n\u003Cpre>\u003Ccode>JSON:     79 = 22(字符串内容) + 57(字段名\u002F引号\u002F冒号\u002F逗号\u002F花括号\u002F数字字符)\nProtobuf: 33 = 22(字符串内容) + 11(5 个 tag + 2 个长度 + 4 个数字值字节)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>包装从 57 字节缩到 11 字节.放大到 1648 条日志,就是正文里那笔\"562.8KB 只装着 29.1KB 内容\"的账.\u003C\u002Fp>\n\u003Cp>顺带一提:正文第 3 节 Avro 版里 416 编码成 \u003Ccode>c0 06\u003C\u002Fcode>,和这里的 \u003Ccode>a0 03\u003C\u002Fcode> 不一样,是因为 Avro 的整数多做了一步 ZigZag 变换(把 416 映射成 832 再走 varint),让负数也能编得短.感兴趣可以拿 832 用上面的规则验算一遍 \u003Ccode>c0 06\u003C\u002Fcode>.\u003C\u002Fp>","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 结构体 -> 编码 -> 字节序列(发给客户端 \u002F 写进存储 \u002F 丢给 worker) -> 解码 -> 另一台机器上的结构体 这篇要做的事很具体:把这份小战报分别用 JSON、Protobuf、Avro 真的编一遍,一个字节一个字节数,看钱花在哪. 心里再挂个数:它来自的那份完整战报有 1648 条日志,JSON 落盘 562.8KB.小样本上看清了,完整版的账自然对得上. 1.JSON 的三个问题 第一个:体积.小战报压成一行(去掉缩进换行)是 508 字节.但里面真正的信息量——text 里那些字加几个小整数——远没有 508 字节.钱花哪了: \"seq\":\"depth\":\"kind\":\"process\":\"text\": 五个字段名, 原样重复了 4 遍 \"calc.damage\": 同一个值, 重复了 4 遍 \"start\"\u002F\"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\u002F3. 第三个:解析贵. 文本 -> 结构体, 要逐字符扫描、转数字、处理转义. 完整战报解析一次要过 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; \u002F\u002F 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(低频, 可读性值钱) 战报\u002F日志长期归档 -> 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. 出题 本文的小战报 JSON 508 字节、Protobuf 289 字节.省下的 219 字节主要来自哪三招? 实习生删了 Protobuf 里一个废弃字段,顺手把它的 tag 号给了新字段.上线后会出什么事? Avro 编码里连 tag 都没有,解码方拿到一串光字节,靠什么知道第 4 个值是 process?这带来什么硬依赖? 你项目里\"客户端协议、运营配置、战报归档\"三类数据,各自该选哪种格式? 解答 字段名换 tag 号(\"seq\" 5 字节 -> 08 一个字节)、枚举字符串换编号(\"end\" 5 字节 -> 02 一个字节)、数字从字符变 varint(416 三字符 -> a0 03 两字节).外加引号冒号花括号这些结构字符全部消失.(对应第 1、2 节) 老数据里原字段的值会被当成新字段解读,数据全乱.tag 号一旦用过就永远不能复用,删字段要把号保留成 reserved.(对应第 2 节) 靠写时 schema——那张\"座位表\"记录了字段顺序和类型,解码方先取到它(从文件头或 schema registry),再和自己的读时 schema 按字段名对齐.代价是 schema 丢了数据就是死字节,schema 管理成为硬依赖.(对应第 3 节) 客户端协议选 Protobuf(高频,体积和解析敏感),运营配置选 JSON(低频,可读性值钱),战报归档选 Avro 类(海量,编码最小).(对应第 4 节) 下一篇把这套知识用到一个实战案例上:我们项目的战报,是怎么从 774KB 压到 32KB 的. 参考资料 Designing Data-Intensive Applications 第 4 章 JSON\u002FProtobuf\u002FAvro 的三层对比框架来自这一章. 引用它是为了支撑\"编码里放多少信息\"这条主线,以及 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 字节 = (字段编号 \u003C\u003C 3) | 类型码 常用类型码就两个: 0 = varint(整数) 2 = 带长度前缀的字节串(字符串、嵌套消息) seq 在 schema 里是 1 号字段,值是整数: (1 \u003C\u003C 3) | 0 = 8 = 0x08 按二进制看: 0000 1___ 前 5 位: 字段编号 1 _____ 000 后 3 位: 类型码 0(varint) 拿这个公式验证正文里其余四个 tag,全都对得上: 10 = (2\u003C\u003C3)|0 -> 2 号字段 depth, varint 18 = (3\u003C\u003C3)|0 -> 3 号字段 kind, varint 22 = (4\u003C\u003C3)|2 -> 4 号字段 process, 字节串(后面跟长度 0b=11) 2a = (5\u003C\u003C3)|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 \u003C\u003C 3) | 0 = 0x08 (1 号字段, 类型码 0 = varint) 值: varint(416) = a0 03 (上一节刚算的) 产出: 08 a0 03 (3 字节) 字段 2:depth = 7 tag: (2 \u003C\u003C 3) | 0 = 0x10 值: 7 \u003C 128, varint 一个字节搞定 -> 07 产出: 10 07 (2 字节) 字段 3:kind = \"end\" schema 里枚举定了编号: start=0 note=1 end=2 trigger=3 tag: (3 \u003C\u003C 3) | 0 = 0x18 (enum 线上就是个 varint, 类型码 0) 值: end -> 2 -> 02 产出: 18 02 (2 字节) 字段 4:process = \"calc.damage\" tag: (4 \u003C\u003C 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 \u003C\u003C 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\u003C\u003C3)|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(字段名\u002F引号\u002F冒号\u002F逗号\u002F花括号\u002F数字字符) 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.",9313,{"id":6,"kind":7,"title":11,"summary":13,"image":14,"href":15,"meta":17,"badge":10,"author":12,"stats":-1,"accent":35,"coverRatio":36,"tags":37},"#2563eb","16 \u002F 10",[7,8],{"targetType":8,"targetId":9,"likedByMe":39,"likeCount":40,"commentCount":40,"contentLikeCount":40,"contentCommentCount":40,"sourceLikeCount":40,"sourceCommentCount":40},false,0,[42,51,57,64,72,79,85,92],{"id":43,"kind":7,"title":44,"summary":45,"image":46,"href":47,"meta":48,"badge":10,"author":12,"stats":-1,"accent":35,"coverRatio":36,"tags":49},"NEWS_ARTICLE:827","MHS 三部曲（下）：谁允许 AI 行动？——权力、合规与中国厂商的答卷","我们总在等待一个像 ChatGPT 那样的机器人时刻。但具身智能真正的拐点，也许先发生在更不起眼的地方：一台陌生设备，第一次能把自己的能力、状态和边界完整告诉 AI；一个 Agent，第一次能把试出来的经验固化成可验证、可复用的机器技能。","https:\u002F\u002Fimg2024.cnblogs.com\u002Fblog\u002F510\u002F202608\u002F510-20260830153745229-1536981088.jpg","\u002Fnews\u002F827","2026 · 人工智能",[50],"人工智能",{"id":52,"kind":7,"title":53,"summary":54,"image":14,"href":55,"meta":17,"badge":10,"author":12,"stats":-1,"accent":35,"coverRatio":36,"tags":56},"NEWS_ARTICLE:829","我不会美工，用 WorkBuddy 1 分钟做出 4 张海报","先说结果：下面这 4 张海报，从我发出指令到拿到图片，大约 1 分钟。 我不会美工，也没有打开 Photoshop。用的工具，是我之前用 WorkBuddy 做的一个海报生成器。这个生成器本身也没花几分钟，具体开发过程我在上一篇文章里写过。 这次我把海报生成器的网址、产品截图和要求一起发给 Work","\u002Fnews\u002F829",[7,8],{"id":58,"kind":7,"title":59,"summary":60,"image":61,"href":62,"meta":17,"badge":10,"author":12,"stats":-1,"accent":35,"coverRatio":36,"tags":63},"NEWS_ARTICLE:828","一个人抵一个团队的时代，企业级 AI Agent 还需要做什么？","对于企业而言，真正需要的，不是一个“会聊天”的 Agent，而是一套能被多用户、多场景复用，且权限清晰、过程可审计、成本可控制的 Agent 能力体系。","https:\u002F\u002Fimg2024.cnblogs.com\u002Fblog\u002F2232255\u002F202609\u002F2232255-20260902173524728-659996478.png","\u002Fnews\u002F828",[7,8],{"id":65,"kind":7,"title":66,"summary":67,"image":14,"href":68,"meta":69,"badge":10,"author":12,"stats":-1,"accent":35,"coverRatio":36,"tags":70},"NEWS_ARTICLE:832","用 runtime-async 写一个支持 async\u002Fawait 的轻量脚本引擎","起因：一个好奇 事情的开头很简单。 给 .NET 做过动态脚本的人大概都碰到过同一堵墙：表达式树也好、Reflection.Emit 也好，都很难支持 async\u002Fawait。原因不在语法，而在 await 的实现方式——C# 编译器要为每个 async 方法生成一个状态机结构体，把方法体切成若干片","\u002Fnews\u002F832","2026 · 软件开发",[71],"软件开发",{"id":73,"kind":7,"title":74,"summary":75,"image":76,"href":77,"meta":17,"badge":10,"author":12,"stats":-1,"accent":35,"coverRatio":36,"tags":78},"NEWS_ARTICLE:831","Agent Sandbox 规模化：JuiceFS 探索与实践","过去一段时间，我们在和云厂商以及 Agent 团队推进 Sandbox 落地时，除了要解决数据如何进入 Sandbox，还需要考虑任务所需的数据和执行过程中产生的数据，如何跨任务、跨阶段持续使用。Sandbox 可以随任务快速创建和销毁，但数据往往需要继续保留、共享和流转。 当 Sandbox 并发","https:\u002F\u002Fimg2024.cnblogs.com\u002Fblog\u002F2544292\u002F202609\u002F2544292-20260902171045357-1907499098.png","\u002Fnews\u002F831",[7,8],{"id":80,"kind":7,"title":81,"summary":82,"image":14,"href":83,"meta":17,"badge":10,"author":12,"stats":-1,"accent":35,"coverRatio":36,"tags":84},"NEWS_ARTICLE:830","百智云长期记忆服务：长期记忆与上下文窗口的区别","很多人在接触 AI 长期记忆时，第一反应是：「这不就是把聊天记录存起来吗？」其实这是一个很常见的误解。今天我们把「长期记忆」和「上下文窗口」这两个概念彻底讲清楚。 一、什么是上下文窗口 上下文窗口（Context Window）是模型在单次推理时能「看到」的文本上限。它像一个临时的工作台：模型只能处","\u002Fnews\u002F830",[7,8],{"id":86,"kind":7,"title":87,"summary":88,"image":89,"href":90,"meta":48,"badge":10,"author":12,"stats":-1,"accent":35,"coverRatio":36,"tags":91},"NEWS_ARTICLE:835","体验向量数据库 qdrant","作者:张富春(ahfuzhang)，转载时请注明作者和引用链接，谢谢！ cnblogs博客 zhihu Github 公众号:一本正经的瞎扯 背景 为了早点搞懂公司的百万行 C# 的祖传代码，我想在缺乏文档、缺乏帮手的情况下，先建立一个 企业知识库 来快速帮我理清头绪。 一开始，我是打算以 weav","https:\u002F\u002Fimg2022.cnblogs.com\u002Fblog\u002F1457949\u002F202202\u002F1457949-20220216153819145-1193738712.png","\u002Fnews\u002F835",[50],{"id":93,"kind":7,"title":94,"summary":95,"image":14,"href":96,"meta":17,"badge":10,"author":12,"stats":-1,"accent":35,"coverRatio":36,"tags":97},"NEWS_ARTICLE:833","如何利用AI技术实现漫画翻译","漫画作为图文结合的特色文化载体，承载着各国潮流文化与人文故事，但跨语言的文字壁垒，长期制约着漫画的传播与交流。传统漫画翻译高度依赖人工操作，需要人工框选文字、擦除原图文字、翻译文案、排版适配画风，流程繁琐、耗时费力，且极易出现排版错乱、画风违和、语义偏差等问题。 随着深度学习、计算机视觉与大模型技术","\u002Fnews\u002F833",[7,8]]