分布式系统中代码、配置、数据变更为什么要分开治理?
简化版
代码、配置、数据的生效机制和回滚能力不同:代码按实例逐批替换,配置可能瞬间推到全量节点,数据变更一旦被新旧版本共同读写往往不能简单回滚。分开治理是为了分别控制权限、灰度、兼容和回滚,并通过“先兼容、再迁移、后清理”保证混部期间仍然正确。
详细版
三类变更不能共用一个“发布成功”概念:
- 代码变更以构建产物为单位,通常通过滚动、蓝绿或金丝雀发布;旧版和新版会同时运行一段时间。
- 配置变更不一定重启实例就能生效,传播速度快、爆炸半径大,还可能出现节点版本不一致。
- 数据变更包括表结构、字段语义、存量数据和索引迁移,持续时间可能从分钟到数天,写入后通常难以原样撤销。
可靠流程应为每类变更设置独立的审批、审计、灰度单元、指标和回滚动作,同时用兼容协议把它们串联。例如字段重命名不能直接删旧列,应先发布能同时读写新旧列的代码,再回填历史数据、校验一致性,切读新列,最后停止写旧列并在观察期后删除。
发布时还要防止控制面与数据面脱节:配置中心显示“已发布”不代表全部客户端已应用;数据库 DDL 执行成功不代表回填已完成;部署平台显示 Pod 就绪也不代表业务指标健康。最终应以版本分布、错误率、延迟和业务正确性共同判定。
完整版教学
一、三类变更的生命周期为何不同
代码、配置和数据看起来都在“改变系统行为”,但载体完全不同。代码随制品部署到进程,配置由控制面动态下发,数据则保存在长期事实源里;因此它们失败时留下的现场和恢复动作也不同。
代码回滚通常是重新部署上一版本,配置回滚是发布旧值或指定历史版本,而数据变更可能已经产生新格式记录,回滚代码后旧程序反而读不懂。若把三者绑成一个按钮,操作人很容易误以为点了“回滚”就恢复了全部状态。
易错点:发布平台的绿色状态只证明动作执行完成,不证明所有实例已生效,更不证明业务数据仍然正确。
二、代码发布必然存在新旧版本共存
滚动发布会逐个替换实例。假设 20 个实例每批替换 2 个、每批观察 5 分钟,整个过程至少有 45 分钟处于混部状态;这期间请求和消息可能在新旧版本之间任意流动。接口、事件和数据库结构必须在这个窗口内双向兼容。
所谓向后兼容,是新消费者能理解旧生产者的输出;向前兼容则是旧消费者遇到新字段时不会崩溃。例如 JSON 新增可选字段通常安全,删除字段、改变单位或把字符串改为对象通常不安全。数据库迁移也应优先采用 expand/contract,而不是同一版本里直接改名删除。
Expand:新增结构,新旧代码都能工作
↓
Migrate:双写或回填,并持续校验
↓
Switch:灰度切换读取来源
↓
Contract:停止旧写,观察后删除旧结构
三、动态配置为什么风险更集中
配置绕过了构建和部署周期,优点是止血快,风险也是影响快。把超时从 500ms 改为 5s、把重试从 0 改为 3,语法都合法,却可能把线程占用放大十倍并制造重试风暴。因此配置校验不能只检查类型,还应检查业务范围和组合约束。
配置发布要记录版本、操作者、审批人、差异、目标范围和生效结果。客户端应上报当前已应用版本,控制台展示“目标版本 v42”之外,还要展示 100 个实例中有多少实际到达 v42;若 3 个实例仍在 v41,系统就在以两套行为运行。
key: payment.retry.max_attempts
old: 0
new: 2
scope: canary-zone-a
guardrail:
retry_qps_increase: "<= 15%"
payment_success_rate: ">= 99.95%"
rollback: publish version 41
四、数据变更为什么最难回滚
数据既有结构又有语义。给表加一列常能快速完成,但回填 10 亿行可能持续数小时;更麻烦的是新代码已按新语义写入数据,旧代码恢复后未必能解释这些记录。于是“代码可回滚”不等于“系统可回滚”。
以价格单位从“元”改为“分”为例,若直接把 price=19.90 改成 price=1990,新旧代码混部时可能把 1990 当作 1990 元。安全做法是新增 price_cent,由新代码双写;回填历史值时记录游标和批次,校验 price_cent = round(price_yuan * 100),切读后再停止旧列写入。
| 变更类型 | 常见灰度单位 | 主要回滚方式 | 隐蔽风险 |
|---|---|---|---|
| 代码 | 实例、流量、租户 | 部署旧制品 | 新数据可能不兼容旧代码 |
| 配置 | 实例组、机房、标签 | 发布历史配置版本 | 节点未全部收到或应用 |
| 表结构 | 影子表、分片、字段 | 反向 DDL 或保留旧结构 | DDL 锁表、复制延迟 |
| 存量数据 | 主键区间、批次 | 反向脚本或备份恢复 | 反向操作可能覆盖期间新写入 |
五、跨类型变更应怎样排序
顺序由兼容关系决定,不应机械地“先发代码再改库”。通用原则是先增加能力而不破坏旧路径,再迁移事实,确认新路径稳定后才收缩旧能力。任何删除动作都应放在最后,并经过至少一个完整业务周期的观察。
一个字段替换可以拆成 6 步:①新增新列;②发布双写代码;③确认双写覆盖全部实例;④分批回填并对账;⑤灰度切读新列;⑥停止旧写并延迟删列。若第 4 步发现 0.02% 差异,应暂停切读,按业务主键定位,而不是用全表覆盖掩盖问题。
并行变更还要防止依赖交叉。服务 A 的新代码依赖配置 v8,而配置 v8 又假设服务 B 已升级,这形成隐形顺序约束;发布计划应显式写出最小版本和阻断条件。
六、灰度、验证与自动停止条件
灰度必须代表真实风险维度。按 1% 随机流量灰度能发现普通错误,却未必覆盖大客户、超大订单或特定机房;数据迁移更适合按租户、分片或主键范围灰度。选择灰度单元时,要确保故障爆炸半径可控且指标能单独观察。
假设日请求量 1 亿,某错误只影响万分之一,1% 流量 10 分钟可能一个样本都遇不到。除在线错误率外,还要运行影子读取、字段级对账和业务不变量检查。自动停止条件应在发布前确定,例如 P99 增加 20%、订单差异率超过 0.001% 或复制延迟超过 30 秒就暂停。
准备 -> 兼容性检查 -> 1 个灰度单元 -> 指标/对账
├─异常:停止扩大,执行对应回滚
└─正常:逐批扩大 -> 全量观察 -> 清理旧路径
七、回滚和前滚要分别设计
代码或配置错误发现得早,回滚通常最快;但当新格式数据已经写入、消息已经对外发送时,强行回滚可能更危险。这时需要前滚修复:保留新协议,发布修复版本或补偿数据。发布预案必须提前定义在什么检查点以前可回滚,超过什么检查点只能前滚。
数据库备份也不是即时撤销键。全量恢复会覆盖故障发生后产生的正确新数据,RTO 可能长达数小时。更实际的方案是保存变更前镜像、记录迁移批次和原值,按业务键做可审计的反向补偿,同时冻结冲突写入。
所有动作应带 change ID,并贯穿部署事件、配置审计、迁移任务和监控标记。这样指标突变时才能回答“当时哪些东西一起变了”,避免靠聊天记录猜测。
八、常见误区与追问
- 误区:配置变更不涉及代码,所以风险较低。 配置能瞬间改变全量实例的超时、路由和功能开关,缺少灰度时爆炸半径往往更大。
- 误区:数据库加字段是向后兼容,所以可以随时执行。 还要评估 DDL 锁、表重写、复制延迟、ORM 映射和旧客户端行为。
- 误区:部署平台显示成功就代表发布完成。 必须确认实际版本分布、业务指标和数据对账,控制面成功不等于数据面健康。
- 误区:发现问题统一回滚到旧版本即可。 新版本写出的数据或事件可能无法被旧版本处理,需要明确回滚检查点和前滚方案。
- 追问:为什么字段删除必须延后? 因为仍可能有旧实例、离线任务或延迟消息读取旧字段,先停止依赖并观察才能证明可删。
- 追问:双写如何保证一致? 优先在同一事务内写同库字段;跨存储双写需借助 outbox、重试和对账,不能假设两次调用原子成功。
- 追问:如何验证回填没有漏数据? 比较总数、分桶校验和及业务不变量,对差异主键抽样,并在增量写持续发生时设置追赶和最终切换水位。
九、加强记忆
记住“代码会混部、配置会扩散、数据会留下”。代码治理重点是协议兼容和流量灰度;配置治理重点是范围、版本与实际生效;数据治理重点是 expand/contract、回填对账和不可逆边界。把三条轨道用同一个 change ID 和检查点串起来,先增加兼容能力,再迁移并验证,最后才删除旧路径。