migration 1 was previously applied but has been modified
主线位置:不在三条主线上。
Migrator::run先走连接池主线借一条连接,在这条连接上拿 advisory lock、读记账表、逐个比对校验和;比对失败就返回,后面的代码(包括解锁)都不执行。迁移的完整流程见迁移。
flowchart TB
run["Migrator::run"] --> lock["pg_advisory_lock"]
lock --> list["读已应用版本和校验和"]
list --> cmp{"1 号校验和一致吗"}
cmp -->|"否"| err["VersionMismatch 1"]
err --> held["锁留在这条连接上"]
held --> other["别的实例的迁移一直等"]
cmp -->|"是"| apply["应用 3 号"]
apply --> unlock["pg_advisory_unlock"]
本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行,依赖 sqlx = "=0.9.0",CLI 是 sqlx-cli 0.9.0。
现象
第一次部署应用了 1、2 号迁移。之后有人直接改了 1_users.sql(想顺手加一列),又新加了 3_users_email.sql。下一次启动:
Display: migration 1 was previously applied but has been modified
Debug: VersionMismatch(1)
3 号没有执行。如果程序没有因此退出,而是记了日志继续跑,同一个库上另一个实例的迁移会一直卡住。
最小复现
src/bin/migration_modified.rs,运行期的 Migrator::new(dir),池只有一条连接,search_path 是 pit2(记账表就是 pit2._sqlx_migrations):
const V1: &str = "create table pit2.mm_users (id int8 primary key);\n";
const V1_EDITED: &str = "create table pit2.mm_users (id int8 primary key, email text);\n";
const V2: &str = "alter table pit2.mm_users add column name text;\n";
const V3: &str = "alter table pit2.mm_users add column email text;\n";
write("1_users.sql", V1)?;
write("2_users_name.sql", V2)?;
let a = pool(&opts).await?;
Migrator::new(dir().as_path()).await?.run(&a).await?;
write("1_users.sql", V1_EDITED)?;
write("3_users_email.sql", V3)?;
let err = Migrator::new(dir().as_path()).await?.run(&a).await.unwrap_err();
// 然后:查池 A 那条连接持有的 advisory 锁;
// 把 1_users.sql 改回 V1,用另一个池 B 跑迁移,等 3 秒;关掉池 A,池 B 再跑
第一次部署: 已应用 [(1, "users"), (2, "users name")]
--- 改了 1_users.sql,又加了 3_users_email.sql ---
Display: migration 1 was previously applied but has been modified
Debug: VersionMismatch(1)
已应用 [(1, "users"), (2, "users name")]
mm_users 的列 ["id", "name"]
出错后池 A 的连接 (pid 1031) 持有的 advisory 锁: [(1031, "ExclusiveLock", true)]
--- 另一个实例(池 B)用改回来的目录跑迁移,等 3 秒 ---
池 B: Err(Elapsed(()))
--- 关掉池 A,池 B 再跑 ---
池 B: Ok(Ok(()))
已应用 [(1, "users"), (2, "users name"), (3, "users email")]
mm_users 的列 ["id", "name", "email"]
池 B 那 3 秒的超时是实验加的;sqlx 自己等 pg_advisory_lock 没有超时,会一直等。
出处
run_direct 按版本顺序遍历目录里的迁移,已应用的比对校验和(第 274 行),未应用的执行:
match applied_migrations.get(&migration.version) {
Some(applied_migration) => {
if migration.checksum != applied_migration.checksum {
return Err(MigrateError::VersionMismatch(migration.version));
}
}
None => {
if skip {
conn.skip(&self.table_name, migration).await?;
} else {
conn.apply(&self.table_name, migration).await?;
}
}
}
}
// unlock the migrator to allow other migrators to run
// but do nothing as we already migrated
if self.locking {
conn.unlock().await?;
}
校验和是文件全文的 SHA-384,改一个字符(加个注释、换行符从 LF 变 CRLF)都算修改,除非在 sqlx.toml 里配了 ignored-chars(见迁移)。比对和执行在同一个循环里,1 号在前,返回时还没轮到 3 号。
return 也跳过了循环后面的 unlock。lock 拿的是会话级的 pg_advisory_lock,只有 pg_advisory_unlock 或会话结束才释放;而 run(&pool) 借来的连接在出错后照常回到池里,锁跟着它。锁的键只由库名算出,同一个库上所有 Migrator 抢同一把(见迁移的 advisory lock 一节),所以别的实例、甚至同一个程序里另开的池都会卡住,直到这条连接被关掉。同一条连接再跑迁移不受影响,advisory lock 在同一会话里可以重入。
在 transact-rs/sqlx 里没有找到「迁移失败后 advisory lock 不释放」的 issue。改已应用迁移的报错本身有一个功能请求 #3794(「migration x was previously applied but has been modified skip」),要一个跳过校验或者更新校验和的选项,写这一页时还开着。
修法
把文件改回去,另写一个新迁移
上面池 B 那一段就是这样:1_users.sql 恢复原样(从版本控制里取回),想加的列放进新的 3 号迁移,run 就通过了,3 号被应用。已应用的迁移文件应当当成只读的:它记录的是「这个库曾经执行过什么」,新库从头跑和老库增量跑要得到同一个结构,靠的就是这一点。
最后手段:改库里的校验和
确实要接受文件的新内容(比如只改了注释),可以把记账表里那一行的 checksum 换成新文件的:
let m = Migrator::new(dir().as_path()).await?;
let new_sum = m.iter().find(|m| m.version == 1).map(|m| m.checksum.to_vec()).unwrap_or_default();
sqlx::query("update pit2._sqlx_migrations set checksum = $1 where version = 1")
.bind(&new_sum)
.execute(&mut observer)
.await?;
接着用这个目录(1 号是改过的版本,带 email;3 号也加 email)先在这个库上跑,再把表都删掉、模拟一个新库从头跑:
--- 最后手段: 把库里的 checksum 改成新文件的 ---
update 1 行
run = Ok(())
mm_users 的列 ["id", "name", "email"]
--- 同一个目录,在一个新库上从头跑 ---
run = Err("while executing migration 3: error returned from database: column \"email\" of relation \"mm_users\" already exists at line 7347")
已应用 [(1, "users"), (2, "users name")]
老库上 run 通过了,但改过的 1 号从没在它上面执行过,email 列其实来自 3 号;新库上 1 号按新内容建了 email,3 号再加一次就失败了。改校验和只是让 run 闭嘴,库和目录对不上的问题还在。只在改动对结构没有影响(注释、空白)时才这么做。
迁移失败之后别继续用这个池
迁移失败通常就该让进程退出:进程退出,连接断开,锁随之释放。确实要继续运行的,先 pool.close().await 把池关掉(上面关掉池 A 之后池 B 立刻跑通了),或者让迁移用一条单独的 PgConnection,出错后把它关掉。
-- no-transaction 里两条 CONCURRENTLY
同一类「迁移跑不动」的还有这一个。CREATE INDEX CONCURRENTLY 不能在事务块里执行,加上 -- no-transaction 之后,一个文件里写两条仍然失败(src/bin/no_tx_multi.rs):
--- 一个 -- no-transaction 文件里两条 CONCURRENTLY ---
run = Err: while executing migration 1: error returned from database: CREATE INDEX CONCURRENTLY cannot run inside a transaction block at line 3497
索引 [],已应用 []
--- 拆成两个文件,各带 -- no-transaction ---
run = Ok
索引 ["multi_a", "multi_b"],已应用 [1, 2]
迁移脚本整个作为一条 Query 消息发出,Postgres 把一条 Query 里的多条语句当成一个隐式事务块执行(见迁移的 -- no-transaction 一节)。修法就是第二段:每个文件只放一条这类语句,每个文件第一行都写 -- no-transaction。上游 #3693(「Support running multiple statements per migrations file」)提议加一个拆分指令,写这一页时还开着。失败的那次同样把 advisory lock 留在了池里的连接上。
怎么确认没中招
sqlx migrate info 会逐个比对校验和。对上面改过 1 号的目录,依次跑 info 和 run,各自打印退出码:
URL='postgres://lab:lab@192.168.64.106/lab?options=-c%20search_path%3Dpit2'
sqlx migrate info --source mm --database-url "$URL"; echo "info exit=$?"
sqlx migrate run --source mm --database-url "$URL"; echo "run exit=$?"
1/installed (different checksum) users
applied migration had checksum 715fc1d96dec702690da76fe9642b7983cd1f4c5cb02d07def6386bf49b263206922263a8f9d1aa06d1f9dcb22cfb473
local migration has checksum 88eda3638e8eb1f1d768a191fc219346861c02fec8a39769ab3b39b8deb8f21290604b91a2f7d5e134667d514ea92be5
2/installed users name
3/installed users email
info exit=0
error: migration 1 was previously applied but has been modified
run exit=1
info 的退出码是 0,在 CI 里要匹配 different checksum 这几个字,不能只看退出码;sqlx migrate run 才以 1 退出。
migrate info 原来不报校验和不一致issue #870(「'migrate info': warn about changed-after-installed migrations」)指出,sqlx database setup 遇到改过的迁移会报 migration ... was previously applied but has been modified,migrate info 却不提示:「It would be useful to have sqlx migrate info warn the user about migrations whose file checksum no longer matches the value recorded in the db」。#1680(「Add checksum mismtaches to sqlx-cli migrate info」,首次发布于 0.5.11)加了 installed (different checksum) 和下面两行完整的校验和,就是上面的输出。
另外几条:
- 代码审查里把「改了
migrations/下已有的文件」当成需要特别说明的改动;只允许新增文件。 - 确认迁移失败时进程会退出(
run(...).await?一路传到main),而不是记日志后继续用同一个池。 -- no-transaction的文件里,CREATE INDEX CONCURRENTLY这类不能进事务块的语句一个文件一条。
小结
- 已应用迁移的文件内容(SHA-384)变了,
run返回VersionMismatch,文本migration N was previously applied but has been modified;比对在执行新迁移的同一个循环里,后面的新迁移一个也不跑。 - 出错返回时跳过了
unlock,会话级 advisory lock 留在池里那条连接上,同一个库上别的Migrator一直等,直到连接被关掉。没找到上游 issue。 - 修法:把文件改回去,改动写进新迁移;改记账表里的校验和只适合注释、空白这类改动,否则新库从头跑会和老库不一样。迁移失败就退出进程或关掉池。
-- no-transaction的文件里写多条CREATE INDEX CONCURRENTLY仍然失败(隐式事务块,#3693),一个文件一条。sqlx migrate info显示installed (different checksum),但退出码是 0。