零停机数据库迁移:用 expand-contract 管住兼容窗口

零停机数据库迁移:用 expand-contract 管住兼容窗口原创封面

零停机首先是版本兼容问题

滚动部署期间至少同时存在旧应用、新应用和正在变化的 schema。数据库迁移先执行时,旧实例仍会按旧列读写;应用先切换时,新实例又可能访问尚不存在的结构。所谓零停机不是一条 DDL 从不加锁,而是每个中间状态都能被当时在线的所有版本正确理解。变更设计要列出兼容矩阵、切换顺序和回滚边界,不能只给出最终表结构。

最稳妥的 expand-contract 流程把破坏性变化拆开:先增加兼容结构,部署双读写或兼容读取,回填并验证历史数据,切换事实来源,观察旧路径归零,最后在独立发布删除旧结构。每一步都能暂停并保留可运行状态。把改名、类型转换、非空约束和旧列删除塞进一次 migration,会让任何一步失败都难以回到已部署应用可理解的 schema。

涉及枚举、扩展和函数的迁移还要检查事务与依赖对象。新增枚举值、替换函数签名或升级扩展可能具有不同回滚限制,视图、触发器和生成列会形成隐含依赖。预检从系统目录列出依赖图,在隔离副本执行完整 upgrade 与 rollback 演练;生产脚本只做已验证步骤,遇到未知对象停止并报告,不使用 CASCADE 删除来强行推进。

扩展阶段只做向后兼容的增加

新增列初始允许为空,避免数据库必须在一个阻塞动作中为大表计算并验证所有旧行。若需要默认值,要核对目标 PostgreSQL 版本和具体表达式是否会重写表,不凭经验假设。新增表和索引也要评估锁级别、构建磁盘与复制压力。应用旧版本看不到新字段仍能工作,新版本则必须在字段为空时采用明确兼容逻辑,而不是直接断言迁移已经回填完成。

列改名通常通过新增目标列实现,而不是立刻 RENAME。新写路径可以短期双写,但必须定义顺序与失败原子性,最好在同一数据库事务更新两个字段。若数据库触发器承担双写,要记录其生命周期并测试所有写入口;应用和触发器同时双写会形成循环或覆盖。扩展发布完成后,用实际生产查询确认旧实例仍正常,再开始回填,避免多个高风险动作叠在同一窗口。

回填是可暂停的在线作业而非巨大事务

大表回填按稳定主键或时间分区取小批次,每批在短事务中使用条件更新,只处理目标列仍为空的行。脚本保存已完成游标并可幂等重跑,遇到单条脏数据时记录精确主键并隔离,不能让数小时事务全部回滚。批次大小由锁等待、WAL、复制延迟和 autovacuum 状态动态约束;高峰时暂停比持续争抢在线查询更安全。

回填期间新写入会并发发生,所以双写必须先于历史扫描启用。脚本不能无条件覆盖目标列,否则可能把新应用已经写入的值替换成由旧列推导的陈旧结果。对于复杂转换,先在只读副本统计异常分布,明确舍入、时区和无效值策略。进度指标应包含总候选、已转换、跳过、失败和最老未处理范围,百分比之外还要观察是否卡在热点区。

约束和索引分阶段建立与验证

回填完成不代表可以立即 SET NOT NULL。先运行对账查询确认没有遗漏,再选择能降低长时间阻塞的约束建立方式,并在受控窗口完成最终验证。唯一约束尤其要先查重复并定义冲突处理;迁移现场临时删除“多余”行可能违反业务保留。外键需要评估引用表索引和并发写入,验证阶段可能消耗大量 I/O,应纳入容量和复制延迟门槛。

索引创建前记录它服务的查询形状,用生产规模副本估计时长和临时空间。并发创建减少某些阻塞,但不是零成本,也有事务和失败状态限制;脚本要检查索引是否有效而非只判断名字存在。新代码在索引可用前仍要保持正确,只是可能较慢,不能依赖执行计划才能满足业务语义。上线后观察真实扫描与写放大,确认收益后才删除被替代索引。

切换读取事实源需要对账证据

双写阶段最容易掩盖分叉。应持续比较旧列与新列的语义等价性,按租户、数据版本和时间分组,不只比较非空数量。新应用可先影子读取新字段并记录差异,实际响应仍由旧字段生成;差异归零并稳定一段时间后灰度切换读取。切换开关要能快速回退,但回退期间仍保持双写,否则新字段独有变化会在旧读路径丢失。

某些转换无法直接等价,例如状态枚举拆分或金额单位改变,需要写出双向映射的适用范围和不可逆点。进入不可逆状态后,回滚应用可能不再安全,此时回滚方案应是前向修复或恢复配套备份,而不是口头承诺切回旧版本。变更单明确最后一个可安全回退步骤,值班人员才能在健康检查失败时做正确选择。

