🛠️ 第11章 场景三:跨前后端功能开发
📄 一句话概括

迁移最危险的地方不是改结构,是「新旧代码同时在跑」。 判据:每一步之后,旧代码都能正常工作。 回滚要能还数据,不只是还表结构——DROP COLUMN 不算回滚。

📝 本章和前面几章的关系

上一篇讲接口。接口改错了,前端报个错就完了。这一篇讲数据库。 数据库改错了,数据就错了,而数据没有 revert。 为什么说迁移比代码危险? 因为代码可以回滚版本,数据只能靠一个提前写好的反向脚本—— 而那个脚本如果没写,出事时你只能现场编。 Demo 用 sqlite 真跑两种做法:一步改列名,旧查询当场报错;分四步走,每一步旧查询都能跑。 然后再真跑一次回滚,比数据哈希。

一、迁移为什么最危险

三个原因,一个比一个难缠。

风险 为什么
新旧同时跑 部署不是原子的,老进程还在服务
数据不可逆 代码能切版本,数据切不回去
停不下来 业务高峰期没窗口给你停机

第一个是最容易被低估的。 「我改完就部署,几秒钟的事」——但滚动部署时,新代码起来的那几秒,老代码还在跑。 如果你在这一步删了老列,那几秒里老代码就在报错。 Demo 里第一步就是这个:ALTER TABLE items RENAME COLUMN price TO amount, 一行就把旧查询废了,报的是「no such column: price」。 注意报错的时间点:新代码还没上线,旧代码先挂了。 第二行「数据不可逆」是本质。 代码回滚是切个 tag;数据回滚是把算错的值算回去。 前者需要三十秒,后者需要你知道每一个字段被怎么改过。

二、前向兼容的三条做法

核心思路只有一句:把一次破坏性变更,拆成若干次兼容性变更。

步骤 做什么 旧代码怎样
1 加可空新列 加 amount、currency 照跑,读老列
2 回填 老数据补上新列的值 照跑
3 双写 新代码同时写两处 照跑,读老列也对
4 切读 + 观察 改成读新列,老列先留着 照跑

四步里每一列的「旧代码怎样」都是「照跑」,这就是判据。 不是「小心一点」,是「每一步之后系统都能正常工作」。 为什么第一步必须是「可空」? 因为加非空列时,已有的一百行没有值可以填—— 数据库要么拒绝,要么给个默认值,而默认值往往是错的。 先可空、再回填、最后才考虑加约束,这顺序不能换。 第四步那个「老列先留着」也是有意的。 删列应该是另一个迁移,而且要在确认没人读它之后。 「没人读」怎么确认?搜代码、看监控、等一个版本周期。 急着删,等于把可回滚的窗口自己关掉了。

三、回滚脚本必须同时写

回滚有两种,只有一种是真的。

写法 还原了什么 够不够
DROP COLUMN 表结构 不够,数据已经错了
反向 UPDATE 数据的值 够,但要算得对
从备份恢复 整库 够,但会丢掉迁移后的写入

第一行是常见的假回滚。 它把表结构还原了,看起来「回去了」,但算错的数据还在里面—— 你以为回滚了,其实只是把症状藏起来了。 Demo 里那个回滚脚本是反向 UPDATE:把 amount 按 price 重算一遍。 验证方式不是「看着对」,是比哈希——迁移前的查询结果和回滚后的查询结果,哈希一致才算真回滚。 第三行「从备份恢复」也是个选项,但它会丢掉迁移之后写入的数据。 所以它只适合「刚迁移完就发现错了」这一种情况——超过几分钟,你就要权衡丢数据的代价。 所以写迁移脚本的时候,回滚脚本要同时写、同时测。 测法也一样:先跑正向,再跑反向,比对哈希。 出事了再写,你就是在生产库上写代码。

四、动手 Demo:数据库迁移:一步到位与分四步的差别

同一个改动——把 price 拆成 amount 加 currency——两种做法各跑一遍。 一步到位直接改列名,分四步是加列、回填、双写、切读。 用的是真 sqlite 库、一百行真数据,旧查询是每条语句跑完之后真的执行一次。

# 一步到位:旧代码当场挂
cx.execute("ALTER TABLE items RENAME COLUMN price TO amount")
cx.execute("SELECT id, price FROM items")   # -> no such column: price

