Database Migration 策略:SaaS 生产环境的零宕机迁移指南

这是一份在不中断应用服务的情况下,对在线运行的 SaaS 系统进行 Database Migration(数据库迁移)的实战指南。本文总结了来自 14 次真实生产部署的经验,深入解析扩展-迁移-收缩模式、并发索引、分块数据回填、可观测性监控以及应对突发故障的可靠回滚计划,助你实现真正的零宕机发布。

Huifer
Huifer
July 13, 202612 min read
其他语言:Deutsch · English

本文由 Huifer 撰写,我是独立开发者兼 TanStack Ship 的维护者。我曾在 PostgreSQL 和 Cloudflare D1 上的 14 个生产环境 SaaS 应用中运行过这些数据库迁移脚本——包括一个拥有约 2.8 万个组织的租户计费平台、一个 B2B 市场、两个开发者工具和 10 个较小的应用——并且我亲自交付、搞砸并重新交付了下文列出的每一种模式。这是我在每次部署前都会查阅的操作手册:如何在不造成应用宕机的情况下演进实时数据库的 schema,如何在不持有写锁的情况下回填上百万行数据,以及当迁移出错时如何快速向前滚动。本文没有任何 vendor 赞助——这些全是在真实事故中存活下来的经过验证的模式。

验证来源:PostgreSQL ALTER TABLE documentation · PostgreSQL CREATE INDEX CONCURRENTLY · Drizzle ORM migrations · Prisma migrate · Cloudflare D1 migrations via Wrangler · SQLite ALTER TABLE limitations · TanStack Ship migration reference repo

最后更新:2026-07-13 · Changelog


TL;DR: 实时 SaaS 上的数据库迁移并非单纯的 SQL 问题,而是应用代码、schema 状态和实时流量之间的协调问题。以下是我在 14 次生产部署中所依赖的模式:命名规范、扩展-迁移-收缩(expand-migrate-contract)、PostgreSQL 和 D1 上的增量式 schema 变更、并发索引创建、分块与幂等的数据回填、作为门控的特性标志(feature flags)、捕获数据飘移的可观测性,以及能应对意外故障的可靠回滚计划。关于数据库选型请参阅 D1 production guide;关于多租户 schema 设计请参阅 SaaS database architecture guide。


为什么数据库迁移会成为 SaaS 的生产环境风险

我在自己的应用中遭遇过的三次中断

2024 年,一次看似平常的数据库迁移毁了我的周五夜晚。当时的变更只是一行简单的 ALTER TABLE subscriptions ADD COLUMN trial_extension_days INTEGER NOT NULL DEFAULT 0。Postgres 短暂地加了锁,部署顺利完成,接着我就去睡觉了。到了周六早上,有 4 个企业客户的账单计算出了错。在应用开始读取该列之前,DEFAULT 0 就已直接应用到了约 300 万行现存数据上;而在较老的 shard 的另一个代码路径中,应用仍在使用旧的 trial_extension JSON 字段。虽然 schema 迁移成功了,但数据在逻辑含义上已然发生冲突,并且整整 6 个小时都没人注意到这个问题。

第二次事故发生在 B2B 市场应用的列重命名上。我执行了 ALTER TABLE products RENAME COLUMN category TO category_id,Postgres 在 2 秒内完成了操作。麻烦在于部署是针对 worker 逐步引流的——这意味着一半的 worker 在读取旧列,另一半在读取新列。于是整整两周的时间里,数据库部分行的数据是由新路径写入了 category_id,另一部分则是旧路径写入了 category。对齐数据耗费了 3 名工程师一个漫长的周末。自那以后,我再也没有在缺乏审查的情况下运行过重命名操作。

第三次事故发生在 D1 系统里的一个小应用上。当时我试图在热表上添加索引,执行 CREATE INDEX idx_events_org_time ON events(org_id, created_at)。SQLite 没有 CONCURRENTLY 子句。这导致在流量高峰期,索引构建操作硬生生持有了长达 90 秒的写锁。接着 Workers 绑定超时、请求堆积排队,甚至有个 Stripe webhook 在进行主动重试时刚好撞到了一个尚未完全提交完毕的事务上。解决办法是:在离线状态下推送索引,切交流量,然后再通过特性标志(feature flag)启用新的查询路径。这也正是我们下文要阐述的方案轮廓。关于更广视角下的技术栈上下文,请参阅 SaaS architecture guide。

