万物皆变,数据格式尤甚

24 阅读9分钟

“万物皆变,无物常驻。”
—— 赫拉克利特(如果他还活着,大概会补一句:尤其是你五年前设计的那个接口)

应用程序在变,功能在变,用户需求也在变。变不可怕,可怕的是数据格式跟不上变化:新代码读不懂旧数据,旧代码撞上新字段直接崩溃。

为了让系统在滚动升级、多版本共存时还能体面运行,我们需要两样东西:向后兼容(新代码能读旧数据)和向前兼容(旧代码能读新数据)。而这一切的起点,是数据编码格式的选择。

一、从精装房到登机箱:数据为什么要编码

内存里的数据是精装修的复式公寓:对象、指针、引用、方法,各种设施一应俱全,住得舒舒服服。
可一旦要出远门(网络传输)或搬仓库(磁盘存储),就得把所有家当压缩成一个登机箱(字节序列)。这个过程叫编码(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 编码后得到以下字节流。
下面把每个字节的二进制拆开,看标签和类型是怎么塞进去的。


  1. 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位二进制十六进制加最高位
    6410000000x400xC0(加1)
    6810001000x440x84(加1)
    700001110x070x07(不加)

    所以字节序列是:C0 84 07


  1. 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
    

  1. active = true(字段标签 = 3,类型 = varint [0])
18  01
  • 第一个字节 18:高5位 00011 = 标签3,低3位 000 = varint。
  • 第二个字节 01 = true(varint 编码 1)。

  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 双精度浮点数,小端字节序。

  1. 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 做兼容性检查。

八、最后一句

数据比代码长寿。
你今天随手定下的格式,五年后还会躺在某个冷存储里,被某个加班的新人捞出来解析。

选对编码,敬畏兼容,就是对未来的自己最好的保护。