多副本同时启动,Migration 被执行了两遍
滚动发布、蓝绿切换、CI/CD 并发触发——这三种场景都会在极短时间内让多个进程同时执行启动时的 Migration 脚本。如果脚本只是「扫目录、逐文件执行」,没有任何互斥机制,两个实例就会各自跑一遍,轻则日志里出现两条记录,重则 CREATE TABLE 报 Table already exists 直接崩掉服务。
现象
服务部署时偶发启动失败,日志里出现:
Error: Table 'xxx' already exists
或者迁移跟踪表里同一个文件名出现了两行记录。问题只在多副本同时拉起时复现,单实例从不触发。
根因
原始代码逻辑如下:
// 有问题的写法:无锁、无状态追踪
const files = readdirSync(dir).filter(f => f.startsWith('migrate-'))
for (const file of files) {
const mod = await import(resolve(dir, file))
await mod.up(pool) // 直接执行,没有任何去重
}每个进程独立扫描目录、独立执行,完全不知道彼此的存在。两个进程同时通过"文件存在"这个条件检查后,都会进入执行阶段——经典的 TOCTOU(Time-of-Check-Time-of-Use)竞态。
很多人意识到要加 _migrations 追踪表,用 UNIQUE 约束防重,但这只解决了「同一 Migration 跑两遍后第二次会报唯一键冲突」——副作用是半成功的迁移留在数据库里,状态不一致。根本问题没有解决:两个进程仍然会同时进入执行窗口。正确做法是先加分布式锁,再查状态表。
正解
MySQL 内置的 GET_LOCK('name', timeout) 是一把进程级的命名咨询锁(Advisory Lock),返回 1 表示拿到,0 表示超时,NULL 表示错误。锁随连接关闭自动释放,不需要显式维护。
配合状态表,两层保障叠加:
- Advisory Lock:同一时刻只有一个进程能进入迁移执行区
- 状态表
_migrations:记录已执行文件,防止重启后重跑
async function runMigrations() {
const pool = mysql.createPool({ /* ...config... */ })
try {
// 第一步:抢锁,最多等 30 秒
const [[lockResult]] = await pool.execute(
"SELECT GET_LOCK('migrate_runner', 30) AS got"
) as [Array<{ got: number | null }>, unknown]
if (lockResult.got !== 1) {
throw new Error('Could not acquire migrate_runner lock within 30s')
}
// 第二步:确保追踪表存在
await pool.execute(`
CREATE TABLE IF NOT EXISTS _migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
// 第三步:diff 磁盘文件 vs 已执行记录
const [executedRows] = await pool.execute(
`SELECT name FROM _migrations ORDER BY id`
) as [Array<{ name: string }>, unknown]
const executed = new Set(executedRows.map(r => r.name))
const pending = readdirSync(SCRIPTS_DIR)
.filter(f => f.startsWith('migrate-') && f.endsWith('.ts'))
.sort()
.filter(f => !executed.has(f))
// 第四步:每个文件独立事务,失败即停
for (const file of pending) {
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
const mod = await import(resolve(SCRIPTS_DIR, file))
await mod.up(conn)
await conn.execute(`INSERT INTO _migrations (name) VALUES (?)`, [file])
await conn.commit()
console.log(` OK: ${file}`)
} catch (err) {
await conn.rollback()
console.error(` FAILED: ${file}`, err)
process.exit(1)
} finally {
conn.release()
}
}
} finally {
// 无论成功失败,一定释放锁
await pool.execute("SELECT RELEASE_LOCK('migrate_runner')")
await pool.end()
}
}GET_LOCK 保证同一时刻只有一个进程持锁,其余进程阻塞等待或超时退出。持锁进程用状态表的 diff 确定待执行文件,每个文件一个独立事务,失败即回滚、立即退出。两层防护互补——锁防并发,状态表防重启重跑。
如果是中途接入这套方案,原有迁移文件已经在生产库执行过了,需要先跑一个 backfill 脚本,把现有文件名批量写入 _migrations,否则老文件会被当成 pending 再次执行。
一句话外卖
启动时跑 Migration,先用
GET_LOCK抢分布式互斥锁,再用状态表做幂等去重——缺任何一层,多副本场景必然翻车。