SaaS 规模带来的特殊挑战

与一般的 web 应用相比,SaaS 系统的数据库迁移挑战更大,原因有三。首先,计费数据无法回滚——丢失一行订阅记录意味着客户失去访问权限,重复开具一张发票则意味着收入泄漏。其次,多租户流量具有突发性——一个客户运行的批处理作业极易占满写锁,而此时可能还有 1000 个其他租户正在发起请求。第三,schema 与应用代码是分开进行版本控制的——不管是带着新 schema 预期的代码在老流量排空之前被部署上去了,还是反过来,都会出问题。下文所介绍的各种模式,正是为了应对这些实打实的运营压力而生的。


迁移前纪律

每次发布都必须通过的迁移检查清单

在我管理的所有应用中,每一次迁移在合并进 main 分支之前,都必须严格对照这份检查清单执行。漏掉这里的任何一项都曾让我付出以一个周末为代价的教训。

  1. 变更方向。 该迁移是可逆的、部分可逆的还是单向不可逆的?请在 PR 描述中标明相关的单向部分。列的删除、类型变更以及数据回填都是单向的;而加入一个可用 null 表记的新列,或加进一张新表则是可逆的操作。
  2. 锁持续时间。 预估 Postgres 或 D1 将持有写锁多长时间。在已有海量数据的表上直接加上带有 NOT NULL DEFAULT 属性的操作可能会非常慢;而在空表上运行 ADD COLUMN IF NOT EXISTS 操作则是瞬间完成的。PostgreSQL ALTER TABLE docs 明确列出了哪些操作是属于 O(1) 复杂度,哪些又必然遭遇全表扫描。
  3. 回填计划。 请在包含迁移的 PR 合并前就写好数据回填(backfill)脚本,无论这部分是否延迟运行。如果不这么做,你往往会用到过时的回填脚本,并在改写时失去设计好的幂等性。
  4. 回滚计划。 每一个 PR 描述开头都应写明:“如果此次变更失败,我们将通过 X 回滚”。如果回答是“我们无法回滚”,那么该 PR 必须配备一份手动恢复操作手册(runbook)。
  5. 可观测性。 如果一个迁移改变了系统行为却没有任何遥测监控,其代价将高昂到无法估量。必须在调整 schema 之前配好指标监控。
  6. 特征标识放置。 由特性标志(feature flags)作门控的迁移,开关必须默认为 off;等到 schema 验证无误后,再开启特征开关。

命名与版本控制约定

我将所有迁移脚本命名为 YYYYMMDDHHMMSS_<动词>_<名词>.sql 格式——绝对不用诸如 migration_47.sql 之类的命名。时间戳决定了顺序;动词(如 add、rename、drop、backfill)让人一眼看出改动形态;名词指出受影响的表。因为时间戳可以被无歧义排序,同一个数据库开发线上的两名工程师绝对不会对执行顺序产生争议。Drizzle migrations guide 采用了这种约定,Prisma migrate 也是如此——选定其中一种并坚持执行。


扩展-迁移-收缩循环

它是什么,为何有效

本指南最重要的一种模式便是 扩展-迁移-收缩(expand-migrate-contract) 循环机制。几乎每个在宣扬“零宕机”营销话术的供应商,本质都是该循环的某种特殊演变。这套机制比 Kubernetes 还要古老,但现在仍然奏效,恰恰是因为它把每个执行阶段分配在独立时间窗口,使应用程序能够持续为你提供服务承接流量。

  • Expand(扩展) —— 始终在原来结构下新增构造。新增的列、新表、新索引。始终以追加的形式进行,并且必须是可逆的。
  • Migrate(迁移) —— 从旧结构向新结构回填数据。此时上线的应用代码会在仍然读取旧列的同时,双写以更新新列。这意味着两种格式会在数据库里并存。
  • Contract(收缩) —— 一旦新结构的数据填充完毕,且所有应用代码不再读取旧的结构,便可以将其安全删除。

如果你时间紧迫只足够融会贯通一种模式,请记住这一个。

阶段 1 — 扩展(Expand)

扩展阶段只允许追加内容。新列默认为 NULL 或配有安全的默认值;新表最初必须为空;新索引应该在有任何查询依赖它们之前就创建好。凡是涉及读取存量记录的操作都会被留在接下来阶段,这里绝不执行。

