Rust in Everything
基于 sqlx 0.9.0

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 退出。

📝NOTE
冷知识: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。

💬 关联讨论

发起讨论
加载中...