【背景】
做了 10 年分布式系统,我见过太多团队在架构评审时把"可回滚性"当默认前提——好像 CI/CD 管道通了,回滚就是点一下按钮的事。但每次真出事,回滚的"最后一公里"才暴露出来:数据库 schema 变更没法回滚、消息队列里的消息已经消费了、缓存里的脏数据还在、下游服务已经按新接口格式解析了……
我管这叫「回滚的最后一公里」——管道是通的,但坑在路上。就像高速公路修好了,但出口匝道被堵了,车还是出不去。
【常见分歧:三派打法】
我观察下来,团队对"回滚"的理解大致分三派,各有各的坑:
派 A:代码回滚 = 全部回滚 认为回滚就是 git revert + 重新部署,一了百了。 问题:DB schema 怎么回?消息队列里已经消费的消息怎么办?缓存清不清?下游服务按新格式解析的数据怎么修?
派 B:回滚 = 降级 + 异步补偿 不追求原子回滚,而是:feature flag 把新逻辑切回旧逻辑 + 跑异步补偿任务修脏数据 + 通知下游刷新。 问题:补偿任务本身可能出错(雪上加霜),而且"降级"粒度往往很粗,影响面不可控。我见过一个团队降级后,3 个下游服务全部超时雪崩。
派 C:根本不该有"回滚"这个概念 认为好的设计应该是"前向修复"(forward fix)——出了 bug 就发 hotfix,而不是回退。 问题:hotfix 的验证周期长,在 P0 事故中"等 40 分钟发 hotfix"和"回滚 5 分钟"完全是两回事。而且 hotfix 本身也可能引入新 bug,形成"补丁叠补丁"。
【我的倾向:把回滚当"分布式事务"来设计,而不是当成一个"按钮"】
我倾向于一个不太主流但很实用的原则:回滚不是一次操作,而是一个有 checkpoint 的状态机。
具体来说,我推团队按三个阶段做:
-
回滚前(snapshot):自动记录当前状态——DB schema 版本号、消息队列 consumer offset、缓存 key 清单、feature flag 当前值。不是"凭记忆回",而是"对着快照回"。这一步很多人忽略,结果就是回滚时不知道"从哪回"。
-
回滚中(分阶段切流量):5% → 50% → 100%,每阶段设 30 秒观察窗口,核心指标(错误率、P99、连接池水位)异常自动停止并回退到上一阶段。不是"一把梭",而是"踩刹车"。
-
回滚后(异步补偿):清理新 schema 产生的脏数据、通知下游服务刷新缓存、对账消息队列 offset。这一步最容易出"回滚了代码但数据回不去"的事故。
【一个最容易漏的坑:「只加不删」字段的回滚残留】(本次修订补上)
很多团队坚持 additive-only(只加字段不删),这个方向对,但它藏了一个回滚陷阱:你"加"了一个字段、写了 3 周数据,然后回滚代码——字段还在表里,数据还在列里,但读写它的代码没了。这就是"回滚了代码但数据回不去"里最隐蔽的一种:不是数据错了,是数据成了孤儿。
打个比方:你给房子加了个房间(新字段),住了三周(写了三周数据),然后拆了墙(回滚代码)——房间还在,但门没了,里面的东西(数据)谁也进不去取,也拿不走,只能烂在那。
怎么防?两条可复现的做法:
-
新字段走两阶段发布(expand → migrate → contract),而不是"加字段 + 改逻辑"一步到位:
- 阶段一(expand):
ALTER TABLE orders ADD COLUMN refund_v2 TINYINT NULL+ 双写(新旧字段都写)。只加列、不动读,回滚 = 停双写,零数据风险。 - 阶段二(migrate):跑一次性 backfill 把存量数据回填到新字段,校验
SELECT COUNT(*) FROM orders WHERE refund_v2 IS NULL收敛到 0。 - 阶段三(contract):确认新字段稳定后再切读、最后删旧字段——删字段永远是另一次发布,和回滚解耦。 关键:回滚只能回退到"上一阶段",不能跨阶段回滚。expand 阶段回滚是安全的(列还在、数据没动),migrate 之后回滚就要跑反向 backfill——所以每个阶段都要有"这一阶段能不能回滚"的明确答案,写进发布单。
- 阶段一(expand):
-
字段淘汰要有"墓碑期":删字段前,先在 release notes 里标
deprecated since v2.3, safe to drop after v2.5,给所有下游(报表、ETL、对账脚本)至少两个版本的缓冲。我见过一个团队删字段后,一个跑在独立调度器里的对账脚本(不在主仓库、没人知道它存在)直接 SQL 报错,对账静默失败三周——删字段的爆炸半径比加字段大得多,因为加字段没人会踩,删字段会踩到所有你不知道的下游。
这段是安全加固视角下对"回滚最后一公里"的补强:回滚的坑不在"能不能回",而在"回完之后,那些你加过又删不掉的东西,会变成谁也不知道的孤儿数据"。
但我不否认,这套东西在 5 人团队里可能是过度设计。所以我想听大家说——你们在什么规模、什么场景下,觉得"回滚"这件事的复杂度开始失控?
【想听大家聊】
- 你们团队"回滚"的实际耗时是多少?从发现事故到服务恢复正常,中间哪一步最耗时?(是代码部署、DB 回滚、缓存清理、还是下游通知?)
- DB schema 变更你们怎么处理的?是坚持"只加不删"(additive-only),还是有 schema migration 回滚机制?有没有踩过"加了字段回不去"的坑?
- 有没有经历过"回滚了代码但数据回不去"的事故?最后怎么收的场?(我想听具体的,不是"重新写数据"这种一笔带过的)
- 你们有没有"回滚演练"?多久一次?最近一次演练发现了什么意料之外的坑?
- 你们用 feature flag 做"逻辑回滚"的比例有多大?有没有 flag 本身变成"新的单点故障"的经历?
欢迎带具体场景来聊,越具体越好——技术细节、踩坑记录、配置截图(脱敏后)都可以。