“万物皆变,无物常驻。”
—— 赫拉克利特(如果他还活着,大概会补一句:尤其是你五年前设计的那个接口)
应用程序在变,功能在变,用户需求也在变。变不可怕,可怕的是数据格式跟不上变化:新代码读不懂旧数据,旧代码撞上新字段直接崩溃。
为了让系统在滚动升级、多版本共存时还能体面运行,我们需要两样东西:向后兼容(新代码能读旧数据)和向前兼容(旧代码能读新数据)。而这一切的起点,是数据编码格式的选择。
一、从精装房到登机箱:数据为什么要编码
内存里的数据是精装修的复式公寓:对象、指针、引用、方法,各种设施一应俱全,住得舒舒服服。
可一旦要出远门(网络传输)或搬仓库(磁盘存储),就得把所有家当压缩成一个登机箱(字节序列)。这个过程叫编码(Encoding / Serialization)。
到了目的地,再从登机箱里把家当复原出来,这叫解码(Decoding / Deserialization)。
听起来简单?不同登机箱的设计哲学,能让你搬家的效率天差地别。
二、语言内置编码:自家的钥匙开不了别家门
Java 有 Serializable,Python 有 pickle,Ruby 有 Marshal……
看起来是“开箱即用”,其实是“开箱即坑”:
- 语言锁死:Java 序列化的数据,Python 看了直摇头,Go 直接报警。
- 安全黑洞:攻击者丢给你一段字节流,你的程序开开心心反序列化——远程代码执行成就达成
- 版本如浮云:向前向后兼容?官方文档里这四个字都搜不到。
- 又慢又胖:Java 原生序列化,性能堪比蜗牛,体积堪比砖头。
结论:除非你写一次性脚本,否则别碰。
三、JSON / XML / CSV:世界通用语,但全是方言
优点:是人(和大部分机器)都能读。
缺点:是人(和大部分机器)都想骂。
JSON
- 数字只有一种,超过 2^53 的整数在 JavaScript 眼里直接幻灭。
- Twitter 的骚操作:同一个 tweet ID,JSON 里放两遍——一遍当数字(给后端),一遍当字符串(给前端)
XML
- 写的人想出家,读的人想还俗。
- 明明只是传个数据,搞得像在签《凡尔赛条约》。
CSV
- “单元格里有逗号怎么办?”“不知道,看解析器心情。”
- “表结构新增一列怎么办?”“不知道,看命。”
JSON Schema
- 功能强到能写正则、条件、远程引用……
- 但也复杂到让新手当场昏迷。
- 默认开放内容模型(
additionalProperties: true)挺灵活,但也经常让数据变得“灵活过头”。
四、BSON:穿着马甲的 JSON
BSON(Binary JSON)是 MongoDB 的“御用”编码,目的是增加类型支持,而不是压缩体积。
结果:类型是丰富了,体积反而更胖了。
拿下面这条用户记录举例:
{
"user_id": 123456,
"username": "tech_guru",
"active": true,
"balance": 99.95,
"tags": ["coding", "coffee"]
}
BSON 的编码结构是:每个字段 = 字段名(C 字符串)+ 类型(1字节)+ 值(变长),最后以 \x00 结束。
BSON 编码示意(字节序列伪代码):
\x03\x00\x00\x00 // 文档总长度(此处简化,实际是4字节)
\x10 // 类型:int32 (0x10)
user_id\x00 // 字段名 + 终止符
\x40\xE2\x01\x00 // 123456 的小端字节序(4字节)
\x02 // 类型:string (0x02)
username\x00 // 字段名
\x09\x00\x00\x00tech_guru\x00 // 字符串长度9 + UTF-8数据 + 终止符
\x08 // 类型:boolean (0x08)
active\x00 // 字段名
\x01 // true (0x01)
\x01 // 类型:double (0x01)
balance\x00 // 字段名
\x9A\x99\x99\x99\x99\x59\x59\x40 // 99.95 的 IEEE 754 双精度(8字节)
\x04 // 类型:array (0x04)
tags\x00 // 字段名
[...数组内部省略...] // 数组元素同样存字段名索引"0","1"
\x00 // 文档结束符
结果:这条记录在 BSON 中占用 130+ 字节,比纯 JSON(约 110 字节)还胖 20%。
BSON 的优势是支持日期、二进制、正则等类型,但代价是字段名反复存储,毫无压缩可言。
五、Protocol Buffers:扔掉字段名,贴上号码牌
Google 的 Protocol Buffers(protobuf) 思路极其粗暴:
字段不要名字,要数字标签(field tags)。
先定义 .proto:
syntax = "proto3";
message User {
int64 user_id = 1;
string username = 2;
bool active = 3;
double balance = 4;
repeated string tags = 5;
}
编码时,每个字段 = 字段标签 + 类型 + 值。
字段名?那是给人看的,机器只看号码牌。
Protobuf 编码拆解
还是那条记录,我们用 protobuf 编码后得到以下字节流。
下面把每个字节的二进制拆开,看标签和类型是怎么塞进去的。
user_id = 123456(字段标签 = 1,类型 = varint [编号0])
08 C0 84 07
-
第一个字节
08:- 标签
1左移 3 位:00001 << 3=01000(二进制) - 类型
0(varint):000 - 按位或:
01000 | 000=01000= 0x08
→ 高5位00001是标签1,低3位000是 varint 类型。
- 标签
-
后面三个字节
C0 84 07是 123456 的 varint 编码。
varint 规则:每个字节最高位是 “是否还有后续字节”(1=还有,0=结束),低7位是数据。
123456 的二进制是11110001001000000,按7位一组从低到高:十进制 7位二进制 十六进制 加最高位 64 1000000 0x40 0xC0(加1) 68 1000100 0x44 0x84(加1) 7 0000111 0x07 0x07(不加) 所以字节序列是:
C0 84 07。
username = "tech_guru"(字段标签 = 2,类型 = length-delimited [编号2])
12 09 74 65 63 68 5F 67 75 72 75
-
第一个字节
12:高5位00010= 标签2,低3位010= 长度分隔类型 -
第二个字节
09= 字符串长度 9(十进制) -
后面 9 个字节:字符串的 ASCII 码
74 65 63 68 5F 67 75 72 75 t e c h _ g u r u
active = true(字段标签 = 3,类型 = varint [0])
18 01
- 第一个字节
18:高5位00011= 标签3,低3位000= varint。 - 第二个字节
01= true(varint 编码 1)。
balance = 99.95(字段标签 = 4,类型 = 64-bit [编号1])
21 9A 99 99 99 99 59 59 40
- 第一个字节
21:高5位00100= 标签4,低3位001= 64-bit 双精度。 - 后面 8 个字节:99.95 的 IEEE 754 双精度浮点数,小端字节序。
tags = ["coding", "coffee"](字段标签 = 5,类型 = length-delimited [2])
2A 06 63 6F 64 69 6E 67
2A 07 63 6F 66 66 65 65
- 每个
2A: 高5位00101= 标签5,低3位010= length-delimited。 - 第一个
06= 字符串长度 6,后面是"coding"的 ASCII。 - 第二个
07= 字符串长度 7,后面是"coffee"的 ASCII。
repeated 字段的编码就是同一个标签反复出现,没有额外数组开销。
总长度:约 40 字节。
整数 123456 原本需要 8 字节(int64),protobuf 用 varint 压缩成 3 字节。
字符串只存值,不存字段名,体积直接腰斩。
兼容性规则:
- 加字段 = 新标签号 → 旧代码自动忽略,向前兼容 ✅
- 删字段 = 该标签号永久封印,不能再用 ✅
- 改名字 = 随便,反正编码只看标签号 ✅
- 改类型 = 看情况,64位变32位会截断 ⚠️
代价:标签号需要人肉维护。数据库加一列,protobuf 就问:“标签号是多少?”
你:“……我哪知道?”
六、Avro:没有标签,全靠默契
Apache Avro 走的是另一个极端:
模式里一个数字标签都没有,编码就是字段值的顺序拼接。
Avro schema(JSON 表示):
{
"type": "record",
"name": "User",
"fields": [
{"name": "user_id", "type": "long"},
{"name": "username", "type": "string"},
{"name": "active", "type": "boolean"},
{"name": "balance", "type": "double"},
{"name": "tags", "type": {"type": "array", "items": "string"}}
]
}
Avro 二进制编码(纯值拼接,无任何字段标识):
// user_id (long) — varint
C0 84 07 // 123456 的 varint 编码(同 protobuf)
// username (string) — 长度 + UTF-8 字节
09 // 长度 9
74 65 63 68 5F 67 75 72 75 // "tech_guru" 的 ASCII
t e c h _ g u r u
// active (boolean)
01 // true
// balance (double) — 8字节 IEEE 754 小端序
9A 99 99 99 99 59 59 40 // 99.95
// tags (array) — 数组长度 + 每个字符串
02 // 数组长度 = 2(varint)
06 // 第一个字符串长度 = 6
63 6F 64 69 6E 67 // "coding"
c o d i n g
07 // 第二个字符串长度 = 7
63 6F 66 66 65 65 // "coffee"
c o f f e e
总长度:约 32 字节 —— 比 protobuf 还小 20%。
因为 Avro 连字段标签都不存,完全依赖写者模式(writer’s schema)和读者模式(reader’s schema)按字段名匹配。
演化全靠默认值:
- 加字段:必须给默认值(否则旧数据读不了,向后兼容崩)
- 删字段:字段必须有默认值(否则旧代码崩,向前兼容崩)
- 改字段名:靠别名(只能向后兼容,不能向前)
null不是默认值,必须显式["null", "string"]
动态生成 schema:
数据库加了一列?重新生成 Avro schema,直接导出数据。
字段名还在,旧 reader 按名字匹配,自动忽略新列。
protobuf 要做到同样的事,得写脚本动态分配标签号,还要小心别重复——麻烦程度堪比手写身份证号。
七、选型指南:没有银弹,但有选项
| 格式 | 空间效率 | 兼容性维护 | 动态 schema | 适用场景 |
|---|---|---|---|---|
| JSON / BSON | 低 | 手写逻辑 | 原生支持 | 跨组织 API,人类可读 |
| Protocol Buffers | 高 | 标签号管理 | ❌ | 服务间 RPC,长期存储 |
| Avro | 最高 | 字段名匹配 | ✅✅ | 数据湖,Hadoop,动态表结构 |
- 如果团队纪律严明,标签号专人维护,protobuf 是性价比之王。
- 如果 schema 每天都在变,或者你要把关系数据库倒进数据湖,Avro 会让你睡着都笑醒。
- 如果对接的是浏览器、第三方、看不懂二进制的人类,JSON 还是得接着用——但强烈建议配合 schema registry 做兼容性检查。
八、最后一句
数据比代码长寿。
你今天随手定下的格式,五年后还会躺在某个冷存储里,被某个加班的新人捞出来解析。
选对编码,敬畏兼容,就是对未来的自己最好的保护。