Course Builder个人学习工作台 · 本地模式
DDIA 第二版:从数据模型到分布式系统设计

09 · 编码与演进:让新旧版本共同工作

目标:通过新旧读写组合检查一次字段演进是否保持兼容。

数据比一次部署活得久

活动记录已经保存在数据库里,旧版手机仍然在线,消费者也可能几小时后才读消息。因此新代码上线不代表世界上只剩一种数据格式。编码把内存对象变成字节;模式规定字段的名称、类型与含义,两者一起决定能否演进。

后向兼容是新代码能读旧数据;前向兼容是旧代码能读新数据。仅测试“新程序写、新程序读”覆盖不了这两个方向。读得懂也不等于写回安全:旧程序只知道几个字段,重建记录时可能把新增字段丢掉。

JSON 便于观察,但不会替你定义兼容规则;字段缺失、null、空字符串应该分别解释。跨语言大整数、二进制数据也要约定。Protocol Buffers 用字段编号识别字段,删除的编号不能随便用于新含义;Avro 解码涉及写者模式与读者模式的匹配。格式能支持演进,应用语义仍要负责。

读成功却破坏了新数据

保存 schema_evolution.py,预测两个旧写者会不会都保留新增的 visibility。

import json

def new_reader(raw):
    item = json.loads(raw)
    return item["title"], item.get("visibility", "public")

def old_writer(raw, title, preserve):
    item = json.loads(raw)
    result = dict(item) if preserve else {"id": item["id"]}
    result["title"] = title
    return json.dumps(result)

old = json.dumps({"id": "a1", "title": "夜谈"})
new = json.dumps({"id": "a1", "title": "夜谈", "visibility": "private"})
assert new_reader(old) == ("夜谈", "public")
bad = old_writer(new, "改期", False)
good = old_writer(new, "改期", True)
assert new_reader(bad)[1] == "public"
assert new_reader(good)[1] == "private"
print("lost_field:", new_reader(bad)[1])
print("preserved_field:", new_reader(good)[1])
uv run --python 3.12 --no-project schema_evolution.py

预期输出:

lost_field: public
preserved_field: private

坏写者用认识的字段重建对象,把私有活动变成默认公开。保留未知字段避免了本次损失,却不保证旧程序理解新的权限语义。涉及安全边界时,需要服务端统一检查,或阻止不兼容旧客户端执行有关写入。

引导练习:① 新增 capacity 字段,旧数据缺失时读取为 20;② 确认未知字段保留版本能写回它;③ 显式 capacity: null 不应悄悄当成合法整数。get 只处理缺失,不处理值类型。

独立练习:把整数 starts_at 改成字符串时间。旧读者仍对它加 60。设计分阶段迁移顺序,不能要求所有节点同一秒升级。

参考迁移步骤

增加新字段 starts_at_iso,保留原字段及含义;先部署能读两个字段的新读者,再让写者按同一时刻生成两个表示。回填历史记录并核对一致性,观察旧读者与旧写者是否仍在使用。确认全部消费者升级、消息留存期和回滚需求允许后,再停止旧字段写入,最后删除。测试矩阵包含旧写新读、新写旧读、新写新读,以及旧代码读新记录后写回。

如果字段消失,沿完整“读取—修改—写回”链路检查,不只验证解码。若新增默认值改变业务,先修语义:没有声明权限不应自动公开敏感内容。本实验中的默认值用于暴露这种风险。

在 design.md 给报名事件定义稳定 ID、版本与字段语义,并记录一次可回滚的加字段迁移。

参考资料