零停机数据库迁移实战:在线 Schema 变更的工程化方案

生产环境数据库迁移如何做到零停机?本文深入解析 Expand-Contract 模式、双写策略、影子表方案,附 Flyway、Prisma、原生 SQL 完整代码示例,助你安全地变更线上数据库 Schema。

数据库 2026-05-29 12 分钟

凌晨三点被叫起来回滚一次失败的数据库迁移,是每个后端工程师的噩梦。据 Datadog 2025 年的报告,超过 38% 的生产事故与数据库变更直接相关,其中大部分源于 Schema 变更期间的锁表、数据不一致或应用兼容性断裂。零停机迁移不是奢侈品——当你的服务日活超过一万、数据库表行数超过千万时,它就是生存必需品。

🎯 一、为什么传统迁移方式在生产环境会翻车?

大多数开发者熟悉的迁移流程是这样的:写一个 migration 文件,跑一下 ALTER TABLE,完事。这个流程在开发环境毫无问题,但到了生产环境,三个致命问题会同时爆发。

🔒 1.1 ALTER TABLE 的隐性成本

在 MySQL InnoDB 中,ALTER TABLE 的行为取决于具体操作类型和版本。MySQL 8.0 引入了 Instant DDL 能力,但并非所有操作都支持:

操作类型 MySQL 5.7 MySQL 8.0+ PostgreSQL
加列(默认值) 🟡 重建表 ✅ Instant ✅ 不锁表
删列 🔴 重建表 🔴 重建表 ✅ 不锁表
修改列类型 🔴 重建表 🔴 重建表 🟡 视情况
加索引 🟡 Online DDL 🟡 Online DDL ✅ CONCURRENTLY
加唯一约束 🔴 表锁 🟡 Online DDL ✅ CONCURRENTLY
改表名 ✅ 原子操作 ✅ 原子操作 ✅ 原子操作

⚠️ **警告:**即使 MySQL 8.0 支持 Instant DDL,加列时如果有 DEFAULT 值且是 VARCHAR/TEXT 类型,可能仍需重建表。不要盲目相信 “Instant” 标签。

对于一张 5000 万行的表,重建表可能耗时 10-30 分钟,期间表处于半锁状态——读可能允许(取决于算法),但写操作会被阻塞。在高并发系统中,这 30 分钟足够引发雪崩。

💥 1.2 应用与数据库的版本不匹配

迁移期间存在一个尴尬的时间窗口:数据库 Schema 已经改了,但应用代码还是旧版本(或反过来)。典型场景:

  • 你删除了一列,但旧版本应用仍在 SELECT 该列
  • 你重命名了一个字段,但 API 层还在用旧字段名
  • 你把 VARCHAR(50) 改成 VARCHAR(20),旧数据被截断

这些不是理论风险——它们在每一次蓝绿部署中都可能发生。

📊 1.3 回滚成本被严重低估

ALTER TABLE 往往是不可逆的。你删了一列,想回滚?得先把数据找回来。你改了列类型,想回滚?可能数据已经不可逆地被转换了。传统迁移的回滚成本远高于迁移本身。

🔄 二、Expand-Contract 模式:零停机迁移的核心范式

Expand-Contract(扩展-收缩)模式是解决上述所有问题的系统性方案。核心思想极其简单:永远不要直接修改,先扩展,再收缩

这个模式分为三个阶段,每个阶段都是独立可部署、可回滚的:

📐 2.1 三阶段概览

阶段一:Expand(扩展) — 只做加法,不做减法。添加新列、新表、新索引,但不删除或修改任何现有结构。

阶段二:Migrate(迁移) — 将数据从旧结构复制/转换到新结构,同时保持双写,确保新旧数据一致。

阶段三:Contract(收缩) — 确认所有代码都已切换到新结构后,删除旧列、旧表。

每个阶段对应一次独立的代码部署。关键原则是:任何一个阶段都可以安全回滚,且不会影响线上服务。

🧩 2.2 实战案例:重命名字段

假设你有一张 users 表,要把 name 字段拆分为 first_namelast_name

❌ 传统做法(危险):

