代码回滚了故障还在:迁移、缓存、队列里的旧状态怎么收拾

2026-07-28

数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。

多数人把这类故障归错了因:以为是「回滚没做干净」,其实回滚这个动作从设计上就只负责代码那一层。 你把镜像换回上一个版本,等了几分钟,错误率没下来,甚至换了一种错法冒出来——这时候再去重滚一遍、重启一轮、清一次 CDN,多半是白费力气。代码是无状态的,回退它只需要一次替换;而数据库结构、缓存里的序列化结果、队列里还没消费完的消息、以及已经发给外部系统的请求,全都是有状态的,它们不会跟着镜像一起回去。

站内有两篇相邻的文章,分工和本篇不同:AI 改坏了代码怎么安全回滚 讲的是回滚代码本身怎么做、用哪种方式回;生产事故里 AI 能帮到哪一步 讲事故现场用 AI 的边界在哪。本篇只管一件事——代码已经回去了、故障还在,你怎么按顺序把残留下来的状态找出来、收拾掉。

一、先把回滚拆成五层状态

把系统当成五层,而「回滚」这个动作天然覆盖的只有第 1 层:

  1. 代码与镜像:服务跑的二进制、依赖包、前端产物。
  2. 配置与开关:环境变量、配置中心的值、特性开关、网关和灰度规则。
  3. 数据库结构与数据:表结构、索引,以及新版本运行期间写进去的数据。
  4. 中间态存储:缓存、会话、搜索索引、CDN 边缘副本、本地临时文件。
  5. 外部副作用:已经投递出去的消息、已经调用的第三方接口、已经发出的邮件短信、已经落到对账文件里的记录。

这五层的可逆性从上往下递减。第 1 层一次替换就回去了;第 2 层技术上完全可逆,但只有配置与代码同源(配置文件跟着仓库走)时才会被换镜像顺带带回去,控制台里点出来的开关和灰度规则不会自己回去,所以它是被漏掉最多的一层;第 3 层只有一半可逆——加一个可空的列可以留着不管,删列、改类型、加非空约束则不可逆;第 4 层能重建,但重建有代价,代价往往是把流量整个打到数据库上;第 5 层根本不可逆,只能补偿。

所以排查顺序要反过来:从第 1 层往下逐层确认,而不是从最像元凶的那层开始猜。 相当一部分「回滚无效」的案例,问题就卡在第 1 层——压根没全回去。多副本滚动更新可能只替换了一部分实例,网关的灰度规则可能还把一小撮流量指向新版本,worker 和定时任务在另一条流水线里根本没被触发。

确认这一层,最省事的办法是让服务暴露一个版本探针,然后打一批请求看返回是不是同一个值:

for i in $(seq 1 20); do curl -sS https://your-service/internal/version; echo; done | sort | uniq -c

如果 uniq -c 出来两行,说明版本没换干净,先别急着往数据层查,回去把剩下的实例滚完再看错误率。反过来只出一行也别马上放心:负载均衡有会话保持时,这一串请求可能全落在同一个实例上,稳妥的做法是绕过入口逐个实例直连探针,或者在探针响应里带上实例标识一起统计。这类「一部分实例和别人不一样」的问题,和运行环境不一致导致的诡异 bug 是同一类思路:先证明所有节点跑的是同一份东西,再谈别的。

二、从现象反推成因:一张判别表

第 1、2 层确认干净之后,用现象去反推残留在哪一层。下面这张表按遇到的频次排,越靠上越常见:

现象大概率成因怎么验证处置动作
回滚后接口大面积 500,日志里是列或字段不存在破坏性迁移已执行(删列、改类型、加非空约束),旧代码的查询对不上表结构直接读线上表结构,和回滚目标版本的迁移文件清单比对别做反向迁移,优先前滚一个兼容补丁;确需恢复列先看备份
一部分用户正常、一部分报反序列化失败缓存里存的是新版本的对象结构,旧代码读不动取一个报错用户的缓存键把内容导出来,和正常用户对比字段按键前缀定向失效,或缩短过期时间让它自然代谢
回滚后安静了一阵,几十分钟后同样的错又冒出来队列里积压的旧消息是新格式,被旧代码消费到了看队列积压深度和死信队列,抽一条消息体看字段暂停消费者,把存量分流到隔离队列,转换或丢弃后再恢复
只在整点、凌晨等固定时刻出错定时任务、离线作业跑在另一套部署里,没跟着回滚比对两边的版本探针;查作业日志里的构建标识同步回滚,或临时停掉该作业
日志一切正常,但数据肉眼看着是错的新版本在运行窗口内写入了语义不兼容的数据(枚举新值、单位、时区、精度)按事故时间窗抽样,和窗口之前的数据做分布对比先只读统计影响面,再写修复脚本
回滚后 401/403 明显增多新版本轮换过密钥或改过签名口径,凭据已经被换掉了用旧凭据手工打一次请求看状态码重新签发,或干脆前滚到能用新凭据的版本
回滚后 429 增多重试或并发口径在两个版本之间被改过(常见于新版本刚收敛过退避策略),回滚后旧口径和积压请求一起冲上游先 diff 两个版本的重试次数、退避方式、连接池上限;再看上游调用量曲线是不是在回滚那一刻抬起来的先降并发和重试次数,等积压泄完再恢复