sql
-- 0001_expand_add_user_locale.sql
-- 安全起见:使用 nullable 的列,无数据移动,在 Postgres 和 D1 上均可瞬间完成。
ALTER TABLE users ADD COLUMN locale TEXT;

-- 安全起见:开启新表,无老代码相关的读取操作,也无写入操作。
CREATE TABLE user_locales (
  user_id  TEXT PRIMARY KEY,
  locale   TEXT NOT NULL,
  updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);

CREATE INDEX idx_user_locales_locale ON user_locales(locale);

SQLite 对 ALTER TABLE 的详说参考文本条例里 (lang_altertable.html) 明确指出 ADD COLUMN 在跑 D1 时为 O(1) 阶的操作指令;Postgres 面对不具有易变副作用的非易失性默认值的表现也是完全一致。而 D1 迁移指南(developers.cloudflare.com/d1/reference/migrations)也同样展示出利用 wrangler d1 migrations apply 全盘套取该相同模型的处理流程。

阶段 2 — 迁移回填(Migrate)

Migrate 阶段通常是发生事故的高发地带。我会在下面专门设一节详述——简而言之就是三分词语:分块处理、保持幂等、实现可观测。此时应用程序可以开始从新结构中读取数据并同时保证旧结构的写入;随后的阶段才会将其废弃。

阶段 3 — 收缩(Contract)

Contract 阶段只有在所有曾经读取旧列的代码路径均已移除后才能执行。我通常会让其放置于一个独立的 PR 中,且该 PR 必须在执行 Expand 的 PR 开启一周之后才建立,并辅之以整个代码库的全范围搜索。这个 PR 的描述里必须加入“确定已无任何读取 <old_column> 列的代码存留”作为检测勾选条目。

sql
-- 0042_contract_drop_old_column.sql
-- 必须满足项:已确认无应用层代码仍在读取 `trial_extension` 这个 JSON 字段记录。
UPDATE users SET trial_extension = NULL WHERE trial_extension IS NOT NULL;
ALTER TABLE users DROP COLUMN trial_extension;

对于计费相关的列,我会在 Contract 执行后再让该列以允许 nullable 的状态保留一个季度的时间,一直等到下一个季度清理时再删——损失过计费字段的教训曾一度让我变得杯弓蛇影。如果你想了解这些模式所运行的多租户上下文信息,请参阅 SaaS database architecture guide。


零宕机 Schema 变更实践

安全添加列

只要默认值是常量或没有默认值,Postgres 和 SQLite/D1 都允许在不实行全表扫描的情况下执行 ADD COLUMN。面对非易失性的默认值(如 0、'{}' 以及空字符串等),从 11 版本开始的 Postgres 已经优化到可以把默认值直接存储在元数据里,继而对新增行实行滞后的延迟改写——这就实现了无需全表重写、也无需加锁。但要是你碰上必须设置的是易变化性质的默认参数请求,你就得去承受一整笔巨大的全表推翻重写开销。解决手法是:先部署以 NULL 为默认值的新列,修改应用程序逻辑去妥善处理 NULL 取值,然后将回填动作作为一个独立的另类步骤进行分批派发执行。

在不断表的情况下进行列重命名

绝对不要在生产环境中对某列直接进行重命名!正确的做法是:新增一个列、从应用程序层面实现双写、将旧列的数据复制填塞至新列、将所有读取切换到新列上,最后再去清理掉那个旧的列。前文所说长达两周的数据不一致事故,其实正是因为我之前侥幸跳过双写环节,盲目轻信 RENAME 放在应用层面会有完美的执行原子性。重命名在单个数据库环境里固然有着原生原子效果保障;但在由众多不同端构成的海量并发分布请求的 worker 端去调取,那必定没有任何原子性保证。

通过影子表变更列类型

类型更改(比如把 INTEGER 换成 BIGINT,或者把 TEXT 置换成 JSONB)均系单向不退的操作。保障实时处置业务不停机的唯一平稳途径,是单另建起带有新类型格式的陪衬“影子表”(shadow table),并在两边实施同推双写入处理,跑完回填工序后强制截留转移读取流量,并在收工前扔抛旧表完事。我就在那座计费总台为进行一项从 usage_events.quantity INTEGER 过渡改至 BIGINT 时亲自实践了一趟——在长达一周时间里先后提交了 4 个独立的关联 PR 后才使其最终安全稳定落地。

给线上表加索引

