凌晨三点被叫起来回滚一次失败的数据库迁移,是每个后端工程师的噩梦。据 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_name 和 last_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 阶段确认所有应用已切换
💡 总结
零停机迁移的核心哲学是:用空间换时间,用冗余换安全。每次变更都拆分为可独立回滚的小步骤,永远不要试图一步到位。
三个关键原则:
- 只加不删 — Expand 阶段永远只做加法
- 双写保一致 — Migrate 阶段用触发器或应用层保证新旧数据同步
- 观察再收缩 — Contract 阶段等够时间再删除旧结构
工具选择建议:Java 团队用 Flyway,Node.js 团队用 Prisma Migrate,需要极致控制的用 原生 SQL + CONCURRENTLY。无论选哪种工具,Expand-Contract 的核心思想不变。
⚡ **关键结论:**零停机迁移不是高级技巧,而是生产环境的基本功。掌握 Expand-Contract 模式,你的每一次 Schema 变更都将是可预测、可回滚、零风险的。