能重新部署旧版本,不等于能回滚
把报名表的
phone列改名为mobile,新版本上线后发现问题,把镜像换回旧版本:旧版本的每一个请求都报Unknown column 'phone'。代码回到了昨天,数据库没有。回滚能不能成功,在写迁移脚本的时候就已经决定了。
「出问题就回滚」通常指把应用换回上一个版本的镜像。这一步几乎总能做到,但它只撤销了代码。同一次发布里一起变化的,还有表结构、已经写进去的数据、已经发出去的消息,以及短信、支付这类外部副作用。它们不会跟着镜像一起回去。
本文用一次最小的变更,也就是把一个列改名,在 MySQL 8.4.11 上比较两种迁移方式:新旧版本同时在线时能不能工作、回退到旧版本时能不能工作。每一步都让在线的版本各处理 20 次「报名并读回手机号」,结果见文末配套实验。
一、原地改名:发布和回滚都会出错
最直接的写法是一条迁移语句,随新版本一起执行:
ALTER TABLE registration RENAME COLUMN phone TO mobile;新版本 v2 读写 mobile,旧版本 v1 读写 phone。实测:
| 阶段 | 结果 |
|---|---|
| 迁移前,只有 v1 | v1 成功 20 次 |
| 迁移后滚动发布,v1 与 v2 同时在线 | v1 失败 20 次,v2 成功 20 次 |
| 全部换成 v2 | v2 成功 20 次 |
| v2 有问题,回退到 v1 | v1 失败 20 次(Unknown column 'phone') |
问题在发布的时候就出现了,不必等到回滚:滚动发布期间,旧版本的实例还在接流量,而它依赖的列已经不存在了。回滚只是把这个窗口拉长。想让 v1 重新工作,还要执行一次反向迁移;如果 v2 在这段时间写入了 v1 不认识的数据,反向迁移也未必干净。
所以「能回滚」的准确含义是:旧版本的代码能在新的表结构上工作。滚动发布本身已经提出了这个要求,回滚只是要求这个状态能维持得更久。
二、扩展—迁移—收缩
把一次改名拆开,让中间状态对新旧版本都成立:
-- 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;实测每一步的结果:
| 阶段 | 在线版本 | 结果 |
|---|---|---|
| 加列之后 | v1 | 成功 20 次 |
| 双写版本滚动发布 | v1、v1.5 | 都成功 20 次 |
| 回填 | — | 回填 60 行 |
| 读新列的版本滚动发布 | v1.5、v2 | 都成功 20 次 |
| v2 有问题,回退到 v1.5 | v1.5 | 成功 20 次 |
| 继续回退到 v1 | v1 | 成功 20 次 |
| 删除旧列之后 | v2 | 成功 20 次 |
| 删除旧列之后再回退到 v1 | v1 | 失败 20 次 |
收缩之前,每一步都能退回到更早的版本继续服务。把四个版本分别放到三种表结构上各跑 20 次,能看得更清楚:
只有「两列都在」这个状态,四个版本都能工作。扩展—收缩的全部意义,就是在这个状态里完成切换,并且在这里停留足够久,直到确认不会再回退。
有一个细节容易漏掉:回退到 v1 期间写入的数据,只有旧列有值。 实测回退到 v1 后又报名了 20 次,这 20 行的 mobile 全部为空。如果直接重新发布 v2,这 20 个人的手机号就读不出来了。重新发布之前要再回填一次,收缩之前也要先检查两列是否一致:
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 读到的是它不认识的格式。这类变更同样要先扩展:新格式写到新列,旧列保持旧格式,直到确认不再回退。
四、发布前要回答的问题
- 这次发布会让哪几个版本同时在线?滚动发布、灰度、回退各自会产生哪些组合?
- 每个旧版本能不能在新的表结构上工作?最直接的验证办法,是在 CI 里用新的表结构跑一遍旧版本的集成测试。
- 不可逆的一步是哪一步?它是不是被放在了最后,并且在观察期结束之后才执行?
- 新版本发出的事件,旧消费者读了会怎样?是报错、忽略,还是悄悄读成空值?
- 哪些外部副作用无法撤销?补偿方案是什么,谁来执行?
按比例放量、确定性分桶和规则回滚,见 可复用的服务端组件;新旧实现并行运行、比对输出,见 精益切片与遗留改造。
配套实验
- codesphere-labs/engineering/expand-contract-rollback:原地改名与扩展—迁移—收缩在新旧版本并存、回退时的表现,版本 × 表结构的兼容矩阵,旧消费者读取新增字段与改名字段的事件(验证记录)
参考资料
- MySQL 8.4:ALTER TABLE Statement(
RENAME COLUMN、ADD COLUMN、DROP COLUMN) - MySQL 8.4:Online DDL Operations
- Jackson:DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES
- Martin Fowler:Parallel Change