Postgres 的 CREATE INDEX CONCURRENTLY 可以做到在无需获取写锁的情况下构建索引,固然它会消耗更长时间,且在中断失败后需要另行执行 REINDEX 的收尾操作。面对那些已经被海量灌满大票数据的重沉大表,请坚定执行该法。CREATE INDEX docs 上的描述给得再明确不过:因 CONCURRENTLY 这种本身便不可以包埋置于任何囊括性质底里的特殊事务执行链环节,导致无法随心融入或搭配某些主流迁移工具组合处理。这事须请你及早留出余地应对。

而对于身在用 D1 或者 SQLite 去驱动的开发者而言就不存在能借以使用跑这 CONCURRENTLY 这个设定的这命了。出路留出两条项定论:挑那条能够找到拨退出空来的一截无人问津时间把去搭那底项这招落局干脆做完(这对仅仅只跑着在中小圈子做去跑 SMB 应用尚算行之有招、如属必须满足全天候服务的项目则算白瞎);或者也是对打着对这最频繁走动被摸的最热引引源采取并立跑个外侧同套之“外配的带型带影子底表项套”。多些更深涉这一底内的跟在 D1 指路更靠细微一类的在请全参此文:Cloudflare D1 deep dive。


生产数据的回填

分块更新

设想对包含几百万行数据的表执行单一的 UPDATE users SET locale = 'en-US' WHERE locale IS NULL 是一整个大事务。它会锁定每个更新的行,撑爆 WAL 日志,阻塞复制过程,而在 D1 平台上甚至会强占数据库级别的写锁,让应用程序的其余部分只能干等。解决方案就是分块查询(chunking):

sql
-- 放入某个 cron 定时任务或 worker 进程之中慢慢去跑,绝对不要在做迁移中连带上线直跑。
-- 实现幂等性:针对已完成填补的那些行去重跑就是一次纯属未修改不执行空跑操作(no-op)。
UPDATE users
SET locale = COALESCE(locale, 'en-US')
WHERE locale IS NULL
ORDER BY id
LIMIT 1000;

在低并发环境下循环这条查询(执行设为 LIMIT 1000 并每次批次之间暂停 100ms 休眠)直到 SELECT COUNT(*) FROM users WHERE locale IS NULL 的返回结果为 0 为止。这段做完整盘推下去的总耗时是交由行数及写入吞吐量确定的——就拿那座 300 万位用户的计费账台而言,为了推满这操作它仅安全压在合规大载面之中一共用了刚好 14 分钟而已。

幂等回填

回填操作如果不具备幂等(idempotent)特性,在遭遇网络丢包崩溃之后,往往会导致重跑流程,造成干重活或者直接漏跑的状况。通用制法该是:要求每个执行回填命令的任务脚本里必定被设下一根能划清区分“已经完成”和“仍需处理”这道警戒红线的探针边界。加上可为空限定的目标列配其 WHERE dest IS NULL 的设定手段可就是一套教科书式的用例了。针对涉及派生产物的运算的项的更新,那请用名为 _backfilled_at 这种加带时间戳去作查验证的手段便对了。

验证回填完整性

验证过程解答了“是否每一行都获取到了新值?”这个问题。最少需要执行两类查询:首先是源表和目标表之间合理的行数总数匹配比对,其次是抽取一部分经过分层的样本行,用以根据应用程序所预设期待的值做二次比对查核。我都习惯将一份对应的名为 scripts/verify-backfill.sql 的核检脚本紧凑搁靠立放在对应的各自的回填脚本旁边。


迁移工具:我实际的选用

根据团队规模与数据库选择工具

在 D1 上单人工作时,Wrangler migrations CLI 绝对是得心应手之选——配有版本号标识序列管理的各种 .sql 文件机制、并支持次第执行排序加上带有在重跑时依然保有的那项幂等性能。对于 Postgres 且是小团队或两人小组单干的时候,我反而更偏爱手写 SQL:手写的 SQL 具有审核透明度,版本控制非常直接,并且半途失效的迁移更容易挽救和恢复。一旦你是一个较有规模的大团队,引入 Drizzle migrations 或 Prisma migrate 就很必要了,正是通过类型安全机制与能识别抓捕到 schema 漂移的技术才能体现其核心价值。你可以通过查看 SaaS database architecture guide 去了解在什么情况下,引入具备 ORM 形态的迁移工具能够通过其价值证明这笔开销。

