零停机的模式变更:安全的可增量迁移
了解如何通过可增量迁移、安全回填和分阶段发布实现零停机的模式变更,确保旧客户端在发布期间继续工作。

模式变更中的“零停机”究竟意味着什么
“零停机”的模式变更并不意味着没有任何变化。它的意思是:在你更新数据库和应用时,用户仍能继续工作,不会出现故障或被阻塞的工作流。
停机是指系统停止以正常方式运行的任何时刻。表现可以是 500 错误、API 超时、页面加载后显示空白或错误值、后台任务崩溃,或因为长时间迁移占用锁而导致数据库能读但不能写。
一次模式变更可能破坏的不仅仅是主应用界面。常见的失败点包括:期望旧响应格式的 API 客户端、读写特定列的后台任务、直接查询表的报表、第三方集成,以及那些“昨天还正常”的内部管理脚本。
旧版移动应用和被缓存的客户端是常见问题,因为你不能马上就更新它们。一些用户会保留某个应用版本好几周,另一些因为网络不稳定会在更晚的时候重试旧请求。即便是 Web 客户端,在 service worker、CDN 或代理缓存保留陈旧代码或假设时也会表现得像“旧版本”。
真正的目标不是“做一次大迁移,速度快就好”。目标是一系列小步骤,每一步都能独立工作,即使不同客户端处于不同版本亦然。
一个实用的定义:你应该能以任意顺序部署新代码和新模式,而系统仍然可用。
这种思路能帮你避免经典陷阱:在列还不存在时部署期望新列的新应用,或是在旧代码无法处理时先添加新列。把改动设计为先追加,分阶段推出,只有在确信没人再使用旧路径时才移除旧路径。
从不会破坏现有代码的追加变更开始
实现零停机模式变更最安全的路径是增加而不是替换。添加新列或新表很少会破坏东西,因为现有代码可以继续读写旧的结构。
重命名和删除是高风险操作。重命名实际上等同于“先添加新项 + 后移除旧项”,而“移除旧项”部分会导致旧客户端崩溃。如果必须重命名,把它当成两步走:先添加新字段,保留旧字段一段时间,确认没人依赖后再移除。
添加列时,先用可为空设计。可为空的列让旧代码在不知道新列的情况下仍能插入行。如果最终需要 NOT NULL,先以可为空方式添加、回填数据,再在后续强制 NOT NULL。默认值也能帮忙,但要小心:在某些数据库中添加默认值仍可能触及大量行,从而使变更变慢。
索引也是“安全但并非免费”的添加。索引能加速读取,但构建和维护索引会拖慢写入。只有在明确知道哪个查询会使用该索引时才添加,并在数据库繁忙时考虑在低峰时段创建。
一个简单的追加式数据库迁移规则集:
- 先添加表或列,保留旧的结构不变。
- 新字段先设为可选(可为空),直到数据填入。
- 在客户端更新之前,保持旧的查询和负载可用。
- 在回填完成后再延迟添加约束(
NOT NULL、唯一约束、外键)。
保持旧客户端可用的分步发布计划
把零停机的模式变更当成一次发布演练,而不是一次性部署。目标是让新旧应用版本并存,同时数据库逐步迁移到新结构。
一个实用的序列:
- 以兼容方式添加新模式。 创建新列或新表,允许为空,避免对旧代码无法满足的严格约束。如果需要索引,以不会阻塞写入的方式添加。
- 部署能“说两种语言”的后端变更。 更新 API,使其同时接受老请求和新请求。开始写入新字段,同时保持旧字段的正确性。这个“并写(dual write)”阶段是混合客户端安全共存的关键。
- 分批回填现有数据。 逐步为旧行填充新列。限制批次大小,必要时增加延迟,并跟踪进度以便在负载上升时暂停。
- 在覆盖率高后再切换读取。 一旦大多数行已回填并且你有信心,改为优先读取新字段,同时在一段时间内保留回退到旧字段的逻辑。
- 最后移除旧字段,且仅在确实无人使用时。 等到旧版移动构建大部分退化、日志显示没有对旧字段的读取,并且你有清晰的回滚计划后,再删除旧列和相关代码。
示例:你引入了 full_name,但旧客户端仍发送 first_name 和 last_name。在一段时间内,后端可以在写入时构造 full_name,为现有用户回填数据,然后默认读取 full_name,同时仍支持旧的负载。只有在采纳度明确后才删除旧字段。
无惊喜的回填:如何安全地填充新数据
回填是为已有行填充新列或表的数据。它通常是零停机模式变更中最冒险的部分,因为回填可能对数据库造成高负载、长时间锁定,以及令人困惑的“半迁移”行为。
先选定如何运行回填。对于小数据集,一次性的手动运行手册即可。对于大数据集,优先使用后台 worker 或定时任务,这类任务可以重复运行并能安全停止。
把工作分批,这样你可以控制对数据库的压力。不要在一个事务中更新数百万行。目标是可预测的批次大小和短暂停顿,这样正常用户流量能保持平稳。
一个实用模式:
- 使用有索引的键选择小批(例如接下来的 1,000 行)。
- 只更新缺失项(避免重写已回填的行)。
- 快速提交,然后短暂休眠。
- 记录进度(最后处理的 ID 或时间戳)。
- 失败时重试而不是从头开始。
让任务可重启。把简单的进度标记存入专门表中,设计任务以便重复运行不会破坏数据。幂等更新(例如只在 new_field IS NULL 时更新)非常有用。
边做边验证。跟踪还缺失新值的行数,并添加若干合理性检查,例如:余额不能为负、时间戳在预期范围内、状态属于允许集合。通过抽样抽查真实记录。
决定在回填未完成时应用如何处理。一个安全选项是回退读取:如果新字段为空,则计算或读取旧值。例如:你新增 preferred_language 列。在回填完成前,API 可以在 preferred_language 为空时返回配置文件中的现有语言,并在完成后再开始强制要求新字段。
在客户端混合版本下的 API 兼容性规则
当你发布模式变更时,通常无法控制每一个客户端。Web 用户更新很快,而旧版移动构建可能持续活跃数周。这就是为什么向后兼容的 API 很重要,即便你的数据库迁移本身是“安全的”。
刚开始把新数据当作可选项。在请求和响应中添加新字段,但第一天不要强制要求它们。如果旧客户端不发送新字段,服务器仍应接受请求并表现得像之前一样。
避免改变现有字段的含义。重命名字段可以,但要同时保持旧名称的工作。把字段复用为新含义往往会导致微妙的故障。
服务器端默认值是你的安全网。引入像 preferred_language 这样的新列时,当字段缺失时在服务器端设置默认值。API 响应可以包含新字段,而旧客户端可以选择忽略它。
防止大多数宕机的兼容性规则:
- 先把新字段设为可选,之后在采纳度高时再强制。
- 即便添加了更好的行为,也保持旧行为稳定,必要时通过特性开关逐步推出。
- 在服务器端应用默认值,让旧客户端可以省略新字段。
- 假设会有混合流量,并同时测试两条路径:“新客户端发送它”和“旧客户端省略它”。
- 保持错误信息和错误代码稳定,避免监控突然变得嘈杂。
示例:你在注册流程中添加 company_size。后端可以在字段缺失时设置默认值“unknown”。新客户端可以发送真实值,旧客户端继续工作,仪表板也依然可读。
当你的应用可以重新生成代码时:保持模式与逻辑同步
如果平台支持重新生成应用,你可以更干净地重建代码和配置。这有助于零停机模式变更,因为你可以做小的、可追加的步骤并频繁重部署,而不是长期携带补丁。
关键是要有单一可信来源。如果数据库模式在一个地方变更而业务逻辑在另一个地方变更,差异会迅速出现。决定在哪里定义变更,把其他所有视为生成输出。
清晰的命名能减少分阶段发布期间的事故。如果你引入了新字段,要让哪个字段对旧客户端安全、哪个是新路径一目了然。例如把新列命名为 status_v2 比 status_new 更稳妥,因为多年后仍易理解。
每次重新生成后需要重新测试的内容
即便变更是追加式的,重建也可能暴露隐藏的耦合。在每次重新生成和部署后,重新检查一小组关键流程:
- 注册、登录、密码重置、令牌刷新。
- 核心的创建和更新操作(最常用的那些)。
- 管理与权限检查。
- 支付与 webhook(例如 Stripe 事件)。
- 通知与消息(邮件/SMS、Telegram)。
在动手之前先计划迁移步骤:添加新字段、部署同时支持两字段的代码、回填、切换读取、然后再退役旧路径。这个顺序能让模式、逻辑和生成的代码同步演进,使变更保持小、易审查且可回滚。
导致故障的常见错误(以及如何避免)
大多数零停机迁移中的宕机不是由“重”数据库工作直接造成的,而是因为以错误的顺序改变了数据库、API 与客户端之间的契约。
常见陷阱与更安全的做法:
- 在旧代码仍读取旧名时重命名列。 保留旧列,添加新列并在一段时间内同时映射两者(写入两者或使用视图)。只有在能证明无人依赖旧名后再重命名。
- 过早把可为空字段改为必填。 先把列设为可为空,发布写入该列的代码,回填旧行,然后用最终迁移强制
NOT NULL。 - 在一个巨大事务中回填,导致表被锁。 分批回填,设置限额和间隔。跟踪进度以便安全恢复。
- 在写入产生新数据之前切换读取。 先切换写入,再回填,最后切换读取。若先切换读取,会出现空屏、错误汇总或“缺失字段”错误。
- 在没有证据表明旧客户端已消失时删除旧字段。 比你预计的保留旧字段更久。只有在指标显示旧版本基本不活跃、并且你已发布弃用窗口后才移除。
如果你的应用可以重新生成,可能会想在一次改造中“清理”名称和约束。抵制这种冲动。清理应是最后一步,而不是第一步。
一个好规则:如果某项变更不能安全地向前推进且能回滚,那它还不适合上线。
分阶段迁移的监控与回滚计划
零停机模式变更的成败取决于两件事:你在看什么,以及你能多快停止。
跟踪能反映真实用户影响的信号,而不是只看“部署完成”:
- API 错误率(尤其是已更新端点上的 4xx/5xx 峰值)。
- 慢查询(你触及表的 p95 或 p99 查询时间)。
- 写入延迟(高峰期插入与更新耗时)。
- 队列深度(回填或事件处理的任务积压)。
- 数据库 CPU/IO 压力(变更后是否有突增)。
如果你做并写(dual writes),添加临时日志来比较新旧值。保持精简:仅在值不同时记录,包含记录 ID 和简短原因码,并在量大时做采样。迁移后记得移除这些临时日志,避免长期噪音。
回滚需要现实可行。大多数情况下,你不会回滚模式,而是回滚代码并保留追加的模式不变。
一个实用的回滚手册:
- 把应用逻辑回退到最后一个已知可用的版本。
- 先禁用新读取,然后禁用新写入。
- 保留新表或新列,但停止使用它们。
- 暂停回填,直到指标稳定。
对于回填,构建一个能在几秒内翻停的开关(特性开关、配置值、任务暂停)。提前沟通各阶段:何时开始并写、何时运行回填、何时切换读取,以及“停止”意味着什么,这样在压力下就不会临时拼凑措施。
部署前的快速检查清单
在你发布模式变更前,停下来运行下列快速检查。它能捕捉那些在混合客户端版本下会变成宕机的小假设。
- 变更是追加而非破坏。 迁移仅增加表、列或索引。不删除、重命名或以会拒绝旧写入的方式收紧约束。
- 读取支持两种结构。 新的服务器代码能处理“新字段存在”和“新字段缺失”两种情况,且不会报错。可选值有安全默认值。
- 写入保持兼容。 新客户端可以发送新数据,旧客户端仍能发送旧负载并成功。如果必须同时并存,服务器接受两种格式并产生旧客户端能解析的响应。
- 回填可安全停止与启动。 任务按批执行、可重启且不会重复或破坏数据,并且有可测量的“剩余行数”。
- 你知道删除日期。 对何时安全移除遗留字段或逻辑有明确规则(例如 X 天后,且确认 Y% 的请求来自已更新客户端)。
如果你使用可重新生成的平台,再加一项健全性检查:从你要迁移到的精确模型生成并部署一个版本,确认生成的 API 与业务逻辑仍能容忍旧记录。常见失败是错误地假设新模式必须立即对应新的必需逻辑。
此外写下两项在部署后若出现异常你会采取的快速动作:你会监控什么(错误、超时、回填进度),以及首先回滚什么(关闭特性开关、暂停回填、回退服务器发布)。这使“我们会快速反应”变成可执行计划。
示例:在旧版移动应用仍在使用时添加新字段
你运营一个订单应用,需要一个新字段 delivery_window,并且对新业务规则将其视为必填。问题是旧版 iOS 和 Android 构建仍在使用,他们在接下来几天或几周不会发送该字段。如果你立刻让数据库强制要求它,那些客户端就会开始失败。
一个安全路径:
- 阶段 1:把列添加为可为空,不设约束。保持现有读取和写入不变。
- 阶段 2:并写。新客户端(或后端)写入新字段。旧客户端仍然工作,因为该列允许为空。
- 阶段 3:回填。用规则为旧行填充
delivery_window(根据配送方式推断,或默认设为“任意时间”,直到用户修改)。 - 阶段 4:切换读取。更新 API 与 UI 以优先读取
delivery_window,但在缺失时回退到推断值。 - 阶段 5:稍后再强制约束。待采纳度与回填完成后,添加
NOT NULL并移除回退逻辑。
用户在各阶段的体验应保持平稳(这是目标):
- 旧版移动用户仍能下单,因为 API 不会因缺失数据而拒绝请求。
- 新版移动用户看到并填写新字段,他们的选择被一致地保存。
- 支持与运维看到字段逐步被填充,而不是突然的空白。
每一步的简单监控门控:跟踪新订单中 delivery_window 非空的百分比。当它持续保持高位并且“缺失字段”类的校验错误接近零时,通常可以从回填阶段进入强制约束阶段。
下一步:建立可重复使用的迁移手册
一次性的谨慎发布不是策略。把模式变更当成例行工作:相同的步骤、相同的命名、相同的签批。这样下一次追加变更即便在应用繁忙、客户端版本混合时也能保持平稳。
把手册保持精简,应回答三个问题:我们添加什么、如何安全发布、何时移除旧部分。
一个简单模板:
- 仅添加(新列/表/索引、新的可选 API 字段)。
- 发布能读取旧结构和新结构的代码。
- 分批回填,有清晰的“完成”信号。
- 用特性开关或配置翻转行为,而不是重新部署。
- 只有在截止日期和验证通过后再移除旧字段/端点。
从低风险的表开始(一个新的可选状态、一个备注字段),完整运行一次手册:追加变更、回填、混合版本客户端、然后清理。这个练习会在你尝试重大重构前暴露监控、批处理和沟通上的空白。
一个能防止长期杂乱的习惯:把“稍后删除”的事项当作真实工作来跟踪。当你添加临时列、兼容代码或并写逻辑时,立即创建一个清理工单并指定负责人与日期。把一小项“兼容性债务”记在发布文档中,保持可见。
如果你用 AppMaster,可以把重新生成纳入安全流程:把可追加的模式建模、在过渡期间更新业务逻辑以同时处理新旧字段,并重新生成代码,让源头代码在需求变化时保持整洁。如果你想了解这种工作流如何适配一个能产出真实源代码的无代码平台,可以看 AppMaster(appmaster.io)。
目标不是完美,而是可重复性:每次迁移都有计划、有度量、有退出方案。
常见问题
零停机意味着在你更改模式和部署代码时,用户仍能正常工作。这不仅仅是避免明显的宕机,还包括避免静默故障,比如空白界面、错误的数值、后台任务崩溃,或因长时间迁移导致写入被阻塞的情况。
因为系统的许多部分都依赖于数据库的结构,不仅仅是主 UI。后台作业、报表、管理脚本、集成和旧版移动应用在你部署新代码很久之后仍可能读取或写入旧字段,所以迁移完成并不意味着一切都安全。
旧版移动构建可能会持续使用数周,有些客户端还会重试旧请求。你的 API 需要在一段时间内接受新旧负载并共存,这样混合版本才能不出错地运行。
追加式(additive)的变更通常不会破坏现有代码,因为旧的模式仍然存在。重命名和删除是风险较高的操作,因为它们移除了旧客户端仍在读写的内容,容易导致崩溃或请求失败。
先把列添加为可为空,这样旧代码仍能插入行。以小批量回填旧行,只有在覆盖率高且新写入一致后,才作为最后一步强制 NOT NULL。
把它当成一次发布演练:以兼容的方式添加新模式,部署能同时理解新旧格式的代码,分批回填数据,切换读取时保留回退,然后在确定无人使用旧字段后再移除它。每一步都应能独立安全运行。
将工作分批执行,短事务避免锁表或造成高负载。让任务可重启且幂等(例如只更新 new_field IS NULL 的行),记录进度以便暂停和恢复,必要时缩小批次并增加间隔。
刚开始时把新字段设为可选,并在服务器端对缺失值应用默认值。保持旧行为稳定,避免改变已有字段的含义,并同时测试两条路径:“新客户端发送新字段”和“旧客户端未发送新字段”。
通常你回滚的是应用代码,而不是模式。保留追加的列或表,先禁用新读取,再禁用新写入,暂停回填直到指标稳定,这样可以快速恢复且不会丢失数据。
关注真实用户影响的信号,而不是只看“部署完成”:错误率、慢查询(p95/p99)、写入延迟、队列深度、数据库 CPU/IO 等。每一步只有在指标稳定且新字段覆盖率高时才进入下一步,并把清理工作作为真实任务执行。


