迁移最危险的地方不是改结构,是「新旧代码同时在跑」。
判据:每一步之后,旧代码都能正常工作。
回滚要能还数据,不只是还表结构——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 秒实验
- 翻你们最近一次迁移脚本,找找有没有配套的回滚脚本。 没有的话,现在补一个,还来得及。
- 看有没有一条
DROP COLUMN或RENAME COLUMN是跟发布同一次做的。 有的话,那一次发布里存在一个老代码会报错的窗口。 - 给一次迁移设计「每一步旧代码都能跑」的四步。 哪一步做不到,就说明这一步要再拆。
常见误区
- 「迁移脚本跑得快,几秒钟,不会有问题。」 问题不在快慢,在「跑的时候新旧代码同时在服务」。 滚动部署的那几秒,足够让老进程报一百次错。
- 「回滚就是把迁移脚本反过来跑。」 结构能反过来,数据要算回去。 而且你多半没写那个反向脚本。
- 「加非空列时给个默认值就行。」 默认值往往是错的。 先可空、再回填、再考虑约束,顺序不能换。
自测三问(点开对答案)
1. 迁移为什么比改代码危险? —— **因为新旧代码同时在跑,而数据不可逆。** **代码能切 tag,数据只能靠一个提前写好的反向脚本。** 2. 前向兼容的判据是什么? —— **每一步之后,旧代码都能正常工作。** **不是「小心一点」,是「每步都能跑」。** 3. 为什么 `DROP COLUMN` 不算回滚? —— **它只还原表结构,算错的数据还在。** **回滚要能还数据,而且要能验证——比哈希。**下一篇讲发布:开关、灰度、回滚三件套,以及为什么分流要按用户 ID 而不是随机。
- ☐ (本篇完,下一篇 11.4 渐进式交付:从写完就发到灰度发布)