
最近折腾了几轮数据库迁移,踩了几个很隐蔽的坑,这类问题代码评审看不出来,预发布环境也未必能复现,上线后才突然暴雷。所以这篇把生产环境里最常见的 5 类事故拆开说清楚,顺便聊聊怎么在部署前把它们拦住。
下面这些迁移方案都能顺利通过评审,大多数甚至能跑通预发布环境。它们在生产环境中失败,通常也不是因为代码写得有多复杂,而是因为生产环境才同时具备三个条件:真实数据、真实流量,以及新旧代码并存的滚动发布。
每次审计迁移脚本时,我会优先检查下面 5 种模式。这也是确定性扫描工具最适合机械检测的内容,没必要等线上事故发生后,大家才突然学会这些坑。
你在 Prisma schema(模式定义)里把 users.name 重命名为 users.display_name。下面是 prisma migrate dev 生成的内容:
ALTER TABLE "users" DROP COLUMN "name";
ALTER TABLE "users" ADD COLUMN "display_name" TEXT;
这根本不是重命名,而是先删数据,再加一个空列。name 里的数据全部丢失;更麻烦的是,在滚动发布期间,仍在读写 name 的旧代码,会在迁移落库的那一刻开始报错。
Django 的 RenameField 和手写的 RENAME COLUMN 也有同样的发布窗口问题:新列名已经存在,旧列名却消失了,而新旧版本的应用同时在线。
安全做法是 expand–contract,也就是“扩展—收缩”:
ADD COLUMN display_name,允许为空,不搬迁数据。UPDATE users SET display_name = name WHERE display_name IS NULL;,限定范围、幂等,并且可以分批执行。DROP COLUMN name。四个看似啰嗦的步骤里,只有最后一步不可逆。也正因为如此,最后一步才应该设置严格的发布门槛。
ALTER TABLE "orders" ADD COLUMN "status" TEXT NOT NULL;
在 Postgres 上,只要表里已经有数据,这条语句就会直接报错。如果在较老版本上添加一个非常量默认值,或者所用数据库引擎无法完成仅修改元数据的加列操作,它表面上也能“成功”,代价却是持有 ACCESS EXCLUSIVE 锁并重写整张表。
使用 Django 的开发者会碰到交互式提示,要求提供一次性默认值;使用 Alembic 的开发者则可能写出 op.add_column(..., nullable=False),却没有配置 server_default。本质上都是同一个问题。
解决办法仍然是 expand–contract 的加列版本:先添加一个允许为空的列,再通过 WHERE status IS NULL 分批回填数据,然后发布始终写入该列的代码,最后再添加约束。至于最后一步如何避免长时间持锁,第 4 节会具体说明。
CREATE INDEX orders_user_id_idx ON orders (user_id);
在 Postgres 中,直接执行 CREATE INDEX 允许读取,但会在整个建索引期间阻塞写入。如果目标恰好是那张最大、最热、迫切需要索引的表,那么等待时间有多长,写入故障就会持续多久。
CREATE INDEX CONCURRENTLY orders_user_id_idx ON orders (user_id);
这里的难点主要在运维层面,而不是概念本身:CONCURRENTLY 不能放在事务中执行。Alembic 需要配合 autocommit_block() 和 postgresql_concurrently=True,Django 则要使用设置了 atomic = False 的 AddIndexConcurrently。
另外,并发建索引一旦失败,会留下一个 INVALID 索引,重试前必须先将其删除。MySQL 没有完全对应的方案,需要确认操作符合 ALGORITHM=INPLACE, LOCK=NONE 的条件;如果目标表很大,可以考虑使用 pt-online-schema-change 或 gh-ost。
第 2 节提到的 expand–contract 最后一步通常是:
ALTER TABLE "users" ALTER COLUMN "display_name" SET NOT NULL;
如果直接执行,它会扫描整张表,同时持有 ACCESS EXCLUSIVE 锁。很多时候,它是 PR(拉取请求)里最短的一条迁移,却会成为整个发布过程中持锁时间最长的操作。
在 Postgres 12 及以上版本中,可以先证明所有值均非空,而且第一步不需要长时间持有排他锁。先添加一个尚未验证的检查约束:
ALTER TABLE users ADD CONSTRAINT users_display_name_notnull CHECK (display_name IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_display_name_notnull;
ALTER TABLE users ALTER COLUMN display_name SET NOT NULL;
-- optionally drop the now-redundant check constraint
约束验证阶段不需要排他锁。等到真正执行 SET NOT NULL 时,Postgres 已经知道检查结果,因此持锁时间很短。
ALTER TABLE users ADD COLUMN display_name TEXT;
UPDATE users SET display_name = name;
ALTER TABLE users DROP COLUMN name;
DDL(数据定义语言)和 DML(数据操作语言)三步放在一个文件里,几乎没有任何安全的失败边界。执行到一半遭遇超时、锁等待或发布回滚时,数据库会停在半迁移状态,而迁移执行器可能只知道自己执行过文件,或者根本没执行过。
MySQL 的情况更麻烦,因为 DDL 并不具备事务性:即使外层事务回滚,结构变更依然可能已经提交。更关键的是,破坏性操作和初始化操作放在同一个文件里,发布过程根本找不到一个可以暂停、检查并作出判断的节点。
始终把它们拆开:只修改结构的迁移、只处理数据的迁移、只执行收缩操作的迁移。每一步都可以独立回滚,每次数据回填都限定范围,并且支持断点续跑。拆分看起来像是在增加仪式感,但等到凌晨两点回填失败时,你只需要重新跑一遍,心里会踏实很多。
这 5 类问题都不需要人工判断才能检测,它们本质上都是文本模式。也就是说,第一轮检查应该交给机器,而不是让已经筋疲力尽的评审者在 PR(拉取请求)结尾靠肉眼扫 SQL。
我的做法是使用确定性模式扫描,为每条发现标记严重级别和类别,例如数据丢失、锁、停机、不可逆。真正需要人工判断的部分,则留给交叉检查线上代码是否仍引用即将删除的列,以及编写 expand–contract 改写方案。
如果想先扫描自己的原生 Postgres 迁移脚本,可以使用我维护的一个免费开源快速检查工具。它会检测最常见的前 10 类模式,除了上面 5 类,还包括删除表、TRUNCATE、未限定范围的 UPDATE/DELETE,以及类型收窄:
migration-risk-quick-scan on GitHub:使用 Python 编写,只依赖标准库,不会连接你的数据库。
另外还有一套完整的审计能力,增加了对 Prisma、Django 和 Alembic 的支持,并提供应用代码交叉检查、每次迁移的 GO / NEEDS EXPAND-CONTRACT / NO-GO 判定、可直接应用的 expand–contract 改写,以及每份迁移对应的回滚方案。它由 AutoAI Muse 打造,这是一家自主开发开发工具的 AI(人工智能)公司。AI 提醒你审查未经人工检查的自动化,确实多少有点讽刺。不过迁移还是应该认真审查,最好别等生产环境替你审。