Cloudflare D1 与 PostgreSQL 工具的说明

D1 的迁移本质就是一堆 SQL 文件,必须输入 wrangler d1 migrations apply <db> --remote 这样的命令才能跑动。这里并没有提供自动生成代码差异的功能;这要求所有的 SQL 皆必须由开发人员通过手动编写而成。相对的采用 Drizzle 操作 PostgreSQL 的团队却能够先坐享系统生成的自动对比差异档并在此底子再去动手修订;反之,一直走着手写纯 SQL 那一路的队伍这种差分比较就直接跳过忽略。其中的取舍利弊在于:生成的差分比较在捕猎如字段漂移(column-drift)隐藏大坑方面是绝对能手;然而从它这端批量写出的大长幅 SQL 却往往是熟练的审查者绝不会愿意写出来的烂代码。选中一套数据库的约定并坚决投入到底。


生产切换与可观测性

充当迁移门禁的特征开关 (Feature flags)

如果应用程序代码在这个时候仍旧依然还在读取废弃旧列里的数据项,那么上方谈到的那部分收缩阶段只会在完全没有任何征兆之下默默走向崩溃。特征开关机制彻底封堵了这个问题。将一个诸如 mig_2026_07_trial_extension_v2 标志默认为 off 的设定落在应用程序配置中,只有通过系统在运行时的环境检测后才能真正被启用,并在任何尝试读取新列的代码前对其执行检查。这个特性标记让发布从仅占 1% 的局部细微流量开启,继而在随后一次单次部署即可跃升至 100%,或者在只要某位客户汇报了一点异变你同样是可以瞬退降至 0%。欲想了解全盘的宏大模式,去阅读 TanStack Ship features page 页面,在那里此等各项特征开关统统被视作为了首一级的运行时基础。

能够应对意料之外故障的回滚计划

回滚计划大抵分为三类。完全可逆(仅进行追加操作的变更):回撤代码部署,执行 DROP COLUMN 或 DROP TABLE 即可搞定。部分可逆(进行了数据回填):回撤代码部署;新列将作为一座处于荒废状态的“墓碑”留在那里,而产生的数据漂移也是有边界的。单向操作(列删除、类型变更以及收缩任务):遇到这种回滚就意味着必须从备份中执行硬恢复;操作指导手册上写着“紧急呼叫 on-call 研发,冻结一切写入,调用恢复前一天的全量快照,再进行 WAL 日志回放”。我这辈子只做过一次这种重手,此生绝不想再来一遍。所以在此后每一涉及到收缩阶段之列的 PR,我都硬性要求在合并前必须包含这套快照备份还原的重型恢复手册。

迁移后检查

迁移后的这道检查,是被放在该迁移自身用于上线的同一个部署管线上运行的。最少需要核查三项门底线:比较执行动作前后行数的一致性,抽出样例行配合预期的 schema 进行验证,以及执行对主应用程序代码主要带走新列的那一条主业务路径连带实地烟雾测试。任何一项检查如果失败就会直接触发系统自动回滚。这个验证关卡也就是只落笔写个几行 SQL 加调用一次系统的健康检查(health endpoint);但其带来的回本速度极快,在它帮你首次成功把半路没成的局部残缺的迁移阻挡下来时你就会彻底拜服了。


结论

数据库迁移并不单指你改变 schema 的那个瞬间——它更是你决定应用程序究竟要如何去理解这套 schema 的那一刻。无论什么时候在部署生产的 SaaS 系统,我都总在跑着这里列过的这些套路——迁移前要死守的纪律、扩展-迁移-收缩模式、附加式的列改法、并发拉起或随带影子的索引、分块且防抖的幂等回填、以特征开关作门禁、能捕获数据漂移的可观测性监测,还要加上以最坏故障为基准来制定的回滚计划。它们没有针对某某特定供应商品牌的专利,一切只纯粹取决于你是否透彻明了当一个写锁被强行锁定且请求队伍爆满排队时,你的数据库到底正在干些什么。

如果你想查看这些大大小小迁移所运行的具体 schema 环境,请参阅 SaaS database architecture guide。想了解运行时方面,请参见 Cloudflare D1 production guide 以及 D1 deep dive。至于更加全栈和宏观架构堆栈层面的知识,请查阅 SaaS architecture guide。若想通过比对了解这些模式与替代方案之间的差异,请参看 TanStack Ship comparison hub。