用这张表的方法是:先找那条唯一能同时解释「时间点」和「受影响范围」的行。 全量用户受影响多半是结构层的事,部分用户受影响多半是缓存或数据层的事,延迟一段时间才复发的几乎一定和队列或定时任务有关。

三、按顺序动手:迁移在最前,外部副作用在最后

数据库迁移:默认前滚,不做反向迁移

先判可逆性。加一个可空的列、加一张新表、加一个索引,这类迁移旧代码通常无感,留着就行,不必为了对称去撤。真正伤人的是删列、改类型、加非空约束、重命名——这些一旦执行,旧代码必炸。

很多人第一反应是执行迁移工具的反向操作。这个反应很危险:反向迁移会真的把列和数据删掉,你等于在事故现场又做了一次破坏性变更。更安全的路子是前滚一个兼容补丁:在旧代码基础上打最小的一个 patch,让它能在新表结构下跑起来,然后发这个版本。补丁通常只有几行——补一个默认值、把 SELECT * 换成显式列、给新约束填一个兜底值。

先把两次部署之间到底动了哪些迁移文件列清楚:

git diff --name-only <rollback_target>..<bad_version> -- migrations/
git log --oneline <rollback_target>..<bad_version> -- migrations/

再去迁移版本记录表里核对哪些真的执行了(不同框架的表名不一样,看你项目的配置)。文件清单和已执行记录一比,缺口就出来了。

缓存:定位污染范围,别一键清空

清空全部缓存是最省事、也最容易把小事故变大的动作。缓存全空的一瞬间,所有请求穿透到数据库,本来只是部分用户报错,现在变成全站慢。

分三档处理:能定位到键前缀的,按前缀定向失效;定位不到但知道结构变了的,把过期时间改短让它自然代谢;实在要清的,分批清并且给回源重建加互斥。扫键要用游标方式,别用会阻塞整个实例的全量匹配命令:

redis-cli --scan --pattern 'user:profile:*' | head -n 20
redis-cli TTL user:profile:10086

根治的办法是在缓存键里带一个结构版本号,比如 user:profile:v3:10086。序列化结构一变就升版本号,新旧两份共存、老的自然过期,回滚时不用清任何东西——旧代码去读 v2,只要那批键的 TTL 还没走完就直接命中,走完了也只是一次正常的回源重建,不会读到读不动的新结构。代价是新版本上线后的一段时间里两份数据并存,内存占用会高一截,所以版本号要跟着序列化结构走,别跟着业务版本号乱升。

队列:积压消息是时间胶囊

队列平时是透明的,因为消费得快,你看不见它。一旦回滚,那些在新版本期间生产、还没被消费的消息就成了定时炸弹:它们是新格式,而消费者已经变回旧代码了。

动作顺序固定:暂停消费者,看积压深度和消息样本,把存量整体分流到一个隔离队列,恢复消费者处理新流量,最后单独写一段一次性程序,把隔离队列里的消息转成旧格式再回灌,或者确认可以丢弃就丢。别在原队列上边跑边改,你会分不清哪条处理过。

顺手检查死信队列。很多团队的死信队列平时没人看,事故期间它会吃掉一大批消息,恢复之后没人回灌,第二天变成数据缺失工单。

外部副作用:只能补偿,先把清单拉出来

已经调出去的接口、已经发出去的通知、已经写进对账文件的记录,回滚追不回来。这一层的动作不是撤销,是列清单、判断影响、决定补偿方式:能冲正的走冲正流程,不能冲正的走人工通知,涉及资金的一律留痕并同步给业务方。

拉清单靠调用日志里的幂等键或请求 ID。如果新版本恰好改过幂等键的生成规则,那就是最麻烦的情况——你的冲正请求会被对方当成一笔新操作。这种时候停手,先跟对方系统的人对齐口径,别自己在那里试。

四、什么时候别再折腾:止损点和换路信号

