Rust in Everything
基于 sea-orm 2.0.4

迁移中途失败:留下一半的结构,seaql_migrations 没有记录

主线位置:主线之外、应用启动时的那一步。2.0 的 exec_up_with 对每个迁移按 use_transaction 决定要不要包一个事务,迁移体返回 Ok 之后才往 seaql_migrations 记一行;1.1.20 是全部待跑迁移包一个事务。两种机制见 sea-orm-migration,本页看失败之后库里剩下什么。

flowchart TB
  start["下一个待跑迁移"] --> q{"use_transaction"}
  q -->|"Postgres 默认 / Some(true)"| tx["BEGIN 后执行 up"]
  q -->|"Some(false)"| raw["在池上逐条自动提交"]
  tx -->|出错| rb["整个迁移回滚 不记录"]
  tx -->|成功| rec1["记录 COMMIT"]
  raw -->|出错| half["已执行的留下 不记录"]
  raw -->|成功| rec2["记录"]

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

现象

一个关掉了事务的迁移(为了 CREATE INDEX CONCURRENTLY)在线上失败了。修好数据重新部署,迁移这次报 relation "..." already exists;或者更糟,这次「成功」了,可是唯一索引并不拦重复值。

最小复现

src/bin/mig_partial.rs,三个迁移:

  • p1_create_a:建 a (id serial PRIMARY KEY, v int NOT NULL),插入两行 v = 1。
  • p2_unique_v:use_transaction 返回 Some(false),先 CREATE TABLE IF NOT EXISTS b,再给 a.v 建唯一索引。
  • p3_create_c:建 c。
#[async_trait::async_trait]
impl MigrationTrait for P2Mig {
  async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
    let db = manager.get_connection();
    db.execute_unprepared("CREATE TABLE IF NOT EXISTS b (id int)").await?;
    db.execute_unprepared("CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS a_v_key ON a (v)").await?;
    Ok(())
  }
  fn use_transaction(&self) -> Option<bool> {
    Some(false)
  }
}

第一次 up 撞上重复值;删掉重复的那行再 up;最后再插一行 v = 1。每次之后打印 seaql_migrations、表和 pg_index.indisvalid:

=== 1. p2: use_transaction = Some(false),CREATE UNIQUE INDEX CONCURRENTLY 撞上重复值 ===
第一次 up: Err(Execution Error: error returned from database: could not create unique index "a_v_key" at line 1361)
  seaql_migrations=[p1_create_a] tables=[a,b,seaql_migrations] index=[a_v_key valid=false]
删掉重复行,再 up: Ok
  seaql_migrations=[p1_create_a,p2_unique_v,p3_create_c] tables=[a,b,c,seaql_migrations] index=[a_v_key valid=false]
  再插一行 v = 1:Ok(1)
  • 失败之后:p1 已提交并记录;p2 建的表 b 留下了;seaql_migrations 里没有 p2。
  • 失败的 CREATE INDEX CONCURRENTLY 不会把索引删掉,而是留下一个 indisvalid = false 的索引。这是 Postgres 的行为:并发建索引分几个事务完成,中途失败时目录里已经有它了。
  • 重跑时 IF NOT EXISTS 看到同名索引就跳过,p2 被记为已应用,p3 也跑了,up 返回 Ok。这个无效索引没有拦住重复值,v = 1 照样插进去。

不写 IF NOT EXISTS 时(1b 段),重跑就停在那个无效索引上:

删掉重复行,再 up: Err(Execution Error: error returned from database: relation "a_v_key" already exists at line 885)
  seaql_migrations=[p1_create_a] tables=[a,b,seaql_migrations] index=[a_v_key valid=false]

迁移在事务里时

同一个 p2 不改 use_transaction(Postgres 上默认包事务),CONCURRENTLY 根本跑不了(2 段);换成普通的 ADD CONSTRAINT a_v_key UNIQUE (v) 撞上重复值(3 段):

=== 2. p2: 默认(Postgres 上在事务里),CREATE INDEX CONCURRENTLY ===
up: Err(Execution Error: error returned from database: CREATE INDEX CONCURRENTLY cannot run inside a transaction block at line 3497)
  seaql_migrations=[p1_create_a] tables=[a,seaql_migrations] index=[]
=== 3. p2: 默认(在事务里),普通 UNIQUE 约束撞上重复值 ===
up: Err(Execution Error: error returned from database: could not create unique index "a_v_key" at line 1361)
  seaql_migrations=[p1_create_a] tables=[a,seaql_migrations] index=[]

两种失败都干净:p2 整个回滚,b 不在了,也没有索引;之前的 p1 已经单独提交,留在库里。2 段的 25001 就是要关掉事务的原因。

1.1.20:整批一个事务

sea-orm-migration 1.1.20 没有 use_transaction,同样三个迁移(p2 去掉 use_transaction):

=== 1.1.20 A. p2: CREATE INDEX CONCURRENTLY ===
up: Err(Execution Error: error returned from database: CREATE INDEX CONCURRENTLY cannot run inside a transaction block)
  tables=[]
  读 seaql_migrations:Query Error: error returned from database: relation "seaql_migrations" does not exist
=== 1.1.20 B. p2: 普通 UNIQUE 约束撞上重复值 ===
up: Err(Execution Error: error returned from database: could not create unique index "a_v_key")
  tables=[]
  读 seaql_migrations:Query Error: error returned from database: relation "seaql_migrations" does not exist

