最近用 ChatGPT、Codex 做大型重构时,我越来越不建议一种看起来特别“干净”的做法:
字段准备改名,直接全仓库搜索旧字段,统一替换,然后删掉旧字段。
比如原来数据库里有一个字段:
user_name
现在准备统一改成:
display_name
从代码层面看,这件事似乎很简单。
Codex可以很快完成:
修改Entity。
修改DTO。
修改Repository。
修改SQL。
修改测试。
修改接口返回。
最后把旧字段删掉。
本地Build通过。
测试全绿。
Git Diff看起来也很完整。
于是很容易得出结论:
“迁移完成了。”
但真正上线以后,问题可能才刚开始。
因为生产环境不是:
旧版本瞬间消失 → 新版本瞬间全部替换。
真实的滚动发布通常更像:
旧Pod还在跑。
新Pod已经启动。
两个版本同时访问同一个数据库。
这时候一个非常重要的问题就出现了:
新代码改完了,旧代码还能不能继续工作?
这也是为什么数据库字段准备改名时,我现在更倾向于让Agent遵循:
Expand → Migrate → Contract
而不是:
Rename → Delete → Done
一、最危险的不是字段改错,而是新旧版本共存时互相看不懂
假设旧版本代码一直读取:
user_name
新版本已经全部改成:
display_name
如果数据库直接执行:
DROP COLUMN user_name
ADD COLUMN display_name
新版本当然可以正常工作。
问题是:
滚动发布期间可能还有一半旧Pod没有退出。
这些旧Pod继续执行:
SELECT user_name FROM users
结果立刻报错。
所以你会看到一种特别典型的现场:
新版本单独测试正常。
旧版本单独运行也正常。
但:
新旧版本一起存在时,系统反而坏了。
这个问题本质上不是字段设计问题。
而是:
Version Compatibility——版本兼容。
二、Agent特别容易把“最终状态”当成“迁移步骤”
这是我觉得Agent工程里非常值得注意的一点。
你告诉Codex:
把 user_name 改成 display_name。
它天然会倾向于生成一个“最终正确状态”:
代码里只剩 display_name。
数据库里也只剩 display_name。
测试全部围绕新字段。
从最终架构看,非常漂亮。
但真实工程需要的不是:
Final State
而是:
Safe Transition Path
也就是:
系统怎样从旧状态安全走到新状态。
这两件事完全不同。
最终结果可能只需要:
一个字段。
但迁移过程中,可能必须允许:
两个字段暂时同时存在。
三、第一步不是Rename,而是Expand
假设现在数据库只有:
user_name
更安全的第一步通常不是删掉它。
而是:
先新增 display_name
此时数据库里变成:
user_name
display_name
两个字段同时存在。
旧版本继续读取:
user_name
新版本可以开始识别:
display_name
这样做看起来有点“重复”。
但它真正换来的东西是:
兼容窗口。
你给新旧代码提供了一段可以安全共存的时间。
四、为什么这个兼容窗口这么重要?
因为生产发布不会只包含:
数据库。
还有:
应用实例。
缓存。
消费者。
定时任务。
异步任务。
旧客户端。
这些东西不一定同时升级。
比如:
10:00 数据库Schema已经更新。
10:02 第一批新Pod上线。
10:05 还有一半旧Pod在跑。
10:10 消息消费者才完成升级。
10:20 某个定时任务还是旧版本。
如果你把Schema改成只能支持新代码,
那么整个升级过程必须做到:
所有组件同时切换。
这在真实系统里通常非常困难。
而Expand阶段的作用就是:
让新旧组件在过渡期都能生存。
五、第二步才是Migrate
字段新增以后,
真正复杂的部分才开始。
因为这时候可能出现:
新数据写到哪里?
旧数据怎么办?
旧代码写 user_name。
新代码写 display_name。
两边数据会不会不一致?
这时通常需要设计迁移策略。
比较常见的一种方式是:
Dual Write——双写
在过渡阶段,
新代码同时写:
user_name
和:
display_name
比如用户改昵称:
两个字段都更新。
这样即使请求下一次落到旧Pod,
旧版本读取 user_name,
仍然能拿到最新值。
这解决的是:
写兼容。
六、读也不能直接只读新字段
如果历史数据还没有迁完,
很多老记录里:
display_name
可能还是空的。
所以新版本直接只读新字段,也可能出问题。
更安全的读取方式通常是:
优先读:
display_name
如果为空:
回退到:
user_name
也就是:
Read New, Fallback Old
这样即使历史数据Backfill还没全部完成,
新代码也能继续工作。
所以迁移阶段常见的组合就是:
写:新旧双写
读:新字段优先,旧字段兜底
这套逻辑虽然临时看起来比较“脏”,
但它的价值就是:
把升级风险拆散。
七、历史数据必须单独Backfill
新增字段以后,
数据库里可能有几百万甚至几千万历史记录。
不能简单假设:
以后用户重新保存一次就会自然补齐。
所以通常还需要:
Backfill
把旧数据里的:
user_name
逐步写入:
display_name
这里也不建议让Agent直接生成一句:
UPDATE users SET display_name = user_name
然后在线上一把跑完。
如果表很大,
可能带来:
长事务。
锁等待。
IO压力。
主从延迟。
日志暴涨。
所以更稳的方式是:
分批。
限速。
可恢复。
有进度记录。
例如:
每次迁移1000条。
记录Last ID。
失败后继续。
同时观察:
数据库CPU。
IO。
Replication Lag。
事务耗时。
八、为什么Schema Migration通常要比代码修改更保守?
因为代码回滚相对容易。
新版本有问题:
可以重新部署旧版本。
但数据库变更很多时候没有这么简单。
尤其是:
删除字段。
改变字段类型。
重写历史数据。
合并字段。
一旦数据真的被破坏,
你不能只靠:
git revert
恢复。
所以数据库变更里一个很重要的原则是:
Prefer Additive Changes First
优先做:
新增字段。
新增表。
新增索引。
而不是:
直接删除。
直接覆盖。
直接不可逆修改。
Agent改代码越快,
这条原则反而越重要。
九、真正危险的是“代码可以回滚,数据库却回不去”
假设新版本上线以后出现问题。
你决定:
Rollback。
应用很快切回旧版本。
结果旧版本开始报:
Unknown column 'user_name'
因为数据库已经把旧字段删掉了。
这时候你会发现:
应用回滚成功。
系统还是起不来。
这就是:
Code Rollback ≠ Schema Rollback
所以在设计迁移时,
应该提前问:
如果新版本上线10分钟后必须回滚,旧版本还能不能正常访问当前Schema?
如果答案是:
不能,
说明这个Migration还不够安全。
十、Expand阶段真正想保证的是N/N-1兼容
复杂系统里经常要考虑:
当前版本N。
上一版本N-1。
在一段时间内同时运行。
比如:
Version 2.3
和:
Version 2.2
一起访问数据库。
如果Schema只支持2.3,
滚动发布就会很危险。
所以一个更实用的设计目标是:
当前Schema至少短期同时支持N和N-1。
这对:
滚动发布。
快速回滚。
多Region发布。
灰度升级
都非常重要。
十一、什么时候可以停止Dual Write?
这一步不能凭感觉。
通常至少要确认:
所有新版本已经稳定上线。
旧Pod全部退出。
旧消费者退出。
旧定时任务停止。
历史数据Backfill完成。
新字段覆盖率达到预期。
监控一段时间没有异常。
然后才能进入下一步:
停止写旧字段。
这时可以把:
双写
改成:
只写新字段。
但旧字段仍然先保留。
因为:
停止写旧字段和删除旧字段,不应该是同一个动作。
十二、Contract才是最后一步
等到:
所有运行代码都已经不再依赖旧字段。
历史数据完成迁移。
旧版本已经确认不会再回滚。
观察期也足够长。
这时候才进入:
Contract
也就是:
删除旧读取逻辑。
删除双写。
删除兼容代码。
最终删除:
user_name
这时候系统才真正完成迁移。
所以整个流程其实是:
Expand
新增新字段,保持旧字段。
↓
Migrate
双写。
兼容读。
Backfill。
↓
Switch
新字段成为主路径。
↓
Observe
确认旧字段不再被使用。
↓
Contract
最后删除旧字段。
十三、为什么不能让Codex一次把所有步骤做完?
因为这些阶段之间需要:
Production Evidence
比如:
Backfill到底完成没有?
旧Pod到底退出没有?
还有没有旧客户端?
消息消费者是否全部升级?
新字段还有没有空值?
这些事情不是代码生成完成就能证明的。
它们需要:
真实运行。
监控。
时间窗口。
线上数据。
所以Agent可以一次性帮你生成:
Migration Plan。
新字段代码。
双写逻辑。
Backfill脚本。
监控。
Contract脚本。
但真正执行时:
不应该一次全部放出去。
这里要把:
Code Generation
和:
Change Execution
分开。
十四、让Agent做Migration时,我更建议先输出“阶段计划”
不要直接:
“把user_name改成display_name。”
更好的任务是:
先让它输出:
Phase 1
新增新字段,不改变现有行为。
Phase 2
增加双写与兼容读。
Phase 3
历史数据Backfill。
Phase 4
切新字段为主。
Phase 5
确认旧版本退出。
Phase 6
删除旧字段。
每个Phase再明确:
需要修改哪些文件。
验证什么。
如何回滚。
进入下一阶段的条件是什么。
这样Agent才是在执行:
Migration Workflow
而不是简单:
Search & Replace。
十五、Backfill完成也不能只看“脚本跑完了”
真正应该验证的是:
新字段覆盖率。
新旧字段一致率。
异常数据数量。
空值数量。
失败重试。
例如1000万条记录。
脚本显示:
“Completed”。
但里面有:
3万条失败。
那这次Migration当然还没有结束。
所以迁移完成应该基于:
Data Evidence
而不是:
Job Status
十六、给自己看一个指标:Backward Compatibility Coverage
这篇我建议只保留一个指标:
Backward Compatibility Coverage——向后兼容覆盖率
可以简单理解成:
迁移期间关键读写路径中,同时能够支持新旧版本的路径数量 ÷ 关键迁移路径总数
例如这次字段改名影响:
API读取。
订单写入。
消息消费者。
定时任务。
管理后台。
一共5条关键路径。
其中4条已经兼容新旧Schema。
但一个老定时任务仍然只能读旧字段。
那么:
向后兼容覆盖率 = 80%。
这时候最危险的不是:
新代码写得不好。
而是:
还有一个隐藏旧路径没有被纳入Migration。
十七、兼容覆盖率低,不应该继续删旧字段
如果Agent报告:
代码全绿。
新字段也能用。
但兼容覆盖率仍然只有60%,
这时候最不应该做的就是:
Contract。
更应该继续找:
还有哪些旧消费者。
还有哪些批处理。
还有哪些脚本。
还有哪些外部服务。
仍在使用旧Schema。
只有这些依赖逐渐清零,
旧字段才真正有资格删除。
十八、Plus和Pro怎么判断?
如果你平时主要让ChatGPT、Codex:
做小型数据库修改。
新增几个字段。
改单个接口。
影响范围有限。
这种任务下,Plus通常已经够用。
真正更重要的是:
不要让Agent把数据库Migration当成一次代码重构。
先把:
兼容。
Backfill。
回滚。
阶段边界
设计清楚。
如果你的实际开发已经变成:
大型系统频繁做Schema演进。
一次改动涉及:
多个服务。
多个消费者。
历史数据迁移。
灰度。
滚动发布。
数据回填。
多轮验证。
还需要Codex持续分析依赖、生成Migration、写Backfill、改代码、补测试、做回归,
这种高频、长时间、多阶段工程Workflow下,
Pro会更适合。
因为更多任务容量可以真正用于:
大型代码库理解。
长任务。
多阶段迁移。
反复验证。
但无论使用Plus还是Pro,
最关键的原则都一样:
Agent可以一次把最终代码写出来,但生产迁移不能一次跳到最终状态。
最后
数据库字段准备改名,
为什么不能让Agent一次把旧字段全部删掉?
因为真实生产系统需要解决的不是:
“最终字段叫什么?”
而是:
“旧系统怎么安全走到新系统?”
一个成熟的Schema Migration,
真正重要的不是最终Diff有多干净。
而是整个过程中:
旧版本还能运行。
新版本能够灰度。
历史数据能够迁移。
出现问题还能回滚。
当ChatGPT、Codex开始越来越快地完成大范围代码修改以后,
数据库迁移反而应该更加保守。
因为:
代码可以几分钟重写。
历史数据和线上兼容性,却不能靠一次Prompt重来。
所以真正安全的路线通常不是:
Rename → Delete
而是:
Expand → Migrate → Contract
先兼容。
再迁移。
最后清理。
这才是Agent真正进入生产工程以后,数据库Schema演进应该保持的节奏。
持续分享 Codex、大模型开发与 AI 编程实战内容。
长期深度使用各类代码大模型,也整理了稳定的Plus/Pro会员订阅渠道,有需要可自取。
网硕互联帮助中心




评论前必须登录!
注册