-- 直接改表,应用瞬间崩溃
ALTER TABLE users ADD COLUMN first_name VARCHAR(50);
ALTER TABLE users ADD COLUMN last_name VARCHAR(50);
UPDATE users SET first_name = SPLIT_PART(name, ' ', 1),
                 last_name = SPLIT_PART(name, ' ', 2);
ALTER TABLE users DROP COLUMN name;

✅ Expand-Contract 做法:

阶段一(Expand)— 只加新列:

-- Expand 阶段:添加新列,设置默认值,不碰旧列
ALTER TABLE users ADD COLUMN first_name VARCHAR(50) DEFAULT '';
ALTER TABLE users ADD COLUMN last_name VARCHAR(50) DEFAULT '';

-- 创建触发器,保证双写(PostgreSQL 语法)
CREATE OR REPLACE FUNCTION sync_name_fields()
RETURNS TRIGGER AS $$
BEGIN
  -- 新列写入时同步到旧列
  IF TG_OP = 'INSERT' OR NEW.first_name IS DISTINCT FROM OLD.first_name
     OR NEW.last_name IS DISTINCT FROM OLD.last_name THEN
    NEW.name := COALESCE(NEW.first_name, '') || ' ' || COALESCE(NEW.last_name, '');
  END IF;
  -- 旧列写入时同步到新列
  IF TG_OP = 'INSERT' OR NEW.name IS DISTINCT FROM OLD.name THEN
    NEW.first_name := SPLIT_PART(NEW.name, ' ', 1);
    NEW.last_name := CASE
      WHEN POSITION(' ' IN NEW.name) > 0
      THEN SUBSTRING(NEW.name FROM POSITION(' ' IN NEW.name) + 1)
      ELSE ''
    END;
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_sync_name_fields
  BEFORE INSERT OR UPDATE ON users
  FOR EACH ROW EXECUTE FUNCTION sync_name_fields();

阶段二(Migrate)— 背景迁移已有数据:

# migrate_names.py — 后台批量迁移脚本,分批处理避免锁表
import psycopg2
import time

BATCH_SIZE = 1000
conn = psycopg2.connect("dbname=mydb user=myuser")
cursor = conn.cursor()

while True:
    cursor.execute("""
        UPDATE users
        SET first_name = SPLIT_PART(name, ' ', 1),
            last_name = CASE
                WHEN POSITION(' ' IN name) > 0
                THEN SUBSTRING(name FROM POSITION(' ' IN name) + 1)
                ELSE ''
            END
        WHERE id IN (
            SELECT id FROM users
            WHERE first_name = '' AND name IS NOT NULL AND name != ''
            LIMIT %s
            FOR UPDATE SKIP LOCKED
        )
    """, (BATCH_SIZE,))

    affected = cursor.rowcount
    conn.commit()

    if affected == 0:
        break

    print(f"Migrated {affected} rows")
    time.sleep(0.1)  # 控制迁移速度,降低数据库压力

print("Migration complete!")
cursor.close()
conn.close()

阶段三(Contract)— 确认应用全部切换后:

-- 删除触发器
DROP TRIGGER IF EXISTS trg_sync_name_fields ON users;
DROP FUNCTION IF EXISTS sync_name_fields();

-- 删除旧列(在低峰期执行)
ALTER TABLE users DROP COLUMN name;

📌 记住:阶段三的执行时机至关重要。必须确认所有应用实例都已更新为使用新字段后才能执行。建议设置至少 24-48 小时的观察窗口。

🛠️ 三、主流框架的零停机迁移工具链

手动写 SQL 容易出错,现代框架提供了更好的迁移管理工具。以下是三种主流方案的对比。

⚙️ 3.1 Flyway:Java 生态的首选

Flyway 是 JVM 生态最流行的数据库迁移工具,支持 MySQL、PostgreSQL、Oracle 等主流数据库。它的核心优势是基于版本号的线性迁移和 baseline 机制。

