Rust in Everything
基于 sea-orm 2.0.4

duplicate key value violates unique constraint "pg_type_typname_nsp_index"

主线位置:主线之外、应用启动时的那一步。Migrator::up 先建 seaql_migrations、再读出还没跑的迁移、逐个执行并记一行,整个过程没有任何跨实例的互斥;执行流程见 sea-orm-migration。本页看两个实例同时跑时会发生什么。

sequenceDiagram
  participant A as 实例 A
  participant PG as Postgres
  participant B as 实例 B
  A->>PG: 读待跑迁移 m1 m2 m3
  B->>PG: 读待跑迁移 m1 m2 m3
  A->>PG: BEGIN CREATE TABLE t1
  B->>PG: BEGIN CREATE TABLE t1
  Note over B: 等 A 的事务
  A->>PG: 记 m1 COMMIT
  PG-->>B: 23505 唯一约束冲突
  A->>PG: m2 m3 照常

本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行:sea-orm-migration 2.0.4(sea-orm 2.0.4)和 1.1.20(本站在用)。

现象

滚动发布或扩容时两个实例同时启动,每个都在启动时调 Migrator::up(&db, None)(本站在 crates/app/src/main.rs 里就是这样)。其中一个启动日志里是:

Execution Error: error returned from database: duplicate key value violates unique constraint "pg_type_typname_nsp_index" at line 666

或者同样的话换成 "pg_class_relname_nsp_index"、"seaql_migrations_pkey"。去库里看,表都建好了,seaql_migrations 也是全的。

最小复现

src/bin/mig_concurrent.rs。三个迁移各建一张表,迁移体就是一条 execute_unprepared:

#[async_trait::async_trait]
impl MigrationTrait for CreateTable {
  async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
    let ine = if IF_NOT_EXISTS.load(Ordering::SeqCst) { "IF NOT EXISTS " } else { "" };
    let sql = format!("CREATE TABLE {ine}{} (id serial PRIMARY KEY)", self.1);
    manager.get_connection().execute_unprepared(&sql).await?;
    Ok(())
  }
}

每轮把 schema so_pit3_mig 删掉重建,开两个独立的 DatabaseConnection(两个实例),用 tokio::join!(Migrator::up(&a, None), Migrator::up(&b, None)) 同时跑,每种情形 10 轮。只看汇总:

=== A 空 schema,两个实例同时 Migrator::up ===
--- 10 轮汇总 ---
 10 × Err(Execution Error: error returned from database: duplicate key value violates unique constraint "pg_type_typname_nsp_index" at line 666)
 10 × Ok
 10 × 最终 seaql_migrations=[m1_create_t1,m2_create_t2,m3_create_t3] tables=[seaql_migrations,t1,t2,t3]
=== B 空 schema,两个实例同时只跑 Migrator::install ===
--- 10 轮汇总 ---
 10 × Err(Execution Error: error returned from database: duplicate key value violates unique constraint "pg_type_typname_nsp_index" at line 666)
 10 × Ok
 10 × 最终 seaql_migrations=[] tables=[seaql_migrations]
=== C seaql_migrations 已存在,三个待跑迁移,CREATE TABLE ===
--- 10 轮汇总 ---
 10 × Err(Execution Error: error returned from database: duplicate key value violates unique constraint "pg_class_relname_nsp_index" at line 666)
 10 × Ok
 10 × 最终 seaql_migrations=[m1_create_t1,m2_create_t2,m3_create_t3] tables=[seaql_migrations,t1,t2,t3]
