Appearance
系统设计如何全方位考虑兼容性问题
定位:工程实践方法论。线上最贵的故障不是"新功能写错了",而是"新功能上线,老功能坏了"。兼容性问题的本质是:系统中同时存在新旧两代逻辑,它们必须在共存期间都能正确工作。这篇总结一套四层兼容性思维 + 版本策略。
为什么兼容性要单独建模
因为"我的改动是对的"和"我的改动没有破坏别人"是两个命题:
- 单测全绿 → 只证明新逻辑自身正确。
- 新旧共存、灰度期间半新半旧、消息队列里堆着旧格式的消息、客户端还是 3 天前的版本。这些场景里,正确的代码也会被错误的数据打挂。
兼容性问题必须在设计阶段穷举。原因是一个恶劣性质:测试环境很难完整复现"新旧混合"的生产状态。漏掉的几乎必然在线上爆发。
核心心法:四层兼容性模型
把任何一次变更拆到四个层面检查:接口、数据、协议、行为。四层由外到内,一层漏了就往里传染。
第一层:接口兼容
对外的 API/RPC 签名与语义。检查项:
- 参数:
- 新增参数:老调用方不传,新代码必须给安全默认值。新参数若必填,就是破坏性变更。
- 删除/重命名参数:绝对禁止直接删,先标记 @Deprecated 跑一个完整版本周期。
- 参数类型收窄:long 改 int、对象改子类——序列化后字段错位或丢失。
- 返回值:
- 新增字段:老客户端多收到一个字段,通常无害。除非客户端做了严格校验(无法反序列化就直接抛异常的那种)。
- 删除字段/改字段语义:老客户端取不到值或取到错误值。改语义(如 status 从 0/1 扩成 0/1/2/3)必须确认所有消费方能理解新枚举。
- 错误码:新增错误码时,检查调用方的兜底逻辑是否把它当成"未知异常"重试。重试一个不该重试的错误(如余额不足)会放大问题。
- 接口语义:签名没变但行为变了(原来幂等现在不幂等、原来是弱一致现在强一致)。这对所有调用方是隐形炸弹。语义变更要当接口变更对待。
第二层:数据兼容
表结构、存储格式、存量数据。检查项:
- DDL 变更铁律:加列可以,删列/改列类型要分步走:
第一步:加新列(可空/有默认值),双写新旧列,读旧列
第二步:存量数据刷写到新列(分批,注意主从延迟与锁表风险)
第三步:切读新列,观察一个完整业务周期
第四步:停写旧列,再下一版本删除旧列- rename 等于"删 + 加",数据会丢,同样要走分步流程。
- 唯一索引/约束变更:加唯一索引前先查存量是否已有重复数据,否则 DDL 直接失败或锁表。
- 消息/缓存里的数据格式:MQ 里未消费完的旧消息、Redis 未过期的旧 value,新代码要能兼容读取。兼容方式如按版本字段区分,或先清缓存再上线。清缓存要评估击穿。
- 枚举/状态机扩展:新状态写入后,所有读这张表的系统(包括报表、风控、下游同步)是否认识。
第三层:协议兼容
通信双方的序列化与编码约定。检查项:
- 序列化框架的演进规则不同:
- JSON:加字段天然兼容;改字段名/嵌套结构不兼容;数字精度(JS 端 long 溢出 2^53)。
- Protobuf/Thrift:靠 tag/field id,绝不能复用已删字段的编号。required 字段一旦加上就永远拆不掉(老数据缺字段直接反序列化失败),只用 optional。
- Java 原生序列化:serialVersionUID 不匹配直接炸,且升级 JDK/类结构后跨版本不可靠——生产尽量别用。
- 字符集与编码:新系统 UTF-8,老数据 GBK;emoji/生僻字在 VARCHAR 下的长度问题(utf8mb4)。
- 大小端/时间格式:跨语言交互时时间戳秒/毫秒、时区、BigDecimal 的字符串表示。
- 协议头/版本号:自定义 TCP 协议或 MQ 消息头里留 version 字段,是唯一的逃生通道。
第四层:行为兼容
业务语义与运行时序在共存期的正确性。这一层最隐蔽,检查项:
- 灰度共存期的不变量:beta 发布期间一部分机器跑新代码、一部分跑老代码。对外呈现的整体业务语义必须等于"新功能没上线"(即新功能未切流时禁止产生任何可见影响)。
- 状态切换的原子性:切流开关打开瞬间,半完成状态的单据(老逻辑创建、新逻辑处理)要能正确流转。
- 时序假设:老逻辑"先扣库存再下单",新逻辑"先下单再扣库存"。两种机器混跑时,同一用户先打到老机器再打到新机器,中间状态是否自洽。
- 配置/开关默认值:新功能开关默认必须是"关闭(走老逻辑)",配置中心推送失败时的兜底值也是关闭。
- 定时任务与消息消费者:新老实例都会被调度/都会消费。要检查是否重复执行(幂等是行为兼容的基础保障)。
版本策略
兼容性不能无限背下去,靠版本机制把"兼容期"变成有边界的契约:
- 语义化版本(SemVer):MAJOR.MINOR.PATCH——MAJOR 表示存在破坏性变更,MINOR 向下兼容的功能新增,PATCH 修复。让调用方从版本号就能预判升级风险。
- API 版本管理:
- URL 路径版本(/v2/orders):直观、路由清晰,多版本共存期长。
- 版本号放协议头/消息字段:适合 RPC 与 MQ。
- 原则:新版本上线,老版本保留一个明确的下线时间表(如两个迭代后)。并持续监控老版本的流量归零。
- "新增优于修改"(Postel 法则的工程化):
- 要扩展 → 加新字段/新接口/新枚举值,永远不动老的。
- 要废弃 → 三步走:标记 Deprecated → 监控调用量归零 → 下一版本真正删除。
- 契约测试(Consumer-Driven Contract):调用方把"我依赖你什么"固化成测试用例。提供方每次变更跑一遍全部调用方的契约——把兼容性验证从"人脑穷举"变成"自动拦截"。
- 灰度即兼容性验证:白名单 → 百分比的灰度阶梯,每一步都验证兼容性假设(见《系统切流通用方案》)。兼容性设计错了,灰度是最后的拦截网。
变更前检查清单
每次涉及对外的变更,过一遍这张表:
接口层
[ ] 是否只增不改不删(字段/方法/错误码)?
[ ] 新增参数有无安全默认值?新错误码调用方如何兜底?
[ ] 语义变更(幂等性/一致性/枚举含义)是否已通知全部调用方?
数据层
[ ] DDL 是否遵循"先加后删、分步切换"?
[ ] 存量数据迁移方案与断点续传?
[ ] MQ 积压消息 / 缓存旧值,新代码能否兼容读取?
[ ] 唯一索引变更前查过存量重复吗?
协议层
[ ] 序列化框架的演进规则是否遵守(tag 复用/required 陷阱)?
[ ] 跨语言交互的编码/精度/时区问题?
[ ] 报文里有无 version 字段留逃生通道?
行为层
[ ] 灰度期新旧共存,对外语义是否等于"未上线"?
[ ] 半完成状态的单据在新旧逻辑间能否正确流转?
[ ] 新开关默认值是否为安全值?
[ ] 定时任务/消费者重复执行是否幂等?典型事故形态(对号入座)
- 新代码不认识 MQ 里的旧消息,消费端持续返回错误,积压雪崩——数据层。
- 客户端还没发版,服务端先下线了老字段,老版本 App 大面积白屏——接口层。
- 序列化 ID 变了,灰度期间新老机器互相 RPC 反序列化失败——协议层。
- 切流后新逻辑处理老单据,状态机卡死在中间态——行为层。
- 加唯一索引时存量有脏数据,DDL 卡住锁表,整库抖动——数据层。
一句话总结
接口只增不改、数据先加后删、协议留版本逃生、行为保灰度不变量。兼容靠版本机制收敛,靠契约测试拦截,靠灰度验证兜底。 每次变更把四层清单过一遍,"改了老的、坏了旧的"这类事故可以消灭掉九成。
2026-10 依据主题重写(原文件为空壳)。