正在加载,请稍候…

零停机数据库迁移:模式与策略

在不中断服务的情况下部署数据库架构变更——扩展/收缩模式、向后兼容迁移、新列特性标志、生产环境索引创建及回滚策略。

零停机数据库迁移:模式与策略

零停机迁移的挑战

传统迁移会锁定表。在规模较大的场景下,即使高流量表上1秒的锁也会导致级联超时。

零停机数据库迁移:模式与策略 插图

扩展/收缩模式

永远不要在一次迁移中执行破坏性变更。使用三个阶段:

阶段1:扩展(向后兼容)

添加新列/表,但不移除旧列/表。

阶段2:迁移

回填数据,更新应用程序代码。

零停机数据库迁移:模式与策略 插图

阶段3:收缩(清理)

在所有代码部署完成后移除旧列。

安全重命名列

-- 绝对不要这样做(会破坏正在运行的应用):
-- ALTER TABLE users RENAME COLUMN username TO user_name;

-- 阶段1:扩展——添加新列
ALTER TABLE users ADD COLUMN user_name VARCHAR(255);

-- 复制数据(可在后台运行)
UPDATE users SET user_name = username WHERE user_name IS NULL;

-- 阶段2:让两者在应用程序代码中同时工作
-- 写入两个列,从新列读取并回退到旧列
-- 部署能处理两者的应用

-- 阶段3:收缩——在所有应用实例更新后
ALTER TABLE users DROP COLUMN username;

添加 NOT NULL 列

-- 绝对不要:
-- ALTER TABLE orders ADD COLUMN status VARCHAR(50) NOT NULL DEFAULT 'pending';
-- 这会重写整个表!

-- 阶段1:添加可空列(即时)
ALTER TABLE orders ADD COLUMN status VARCHAR(50);

-- 阶段2:批量回填(无锁)
DO $
DECLARE
  batch_size INT := 1000;
  last_id BIGINT := 0;
  max_id BIGINT;
BEGIN
  SELECT MAX(id) INTO max_id FROM orders;
  WHILE last_id < max_id LOOP
    UPDATE orders
    SET status = 'completed'
    WHERE id > last_id AND id <= last_id + batch_size
      AND status IS NULL;
    last_id := last_id + batch_size;
    PERFORM pg_sleep(0.01);  -- 限速以避免过载
  END LOOP;
END $;

-- 阶段3:添加约束(验证,PG中不重写)
ALTER TABLE orders ADD CONSTRAINT orders_status_not_null
  CHECK (status IS NOT NULL) NOT VALID;

ALTER TABLE orders VALIDATE CONSTRAINT orders_status_not_null;
-- NOT VALID 即时添加;VALIDATE 在后台运行

-- 阶段4:使其成为真正的 NOT NULL(快速,已验证)
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;
ALTER TABLE orders DROP CONSTRAINT orders_status_not_null;

零停机数据库迁移:模式与策略 插图

无阻塞创建索引

-- 不好:在索引构建期间阻塞所有写入
CREATE INDEX idx_orders_user ON orders (user_id);

-- 好:CONCURRENTLY 构建而不阻塞
CREATE INDEX CONCURRENTLY idx_orders_user ON orders (user_id);
-- 耗时更长,但不阻塞读取或写入

-- MySQL 等效
ALTER TABLE orders ADD INDEX idx_user (user_id), ALGORITHM=INPLACE, LOCK=NONE;

大表数据回填

// 无锁批量回填
async function backfillInBatches(db: Pool) {
  const BATCH_SIZE = 500
  let lastId = 0
  let processed = 0

  while (true) {
    const result = await db.query(
      `UPDATE orders
       SET normalized_status = LOWER(status)
       WHERE id > $1 AND id <= $1 + $2
         AND normalized_status IS NULL
       RETURNING id`,
      [lastId, BATCH_SIZE]
    )

    if (result.rowCount === 0) break

    processed += result.rowCount
    lastId += BATCH_SIZE
    console.log(`Processed: ${processed}`)

    await sleep(10) // 限速——给数据库喘息空间
  }
}

架构变更的特性标志

// 使用特性标志逐步推出架构变更
async function getUser(id: string) {
  const useNewSchema = await featureFlags.isEnabled('new_user_schema', { userId: id })

  if (useNewSchema) {
    return db.query('SELECT id, full_name, email FROM users_v2 WHERE id = $1', [id])
  }
  return db.query('SELECT id, first_name || ' ' || last_name AS full_name, email FROM users WHERE id = $1', [id])
}

迁移检查清单

  • 先在生产数据的副本上测试
  • 为每次迁移准备回滚计划
  • 迁移期间监控数据库指标
  • 使用 CONCURRENTLY 创建索引
  • 批量处理大数据更新
  • 永远不要在代码变更的同一部署中删除列
  • 设置 lock_timeout 以防止长时间等待
  • 对于风险变更,在低流量时段执行

锁超时安全

-- 设置锁超时以快速失败而不是排队
SET lock_timeout = '2s';

BEGIN;
  ALTER TABLE users ADD COLUMN score INT DEFAULT 0;
  -- 如果在2秒内未获得锁,则干净地失败
COMMIT;