Rust in Everything
基于 sqlx 0.9.0

迁移

主线位置:不在三条主线上,但用到其中两条。sqlx::migrate!() 和 query! 一样是编译期展开的宏,区别是它只读文件、不连数据库;Migrator::run(&pool) 运行时先走连接池主线 acquire 一条连接,然后在这一条连接上顺序发十几条普通查询,每条都走查询主线。

flowchart TB
  dir["migrations 目录"] -->|"编译期 migrate!"| mig["Migrator 常量"]
  mig --> run["run(&pool)"]
  run --> lock["pg_advisory_lock"]
  lock --> table["CREATE TABLE IF NOT EXISTS"]
  table --> dirty["查 success = false"]
  dirty --> list["读已应用版本和校验和"]
  list --> each{"逐个迁移"}
  each -->|"已应用"| cmp["比对校验和"]
  each -->|"未应用"| apply["BEGIN 脚本 INSERT COMMIT"]
  cmp -->|"不同"| err["VersionMismatch"]
  apply --> upd["UPDATE execution_time"]
  each -->|"跑完"| unlock["pg_advisory_unlock"]

代码在 sqlx-core/src/migrate/(约 1000 行,与数据库无关的 Migrator、Migration、目录解析)、sqlx-postgres/src/migrate.rs(Migrate trait 的 Postgres 实现,那些 SQL 都在这里)、sqlx-macros-core/src/migrate.rs(migrate! 的展开)和 sqlx-core/src/config/migrate.rs(sqlx.toml 的 [migrate] 段)。

本页的实验 crate 有一个 migrations/ 目录,三个可逆迁移,表都建在实验专用的 schema tx 里:

文件内容
1_widgets.up.sql / .down.sqlcreate table tx.widgets (...) / drop table tx.widgets
2_widget_color.up.sql / .down.sqlalter table tx.widgets add column color text / drop column color
3_widgets_name_idx.up.sql / .down.sql第一行 -- no-transaction,然后 create index concurrently ... / drop index concurrently ...

crate 根目录的 sqlx.toml 把记账表也放进 tx:

[migrate]
table-name = "tx._sqlx_migrations"

目录怎么变成 Migration

目录解析只有一个实现,resolve_blocking_with_config,编译期的宏和运行期的 Migrator::new 都调它。逐个文件:

  • 不是普通文件的跳过;文件名按第一个 _ 切成两半(splitn(2, '_')),切不出两半或者后半不以 .sql 结尾的,静默跳过。
  • 前半必须能解析成 i64,否则报 error parsing migration filename ...; expected integer version prefix (e.g. 01_foo.sql)。
  • 后半以 .up.sql 结尾是 ReversibleUp,.down.sql 是 ReversibleDown,其余是 Simple(MigrationType::from_filename)。去掉后缀、_ 换成空格就是描述。
  • 文件内容以 -- no-transaction 开头就标记 no_tx,下面单独讲。
  • 校验和是整个文件内容的 SHA-384(checksum),48 字节。配置了忽略字符时改用 checksum_with,见后面的 sqlx.toml 一节。

