使用 Claude Code 设计零停机时间 DB 迁移:Expand-Contract 与向后兼容

发布: (2026年3月11日 GMT+8 14:53)
6 分钟阅读
原文: Dev.to

Source: Dev.to

请提供您希望翻译的完整文本内容,我将按照要求将其译成简体中文并保留原有的格式、Markdown 语法以及技术术语。谢谢!

前言

服务を止めずにデータベーススキーマを変更する「ゼロダウンタイムマイグレーション」は、本番運用において必須のスキルです。本記事では Claude Code を活用し、Expand‑Contract 模式·向后兼容回填·并行索引创建を組み合わせた設計手法を解説します。

Expand‑Contract 模式是什么

“Expand(扩展)→ 迁移 → Contract(收缩)” 这种通过四个阶段安全进行模式更改的方法。

フェーズ1: Expand   → NULL許容で新カラム追加
フェーズ2: Backfill → バッチで既存データ移行
フェーズ3: 両書き   → アプリが旧・新カラムの両方に書く
フェーズ4: Contract → 旧カラム削除(完全移行後)

这个模式的核心是 将部署顺序和 DB 修改顺序分离

阶段1:添加可为NULL的列(Expand)

-- 新列必须以允许 NULL 的方式添加
-- 绝不要使用 NOT NULL,因为会破坏现有行
ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMPTZ;
ALTER TABLE users ADD COLUMN display_name VARCHAR(100);

NG 模式: ADD COLUMN foo VARCHAR NOT NULL DEFAULT '' 在某些情况下会导致表锁(取决于 PostgreSQL 版本)。

第2阶段:基于游标的批量回填

一次性 UPDATE 所有记录会导致锁,生产环境会停顿。使用基于游标的方式分批处理。

import { Pool } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const BATCH_SIZE = 500;
const SLEEP_MS = 50;

async function sleep(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function backfillDisplayName(): Promise {
  let lastId = 0;
  let processed = 0;

  while (true) {
    const { rows } = await pool.query(
      `UPDATE users
       SET display_name = username
       WHERE id > $1
         AND display_name IS NULL
       RETURNING id
       LIMIT $2`,
      [lastId, BATCH_SIZE]
    );

    if (rows.length === 0) break;

    lastId = rows[rows.length - 1].id;
    processed += rows.length;
    console.log(`Backfilled ${processed} rows (last id: ${lastId})`);

    // 通过 50ms 睡眠分散数据库负载
    await sleep(SLEEP_MS);
  }

  console.log(`Backfill complete: ${processed} rows`);
}

backfillDisplayName().catch(console.error);

第三阶段:应用程序双写支持

在回填期间及完成后的一段时间内,让应用程序 同时写入旧列和新列

async function updateUser(userId: number, name: string): Promise {
  await pool.query(
    `UPDATE users
     SET username = $2,        -- 旧カラム(後方互換)
         display_name = $2     -- 新カラム
     WHERE id = $1`,
    [userId, name]
  );
}

async function getDisplayName(userId: number): Promise {
  const { rows } = await pool.query(
    `SELECT COALESCE(display_name, username) AS name
     FROM users WHERE id = $1`,
    [userId]
  );
  return rows[0]?.name ?? '';
}

使用 COALESCE 优先使用新列,若为空则回退到旧列,这样在迁移过程中也能安全读取。

CREATE INDEX CONCURRENTLY

普通的 CREATE INDEX 会对整个表加锁。在生产环境中一定要使用 CONCURRENTLY

-- 在不加锁的情况下创建索引(虽然耗时,但对生产无影响)
CREATE INDEX CONCURRENTLY idx_users_display_name
  ON users (display_name);

-- 完成确认
SELECT schemaname, tablename, indexname, indexdef
FROM pg_indexes
WHERE tablename = 'users'
  AND indexname = 'idx_users_display_name';

并发索引创建通常需要普通方式的 2~3 倍时间,但可以在没有停机时间的情况下完成。

阶段4:删除旧列(Contract)前的版本检查

在删除旧列之前,确保旧版本的应用已完全停止,并在部署系统中进行确认。

const MINIMUM_APP_VERSION = '2.5.0';

async function contractPhaseCheck(): Promise {
  // アプリバージョンを Redis やヘルスエンドポイントで確認
  const runningVersions = await getRunningAppVersions();
  const hasOldVersion = runningVersions.some(
    (v) => compareVersion(v, MINIMUM_APP_VERSION) < 0
  );

  if (hasOldVersion) {
    throw new Error(
      `Contract phase blocked: old app versions still running: ${runningVersions.join(', ')}`
    );
  }

  console.log('Version check passed. Running Contract phase...');
  await pool.query('ALTER TABLE users DROP COLUMN username');
  console.log('Old column dropped successfully.');
}

总结

  • Expand‑Contract 的 4 个阶段:通过遵循 NULL 允许添加 → 批量回填 → 双写 → 删除旧列 的顺序,可实现零停机时间
  • 游标回填 + 50ms 睡眠:批量大小 500 行、间隔 50ms,最小化对生产数据库的负载
  • CREATE INDEX CONCURRENTLY:生产环境添加索引必须使用 CONCURRENTLY,以避免表锁
  • Contract 前版本检查:在旧代码完全退役后自动删除旧列,防止人为错误

Code Review Pack

一次性审查安全性、性能和可读性的提示包。兼容 Claude Code / Codex CLI。

👉 查看 Code Review Pack(PromptWorks)

0 浏览
Back to Blog

相关文章

阅读更多 »