Codex写数据库迁移为什么一上线就锁表?用零停机迁移避免生产事故
使用 Codex 给项目增加字段、修改索引或调整表结构时,本地开发环境通常很顺利。
例如只需要新增一个字段:
ALTER TABLE users
ADD COLUMN nickname VARCHAR(100);
本地几千条数据,执行可能只需要很短时间。
但到了生产环境,如果表中已经有几百万甚至上千万条记录,同样的迁移就可能引发:
-
SQL执行很久;
-
数据库出现锁等待;
-
正常接口突然变慢;
-
写请求大量超时;
-
服务已经发布,新字段却还没准备好;
-
回滚代码以后,数据库结构却回不去了;
-
一个看似简单的字段修改,最后变成生产事故。
这类问题的核心通常不是迁移文件本身写错,而是数据库 Schema 变化和应用发布没有按照可兼容方式进行设计。
真正可靠的数据库迁移,不能只考虑:
最终数据库应该长什么样?
还要考虑:
从旧结构变化到新结构的过程中,线上服务还能不能继续工作?
一、为什么本地迁移正常,生产却可能锁表?
本地数据库可能只有:
1000条
5000条
10000条
而生产表可能已经有:
500万条
3000万条
1亿条
一些 DDL 操作在不同数据库、版本和表结构下,可能需要:
-
扫描大量数据;
-
重写表;
-
更新索引;
-
获取较强锁;
-
等待正在运行的事务。
如果 Codex 只根据本地环境判断:
Migration successful
并不能说明生产环境同样安全。
数据库迁移首先应该评估:
表有多大?
当前QPS多少?
有没有长事务?
这个DDL需要什么锁?
预计执行多久?
失败后如何恢复?
二、最危险的做法:代码和Schema同时强依赖
假设原来的用户表只有:
id
name
现在准备把:
name
拆成:
first_name
last_name
如果一次发布直接:
删除name
↓
新增first_name
↓
新增last_name
↓
新代码上线
会产生一个危险窗口。
旧服务仍然可能执行:
SELECT name FROM users;
但数据库已经没有 name。
或者反过来,新代码先上线:
SELECT first_name FROM users;
数据库迁移还没完成。
结果就是:
代码版本
和
数据库版本
无法兼容
因此零停机迁移的核心原则之一是:
新旧应用版本必须在迁移期间同时兼容数据库结构。
三、使用Expand / Contract迁移模式
大型线上系统中,可以把破坏性修改拆成两个阶段。
Expand:先扩展
只新增,不删除旧结构。
例如:
ALTER TABLE users
ADD COLUMN first_name VARCHAR(100);
ALTER TABLE users
ADD COLUMN last_name VARCHAR(100);
此时仍然保留:
name
旧代码继续使用 name,不会受到影响。
新代码可以逐步开始使用:
first_name
last_name
Contract:最后收缩
等确认:
-
新代码已经全部上线;
-
老版本实例已经退出;
-
历史数据完成迁移;
-
没有程序继续读取旧字段;
再删除:
ALTER TABLE users
DROP COLUMN name;
这一步可以放到后续单独版本中。
最终流程是:
新增新结构
↓
让新旧结构并存
↓
迁移数据
↓
代码切换
↓
观察
↓
删除旧结构
比一次完成所有变化安全很多。
四、不要一次UPDATE几千万行
新增字段后,经常需要历史数据回填:
UPDATE users
SET first_name = name
WHERE first_name IS NULL;
如果 users 有几千万行,这条 SQL 可能:
-
长时间占用事务;
-
产生大量日志;
-
增加复制延迟;
-
造成锁等待;
-
影响正常业务写入。
更稳妥的是分批回填。
例如:
每次1000条
↓
提交
↓
短暂等待
↓
继续下一批
伪代码:
while (true) {
const rows = await getUnmigratedUsers(1000);
if (rows.length === 0) {
break;
}
await migrateBatch(rows);
}
这样可以控制每次事务规模。
五、Backfill要支持断点续跑
如果需要迁移:
2000万条历史数据
任务可能运行几个小时。
不能假设它一定一次完成。
可以记录:
{
"migration": "users_name_split",
"lastId": 5820000,
"status": "running"
}
服务重启后:
从lastId继续
而不是重新扫描前面已经处理的数据。
这和前面大型批任务中的 Checkpoint 思路非常类似,但迁移任务还要额外关注:
老数据
+
实时新数据
是否会同时发生变化。
六、回填期间应用应该“双写”
假设历史数据正在把:
name
迁移为:
first_name
last_name
但迁移过程可能需要2小时。
这2小时内还有新用户注册。
如果应用仍然只写:
name
回填任务刚处理完的部分又可能产生新旧结构不一致。
可以在过渡期间双写:
await updateUser({
name: fullName,
firstName,
lastName
});
也就是:
旧字段
+
新字段
同时维护。
等确认所有服务都切换到新字段以后,再停止写旧字段。
七、读取也可以使用兼容策略
迁移期间,部分数据可能已经有新字段,部分还没有。
可以临时读取:
const displayName =
user.firstName
? `${user.firstName} ${user.lastName}`
: user.name;
形成:
优先新字段
↓
没有则回退旧字段
这样历史数据没有完全回填之前,新版本应用仍然可以正常工作。
这种兼容逻辑应该是临时的。
迁移完成后需要删除,否则项目会长期保留两套数据模型。
八、新增NOT NULL字段要特别谨慎
假设直接执行:
ALTER TABLE users
ADD COLUMN status VARCHAR(20) NOT NULL;
如果历史数据没有值,就可能产生问题。
更安全的思路通常是分阶段:
第一阶段:
新增可空字段
第二阶段:
新代码开始写入status
第三阶段:
回填历史数据
第四阶段:
确认不存在NULL
第五阶段:
再增加NOT NULL约束
也就是:
先让数据满足约束
↓
再启用约束
而不是反过来。
九、增加唯一约束前必须先清理重复数据
例如要给:
users.email
增加唯一索引。
不能直接认为生产数据一定符合要求。
先检查:
SELECT email, COUNT(*)
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
如果已经存在重复邮箱,直接增加唯一约束可能失败。
因此流程应该是:
扫描重复数据
↓
确定清理规则
↓
修复历史数据
↓
阻止新重复数据产生
↓
再建立唯一约束
Codex 生成迁移时,不能只关注最终 Schema。
还要检查历史数据能不能满足新的约束。
十、索引创建也可能影响线上性能
给大型表新增索引通常会消耗:
-
CPU;
-
IO;
-
磁盘;
-
数据库连接资源。
如果线上正处于业务高峰:
大量正常查询
+
大型索引构建
数据库性能可能明显下降。
因此大型索引变更应该评估:
-
表规模;
-
业务高峰时间;
-
数据库支持的在线索引能力;
-
是否会阻塞写入;
-
创建失败后如何处理。
不要因为 Codex 看到慢查询,就立即在线上执行多个索引创建。
十一、不要在应用启动时自动执行大型迁移
小型项目常见:
服务启动
↓
自动执行Migration
↓
启动成功
对于生产大型数据库,这种方式风险很高。
如果迁移执行需要20分钟:
新服务实例
↓
一直等待Migration
↓
健康检查失败
↓
部署平台不断重启
更合理的方式是把大型迁移作为独立发布步骤:
Migration Job
↓
验证完成
↓
再逐步发布应用
这样迁移失败不会直接导致所有新服务实例无法启动。
十二、迁移文件一旦上线不要随意修改
假设已经执行:
migration_001
migration_002
migration_003
不要为了修复生产问题,直接修改:
migration_002
因为开发、测试和生产可能已经执行过不同版本的内容。
正确方式通常是继续增加:
migration_004_fix_xxx
Migration 应该像 Git 提交一样:
执行过以后
尽量保持不可变
否则不同环境的 Schema 历史会越来越难追踪。
十三、数据库回滚和代码回滚不是一回事
例如发布:
v2应用
+
新增字段
上线后发现应用 Bug。
代码可以快速回滚:
v2
↓
v1
如果数据库只是:
新增字段
v1通常还能继续工作。
这就是为什么迁移应该优先:
向后兼容
反过来,如果发布时直接删除:
旧字段
代码回滚到v1后:
v1仍然需要旧字段
但数据库已经没有了。
所以破坏性 Schema 修改一定要延后。
十四、迁移前一定要做数据规模检查
可以让 Codex 先输出:
目标表:
users
总行数:
18,520,000
需要回填:
17,930,000
主要索引:
...
高风险操作:
ALTER / 大范围UPDATE
如果目标表只有:
3000条
方案可以简单一些。
如果是:
3000万条
就应该采用完全不同的迁移策略。
同一条SQL,在不同数据规模下可能是完全不同的风险等级。
十五、为迁移建立明确验收指标
数据库迁移完成不能只看:
Migration Success
还可以检查:
历史数据迁移完成率 = 100%
NULL异常数量 = 0
重复数据数量 = 0
旧字段读取量 = 0
错误率无明显上升
接口P95无明显恶化
例如回填任务完成后:
SELECT COUNT(*)
FROM users
WHERE first_name IS NULL;
应该得到:
0
再进入后续删除旧字段阶段。
十六、让Codex先输出迁移计划,不要直接执行
面对生产数据库任务,可以先这样要求:
请先不要生成最终迁移SQL。
分析本次Schema变化:
1. 哪些操作可能锁表;
2. 是否存在破坏性修改;
3. 新旧版本是否兼容;
4. 历史数据是否需要Backfill;
5. Backfill预计数据量;
6. 是否需要双写;
7. 是否可以拆成Expand / Contract;
8. 代码回滚后旧版本能否继续运行;
9. 最终什么时候可以删除旧字段。
先把发布过程设计清楚,再写 Migration。
十七、测试不能只使用空数据库
很多Migration测试:
创建全新数据库
↓
执行所有Migration
↓
成功
这只能证明:
新环境可以创建
不能证明:
真实旧数据可以安全升级
建议增加迁移测试:
准备旧版本Schema
↓
插入历史数据
↓
执行新Migration
↓
验证数据
↓
运行新应用
还应该覆盖:
-
重复历史数据;
-
NULL;
-
大批量数据;
-
Migration中途失败;
-
回填任务重启;
-
新旧应用同时运行。
十八、把数据库迁移规则写进AGENTS.md
可以加入:
# Database Migration规则
- 生产Schema修改必须优先考虑向后兼容
- 破坏性字段删除必须延后到独立版本
- 大表迁移必须先检查数据规模
- 大范围Backfill必须分批执行
- 长时间Backfill必须支持断点续跑
- 迁移期间新旧字段需要评估双写
- 增加NOT NULL前必须先完成历史数据回填
- 增加唯一约束前必须检查历史重复数据
- 大型DDL禁止默认在应用启动时执行
- 已上线Migration文件不得随意修改
- 修改Schema后必须测试代码回滚兼容性
这样 Codex 就不会只生成一条能够执行的 ALTER TABLE,而是同时考虑上线过程。
十九、一个推荐的零停机迁移流程
可以固定为:
分析Schema变化
↓
检查生产数据规模
↓
Expand:新增兼容结构
↓
发布兼容代码
↓
开启双写
↓
分批Backfill
↓
验证新旧数据一致
↓
代码完全切换新字段
↓
观察一段时间
↓
Contract:删除旧结构
整个过程的关键不是追求一次完成,而是:
任何一个阶段都可以暂停,而且线上服务仍然能够正常运行。
二十、Plus还是Pro?
如果主要使用 Codex 处理:
-
小型数据库;
-
简单新增字段;
-
普通Migration;
-
单个后端项目;
Plus通常已经可以覆盖大多数任务。
如果长期处理:
-
大型生产数据库;
-
数千万级表;
-
多服务Schema联动;
-
Backfill;
-
零停机迁移;
-
多轮测试与发布验证;
则可以根据实际开发强度评估 Pro。
对于复杂迁移任务,更大的使用空间主要帮助持续分析 Schema、代码调用和发布步骤。
但不论使用哪个版本,都不能省略真正重要的步骤:
先设计迁移过程,再生成SQL。
总结
Codex 写数据库迁移一上线就锁表,通常不是因为 Migration 工具本身有问题,而是开发过程只考虑了最终 Schema,没有考虑从旧版本变化到新版本的线上过渡过程。
通过 Expand / Contract、双写、分批 Backfill、向后兼容和独立迁移任务,可以把一次高风险数据库修改拆成多个可验证阶段。
真正可靠的数据库迁移,不应该要求:
代码
数据库
所有服务
必须在同一秒完成切换。
更好的设计应该允许新旧版本短时间共存,并且在任何阶段出现问题时,都能够暂停、回滚代码,而不会让线上数据进入不可恢复状态。
CSDN文章描述
本文介绍 Codex 编写生产数据库迁移时常见的锁表和版本不兼容问题,并通过 Expand/Contract、双写、分批 Backfill、向后兼容和迁移验证,实现更安全的零停机 Schema 变更。
更多推荐




所有评论(0)