迁移
主线位置:不在三条主线上,但用到其中两条。
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.sql | create table tx.widgets (...) / drop table tx.widgets |
2_widget_color.up.sql / .down.sql | alter 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 formigrate!()to be able to initialize them in an implicitly const-promotable context. Aconst fnconstructor isn't implicitly const-promotable.」这些字段不受 semver 约束。
include_str! 只盯已有的文件,目录本身没人盯。expand_with_path 里调 proc_macro::tracked::path 的那段代码只在 sqlx_macros_unstable 下编译,稳定版 Rust 用不上。
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(())
})
}
// 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 在同一会话里可以重入;换一个进程或池里另一条连接来迁移就会一直等,直到这条连接被关掉。启动时迁移失败就退出的程序不受影响,进程退出连接就断了;出错后还继续用这个池的程序,最好把池关掉重建。
问: 我把两个服务的记账表分别设成 a._sqlx_migrations 和 b._sqlx_migrations,它们的迁移还会互相等吗?
答: 会。键只由 current_database() 算出,和表名、schema、迁移目录都无关,同一个库上的所有 Migrator 抢的是同一把锁,table-name 只决定记账记在哪。两个服务同时启动时一个等另一个跑完,不会出错,只是串行。不想要这把锁(比如 CockroachDB 这类讲 Postgres 协议但不支持 advisory lock 的库)可以 set_locking(false),文档警告这样并发迁移可能出错或丢数据。
每个迁移一个事务
// 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。
#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 | 算校验和时丢掉的字符 | 会改变已有迁移的校验和 |
defaults | sqlx 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只记账不执行。