=== D seaql_migrations 已存在,三个待跑迁移,CREATE TABLE IF NOT EXISTS ===
--- 10 轮汇总 ---
 10 × Err(Execution Error: error returned from database: duplicate key value violates unique constraint "pg_class_relname_nsp_index" at line 666)
 10 × Ok
 10 × 最终 seaql_migrations=[m1_create_t1,m2_create_t2,m3_create_t3] tables=[seaql_migrations,t1,t2,t3]
  • 每一轮都恰好一个成功、一个失败,最后的结构是对的。
  • A 和 B 的报错相同:空库上两边都先执行 CREATE TABLE IF NOT EXISTS "seaql_migrations",失败发生在这一步,后面的迁移根本没轮到。
  • C:seaql_migrations 已经存在(这是部署新版本时的常态),两边读到的待跑列表都是 m1–m3,在 m1 的 CREATE TABLE t1 上撞车。输的一方先等赢的一方提交,再报唯一约束冲突。
  • D:迁移写了 IF NOT EXISTS 也一样。Postgres 的 IF NOT EXISTS 只检查已提交的目录,对方还没提交的同名表它看不见,两边照样往 pg_class / pg_type 的唯一索引里插同一个名字。另一次运行里 D 有一轮报的是 pg_type_typname_nsp_index,哪个目录索引先撞上看时机。

迁移体里不建对象的时候,撞车要到最后记账那一步。F、G 只剩一个数据迁移 m4(INSERT INTO t1 DEFAULT VALUES; SELECT pg_sleep(0.3),睡 0.3 秒让两边重叠),G 让它 use_transaction 返回 Some(false):

=== F 只剩一个数据迁移 m4(默认,在事务里) ===
--- 10 轮汇总 ---
 10 × Err(Query Error: error returned from database: duplicate key value violates unique constraint "seaql_migrations_pkey" at line 666)
 10 × Ok
 10 × 最终 seaql_migrations=[m1_create_t1,m4_seed_t1] tables=[seaql_migrations,t1] t1 行数=1
=== G 只剩一个数据迁移 m4,use_transaction = Some(false) ===
--- 10 轮汇总 ---
 10 × Err(Query Error: error returned from database: duplicate key value violates unique constraint "seaql_migrations_pkey" at line 666)
 10 × Ok
 10 × 最终 seaql_migrations=[m1_create_t1,m4_seed_t1] tables=[seaql_migrations,t1] t1 行数=2

F 里输的一方的 INSERT 和记账在同一个事务里,一起回滚,t1 只有 1 行。G 里迁移体不在事务里,INSERT 已经自动提交,记账失败时收不回来:数据迁移执行了两次,而两边的日志一个成功、一个报主键冲突,看不出来。

sea-orm-migration 1.1.20 上 A–D、F 每轮同样是一成一败(1.1.20 没有 use_transaction,不跑 G),报错没有 at line 666;C 报的是 pg_type_typname_nsp_index,F 的前缀是 Execution Error 而不是 Query Error。1.1.20 把所有待跑迁移放在一个事务里,输的一方整批回滚。

出处

exec_up 依次调 M::install(db)(install 是一条 CREATE TABLE IF NOT EXISTS)、M::get_pending_migrations(db)(一条普通的 SELECT),再进 exec_up_with 逐个执行、用 insert_migration_record 记一行。没有一步加锁,SELECT 也不是 FOR UPDATE;seaql_migrations 的主键 version 是唯一能拦住第二次执行的东西,而它在迁移体跑完之后才被检查。

