使用 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 变更。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