Skip to content

能重新部署旧版本,不等于能回滚 ​

把报名表的 phone 列改名为 mobile,新版本上线后发现问题,把镜像换回旧版本:旧版本的每一个请求都报 Unknown column 'phone'。代码回到了昨天,数据库没有。回滚能不能成功,在写迁移脚本的时候就已经决定了。

「出问题就回滚」通常指把应用换回上一个版本的镜像。这一步几乎总能做到,但它只撤销了代码。同一次发布里一起变化的,还有表结构、已经写进去的数据、已经发出去的消息,以及短信、支付这类外部副作用。它们不会跟着镜像一起回去。

本文用一次最小的变更,也就是把一个列改名,在 MySQL 8.4.11 上比较两种迁移方式:新旧版本同时在线时能不能工作、回退到旧版本时能不能工作。每一步都让在线的版本各处理 20 次「报名并读回手机号」,结果见文末配套实验。

一、原地改名:发布和回滚都会出错 ​

最直接的写法是一条迁移语句,随新版本一起执行:

sql
ALTER TABLE registration RENAME COLUMN phone TO mobile;

新版本 v2 读写 mobile,旧版本 v1 读写 phone。实测:

阶段结果
迁移前,只有 v1v1 成功 20 次
迁移后滚动发布,v1 与 v2 同时在线v1 失败 20 次,v2 成功 20 次
全部换成 v2v2 成功 20 次
v2 有问题,回退到 v1v1 失败 20 次(Unknown column 'phone')

问题在发布的时候就出现了,不必等到回滚:滚动发布期间,旧版本的实例还在接流量,而它依赖的列已经不存在了。回滚只是把这个窗口拉长。想让 v1 重新工作,还要执行一次反向迁移;如果 v2 在这段时间写入了 v1 不认识的数据,反向迁移也未必干净。

所以「能回滚」的准确含义是:旧版本的代码能在新的表结构上工作。滚动发布本身已经提出了这个要求,回滚只是要求这个状态能维持得更久。

二、扩展—迁移—收缩 ​

把一次改名拆开,让中间状态对新旧版本都成立:

sql
-- 1. 扩展:只加列,旧版本看不见它
ALTER TABLE registration ADD COLUMN mobile VARCHAR(32) NULL;
-- 2. 发布 v1.5:写入时两列都写,读取仍用 phone
-- 3. 回填历史数据
UPDATE registration SET mobile = phone WHERE mobile IS NULL;
-- 4. 发布 v2:读取改用 mobile,写入仍然两列都写
-- 5. 观察一段时间,确认不再需要回退
-- 6. 收缩:删除旧列,这是唯一不可逆的一步
ALTER TABLE registration DROP COLUMN phone;
扩展加 mobile 列双写发布 v1.5回填补历史数据切读发布 v2 读新列观察按业务指标确认收缩删 phone 列可回退到v1可回退到v1可回退到v1可回退到v1.5、v1可回退到v1.5、v1可回退到无不可逆点回退到 v1 之后,v1 写入的新行没有 mobile:重新发布 v2 前要再回填一次(实测 20 行)原地改名把六步压成一步:滚动发布期间旧实例立即全部失败,回退也不能恢复服务
图 1 · 把一次改名拆成六步。收缩之前的每一步,都能把应用回退到更早的版本继续服务;删除旧列是唯一不可逆的一步,放在最后,并且只在观察期结束后执行

实测每一步的结果:

阶段在线版本结果
加列之后v1成功 20 次
双写版本滚动发布v1、v1.5都成功 20 次
回填—回填 60 行
读新列的版本滚动发布v1.5、v2都成功 20 次
v2 有问题,回退到 v1.5v1.5成功 20 次
继续回退到 v1v1成功 20 次
删除旧列之后v2成功 20 次
删除旧列之后再回退到 v1v1失败 20 次

收缩之前,每一步都能退回到更早的版本继续服务。把四个版本分别放到三种表结构上各跑 20 次,能看得更清楚:

