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;或者把迁移从应用启动里拿出来单独跑。