收缩阶段确认旧代码与旧任务已经消失

删除旧列前,不仅要确认 Web 实例完成滚动,还要检查后台工作者、定时脚本、报表、数据导出和临时运维命令。通过数据库查询统计、应用版本指标和代码搜索证明旧字段读取写入为零,并保留覆盖一个完整业务周期的观察窗。只看当前容器列表可能遗漏暂停队列中的旧任务,它们恢复后仍会按旧 schema 执行。

收缩作为独立 release,先移除应用双写和兼容分支,保持数据库旧结构一段时间;确认新 release 稳定后再执行 DROP。大字段删除可能很快获得元数据变化,但锁等待仍会被长事务放大,需要设置合理 lock_timeout,拿不到锁就安全退出重试,而不是无限等待阻塞业务。删除后的磁盘回收方式另行评估,不在同一窗口追加重写大表。

测试覆盖每个中间 schema 与失败点

兼容测试矩阵至少运行旧应用对扩展 schema、新应用对扩展未回填 schema、新应用对完成 schema,以及回滚版本对仍保留旧结构的 schema。测试数据包含 null、边界值、历史异常和回填期间并发写入。迁移脚本在每批前后强制终止并重跑,确认游标和条件更新不会遗漏或重复覆盖。只测试从全新空数据库一次迁到最新版本,无法证明生产升级安全。

在生产规模副本记录每条 DDL 的锁、耗时、WAL、CPU、磁盘和复制延迟。故意保持长查询占锁,确认 lock_timeout 后迁移退出且应用继续服务;模拟回填单条失败,其他批次按策略推进。部署冒烟同时访问旧字段路径和新路径,健康检查必须验证业务读写而不只是端口存活。最终演练从备份恢复数据库与对应 release,确认恢复组合真实可启动。

运行手册把停止条件写在执行之前

迁移开始前声明允许的锁等待、复制延迟、错误率、回填吞吐和磁盘余量,任何一项越界就暂停当前可重入步骤。脚本使用非交互账户和最小权限,输出 migration id、批次范围与结果,不打印连接串。备份必须经过恢复验证,并标记与 release 的兼容版本;仅生成一个文件而从未恢复,不能作为破坏性收缩的依据。

完成后记录实际耗时、异常数据决策、保留的兼容分支和下一阶段最早执行时间。监控旧字段使用直至收缩完成,避免迁移“成功”后无人继续清理。若团队无法清楚回答当前处于 expand、backfill、switch 还是 contract,就应停止新的 schema 变化。零停机依赖的是阶段证据和可逆操作,不是一次凌晨维护中执行得足够快。

连接池、复制与长事务决定迁移的真实停顿

DDL 等待锁时后续请求可能在队列中堆积,造成看似短暂的元数据操作拖住整个服务。迁移会话设置有限 lock_timeout 和 statement_timeout,拿不到锁就退出并在下一安全窗口重试,不无限等待。执行前查询长事务、空闲事务和热点写入,必要时先解决异常客户端;不能为迁移方便粗暴终止未知生产会话。

连接池中的旧 prepared statement 和类型缓存可能在 schema 切换后继续使用。应用 release 切换要验证连接重建或驱动能正确刷新,不能只看新进程代码。代理连接池若采用事务模式,还要确认迁移账户与会话特性兼容。灰度实例先执行关键查询,观察 undefined column、缓存计划和类型解码错误,再扩大流量。

物理副本和逻辑订阅会承受回填 WAL 与 DDL 传播。门禁同时监控 replay lag、复制槽积压和磁盘,超过阈值暂停批次。只观察主库 CPU 会在副本落后时继续施压,最终读流量得到陈旧 schema 或槽占满磁盘。跨版本应用若读副本,还要保证 schema 与代码切换顺序不会在复制延迟窗口产生错误。

迁移完成后清理临时触发器、双写开关、回填账户和额外索引,逐项有证据而非顺手重构。运行手册记录当前兼容阶段、最后安全回退点、仍在线最低应用版本和下一步负责人。若这些状态没有可查询来源,团队应暂停后续 contract 操作;删除旧列比新增列风险高,等待完整证据是协议的一部分。

实现片段

ALTER TABLE accounts ADD COLUMN display_name text;

一手参考资料

评论 · 0

还没有评论留下第一句经过思考的话。

游客评论需审核。注册后可直接公开,无需审核。

SHARE / 分享

分享这篇文章

WECHAT / 微信

用微信扫一扫

在手机微信中打开文章后,再从微信右上角分享给朋友或朋友圈。