只有 phonephone + mobile只有 mobilev1只读写 phone能工作能工作每次请求都失败v1.5双写,读 phone每次请求都失败能工作每次请求都失败v2(收缩前)双写,读 mobile每次请求都失败能工作每次请求都失败v2(收缩后)只读写 mobile每次请求都失败能工作能工作原地改名:v1 从第一列直接落到第三列的「每次请求都失败」
图 2 · 纵向是应用版本,横向是表结构,每格实测 20 次。只有「两列都在」这一列四个版本都能工作,扩展—收缩路线就是在这个状态里完成切换与回退;原地改名让表直接从第一列跳到第三列

只有「两列都在」这个状态,四个版本都能工作。扩展—收缩的全部意义,就是在这个状态里完成切换,并且在这里停留足够久,直到确认不会再回退。

有一个细节容易漏掉:回退到 v1 期间写入的数据,只有旧列有值。 实测回退到 v1 后又报名了 20 次,这 20 行的 mobile 全部为空。如果直接重新发布 v2,这 20 个人的手机号就读不出来了。重新发布之前要再回填一次,收缩之前也要先检查两列是否一致:

sql
SELECT COUNT(*) FROM registration WHERE NOT (phone <=> mobile);   -- 实测回退后 20,再次回填后 0

每一步的进入条件、观察信号和回退动作可以写成一张表,跟着发布单一起评审:

步骤进入条件观察什么出问题怎么退
扩展迁移在预发环境验证过,不锁表或锁表时间可接受迁移耗时、复制延迟删掉新列,旧版本不受影响
双写扩展完成写入错误率、两列不一致的行数回退到 v1
回填所有实例都已双写回填进度、复制延迟停止回填,可以重跑
切读回填完成、两列一致业务指标、读取错误率回退到 v1.5 或 v1,事后再回填
收缩观察期结束,确认不再回退,再核对一次两列—无法回退,只能向前修复

大表上的加列、回填本身也有耗时和锁的问题,见 千万级大表怎么清理数据 与 百万行数据导入。

三、回滚撤销不了的东西 ​

表结构还有扩展—收缩可用,已经发出去的东西就没有了。

消息和事件。 v2 发出的事件,会被还没升级的消费者读到;回退 v2 之后,队列里已经有的 v2 事件还会继续被消费。实测一个只认识 id、attendee、phone 三个字段的旧消费者:

v2 事件的变化严格解析(遇到未知字段报错)宽容解析(忽略未知字段)
新增字段 channel失败:UnrecognizedPropertyException成功
phone 改名为 mobile失败:UnrecognizedPropertyException「成功」,但 phone=null

宽容解析能扛住新增字段,扛不住改名:它把不认识的 mobile 忽略了,又找不到 phone,于是得到一个空的手机号,而且不报任何错误。这比严格解析直接失败更危险。事件格式的规则和表结构一样:只增加可选字段;要改名,先同时带上新旧两个字段,等所有消费者都升级之后再去掉旧字段。 投递和消费本身的可靠性,见 Kafka 不丢、不重与 Exactly Once。

外部副作用。 已经发出去的短信、已经扣的款、已经推给合作方的数据,不会因为回滚而消失。能做的只有补偿:发一条更正短信、发起退款、给合作方发撤销通知。这些要在发布前想好,并且和业务方确认,而不是等出事了再想。

已经被新版本改过的数据。 如果 v2 按新规则改写了已有数据(比如把手机号统一成带国际区号的格式),回退到 v1 之后,v1 读到的是它不认识的格式。这类变更同样要先扩展:新格式写到新列,旧列保持旧格式,直到确认不再回退。

四、发布前要回答的问题 ​

  1. 这次发布会让哪几个版本同时在线?滚动发布、灰度、回退各自会产生哪些组合?
  2. 每个旧版本能不能在新的表结构上工作?最直接的验证办法,是在 CI 里用新的表结构跑一遍旧版本的集成测试。
  3. 不可逆的一步是哪一步?它是不是被放在了最后,并且在观察期结束之后才执行?
  4. 新版本发出的事件,旧消费者读了会怎样?是报错、忽略,还是悄悄读成空值?
  5. 哪些外部副作用无法撤销?补偿方案是什么,谁来执行?

按比例放量、确定性分桶和规则回滚,见 可复用的服务端组件;新旧实现并行运行、比对输出,见 精益切片与遗留改造。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权