// Flyway 配置示例 — Spring Boot application.yml
// 在 Spring Boot 中,Flyway 在应用启动时自动执行
spring:
  flyway:
    enabled: true
    locations: classpath:db/migration
    baseline-on-migrate: true
    baseline-version: 0
    # 关键:在生产环境禁用 clean 操作
    clean-disabled: true
    # 并发迁移控制
    connect-retries: 3
-- db/migration/V20260530__add_user_name_split.sql
-- Expand 阶段:添加新列
ALTER TABLE users ADD COLUMN first_name VARCHAR(50) DEFAULT '';
ALTER TABLE users ADD COLUMN last_name VARCHAR(50) DEFAULT '';

-- 创建回填存储过程
CREATE OR REPLACE PROCEDURE backfill_user_names()
LANGUAGE plpgsql AS $$
DECLARE
  batch_size INT := 1000;
  affected INT;
BEGIN
  LOOP
    UPDATE users
    SET first_name = SPLIT_PART(name, ' ', 1),
        last_name = CASE
          WHEN POSITION(' ' IN name) > 0
          THEN SUBSTRING(name FROM POSITION(' ' IN name) + 1)
          ELSE ''
        END
    WHERE id IN (
      SELECT id FROM users
      WHERE first_name = '' AND name IS NOT NULL AND name != ''
      LIMIT batch_size FOR UPDATE SKIP LOCKED
    );
    GET DIAGNOSTICS affected = ROW_COUNT;
    EXIT WHEN affected = 0;
    COMMIT;
    PERFORM pg_sleep(0.1);
  END LOOP;
END;
$$;

-- 调用回填(Flyway 中可用 Java 回调替代)
CALL backfill_user_names();
-- db/migration/V20260601__drop_name_column.sql
-- Contract 阶段:删除旧列(在确认所有应用更新后执行)
ALTER TABLE users DROP COLUMN name;

💡 **提示:**Flyway 的商业版支持 undo 迁移(U 前缀),可以自动回滚。开源版需要手动编写回滚 SQL。建议每个 migration 文件都附带注释说明回滚方案。

🔄 3.2 Prisma Migrate:Node.js 生态的类型安全方案

Prisma 的迁移系统基于声明式 Schema diff,生成 SQL 迁移文件。它的 shadow database 机制能自动检测 Schema drift。

// prisma/schema.prisma — Expand 阶段
model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  // 旧字段保留,加 @deprecated 标记(Prisma 不支持 deprecation,用注释标记)
  name      String   // TODO: 待删除,已迁移至 firstName/lastName
  firstName String   @map("first_name") @default("")
  lastName  String   @map("last_name") @default("")
  createdAt DateTime @default(now()) @map("created_at")

  @@map("users")
}
# 生成迁移文件
npx prisma migrate dev --name expand_user_name_split

# 生产环境部署(只执行 pending migrations,不重置数据库)
npx prisma migrate deploy

Prisma 的一个实用技巧是在 Contract 阶段使用 @@ignore 或手动修改生成的 SQL:

// prisma/schema.prisma — Contract 阶段
model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  firstName String   @map("first_name")
  lastName  String   @map("last_name")
  createdAt DateTime @default(now()) @map("created_at")

  @@map("users")
}
# Prisma 的 migrate diff 命令可以生成 raw SQL
npx prisma migrate diff \
  --from-schema-datamodel prisma/schema.prisma \
  --to-schema-datamodel prisma/schema_new.prisma \
  --script > contract_migration.sql

🐘 3.3 原生 PostgreSQL Online DDL

如果你追求极致控制,PostgreSQL 原生的 CONCURRENTLY 关键字是最强大的工具:

-- 在线创建索引,不阻塞读写
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);

-- 唯一索引也可以并发创建
CREATE UNIQUE INDEX CONCURRENTLY idx_users_email_unique ON users(email);

-- 在线加约束(先创建索引,再加约束)
ALTER TABLE users ADD CONSTRAINT chk_email_format
  CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')
  NOT VALID;  -- 不验证现有数据,极快

-- 然后在后台验证现有数据(不锁表)
ALTER TABLE users VALIDATE CONSTRAINT chk_email_format;

