使用 Claude Code 设计零停机时间 DB 迁移:Expand-Contract 与向后兼容
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。