连 p1 和 seaql_migrations 表本身都回滚了:1.1.20 的 exec_with_connection 在 Postgres 上先 db.begin(),install 和所有迁移都在这个事务里。所以 1.1.20 不会留下半截结构,代价是迁移里永远不能用 CONCURRENTLY;要并发建索引,只能放到 Migrator::up 之外自己执行。

出处

exec_up_with 里每个迁移的两条路:

        let use_txn = should_use_transaction(migration.as_ref(), db.get_database_backend());
        info!("Applying migration '{}'", migration.name());

        if use_txn {
            let transaction = db.begin().await?;
            let txn_manager = SchemaManager::new(&transaction);
            migration.up(&txn_manager).await?;
            info!("Migration '{}' has been applied", migration.name());
            insert_migration_record(&transaction, migration.name(), migration_table_name.clone())
                .await?;
            transaction.commit().await?;
        } else {
            migration.up(manager).await?;
            info!("Migration '{}' has been applied", migration.name());
            insert_migration_record(db, migration.name(), migration_table_name.clone()).await?;
        }

should_use_transaction 在 use_transaction() 返回 None 时只对 Postgres 返回 true(MigrationTrait::use_transaction 默认 None)。else 分支的 manager 建在池上,迁移体里每条 execute_unprepared 各自借连接、自动提交;up 返回 Err 时 ? 直接返回,前面执行过的语句收不回来,也不会调 down。记录只在 up 成功之后写,所以 seaql_migrations 永远不知道一个迁移执行了一半。

📝NOTE
冷知识:1.x 的「整批一个事务」和 2.0 的「每个一个」都是有意的

PR #1379(「Atomic migration」,首次发布于 0.11.0-rc.1,正式版 0.11.0)让 Postgres 上的迁移「will be executed with a transaction when the target database is Postgres」,做法就是 1.1.20 里那个包住全部迁移的 begin。到了 PR #2980(「Add per-migration transaction control for SeaORM migrations」,首次发布于 2.0.0-rc.36),说明写的是「replacing the previous all-or-nothing batch transaction behavior on Postgres」,changelog/2.0.0-rc.36.md 给的理由正是本页 2 段的报错:「e.g. CREATE INDEX CONCURRENTLY on Postgres requires running outside a transaction」。PR 的测试清单里专门有一项「failure-without-txn (DDL persists)」:关掉事务后失败会留下 DDL,是设计上接受的结果,不是 bug。

修法

关掉事务的迁移要写成可以从任意一步重跑的样子。对 CONCURRENTLY 来说,就是先删掉可能残留的无效索引,再不带 IF NOT EXISTS 地建(4 段,p2 换成这两条):

db.execute_unprepared("DROP INDEX CONCURRENTLY IF EXISTS a_v_key").await?;
db.execute_unprepared("CREATE UNIQUE INDEX CONCURRENTLY a_v_key ON a (v)").await?;
=== 4. 修法:p2 先 DROP INDEX CONCURRENTLY IF EXISTS,再不带 IF NOT EXISTS 地建 ===
第一次 up: Err(Execution Error: error returned from database: could not create unique index "a_v_key" at line 1361)
  seaql_migrations=[p1_create_a] tables=[a,b,seaql_migrations] index=[a_v_key valid=false]
删掉重复行,再 up: Ok
  seaql_migrations=[p1_create_a,p2_unique_v,p3_create_c] tables=[a,b,c,seaql_migrations] index=[a_v_key valid=true]
  再插一行 v = 1:Execution Error: error returned from database: duplicate key value violates unique constraint "a_v_key" at line 666

p2 只会在没记录时重跑,所以这里的 DROP 删掉的只可能是上次失败留下的那个。另外两条:

  • 一个关掉事务的迁移里只放必须在事务外执行的语句,能放进事务的 DDL 拆到相邻的、默认带事务的迁移里,失败时留下的东西就少。
  • 每条 execute_unprepared 只放一条语句:多条语句的字符串在简单查询协议里是一个隐式事务。写实验时把 set client_min_messages = warning; DROP INDEX CONCURRENTLY ... 放进同一次 execute_unprepared,就报了 DROP INDEX CONCURRENTLY cannot run inside a transaction block。

怎么确认没中招

  • 列出所有 use_transaction 返回 Some(false) 的迁移,逐条问:第 N 条语句失败后重跑,前 N−1 条会不会报 already exists,会不会被 IF NOT EXISTS 静默跳过。
  • 查无效索引:select indexrelid::regclass from pg_index where not indisvalid,有结果就说明某次并发建索引失败过。
  • 从 1.1.20 升到 2.0 时,同一个失败的迁移批次从「全部回滚」变成「之前的迁移已提交」;本站的 9 个迁移都没有关事务,影响见升级到 2.0。

小结

  • 2.0 里每个迁移单独一个事务(Postgres 默认),失败时这个迁移整个回滚、之前的已提交;use_transaction(false) 的迁移失败时已执行的语句留下,seaql_migrations 没有记录。
  • 失败的 CREATE INDEX CONCURRENTLY 留下 indisvalid = false 的索引:重跑不带 IF NOT EXISTS 时报 already exists,带了则静默跳过、迁移记为成功、唯一性不生效。
  • 1.1.20 把 install 和全部迁移放在一个事务里,失败时连 seaql_migrations 表都回滚;迁移里不能用 CONCURRENTLY(25001)。
  • 关掉事务的迁移要能从任意一步重跑:CONCURRENTLY 索引先 DROP INDEX CONCURRENTLY IF EXISTS 再建。

💬 关联讨论

发起讨论
加载中...