迁移中途失败:留下一半的结构,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 永远不知道一个迁移执行了一半。
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再建。