最后 migrations.sort()。Migration 的 Ord 先比版本,再比类型(枚举顺序 Simple、ReversibleUp、ReversibleDown),同一版本的 up 一定排在 down 前面。read_dir 的顺序随文件系统而变,这个次序让 migrate! 嵌进二进制的数组每次都一样(#4136,0.9.0,为了可复现构建)。

一个可逆迁移是两个 Migration,up 和 down 各一个,版本相同。实验程序把 MIGRATOR 的内容打印出来(src/bin/migrate_run.rs,M2 Max、macOS、rustc 1.99.0、Postgres 16.14):

MIGRATOR.table_name = "tx._sqlx_migrations"
  1 "widgets" ReversibleUp no_tx=false checksum 48 字节
  1 "widgets" ReversibleDown no_tx=false checksum 48 字节
  2 "widget color" ReversibleUp no_tx=false checksum 48 字节
  2 "widget color" ReversibleDown no_tx=false checksum 48 字节
  3 "widgets name idx" ReversibleUp no_tx=true checksum 48 字节
  3 "widgets name idx" ReversibleDown no_tx=true checksum 48 字节

migrate!:编译期把目录变成常量

sqlx::migrate!() 是 sqlx 里的一个 macro_rules!,转给 sqlx-macros 的过程宏,后者调 sqlx_macros_core::migrate::expand:读 $CARGO_MANIFEST_DIR/sqlx.toml(没有就用默认配置),目录默认是 ./migrations(或 sqlx.toml 的 migrations-dir),按上面的规则解析,再把每个 Migration 写成一个结构体字面量。cargo expand 的结果(src/bin/expand_migrate.rs,只留第一个和最后的字段):

static MIGRATOR: sqlx::migrate::Migrator = {
    ::sqlx::migrate::Migrator {
        migrations: ::std::borrow::Cow::Borrowed(const {
            &[
                ::sqlx::migrate::Migration {
                    version: 1i64,
                    description: ::std::borrow::Cow::Borrowed("widgets"),
                    migration_type: ::sqlx::migrate::MigrationType::ReversibleUp,
                    sql: ::sqlx::SqlStr::from_static(
                        "create table tx.widgets (id int8 primary key, name text not null);\n",
                    ),
                    no_tx: false,
                    checksum: ::std::borrow::Cow::Borrowed(
                        &[
                            35u8, 247u8, 221u8, 183u8, 173u8, 171u8, 232u8, 204u8, 216u8,
                            // ...
                        ],
                    ),
                },
                // ...
            ]
        }),
        create_schemas: ::std::borrow::Cow::Borrowed(&[]),
        table_name: ::std::borrow::Cow::Borrowed("tx._sqlx_migrations"),
        ..::sqlx::migrate::Migrator::DEFAULT
    }
};
  • 校验和在编译期算好,以字节数组嵌进去;运行时不再读文件,也不再算哈希。
  • sql 在宏的输出里其实是 include_str!("<绝对路径>"),cargo expand 把它展开成了字符串。用 include_str! 是为了让 cargo 记住这个文件:注释写着「this tells the compiler to watch this path for changes」,文件一改,crate 就重新编译。
  • 表名、要建的 schema 来自 sqlx.toml,其余字段取 Migrator::DEFAULT:locking: true、ignore_missing: false、no_tx: false。
  • 整个值能放进 static。Migrator 的字段因此都是 pub 加 #[doc(hidden)],注释解释了原因:「These have to be public for migrate!() to be able to initialize them in an implicitly const-promotable context. A const fn constructor isn't implicitly const-promotable.」这些字段不受 semver 约束。

include_str! 只盯已有的文件,目录本身没人盯。expand_with_path 里调 proc_macro::tracked::path 的那段代码只在 sqlx_macros_unstable 下编译,稳定版 Rust 用不上。

❗IMPORTANT
动脑筋:加一个迁移文件,MIGRATOR 里有几个?

src/bin/expand_migrate.rs 只打印迁移个数,migrations/ 里是上面的 6 个文件(M2 Max、macOS、rustc 1.99.0):

static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!();

fn main() {
  println!("{}", MIGRATOR.iter().count());
}
cargo run -q --offline --bin expand_migrate      # 6
printf 'select 1;\n' > migrations/4_extra.sql
cargo run -q --offline --bin expand_migrate      # ?
touch migrations/1_widgets.up.sql
cargo run -q --offline --bin expand_migrate      # ?
两个问号各是几?

Migrator::run 一步步

Migrator::run 收任何 Acquire(池、连接、事务都行),acquire 一条连接后交给 run_direct:

        // lock the database for exclusive access by the migrator
        if self.locking {
            conn.lock().await?;
        }

        for schema_name in self.create_schemas.iter() {
            conn.create_schema_if_not_exists(schema_name).await?;
        }

        // creates [_migrations] table only if needed
        // eventually this will likely migrate previous versions of the table
        conn.ensure_migrations_table(&self.table_name).await?;

        let version = conn.dirty_version(&self.table_name).await?;
        if let Some(version) = version {
            return Err(MigrateError::Dirty(version));
        }

        let applied_migrations = conn.list_applied_migrations(&self.table_name).await?;
        validate_applied_migrations(&applied_migrations, self)?;
        // ...
        for migration in self.iter() {
            if target.is_some_and(|target| target < migration.version) {
                // Target version reached
                break;
            }

            if migration.migration_type.is_down_migration() {
                continue;
            }

            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?;
        }

conn 上的这些方法来自 Migrate trait,除了 lock / unlock,每个都带 table_name 参数:表名可配置是 0.9 的 sqlx.toml(#3383)带来的,CHANGELOG 把它列为「Significant changes to the Migrate trait」。down 迁移在 run 里一律跳过,只有 undo 用它们。

线上的样子。实验先在另一条连接上自己拿住同一把锁(锁的算法见下一节),再 spawn 出 MIGRATOR.run(&pool),500 毫秒后查 pg_locks、放锁(池只有一条连接,经过打印协议消息的代理):

current_database = "lab", crc32 = 0x61d6b1c4, lock key = 1685338728340073208
--- 第一次 MIGRATOR.run(锁被别人拿着)---
  [conn 1] C->S Sync
  [conn 1] S->C ReadyForQuery(I)
  [conn 1] C->S Parse(sqlx_s_1, "SELECT current_database()"), Describe(S, sqlx_s_1), Sync
  [conn 1] S->C ParseComplete, ParameterDescription, RowDescription, ReadyForQuery(I)
  [conn 1] C->S Bind(portal unnamed, stmt sqlx_s_1), Execute, Close(P, unnamed), Sync
  [conn 1] S->C BindComplete, DataRow(列长 3), CommandComplete("SELECT 1"), CloseComplete, ReadyForQuery(I)
  [conn 1] C->S Parse(sqlx_s_2, "SELECT pg_advisory_lock($1)"), Describe(S, sqlx_s_2), Sync
  [conn 1] S->C ParseComplete, ParameterDescription, RowDescription, ReadyForQuery(I)
  [conn 1] C->S Bind(portal unnamed, stmt sqlx_s_2), Execute, Close(P, unnamed), Sync
pg_locks 等待者: classid=392398500 objid=3840617208 objsubid=1 granted=false => (classid<<32)|objid = 1685338728340073208
释放我们手里的锁
  [conn 1] S->C BindComplete, DataRow(列长 0), CommandComplete("SELECT 1"), CloseComplete, ReadyForQuery(I)
  [conn 1] C->S Query("\nCREATE TABLE IF NOT EXISTS tx._sqlx_migrations (\n    version BIGINT PRIMARY KEY,\n    description TEXT NOT NULL,\n    installed_on TIMESTAMPTZ NOT NULL DEFAULT now(),\n    success BOOLEAN NOT NULL,\n    checksum BYTEA NOT NULL,\n    execution_time BIGINT NOT NULL\n);\n                ")
  [conn 1] S->C CommandComplete("CREATE TABLE"), ReadyForQuery(I)
  [conn 1] C->S Parse(sqlx_s_3, "SELECT version FROM tx._sqlx_migrations WHERE success = false ORDER BY version LIMIT 1"), Describe(S, sqlx_s_3), Sync
  ...
  [conn 1] C->S Parse(sqlx_s_4, "SELECT version, checksum FROM tx._sqlx_migrations ORDER BY version"), Describe(S, sqlx_s_4), Sync
  ...
  [conn 1] C->S Query("BEGIN")
  [conn 1] S->C CommandComplete("BEGIN"), ReadyForQuery(T)
  [conn 1] C->S Query("create table tx.widgets (id int8 primary key, name text not null);\n")
  [conn 1] S->C CommandComplete("CREATE TABLE"), ReadyForQuery(T)
  [conn 1] C->S Parse(sqlx_s_5, "\n    INSERT INTO tx._sqlx_migrations ( version, description, success, checksum, execution_time )\n    VALUES ( $1, $2, TRUE, $3, -1 )\n                "), Describe(S, sqlx_s_5), Sync
  [conn 1] S->C ParseComplete, ParameterDescription, NoData, ReadyForQuery(T)
  [conn 1] C->S Bind(portal unnamed, stmt sqlx_s_5), Execute, Close(P, unnamed), Sync
  [conn 1] S->C BindComplete, CommandComplete("INSERT 0 1"), CloseComplete, ReadyForQuery(T)
  [conn 1] C->S Query("COMMIT")
  [conn 1] S->C CommandComplete("COMMIT"), ReadyForQuery(I)
  [conn 1] C->S Parse(sqlx_s_6, "\n    UPDATE tx._sqlx_migrations\n    SET execution_time = $1\n    WHERE version = $2\n                "), Describe(S, sqlx_s_6), Sync
  [conn 1] S->C ParseComplete, ParameterDescription, NoData, ReadyForQuery(I)
  [conn 1] C->S Bind(portal unnamed, stmt sqlx_s_6), Execute, Close(P, unnamed), Sync
  [conn 1] S->C BindComplete, CommandComplete("UPDATE 1"), CloseComplete, ReadyForQuery(I)
  ... (2 号迁移同上)
  [conn 1] C->S Query("-- no-transaction\ncreate index concurrently widgets_name_idx on tx.widgets (name);\n")
  [conn 1] S->C CommandComplete("CREATE INDEX"), ReadyForQuery(I)
  [conn 1] C->S Bind(portal unnamed, stmt sqlx_s_5), Execute, Close(P, unnamed), Sync
  [conn 1] S->C BindComplete, CommandComplete("INSERT 0 1"), CloseComplete, ReadyForQuery(I)
  [conn 1] C->S Bind(portal unnamed, stmt sqlx_s_6), Execute, Close(P, unnamed), Sync
  [conn 1] S->C BindComplete, CommandComplete("UPDATE 1"), CloseComplete, ReadyForQuery(I)
  [conn 1] C->S Bind(portal unnamed, stmt sqlx_s_1), Execute, Close(P, unnamed), Sync
  [conn 1] S->C BindComplete, DataRow(列长 3), CommandComplete("SELECT 1"), CloseComplete, ReadyForQuery(I)
  [conn 1] C->S Parse(sqlx_s_7, "SELECT pg_advisory_unlock($1)"), Describe(S, sqlx_s_7), Sync
  ...
  [conn 1] C->S Sync
  [conn 1] S->C ReadyForQuery(I)
--- 第一次 run 返回 ---
  • pg_advisory_lock 的 Bind / Execute 发出去以后,回应一直没来,直到我们放锁:pg_advisory_lock 会一直等,sqlx 这边没有超时。
  • 建表、迁移脚本本身是 Query(SqlStr 没有参数,走简单查询协议,一条消息里可以有多条语句);带参数的记账语句是预备语句,第二个迁移起直接 Bind 缓存里的 sqlx_s_5 / sqlx_s_6。
  • 3 号迁移前后没有 BEGIN / COMMIT,-- no-transaction 那行注释原样发给了服务端。
  • 加锁和解锁各查一次 current_database(),结尾的 Sync 是连接归还时的 ping。

跑完之后的记账表(第二次 run 时 CREATE TABLE IF NOT EXISTS 收到 NoticeResponse("relation \"_sqlx_migrations\" already exists, skipping"),读出三行、校验和都对得上,什么也不做):

tx._sqlx_migrations:
  (1, "widgets", true, "23f7ddb7adabe8cc", 48, 7641125)
  (2, "widget color", true, "8dada9d67fce8b19", 48, 1685000)
  (3, "widgets name idx", true, "d321ecb11b0fce22", 48, 3436042)
  列 ("version", "bigint", "NO")
  列 ("description", "text", "NO")
  列 ("installed_on", "timestamp with time zone", "NO")
  列 ("success", "boolean", "NO")
  列 ("checksum", "bytea", "NO")
  列 ("execution_time", "bigint", "NO")
运行后 (public, tx) = (None, Some("tx._sqlx_migrations"))
MIGRATOR 里 1 号的 checksum 前 8 字节 = Some("23f7ddb7adabe8cc")

每行依次是版本、描述、success、校验和的前 8 字节(十六进制)、校验和长度、execution_time。execution_time 的单位是纳秒(elapsed.as_nanos() as i64)。shasum -a 384 migrations/1_widgets.up.sql 的开头也是 23f7ddb7adabe8cc。表落在 tx 里,public 里没有:table-name 是原样拼进 SQL 的,写成 schema.table 就指定了 schema。

advisory lock:按库名算出来的一个 i64

lock 和 unlock 都先查库名再算键:

    fn lock(&mut self) -> BoxFuture<'_, Result<(), MigrateError>> {
        Box::pin(async move {
            let database_name = current_database(self).await?;
            let lock_id = generate_lock_id(&database_name);

            // create an application lock over the database
            // this function will not return until the lock is acquired
            // ...
            let _ = query("SELECT pg_advisory_lock($1)")
                .bind(lock_id)
                .execute(self)
                .await?;

            Ok(())
        })
    }

generate_lock_id:

// inspired from rails: https://github.com/rails/rails/blob/6e49cc77ab3d16c06e12f93158eaf3e507d4120e/activerecord/lib/active_record/migration.rb#L1308
fn generate_lock_id(database_name: &str) -> i64 {
    const CRC_IEEE: crc::Crc<u32> = crc::Crc::<u32>::new(&crc::CRC_32_ISO_HDLC);
    // 0x3d32ad9e chosen by fair dice roll
    0x3d32ad9e * (CRC_IEEE.checksum(database_name.as_bytes()) as i64)
}

库名 lab 的 CRC-32 是 0x61d6b1c4,乘以常数 0x3d32ad9e 得 1685338728340073208。实验程序自己逐位算了 CRC-32,拿这个键先锁住,迁移果然卡在 pg_advisory_lock 上;pg_locks 里等待者的 classid / objid 是这个 bigint 键的高 32 位和低 32 位(objsubid = 1 表示单个 bigint 形式的键),拼回去正好相等。

这是会话级的锁,pg_advisory_unlock 或连接断开才释放。run_direct 里每一步都是 ?,中途出错就直接返回,走不到 unlock。连接是从池里借的话,它带着锁回到池里:

--- 1. 改一个已应用的迁移 ---
运行期 Migrator 的 table_name = "_sqlx_migrations"
Display: migration 1 was previously applied but has been modified
Debug:   VersionMismatch(1)
出错之后池里那条连接 (pid 361) 持有的 advisory 锁: [(361, true)]

(src/bin/migrate_errors.rs。)同一条连接再跑迁移不受影响,advisory lock 在同一会话里可以重入;换一个进程或池里另一条连接来迁移就会一直等,直到这条连接被关掉。启动时迁移失败就退出的程序不受影响,进程退出连接就断了;出错后还继续用这个池的程序,最好把池关掉重建。

📝NOTE
没有蠢问题

问: 我把两个服务的记账表分别设成 a._sqlx_migrations 和 b._sqlx_migrations,它们的迁移还会互相等吗?

答: 会。键只由 current_database() 算出,和表名、schema、迁移目录都无关,同一个库上的所有 Migrator 抢的是同一把锁,table-name 只决定记账记在哪。两个服务同时启动时一个等另一个跑完,不会出错,只是串行。不想要这把锁(比如 CockroachDB 这类讲 Postgres 协议但不支持 advisory lock 的库)可以 set_locking(false),文档警告这样并发迁移可能出错或丢数据。

每个迁移一个事务

apply:

            // execute migration queries
            if migration.no_tx {
                execute_migration(self, table_name, migration).await?;
            } else {
                // Use a single transaction for the actual migration script and the essential bookkeeping so we never
                // execute migrations twice. See https://github.com/launchbadge/sqlx/issues/1966.
                // The `execution_time` however can only be measured for the whole transaction. This value _only_ exists for
                // data lineage and debugging reasons, so it is not super important if it is lost. So we initialize it to -1
                // and update it once the actual transaction completed.
                let mut tx = self.begin().await?;
                execute_migration(&mut tx, table_name, migration).await?;
                tx.commit().await?;
            }

execute_migration 先 conn.execute(migration.sql.clone()),再插一行 success = TRUE, execution_time = -1。脚本和记账在同一个事务里,要么都在,要么都不在;耗时只能在 COMMIT 之后才知道,所以另发一条 UPDATE。这条 UPDATE 丢了也无妨,最多留下一个 -1。事务就是 事务 一页讲的 Transaction,线上就是上面那组 BEGIN / COMMIT。

📝NOTE
冷知识:脚本和记账行曾经分两个事务

#1966 报告:「For postgres, the migration script and the _sqlx_migrations state change run in two different transactions, which requires that the migration scripts are idempotent.」当时的 apply 先在事务里跑脚本、commit,再单独 INSERT 记账行,带着耗时;进程死在两者之间,脚本已经生效,sqlx 却不知道,下次启动会再跑一遍。#1991(首次发布于 0.6.2)把 INSERT 挪进事务、先写 -1,提交后再 UPDATE 耗时,也就是今天这段代码和它的注释。MySQL、SQLite 的实现在同一个 PR 里一起改了。

Postgres 实现插入记账行时 success 只写 TRUE,所以 dirty_version(查 success = false)在 Postgres 上只会被别处写进去的行触发;MigrateError::Dirty 的注释也说它只发生在没有事务性 DDL 的数据库上。

-- no-transaction

有些语句不能在事务块里执行,CREATE INDEX CONCURRENTLY 是最常见的一个。把它放进普通迁移(src/bin/migrate_errors.rs 第 3 段,2 号迁移没有标记):

--- 3. CREATE INDEX CONCURRENTLY 没写 -- no-transaction ---
Display: while executing migration 2: error returned from database: CREATE INDEX CONCURRENTLY cannot run inside a transaction block at line 3497
tx._sqlx_migrations_err: [(1, true)]

1 号成功、已记账,2 号连同它的记账行一起回滚。末尾的 at line 3497 是服务端 C 源码的行号(见 错误)。

标记是一个字面前缀检查(source.rs):

        // opt-out of migration transaction
        let no_tx = sql.starts_with("-- no-transaction");

文件开头多一个空行、大小写不同、带 BOM,都不算:

--- 4. 行首不是 -- no-transaction ---
  1 no_tx = true
  2 no_tx = false
  3 no_tx = false
  4 no_tx = false

四个文件依次是 "-- no-transaction\n..."、"\n-- no-transaction\n..."、"-- No-Transaction\n..."、"\u{feff}-- no-transaction\n..."。

去掉 sqlx 的事务还不够。迁移脚本整个作为一条 Query 消息发出,而 Postgres 把一条 Query 里的多条语句当成一个隐式事务块执行。一个 -- no-transaction 迁移里写两条 CREATE INDEX CONCURRENTLY(src/bin/no_tx_multi.rs):

no_tx = true
Display: while executing migration 1: error returned from database: CREATE INDEX CONCURRENTLY cannot run inside a transaction block at line 3497
tx.multi 上的索引: []

报错和没写标记时一模一样。这类语句每个迁移只能放一条。

改了已应用的迁移

run 对每个已应用的版本比对校验和,不同就返回 VersionMismatch,报错文本是 migration 1 was previously applied but has been modified(见上一节的输出:只在 1_widgets.up.sql 末尾加了一个 SQL 注释)。在它之前,validate_applied_migrations 先检查库里有、目录里没有的版本(VersionMissing,set_ignore_missing(true) 可以关掉)。校验和的比对在同一个循环里按版本顺序做,遇到不匹配的版本就返回:排在它后面的迁移都不会执行。已应用的版本通常都排在新迁移前面,所以实际效果是改了旧迁移之后,新迁移一个也跑不了。

只比对 up 迁移(以及 Simple)的校验和;down 迁移在 run 里被跳过,改了 .down.sql 不会报错,undo 时也不比对。sqlx-cli 0.9 的 migrate override 下只有 skip 一个子命令,没有「接受新校验和」的命令。要修正已应用的迁移,另写一个新迁移,而不是改旧文件。

可逆迁移与回滚

Migrator::undo(conn, target) 拿锁、建表、做同样的校验,然后按版本倒序跑所有「已应用、版本大于 target」的 down 迁移。revert 和 apply 对称:同一个事务里跑 down 脚本、DELETE 记账行,no_tx 的 down 迁移不包事务。target = 0 就是全部回滚。

sqlx migrate revert 用的是同一套代码。实验用的 sqlx-cli 0.9.0 编译时没开 sqlx-toml feature,在有 sqlx.toml 的目录里一跑就报:

Found `sqlx.toml` in current directory; reading...
error: SQLx found config file at "sqlx.toml" but the `sqlx-toml` feature was not enabled

所以改到 migrations/ 目录里跑(--source .),URL 带 options[search_path]=tx:这时表名是默认的裸 _sqlx_migrations,按 search_path 解析到 tx._sqlx_migrations,和 migrate! 写的是同一张表:

$ sqlx migrate info
1/installed widgets
2/installed widget color
3/installed widgets name idx
$ sqlx migrate revert
Applied 3/revert widgets name idx (3.78075ms)
$ sqlx migrate info
1/installed widgets
2/installed widget color
3/pending widgets name idx
$ sqlx migrate revert --target-version 0
Applied 2/revert widget color (2.421542ms)
Applied 1/revert widgets (2.514375ms)
$ sqlx migrate info
1/pending widgets
2/pending widget color
3/pending widgets name idx

不带 --target-version 只回滚最近一个;3 号的 down 脚本是 drop index concurrently,也带着 -- no-transaction,同样不包事务。

sqlx.toml 的 [migrate]

sqlx.toml 要开 sqlx 的 sqlx-toml feature(默认不开;sqlx-cli 默认开),宏从 $CARGO_MANIFEST_DIR/sqlx.toml 读,CLI 从当前目录读或用 --config 指定。[migrate] 段对应 config::migrate::Config,有五项:

键作用文档里的警告
table-name记账表名,可以带 schema、带引号;默认 _sqlx_migrations生产库上改它,sqlx 会以为没跑过迁移、重新跑一遍
create-schemas建记账表之前先 CREATE SCHEMA IF NOT EXISTS,配合 table-name 把表放进新 schema—
migrations-dir迁移目录,默认 migrations—
ignored-chars算校验和时丢掉的字符会改变已有迁移的校验和
defaultssqlx migrate add 默认建可逆还是简单迁移、用时间戳还是序号—

只有 migrate! 和 sqlx-cli 读这些配置,手写的 Migrator::new(path) 不读。上面「改了已应用的迁移」那次实验用的就是 Migrator::new,它的 table_name 是 "_sqlx_migrations",靠连接的 search_path=tx 才落在 tx 里。运行期要同样的效果,得自己调 dangerous_set_table_name、create_schema,以及用 ResolveWith 传 ResolveConfig。

ignored-chars 落在 checksum_with:按这些字符把 SQL 切开,逐段喂给 SHA-384,等于对去掉这些字符的字符串求哈希。迁移执行的 SQL 不变,变的只是校验和:

let mut ws = ResolveConfig::new();
ws.ignore_chars([' ', '\t', '\r', '\n']);
let b = ResolveWith(dir.clone(), ws).resolve().await.map_err(|e| anyhow::anyhow!(e))?;

把 "create table tx.t (id int8);\n" 改写成 "create table tx.t (\r\n id int8\r\n);\r\n" 前后:

--- 2. ignored-chars 和 checksum ---
原文件     默认 0fa7f87ac75a0c6e  忽略空白 5457a13cba8fe4a9
重新排版后 默认 660fe9b15a7205dd  忽略空白 5457a13cba8fe4a9

忽略空白之后重新排版不改校验和;但「忽略空白」和「默认」两列在原文件上就不一样,已经部署过的库打开这个选项,所有已应用迁移都会报 VersionMismatch。文档举的典型用法是 ignored-chars = ["\r"],解决 Windows 上 CRLF 换行让校验和对不上的问题。

跳过迁移

0.9 新加了 Migrator::skip(conn, target)(#3846),CLI 里是 sqlx migrate override skip。它和 run 走同一个 run_direct,只是对未应用的迁移调 skip:只插记账行(execution_time = -1),不执行脚本。用处是库里的结构已经用别的办法建好了,只想让 sqlx 认账。

let mut m = Migrator::new(dir.as_path()).await?; // 1_never.sql, 2_conc.sql
m.dangerous_set_table_name("tx._sqlx_migrations_skip");
m.skip(&pool, Some(1)).await?;
m.run(&pool).await?;
--- 5. Migrator::skip(#3846)---
tx._sqlx_migrations_skip: [(1, "never", -1), (2, "conc", 1153625)]
(tx.never, tx.conc) = (None, Some("tx.conc"))

1 号被记成已应用,但 tx.never 不存在;2 号由后面的 run 正常执行。CLI 的输出是 Skipped on request 3/migrate widgets name idx (0ns)。

动脑筋答案

6
加了 4_extra.sql 之后:
6
touch 1_widgets.up.sql 之后:
7
  • 新加的 4_extra.sql 没有任何东西在盯:宏上一次展开时它还不存在,include_str! 只登记了已有的 6 个文件,cargo 认为没有输入变化,不重新编译,二进制里还是 6 个迁移。
  • touch 一个已登记的文件改了它的修改时间,crate 重新编译,宏重新读目录,这次读到 7 个。
  • 稳定版上的办法是加一个 build.rs:sqlx migrate build-script 生成的内容就是一行 println!("cargo:rerun-if-changed={source}")(build_script),让 cargo 盯住整个目录。

小结

  • 迁移文件名是 <版本>_<描述>.sql,.up.sql / .down.sql 是可逆迁移;格式不对的文件静默忽略。校验和是整个文件的 SHA-384,排序先版本后类型。
  • migrate! 在编译期解析目录、算好校验和,生成一个能放进 static 的 Migrator;脚本经 include_str! 嵌入,新增文件不会触发重新编译,要 build.rs。
  • run:拿 advisory lock(键 = 库名的 CRC-32 乘 0x3d32ad9e,同库所有迁移共用)→ 建记账表 → 校验已应用迁移 → 逐个执行未应用的 → 解锁。中途出错不解锁,池连接带着锁回池。
  • 记账表六列:version、description、installed_on、success、checksum、execution_time(纳秒)。脚本和记账行同一个事务,耗时事后 UPDATE。
  • 改了已应用的迁移:migration N was previously applied but has been modified,任何新迁移都不会执行。
  • -- no-transaction 必须是文件的字面开头;脚本作为一条 Query 发出,多条语句仍在隐式事务里,CREATE INDEX CONCURRENTLY 一个迁移只能放一条。
  • sqlx.toml 的 [migrate] 能改表名(带 schema 即可换位置)、建 schema、改目录、忽略字符;只有 migrate! 和 CLI 认它。skip 只记账不执行。

💬 关联讨论

发起讨论
加载中...