排查是有预算的。下面几条任何一条成立,就该从修复切换成隔离:

  1. 同一个方向试了三次,错误率曲线没有任何变化。 说明你的因果假设是错的,继续试只是在换排列组合。停下来重新走第二节那张表。
  2. 发现第 3 层出现了不可逆变更。 列已经删了、类型已经改了,那么回到旧版本这条路的成本已经超过前滚。放弃回去的念头,集中人力做兼容补丁并往前发。
  3. 数据还在被持续写脏。 第一动作永远是止血——关掉写入入口、把功能开关切到旧行为、必要时让这个接口直接返回降级结果。先停止污染扩大,再谈修数据。修数据这件事永远可以晚半小时做,污染扩大不可以。
  4. 你需要同时持有三个系统的状态才能推理。 这是人脑要溢出的信号。叫人,分工,一人盯一层,用一条共享时间线记录谁在什么时刻做了什么。事故里最贵的错误是两个人同时改同一个东西。
  5. 你开始靠猜。 如果你说不出下一步动作要验证哪一条假设,那它就不是排查,是碰运气。

关于 AI 在这个阶段的位置:让它读日志找模式、写只读的对比查询、生成一次性转换程序的草稿,都合适;让它直接在生产上执行动作、或者交给你一段你没逐行看过的更新语句,不合适。写修复脚本最容易失控的就是影响范围,这和让 AI 改动越界的常见成因 是同一个问题——脚本一长就没人看 WHERE 条件了。

五、避坑清单:为什么会踩,怎么避

把反向迁移当回滚手段。 会踩是因为迁移工具都提供成对的正向和反向操作,看起来天然对称。避法:把反向操作当成开发期的便利,生产只用前滚补丁;破坏性变更一律拆成两次发布——第一次让代码停止读写这个字段并上线观察,第二次再删。两次之间隔开,中间任何时刻回滚都安全。

一键清空全部缓存。 会踩是因为它最彻底最快,事故当下人本能选最彻底的动作。避法:缓存键带结构版本号让新旧共存;实在要清就分批,并且给回源重建加互斥,避免同一个键被上千请求同时打穿。

忘了队列里的存量。 会踩是因为队列平时消费得快,深度长期接近零,它在你的心智模型里不存在。避法:把暂停消费者写进回滚检查单的第一条;消息体里带 schema 版本字段,消费端遇到不认识的版本直接进隔离队列,而不是报错重试。

只回了 Web 服务,忘了 worker、定时任务、边缘函数。 会踩是因为它们在不同流水线里,回滚按钮不在同一个页面上。避法:所有可部署单元统一暴露版本探针,回滚后用一条命令把版本值收上来比对,不一致就还没完。

配置和代码不同源。 会踩是因为配置是在控制台点出来的,不进 git,回滚代码时它纹丝不动。避法:配置纳入版本管理;做不到的话,至少保证每次变更留痕带时间戳,事故时能和部署记录按时间线对齐。

特性开关处在半开状态。 会踩是因为新功能靠开关放量,大家只记得回滚代码,忘了开关还开着,旧代码走进了一条没准备好的分支。避法:开关的默认值必须是旧行为;回滚流程里把关开关排在换镜像之前。

修数据的脚本直接跑全量更新。 会踩是因为着急,而且脚本看起来很简单。避法:分三步——先跑只读统计(分组计数),把预期影响行数记下来;再跑一批小样本,核对结果;最后才全量,全量前留快照。实际影响行数和预期对不上就立刻回滚事务。

事故过去了不补一条测试。 会踩是因为大家只想赶紧下班。避法:当天就补一条能复现这次残留状态的用例。这类缺口怎么补,测试全绿线上还是爆 里讲得更细,核心是把跨版本兼容也纳入用例,而不只是测当前版本自洽。

六、收尾:一份十分钟自检清单

这类故障的本质是代码那一层的时间被拨回去了,其他四层没有。你要做的不是把所有层都拨回去(很多层根本拨不回去),而是快速判断残留在哪一层,然后用兼容、失效、隔离、补偿这四种手段分别处理。

回滚之后如果十分钟内没恢复,按这份清单过一遍:

  • 所有实例、worker、定时任务的版本探针是不是同一个值?
  • 特性开关、灰度规则、配置中心的值是不是也回到旧行为了?
  • 这次部署执行过哪些迁移?其中有没有删列、改类型、加非空约束?
  • 缓存里有没有按新结构序列化的对象?能不能定位到键前缀?
  • 队列积压深度是多少?死信队列里有没有东西?消费者停了吗?
  • 事故窗口内有没有写入语义不兼容的数据?影响多少行,统计过没有?
  • 有没有已经溢出到系统外的副作用?清单拉出来了吗?
  • 现在这一步,你要验证的是哪一条假设?答不上来就停手叫人。

复盘只落地两条也够:破坏性变更必须拆成两次发布,缓存键必须带结构版本。它们不能让事故不发生,但能让下一次回滚真的把状态一起带回去。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。