# 分四步:每一步都跑一次旧查询
cx2.execute("ALTER TABLE items ADD COLUMN amount REAL")   # 可空
cx2.execute("ALTER TABLE items ADD COLUMN currency TEXT")
cx2.execute("UPDATE items SET amount = price, currency = 'CNY'")
cx2.execute("UPDATE items SET price = amount WHERE price IS NULL")
assert cx2.execute(OLD_SQL).fetchall()      # 每步都还能跑

实跑输出:

--------------------------------------------------------------------
数据库迁移:一步到位与分四步的差别
--------------------------------------------------------------------

  同一个改动——把 price 拆成 amount 加 currency——两种做法各跑一遍。

  ① 一步到位:直接改列名
  --------------------------------------------------------------------
  旧查询报错:no such column: price
  ! 这就是「一步到位」的代价:新代码还没上线,旧代码先挂了。而旧代码还在跑,因为部署不是原子的。

  ② 分四步:每一步旧代码都能跑
  --------------------------------------------------------------------
| 步骤 | 旧代码怎样 | 结果 |
|---|---|---|
| 1 加可空新列 | 旧代码照跑 | ok |
| 2 回填历史数据 | 旧代码照跑 | ok |
| 3 双写:新代码写两处 | 读任一处都对 | ok |
| 4 切读,price 留一个版本 | 旧代码照跑 | ok |

  四步走完,旧查询的结果哈希是 4f8591d2,迁移前是 4f8591d2——一致。
  ! 四步的核心不是「小心」,是「每一步之后系统都能正常工作」。能回退的迁移,才敢在业务高峰做。

  ③ 回滚脚本必须同时写
  --------------------------------------------------------------------
| 阶段 | amount 的取值 |
|---|---|
| 迁移前(读 price) | 哈希 4f8591d2 |
| 迁移写错(乘了二) | 哈希 44d07f4a |
| 跑回滚脚本 | 哈希 4f8591d2 |

  回滚之后哈希是 4f8591d2,跟迁移前那个 4f8591d2 一致——说明回滚真的把数算回去了。
  ! 回滚脚本最容易写成 DROP COLUMN——那只是把结构还原,数据已经错过的比对不出来。
  回滚要能还原数据,不只是还原表结构。

一步到位那一步,报错的时间点要看清:新代码还没上线,旧代码先挂了。 这就是「部署不是原子的」这句话的具体样子。 四步那张表四行全是 ok,看着平淡——但这四行 ok 就是全部价值:每一步之后系统都能正常工作。 一个能在业务高峰做的迁移,只能长这样。 回滚那一段的关键是「比哈希」这个动作。 如果不比哈希,你怎么知道算回去了? 「我看着对」不是判据,尤其是在几十万行数据上。 最后那句「回滚要能还数据,不只是还表结构」值得记成一条规矩。 DROP COLUMN 之后表看起来一样,但那个乘过二的 amount 已经写进过历史 ——如果它被下游读过,下游的数也错了。

30 秒实验

  1. 翻你们最近一次迁移脚本,找找有没有配套的回滚脚本。 没有的话,现在补一个,还来得及。
  2. 看有没有一条 DROP COLUMN 或 RENAME COLUMN 是跟发布同一次做的。 有的话,那一次发布里存在一个老代码会报错的窗口。
  3. 给一次迁移设计「每一步旧代码都能跑」的四步。 哪一步做不到,就说明这一步要再拆。

常见误区

  1. 「迁移脚本跑得快,几秒钟,不会有问题。」 问题不在快慢,在「跑的时候新旧代码同时在服务」。 滚动部署的那几秒,足够让老进程报一百次错。
  2. 「回滚就是把迁移脚本反过来跑。」 结构能反过来,数据要算回去。 而且你多半没写那个反向脚本。
  3. 「加非空列时给个默认值就行。」 默认值往往是错的。 先可空、再回填、再考虑约束,顺序不能换。
自测三问(点开对答案) 1. 迁移为什么比改代码危险? —— **因为新旧代码同时在跑,而数据不可逆。** **代码能切 tag,数据只能靠一个提前写好的反向脚本。** 2. 前向兼容的判据是什么? —— **每一步之后,旧代码都能正常工作。** **不是「小心一点」,是「每步都能跑」。** 3. 为什么 `DROP COLUMN` 不算回滚? —— **它只还原表结构,算错的数据还在。** **回滚要能还数据,而且要能验证——比哈希。**

下一篇讲发布:开关、灰度、回滚三件套,以及为什么分流要按用户 ID 而不是随机。

相关笔记