凌晨2点也能回滚的数据库迁移实践

把数据库迁移拆成可回滚、可重跑的小步,让凌晨事故变成常规操作。
文章源于作者凌晨两点被报警叫醒、花三小时从日志重建数据的真实事故。核心主张是让回滚变得无聊:每个迁移必须写得出并测试过 down 步骤,写不出 down 就不发 up。危险变更应采用扩展与收缩模式,拆成扩展、回填、迁移读取、收缩四步,唯一不可逆的删除发生在冷静确定时。部署顺序上,加法迁移先于部署、破坏性迁移后于部署,回滚应在当前存活结构上生效而无需运行 down。结构性错误才回滚,瞬时网络或锁问题通过幂等回填与带退避重试解决,并为每个迁移设置语句超时避免锁表。
凌晨两点被报警叫醒,发现一个没有回滚步骤的数据库迁移把列删了,而备份还没做——作者花了三个小时从日志里重建数据。这段经历催生了一套让回滚变得无聊的迁移规范。本文基于 rss 源文章整理,提炼出可落地的操作原则。

每个变更都带上下行步骤
迁移有两个方向:上行(up)推进结构,下行(down)回退。多数 ORM 能自动生成两者,但很多人会删掉或忽略 down 步骤,觉得那是额外负担。作者的规则很硬:写不出 down 步骤,就不发 up 步骤。原因在于凌晨半睡半醒、错误飙升时,没人愿意临时写 SQL,只想跑一条命令把结构恢复到五分钟前。
down 步骤还有一个隐性价值:它逼你判断变更是否真的可逆。加列容易回退,删掉即可;重命名列其实是两个操作伪装成一个,朴素写法会在回滚时丢数据;删列则没有诚实的 down,因为数据已经没了。当你写下“down:重新加回该列”却意识到没有数据可填时,这个认知就是全部意义——down 步骤是变更安全性的测试,宁可死在工位上,也不要死在生产里。
作者会在部署前测试:先 up,再立刻 down,再 up。如果一轮往返后结构没回到可用状态,迁移就不合格。常见触发点包括回退时丢掉外键却没重建,或索引在回滚后残留并与下一次 up 冲突。
数据库迁移(Database Migration) 是描述数据库结构变更的版本化脚本,是 Code as Infrastructure 的具体实践。每次 schema 变化(加表、加列、改索引、改类型等)都对应一条迁移记录,工具按顺序执行以保证环境一致。主流工具包括 Rails 的 ActiveRecord Migration、Django 的 migrations、Flyway、Liquibase、Alembic、golang-migrate 等。
迁移的核心契约是成对出现:up 定义如何把结构推进到新版本,down 定义如何回到上一个版本。许多 ORM 能根据模型差异自动推导 down,但对数据型变更(如删列、改类型)往往无能为力或生成错误的 down,这恰恰是最需要人工审视的地方。把迁移纳入版本控制、CI 与代码评审,是让回滚变得可预测的前提。
一套可靠的迁移规范通常包含:命名带时间戳保证顺序、禁止在迁移里调用应用模型代码(模型会随时间变形)、对生产大表操作时显式声明锁策略、以及在预发环境跑一遍完整的 up-down-up。
扩展与收缩:一次变更拆成四步
真正救命的是 expand 和 contract 模式。不要原地改列,而是把变更拆成各自安全的阶段。以把 user_name 重命名为 username 为例,危险做法是一条迁移直接改名:部署中途一旦出错,旧代码找 user_name、新代码找 username,总有一边是错的。
展开收缩法用四次步骤跨多次部署完成。
第一步,扩展。 在旧列旁新增 username 列,暂时无人读取。这是纯加法,down 步骤就是干净地删掉,零数据丢失。
第二步,回填。 把 user_name 的数据复制进 username,并让应用在每次更新时同时写两列,保持同步。旧代码读旧列,新代码可以开始读新列。
第三步,迁移读取。 部署读取 username 的代码。旧列仍存在且仍被写入,因此这个部署即使回滚也不会坏。
第四步,收缩。 确认新列正确、无人再读旧列后,隔几天再用单独迁移删掉 user_name。
除了最后一步,每一步都可逆。唯一不可逆的删除操作,发生在冷静且确定的时候,而不是部署风暴中。对大表,回填要分批,避免在一个事务里锁住五万行。作者的原话很直白:这感觉更慢,事实上也更慢,四次部署换一次三步——但四小步各两分钟,一大步出错就是三小时。
部署与迁移的顺序
避免大多数凌晨事故的单条规则:加法迁移在部署前跑,破坏性迁移在部署后跑。
部署期间存在一个窗口,旧代码和新代码同时存在,实例逐个重启,数据库可能在三十秒到两分钟内同时服务两个版本。如果新增列在使用它的代码之前完成,新旧代码都安全:旧代码忽略该列,新代码发现它已就位。结构永远领先代码。
但如果在窗口里删列,仍期待该列的旧代码就会崩。所以删除要等到所有实例都跑新代码,破坏性迁移最后执行。顺序随方向翻转:加东西,先迁移再部署,结构领先;删东西,先部署再迁移,代码领先。
回滚遵循同样逻辑的反向。回滚部署时旧代码回来,需要旧结构仍能支撑它——这正是破坏性步骤等待的原因。列还在,回滚的代码找到它需要的一切。作者在部署笔记里放了一行规则:回滚必须在当前存活的结构上生效,完全不运行任何 down 迁移。如果做不到,就是迁移顺序错了。
需要重试而非回滚的失败
并非所有迁移问题都该回滚。大表上的长回填可能因连接断开、锁超时或副本落后而中途失败。若慌张回滚,就丢掉了已完成的部分。更好的做法是让迁移可安全重跑。
作者把回填写成幂等:不是“复制所有行”,而是“复制目标仍为空的所有行”。跑一次填两万行,连接断了再跑,填剩余三万行并跳过已完成的,没有重复、没有损坏、无需重来。针对迁移执行器的瞬时故障,使用带退避和抖动的重试——遇到临时锁应等待重试,而不是让整个部署失败。
区分很重要:结构错误的变更回滚,结构正常但遇到网络抖动的变更重试。混淆两者会把小问题放大。作者还给每个迁移设置语句超时:没有超时,一个抢锁的迁移会阻塞表上所有查询,拖垮整个应用;有超时,迁移快速失败,看到锁竞争,换个安静窗口再跑。两秒失败的迁移是小事,挂十分钟持锁的迁移是事故。
最小可执行清单
如果只改一件事:先写 down 步骤再写 up 步骤,并在工位上测试完整的 up-down-up 循环。这个习惯能在不可逆变更进入生产前抓住它们。
再叠加顺序规则:加法变更先迁移后部署,破坏性变更先部署后迁移,删除要带着自己的独立迁移等上几天。回滚应当无需任何 down 迁移即可在当前存活结构上工作。做不到,就是顺序错了。
扩展收缩在第一次帮你免于凌晨数据丢失前,看起来都像杀鸡用牛刀。那之后,它就只是重命名一列的常规做法。幂等、带超时、可重跑——这三样让一次摇晃的迁移不至于变成凌晨两点的艰难抉择。
原文提及作者将日志、重试与迁移规则整合在项目蓝图里,并包含推广链接与免责声明。本文仅提炼可迁移的工程实践,不构成对特定工具或服务的推荐。
相关推荐

3分钟看懂DeepSeek:从量化私募到开源AI领跑者的崛起之路
3分钟看懂DeepSeek发展史:从幻方量化的算力积累,到V2的MLA推理降本、V3的557.6万美元训练账单、R1开源引爆全球,再到V4与70亿美元A轮融资,梳理深度求索的关键节点与技术路线。

本地文学创作模型PK:Qwen3.8 27B微调版胜出,DeepSeek v4.1表现平淡
B站UP主横向测评七款本地文学创作模型:海明威、HumanLike Chat基于Qwen3.8 27B微调表现最佳,DeepSeek v4.1叙事平淡,东方朔角色混乱垫底。附三道测试题评分与使用建议。

梁文锋的三次豪赌:DeepSeek为何将82%毛利开源?
DeepSeek为何把82%毛利的推理框架DeepInfer开源,又启动千亿融资?从技术路线、国产芯片到组织纯粹性,解析梁文锋的三次豪赌与开源战略背后的商业与理想逻辑。