数据库迁移没有统一的“先于应用”或“晚于应用”答案。安全顺序取决于新旧应用能否同时使用当前 schema:新增兼容结构先执行,应用再上线,数据随后回填;删除字段、改名和收紧约束放到旧版本退出后的独立发布。
先按兼容性给变更分类
同一条 DDL 在不同数据规模、PostgreSQL 版本和流量条件下,锁时间可能完全不同。发布前先确认表大小、写入频率、SQL 计划和新旧应用行为。
| 变更 | 常用阶段 | 主要风险 | 发布要求 |
|---|---|---|---|
| 新增可空字段 | Expand | 老代码是否使用 SELECT * 或位置扫描 | 先迁移,确认新旧版本都能读写 |
| 新建表 | Expand | 权限、外键和初始化数据 | 先迁移,再部署使用它的应用 |
| 新建索引 | Expand | 写阻塞、磁盘和失败后的 invalid 索引 | 大表使用 CONCURRENTLY,独立执行 |
| 大批量回填 | Migrate | 锁、WAL、复制延迟和业务写竞争 | 分批、可续跑、有限速 |
| 增加 NOT NULL | Contract | 未清理的 NULL、全表扫描和锁 | 先回填并验证,再收紧约束 |
| 字段改名 | Expand → Contract | 新旧应用引用不同名称 | 新字段与过渡写入先上线,旧字段后删 |
| 删除字段或表 | Contract | 旧实例、任务和报表仍在读取 | 确认所有消费者退出后执行 |
| 改字段类型 | 视情况拆分 | 表重写、长锁、语义变化 | 大表优先新字段、回填、切读、删除旧字段 |
PostgreSQL ALTER TABLE 文档(在新标签页打开)说明,多种子命令会取得 ACCESS EXCLUSIVE 锁;这是 PostgreSQL 最强的表锁,可能阻塞普通查询和写入。DDL 语句短,不代表等待锁的时间短。
Expand:先增加旧应用不会拒绝的结构
新增字段先保持可空或提供不会改变旧行为的默认值:
SET lock_timeout = '3s';
SET statement_timeout = '30s';
ALTER TABLE accounts
ADD COLUMN billing_email text;
lock_timeout 防止迁移在繁忙事务后无限等待;statement_timeout 限制执行时间。超时后发布任务应失败停止,由操作员检查阻塞者,而不是忽略错误继续部署应用。
新增索引若用于线上大表,使用:
CREATE INDEX CONCURRENTLY idx_orders_created_at
ON orders (created_at);
PostgreSQL CREATE INDEX 文档(在新标签页打开)指出,并发建索引不会像普通建索引一样阻止写入,但它会执行两次表扫描,等待相关事务结束,并消耗额外 CPU、I/O 和时间;它也不能在事务块中运行。CONCURRENTLY 降低了写阻塞,不代表对生产负载没有影响。失败后检查索引状态:
SELECT indexrelid::regclass AS index_name,
indisvalid,
indisready
FROM pg_index
WHERE indexrelid = 'idx_orders_created_at'::regclass;
indisvalid = false 时,先确认没有查询依赖该索引,再删除并重建。迁移框架必须支持“非事务迁移”,不能把 CREATE INDEX CONCURRENTLY 强塞进默认事务。
应用发布:新版本先兼容旧数据
Expand 完成后,新版本可以开始写入新字段。只要旧实例仍在处理流量,新版本就不能假设每一行已经有新值。
字段改名需要一个有截止时间的过渡:
- 增加新字段;
- 部署同时写入新旧字段的版本;
- 回填历史数据并核对差异;
- 把读取切到新字段;
- 确认旧版本、定时任务和报表都已退出;
- 在约定版本删除旧字段和过渡写入。
过渡逻辑是生产数据迁移的一部分,必须绑定删除版本或日期,并由测试阻止过期路径继续存在。没有退出条件的“双写兼容”会永久扩大故障面。
GitLab 的多版本兼容指南(在新标签页打开)采用 expand、migrate、contract,把破坏性变更拆到不同发布周期。每个中间 schema 都必须支持当时仍在运行的应用版本。
Migrate:数据回填独立于 DDL 和应用启动
以下示例假设旧字段 accounts.email 已经是 NOT NULL。大表回填按稳定主键分批,不使用一个覆盖全表的长事务:
WITH batch AS (
SELECT id
FROM accounts
WHERE billing_email IS NULL
ORDER BY id
LIMIT 1000
FOR UPDATE SKIP LOCKED
)
UPDATE accounts AS a
SET billing_email = lower(a.email)
FROM batch
WHERE a.id = batch.id;
每批提交后记录处理行数、耗时和错误。billing_email IS NULL 在这段 SQL 中同时充当续跑条件:任务重启后继续选择尚未处理的行,重复执行不会改动已经写入的行。目标值可以合法保持 NULL,或转换本身不幂等时,数据条件无法表示真实进度;改用独立任务表保存最后一个稳定主键和任务状态。
回填结束后验证总量和异常样本:
SELECT count(*) AS missing
FROM accounts
WHERE billing_email IS NULL;
SELECT id, email, billing_email
FROM accounts
WHERE billing_email IS DISTINCT FROM lower(email)
ORDER BY id
LIMIT 20;
只有 missing = 0 且差异符合预期,才能进入 Contract。应用指标还要确认新字段读写已经稳定,不能只看 SQL 计数。
先验证约束,再把约束收紧
外键和 CHECK 约束可以先以 NOT VALID 添加:
ALTER TABLE accounts
ADD CONSTRAINT accounts_billing_email_present
CHECK (billing_email IS NOT NULL) NOT VALID;
ALTER TABLE accounts
VALIDATE CONSTRAINT accounts_billing_email_present;
ALTER TABLE accounts
ALTER COLUMN billing_email SET NOT NULL;
ALTER TABLE accounts
DROP CONSTRAINT accounts_billing_email_present;
NOT VALID 会约束新写入,但暂不扫描所有历史行;VALIDATE CONSTRAINT 在后续阶段验证旧数据,并使用较弱的锁。有效的 CHECK 约束已经证明该列没有 NULL 时,PostgreSQL 可以在 SET NOT NULL 时避免再次扫描全表;原生约束生效后再删除临时 CHECK。
SET NOT NULL 仍要取得表锁,具体锁等待和执行时间受 PostgreSQL 主版本、并发事务和表状态影响。生产执行前应在相同主版本、相近数据量的副本上预演。
Contract:旧消费者退出后再删除
破坏性迁移开始前,必须具备以下证据:
- 旧应用实例数为零;
- 队列消费者、定时任务、脚本和报表不再引用旧字段;
- 新字段已完成回填和约束验证;
- 最近备份可恢复,恢复时间满足业务要求;
- 回退应用不会再次依赖准备删除的结构。
删除操作设置短锁等待:
SET lock_timeout = '3s';
ALTER TABLE accounts DROP COLUMN email;
锁未取得时让迁移失败,并在低流量窗口重试。不要为了“发布继续”取消业务长事务或提高无限锁等待;这会把一个可控的迁移失败变成线上请求堆积。
迁移任务只能有一个执行者
迁移由发布流水线中的独立作业执行,不让每个应用副本在启动时自行迁移。运行器至少保证:
- 迁移文件有唯一、单调的版本;
- 已执行版本记录名称、校验和和完成时间;
- 同一数据库只允许一个迁移执行者;
- 文件被执行后不可静默改写;
- 失败版本记录为 dirty 或明确未完成;
- 前置版本缺失、校验和变化或数据库主版本不符时拒绝继续。
PostgreSQL advisory lock(在新标签页打开) 可以阻止两个流水线同时迁移;锁必须和执行迁移的数据库会话绑定。仅在 CI 平台配置“同一时间一个任务”,无法覆盖手工运行或另一套部署系统。
SELECT pg_advisory_lock(861204);
-- 校验版本和校验和,再按顺序执行迁移
SELECT pg_advisory_unlock(861204);
连接池若在锁和迁移之间切换会话,session-level advisory lock 就失去保护。迁移运行器应固定一条连接,或使用同一事务中的 transaction-level advisory lock。
失败后先判断处于哪个阶段
| 失败位置 | 常用处理 | 不能直接做的事 |
|---|---|---|
| Expand 尚未完成 | 停止应用发布,修复或重试兼容迁移 | 启动依赖新结构的应用 |
| 新应用未接流量 | 删除候选实例,保留兼容结构 | 把未验证版本开放 |
| 回填中断 | 从数据条件或持久主键游标继续,核对重复执行结果 | 重新跑一个全表长事务 |
| 新应用已接流量 | 先保持新旧 schema 兼容,恢复应用或前向修复 | 盲目恢复旧数据库快照 |
| Contract 失败 | 保留旧结构,检查消费者和锁 | 在证据不足时强制删除 |
恢复数据库快照会覆盖备份之后的合法写入,还需要停机、数据对账和明确的恢复点目标。日常发布应让旧应用和新应用在过渡 schema 上都能工作;真正不可逆的变更在维护窗口中执行。
发布记录至少保存这些迁移信息
每次发布保存:
- 应用提交和镜像 digest;
- 迁移版本、文件校验和和执行顺序;
- PostgreSQL 主版本;
- Expand、回填、验证和 Contract 的开始与结束时间;
- 受影响行数、锁等待、执行耗时和失败信息;
- 发布前恢复点及其恢复验证日期。
应用回滚前先确认当前 schema 仍支持旧版本。字段已经删除或语义已经改变时,应先恢复兼容结构或执行前向修复。
常见问题
数据库迁移应该在应用发布前还是发布后?
兼容的加法迁移通常在前,数据回填在应用兼容后,删除和收紧约束在旧消费者退出后。按变更类型决定,不按一条固定规则决定。
为什么不让应用启动时自动迁移?
多副本会争抢迁移,DDL 失败也会和应用就绪混在一起。独立唯一的迁移任务更容易加锁、记录和停止发布。
CREATE INDEX 会阻塞写入吗?
普通创建会阻塞写入。CREATE INDEX CONCURRENTLY 可降低写阻塞,但不能放在事务块中,失败后还要处理 invalid 索引。
迁移失败后可以直接回滚镜像吗?
只有当前 schema 仍兼容旧镜像时可以。字段已删除或语义已改变时,应先恢复兼容结构或执行前向修复。
备份能代替迁移回滚吗?
不能。备份恢复可能丢失后续写入,是灾难恢复手段;正常发布依赖兼容迁移、校验和、幂等回填和失败停止。