⚠️ 警告:CREATE INDEX CONCURRENTLY 失败时会留下一个 INVALID 状态的索引。使用前确保用 SET statement_timeout = 0 避免超时中断,失败后需要手动 DROP INDEX 清理。

⚠️ 四、常见坑点与避坑指南

💣 4.1 外键约束的连锁反应

在有外键关系的表上做迁移,删除列时会遇到 CASCADE 风险:

-- ❌ 危险:如果其他表有外键引用 users.name,CASCADE 会连带删数据
ALTER TABLE users DROP COLUMN name CASCADE;

-- ✅ 安全做法:先删除外键约束,再删列
ALTER TABLE orders DROP CONSTRAINT IF EXISTS fk_orders_user_name;
ALTER TABLE users DROP COLUMN name;

🕐 4.2 大表迁移的超时与锁等待

对于千万级大表,即使使用 Online DDL,也要注意以下参数:

-- 设置合理的锁等待超时
SET lock_timeout = '5s';  -- 5 秒拿不到锁就放弃,不要无限等待

-- 设置语句超时
SET statement_timeout = '10min';  -- 防止长时间 DDL 阻塞连接池

-- 对于需要排他锁的操作,在低峰期执行
-- 可以使用 pg_advisory_lock 做分布式锁
SELECT pg_advisory_lock(12345);  -- 获取锁
-- ... 执行迁移 ...
SELECT pg_advisory_unlock(12345);  -- 释放锁

🧪 4.3 测试环境与生产环境的差异

📌 **记住:**在 1 万行的测试表上 0.1 秒完成的 ALTER TABLE,在 5000 万行的生产表上可能需要 30 分钟。永远在生产规模的数据集上预演迁移

推荐做法:用 pg_dump 导出生产数据的 10% 子集,在独立的测试数据库上执行完整迁移流程,记录耗时和锁行为。

🔁 4.4 双写一致性保障

Expand-Contract 模式中,双写阶段最容易出 bug。触发器的执行顺序、NULL 值处理、并发写入都可能导致数据不一致。

-- 用事务保证双写原子性
BEGIN;
  -- 更新新字段
  UPDATE users SET first_name = 'John', last_name = 'Doe' WHERE id = 1;
  -- 触发器自动同步旧字段
  -- 如果触发器失败,整个事务回滚,保证一致性
COMMIT;

建议在双写阶段增加数据一致性校验定时任务:

-- 每小时校验一次新旧字段是否一致
SELECT COUNT(*) FROM users
WHERE name != TRIM(first_name || ' ' || last_name);
-- 如果结果 > 0,说明有数据不一致,需要排查

📋 五、迁移 Checklist

每次执行生产迁移前,过一遍这个清单:

  • ✅ 在 staging 环境用生产规模数据测试过
  • ✅ 有明确的回滚方案(且已测试回滚方案可行)
  • ✅ 迁移脚本是幂等的(可重复执行不出错)
  • ✅ 已评估迁移对主从复制延迟的影响
  • ✅ 通知了 DBA 或 on-call 工程师
  • ✅ 在低峰期执行排他锁操作
  • ✅ 监控了迁移期间的连接数、锁等待、CPU/IO
  • ✅ 设置了合理的超时时间
  • ✅ 双写阶段有数据一致性校验
  • ✅ Contract 阶段确认所有应用已切换

💡 总结

零停机迁移的核心哲学是:用空间换时间,用冗余换安全。每次变更都拆分为可独立回滚的小步骤,永远不要试图一步到位。

三个关键原则:

  1. 只加不删 — Expand 阶段永远只做加法
  2. 双写保一致 — Migrate 阶段用触发器或应用层保证新旧数据同步
  3. 观察再收缩 — Contract 阶段等够时间再删除旧结构

工具选择建议:Java 团队用 Flyway,Node.js 团队用 Prisma Migrate,需要极致控制的用 原生 SQL + CONCURRENTLY。无论选哪种工具,Expand-Contract 的核心思想不变。

⚡ **关键结论:**零停机迁移不是高级技巧,而是生产环境的基本功。掌握 Expand-Contract 模式,你的每一次 Schema 变更都将是可预测、可回滚、零风险的。

📚 相关文章