三年前选了一个二进制格式,今天每接一个新工具,都得先为它写一遍适配。问题不在格式本身有多差,而在于序列化格式不是库,是协议:一旦有第二个进程、第二份历史数据、第二个团队读它,替换成本就从"改一个依赖"变成"迁移全部存量与全部参与方"。
选型时看起来在比较编解码速度,实际在决定一件更长寿的事:未来几年里,谁能读、谁能改、改错了会不会静默。
1. 格式是协议:寿命与代码不匹配
| 对象 | 寿命量级 | 更换成本 | 谁承担 |
|---|---|---|---|
| 代码 | 一两个发布周期 | 一次重构 | 本团队 |
| 库实现 | 跟随依赖升级 | 换依赖、跑一轮回归 | 本团队 |
| 格式与 schema | 数年,常长于代码库本身 | 存量数据 + 全部读写方 | 所有下游 |
| 对外的线协议 | 与商业约定同寿 | 双版本并行一整个过渡期 | 双方 |
─── 落盘与上网都带版本:这是事后唯一能区分"老数据"的东西
{"v": 3, "id": 10231, "payload": {...}}
判据只有一条:有没有第二个读方。只有本进程自己读、生命周期跟着进程走,格式就是实现细节;一旦落盘、上网、给下游,它就是协议,此后所有决定都要按协议的标准做,包括命名、编号、默认值、错误语义。
2. 四个维度的矛盾:不能同时取强
| 维度 | 取强的代价 | 取弱的代价 | 常见落点 |
|---|---|---|---|
| 可读性 | 文本体积大、解析慢 | 二进制需要工具与 schema 才能看 | 对外接口、配置、日志走文本 |
| 体积 | 压缩换 CPU、调试变难 | 大包挤带宽与页缓存 | 高频内部调用走二进制 |
| 演进能力 | 强 schema 要维护 IDL 与生成链 | 靠口头约定,改一次崩一次 | 跨团队走强 schema |
| 生态 | 强生态绑定语言与版本 | 弱生态要自己写适配 | 多语言场景先查各语言实现质量 |
─── 文本格式的两个隐性损失:精度,与"缺失"
{"amount": 0.1} // 二进制浮点表示,不是精确的 0.1
{"amount": null} 对比 {} // null 与"没有这个字段"多数实现不区分
四维不能同时最优,只能回答"哪个维度坏了更要命"。对外可读性坏了,排查成本永久上升;体积坏了,代价主要在流量账上,通常可买。把体积当第一判据的团队,往往是在用一个可测的小数换一个不可测的大风险。
3. "可演进"的真正含义:加、删、改各自能不能安全做
| 操作 | 安全条件 | 不安全时的表现 |
|---|---|---|
| 加字段 | 读方忽略未知字段,新字段有默认值或可选 | 读方遇到未知字段直接反序列化失败 |
| 删字段 | 旧数据不再被读,且名字与编号永不复用 | 复用编号 → 旧数据被解析成新语义 |
| 改类型 | 新类型是旧类型的超集且编码兼容 | 跨编码(如数值与字符串)旧读方直接报错 |
| 改语义(类型不变) | 必须靠版本号或改名 | 最难发现:能解析,值全错 |
// 删除字段的正确姿势:编号与名字都别再回来
message Order {
reserved 7, 9; // 曾用编号,永久退役
reserved "channel"; // 曾用名字
optional string channel_code = 12;
}
最危险的是最后一行。类型没变、结构没变,含义变了——把"金额单位从元改成分"这类改动,任何 schema 机制都检测不到,只有版本字段加双读比对能拦住。同样的坑还有时区(本地时间换 UTC)与精度(分改厘),它们都表现为"解析成功、数值不对"。
所以真正该被评审的不是"改了什么字段",而是"这次改动属于哪一类"。把四类改动写成一份检查清单挂进 schema 的 MR 模板,比任何工具都省事:
"可演进"不等于"随便改":它等于每一类改动都有明确的安全条件,且这些条件被文档化、被评审。没有这份规则的强 schema,只是把错误从运行期提前到了"生成链报错"。
4. schema 的三档严格度,与它们各自的失效方式
| 严格度 | 谁来保证 | 失效方式 | 适合 |
|---|---|---|---|
| 无 schema | 人 | 拼写、缺字段、类型漂移全部在运行期暴露 | 一次性脚本、内部临时数据 |
| 弱 schema | 运行期校验(DTO 校验、校验库) | 校验漏一处即静默通过;文档与实现不同步 | 单团队、中等寿命 |
| 强 schema | 编译器 + IDL + 编号规则 | 规则被执行了但语义错了;生成链成为发布瓶颈 | 跨团队、长期 |
// 弱 schema 的真实边界:校验只覆盖被声明的字段
class OrderDto { String channelCode; BigDecimal amount; } // 无约束
// 新增字段默认放行,字段名拼错也默认放行——两者在日志里长得一样
强 schema 不是"更安全",而是把错误提前到编译期,代价是新增一条生成链:IDL 版本、插件版本、生成代码是否入库、CI 里跑不跑生成检查。这条链的维护成本最常被算漏,它会在换构建工具、升编译器、加一门语言时集中爆发。
5. 字段编号与命名:最难事后更改的决定
| 绑定对象 | 典型改动 | 后果 |
|---|---|---|
| 二进制格式的编号 | 复用已删编号 | 旧数据被读成新字段,静默错值 |
| JSON 的键名 | 改名 | 旧读方取不到值,拿到 null 或缺省 |
| 数据库列名 | 改名 | 依赖列名的 SQL、映射、报表同时断 |
| 类字段名 | 改名 | 反射与映射框架按名匹配时断 |
// 编号台账:退役编号永不回收,回收即数据损坏
message: Order
next_free: 13
assigned:
1: id
2: amount
7: channel // deprecated 2026-03,禁止复用
文本协议里主要成本是命名,二进制协议里主要成本是编号。选二进制格式时,编号台账必须进仓库、与 schema 同一次评审——它比字段名更难事后修,因为字段名至少还能在代码里全局替换,编号一旦写进存量字节就固化在数据里了。
6. 兼容方向、跨语言成本、性能权重与包裹层
| 方向 | 定义 | 谁必须先升级 |
|---|---|---|
| 向后兼容 | 新代码能读旧数据 | 数据先老,代码后新,随时可发 |
| 向前兼容 | 旧代码能读新数据 | 旧读方必须先学会忽略未知字段 |
| 语言 | 实现来源 | 隐性成本 |
|---|---|---|
| JVM | 官方或社区 | 生成代码与构建插件版本互相绑定 |
| Go | 官方 | 零值与可选语义的差异要单独处理 |
| Python | 纯实现慢,C 扩展装不上 | 依赖编译环境,镜像里常缺 |
| 前端 | 需要额外运行时 | 包体积进入首屏预算 |
两个方向常被混为一谈。向后兼容坏了,表现是"历史数据读不出来";向前兼容坏了,表现是"灰度期间旧实例遇到新写入就崩"。灰度发布真正需要的是后者。
// 端口在内、格式在外:换格式只改适配器
interface OrderCodec { byte[] encode(Order o); Order decode(byte[] b); }
// 业务代码只依赖接口,字节只在 codec 边界出现
包裹层常被当成"以后换格式很便宜"的理由,这一点要打折:它确实让替换实现变便宜,却对迁移存量毫无帮助。存量数据是按旧格式写的,换与不换都要读一遍、写一遍。所以包裹层的真实价值是隔离变化与支持双读,不是抹掉迁移。
判据是存量的可弃性:存量能重建(缓存、派生表、日志),包裹层的价值接近满分;存量不可重建(订单、账单),包裹层只是一层缓冲,真正决定成本的是迁移工程。
| 包裹层能给你 | 不能给你 |
|---|---|
| 换格式不动调用方 | 存量数据仍要迁移 |
| 双读双写、灰度过渡 | 语义差异(时间精度、null 与缺省) |
| 统一的版本与错误语义 | 对外线协议的兼容性(内部换了、对外没变才成立) |
性能在这个决策里的权重经常被高估:端到端耗时里序列化通常是位数百分比量级(按自己的 profile 结果核对,不要按基准文章),真正的瓶颈多在网络往返次数与数据库。可读性相反,常被低估——线上排查时能直接用文本看一眼,与需要工具加 schema 才能解码,差的是一整次定位时长。
排错:五种现场
| 现象 | 原因 | 处理 |
|---|---|---|
| 旧数据读不出来 | 向后兼容坏了:字段被删或编号被复用 | 用版本字段分支解码,禁止复用编号 |
| 灰度期间旧实例崩 | 向前兼容坏了:遇到未知字段报错 | 打开忽略未知字段,先升级读方 |
| 解析成功但值全错 | 语义漂移(单位、精度、含义) | 版本号 + 双读比对,类型检查救不了 |
| 某语言实现慢一个数量级 | 走了纯解释实现或未走二进制通道 | 换官方实现,按 profile 定位 |
| 前端包体积超预算 | 引入了完整运行时 | 只在必要端引入,或改文本格式 |
边界:这些情况选最省事的即可
| 场景 | 做法 | 理由 |
|---|---|---|
| 进程内或单次任务的数据 | 内存结构、随手 JSON | 没有第二读方,协议属性不存在 |
| 生命周期短于一年 | 用团队最熟的格式 | 迁移成本不会真正发生 |
| 单语言、单团队 | 语言原生序列化 | 没有跨语言适配面 |
| 同一制品写、同一制品读回 | 带一个版本字段即可 | 无第三方兼容约束 |
| 数据量极小且以人看为主 | 可读性优先 | 体积差在噪声里 |
判据是读方的数量与存量的寿命。读方只有自己、存量几个月就过期,格式就是实现细节,选最顺手的最划算;读方包含未知的第三方、存量要留几年,它就已经是协议,此时省下的每一分钟都要在未来几年里还回去。
自测
- 判断一个序列化格式是"实现细节"还是"协议",判据是什么?
- 加字段、删字段、改类型各自的安全条件是什么,哪一类最危险?
- 弱 schema 与强 schema 的失效方式有什么本质不同?
- 为什么"复用已删除的字段编号"比改字段名危险得多?
- 灰度发布更需要向后兼容还是向前兼容,为什么?
- 把格式包在接口后面,哪些成本被消掉了,哪些仍在包裹层之外?