返回博客

多副本同时启动,Migration 被执行了两遍

滚动发布、蓝绿切换、CI/CD 并发触发——这三种场景都会在极短时间内让多个进程同时执行启动时的 Migration 脚本。如果脚本只是「扫目录、逐文件执行」,没有任何互斥机制,两个实例就会各自跑一遍,轻则日志里出现两条记录,重则 CREATE TABLETable 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 表示错误。锁随连接关闭自动释放,不需要显式维护。

配合状态表,两层保障叠加:

  1. Advisory Lock:同一时刻只有一个进程能进入迁移执行区
  2. 状态表 _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()
  }
}
正解:Advisory Lock 先挡门,状态表再去重

GET_LOCK 保证同一时刻只有一个进程持锁,其余进程阻塞等待或超时退出。持锁进程用状态表的 diff 确定待执行文件,每个文件一个独立事务,失败即回滚、立即退出。两层防护互补——锁防并发,状态表防重启重跑。

存量项目接入提示

如果是中途接入这套方案,原有迁移文件已经在生产库执行过了,需要先跑一个 backfill 脚本,把现有文件名批量写入 _migrations,否则老文件会被当成 pending 再次执行。

一句话外卖

启动时跑 Migration,先用 GET_LOCK 抢分布式互斥锁,再用状态表做幂等去重——缺任何一层,多副本场景必然翻车。