数据库迁移没有统一的“先于应用”或“晚于应用”答案。安全顺序取决于新旧应用能否同时使用当前 schema:新增兼容结构先执行,应用再上线,数据随后回填;删除字段、改名和收紧约束放到旧版本退出后的独立发布。

Expand 阶段增加兼容结构,Migrate 阶段部署应用并回填数据,Contract 阶段删除旧结构
每一阶段都保持当前生产应用可运行;破坏性变更不和新应用第一次上线绑在一起。

先按兼容性给变更分类

同一条 DDL 在不同数据规模、PostgreSQL 版本和流量条件下,锁时间可能完全不同。发布前先确认表大小、写入频率、SQL 计划和新旧应用行为。

变更常用阶段主要风险发布要求
新增可空字段Expand老代码是否使用 SELECT * 或位置扫描先迁移,确认新旧版本都能读写
新建表Expand权限、外键和初始化数据先迁移,再部署使用它的应用
新建索引Expand写阻塞、磁盘和失败后的 invalid 索引大表使用 CONCURRENTLY,独立执行
大批量回填Migrate锁、WAL、复制延迟和业务写竞争分批、可续跑、有限速
增加 NOT NULLContract未清理的 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 完成后,新版本可以开始写入新字段。只要旧实例仍在处理流量,新版本就不能假设每一行已经有新值。

字段改名需要一个有截止时间的过渡:

  1. 增加新字段;
  2. 部署同时写入新旧字段的版本;
  3. 回填历史数据并核对差异;
  4. 把读取切到新字段;
  5. 确认旧版本、定时任务和报表都已退出;
  6. 在约定版本删除旧字段和过渡写入。

过渡逻辑是生产数据迁移的一部分,必须绑定删除版本或日期,并由测试阻止过期路径继续存在。没有退出条件的“双写兼容”会永久扩大故障面。

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 仍兼容旧镜像时可以。字段已删除或语义已改变时,应先恢复兼容结构或执行前向修复。

备份能代替迁移回滚吗?

不能。备份恢复可能丢失后续写入,是灾难恢复手段;正常发布依赖兼容迁移、校验和、幂等回填和失败停止。