sqlx 的 Migrator::run 在迁移用的那条连接上先 pg_advisory_lock(sqlx 卷迁移的「advisory lock」一节)。sea-orm 照搬不了:Migrator::up(&db) 的语句分散在池里的多条连接上,2.0 的每个迁移各自 db.begin() 借一条,而 DatabaseConnection 没有「借出一条连接给我一直用」的 API(#2846「Ability to acquire a connection」还开着)。在 SeaQL/sea-orm 搜 migration concurrent、migration lock、advisory lock、migration multiple instances,没有找到报告并发迁移的 issue。

修法:advisory lock 包住 Migrator::up

锁放在一条单独的连接上,迁移照常在池上跑。这条连接用 sqlx 的 PoolConnection::detach 从池里拿出来:

const LOCK_KEY: i64 = 0x5e_a0_4d_16;

async fn up_locked<M: MigratorTrait>(db: &DatabaseConnection) -> Result<(), DbErr> {
  let pool = db.get_postgres_connection_pool();
  let mut lock = pool.acquire().await.map_err(|e| DbErr::Custom(e.to_string()))?.detach();
  sqlx::query("select pg_advisory_lock($1)")
    .bind(LOCK_KEY)
    .execute(&mut lock)
    .await
    .map_err(|e| DbErr::Custom(e.to_string()))?;
  let r = M::up(db, None).await;
  sqlx::Connection::close(lock).await.map_err(|e| DbErr::Custom(e.to_string()))?;
  r
}
=== H 同 G,advisory lock 包住 ===
--- 10 轮汇总 ---
 20 × Ok
 10 × 最终 seaql_migrations=[m1_create_t1,m4_seed_t1] tables=[seaql_migrations,t1] t1 行数=1
=== E 空 schema,advisory lock 包住 Migrator::up ===
--- 10 轮汇总 ---
 20 × Ok
 10 × 最终 seaql_migrations=[m1_create_t1,m2_create_t2,m3_create_t3] tables=[seaql_migrations,t1,t2,t3]

后到的实例在 pg_advisory_lock 上等,拿到锁时 seaql_migrations 已经记全,它读到的待跑列表是空的,直接 Ok。

为什么要 detach:会话级 advisory lock 跟着连接走,pg_advisory_unlock 或连接断开才释放。如果拿的是普通的 PoolConnection,持锁的 future 被取消(启动超时、进程收到信号走优雅退出)时,连接带着锁回到池里,别的实例会一直等下去。程序最后一段在任务持锁时 abort 它,再从另一个实例试 pg_try_advisory_lock:

持锁的任务被 abort(PoolConnection)之后,另一个实例 pg_try_advisory_lock = false
持锁的任务被 abort(detach 出来的连接)之后,另一个实例 pg_try_advisory_lock = true

detach 出来的 PgConnection 被 drop 时 socket 直接关掉,服务端结束会话、释放锁。sqlx 文档说 detach 会「allowing it to open a replacement」(PoolConnection::detach),所以迁移仍有完整的 max_connections 可用(实验里每个实例 max_connections(2))。所有实例要用同一个键;1.1.20 上同一段代码(sqlx 换成 sea_orm::sqlx)的 E 段和 abort 对比输出相同。

另一个办法是不让应用实例自己迁移:部署流程里先单独跑一次迁移(sea-orm-cli 或一个只调 Migrator::up 的二进制),成功后再启动应用。

怎么确认没中招

  • 数一下会在启动时调 Migrator::up 的进程:副本数大于 1、或者滚动发布时新旧实例重叠,就有可能。本站失败时默认只记日志、/healthz 报 degraded,设了 STRICT_MIGRATION=1 时 panic 拒绝启动,在这个坑上就是一个实例起不来。
  • 日志里搜这三个约束名:pg_type_typname_nsp_index、pg_class_relname_nsp_index、seaql_migrations_pkey。
  • use_transaction 返回 Some(false) 的数据迁移要么本身可重入,要么必须在锁里跑,否则 G 段那样的重复执行没有任何报错提示。

小结

  • Migrator::up 不加锁:两个实例同时跑,一个成功,另一个在建表时报 pg_type_typname_nsp_index / pg_class_relname_nsp_index,或在记账时报 seaql_migrations_pkey。IF NOT EXISTS 挡不住。2.0.4 和 1.1.20 都是这样。
  • 在事务里的迁移,输的一方整个回滚,结构最终是对的;use_transaction(false) 的迁移会被执行两次。
  • 修法:在一条 detach 出来的连接上 pg_advisory_lock,再跑 Migrator::up;或者把迁移从应用启动里拿出来单独跑。

💬 关联讨论

发起讨论
加载中...