SQLx 源码深度解析
第20章 迁移系统:Migrator、checksum 与 migrate!
迁移系统由运行时的
Migrator、编译期的migrate!()宏和 sqlx-cli 三部分组成。而让它能安全重复执行的,是_sqlx_migrations表里那一列 checksum。
本章要点
- sqlx 的迁移系统由三部分组成——运行时的
Migrator(sqlx-core/src/migrate/migrator.rs)+ 编译期的migrate!()宏(嵌入 SQL 到 binary)+sqlx-cli的migrate子命令(脚手架 + 本地运行)。 - 迁移文件命名——
migrations/<version>_<description>.sql或<version>_<description>.{up,down}.sql(reversible)。version 是数字(通常时间戳)、description 是 slug。 _sqlx_migrations表 schema(sqlx-postgres/src/migrate.rs:119-126)——version (BIGINT PK) + description + installed_on + success + checksum (BYTEA) + execution_time。每家驱动略有差异但字段一致。checksum = SHA-384(sql_bytes)(sqlx-core/src/migrate/migration.rs:25,sha2::Sha384)——迁移应用时记录 checksum、后续启动时检查"schema 文件有没有被改过"——已应用的迁移不能改动的铁律保护。- MigrationType 三变体(
migration_type.rs):Simple(.sql)/ReversibleUp(.up.sql)/ReversibleDown(.down.sql)。reversible 支持undo回滚到指定版本。 migrate!()宏—— 编译期读取migrations/下所有 SQL 文件内容、嵌入到二进制的&'static [Migration]。部署时不用带 migrations 目录——发布一个 binary 搞定。cargo sqlx migrate add/run/revert/info子命令 —— 本地脚手架(add 生成文件)+ 执行(run 应用所有 pending)+ 回滚(revert)+ 查状态(info)。- dirty version 保护——
_sqlx_migrations里只要有success = false的记录,下次 run 就拒绝继续——避免半成品 schema 上叠加新迁移。0.8.6 里只有 MySQL 驱动会写出success = false(先插 FALSE 行、脚本跑完再改 TRUE;DDL 隐式提交时这行会残留);Postgres / SQLite 直接插 TRUE,失败时整个事务回滚、不留行。
20.1 问题引入:为什么 schema migration 要单独工具
Schema 管理是生产 DB 的必修课——建表、加列、改类型、加索引——每次改动都要跨多个环境同步(dev / staging / prod)、全团队一致(不能 A 在本地改、B 不知道)、可追溯(出问题能回滚)。
"纯手工 SQL 文件"方式的问题:
- 没人知道当前 schema 到底是什么状态——production 跑了哪些脚本?dev 跑了哪些?
- 团队协作混乱——两个开发者同时加列、merge 时冲突。
- 回滚难——生产上应用了错 SQL、怎么反向改回?
- 新环境 bootstrap——复制 production 的所有历史 SQL 脚本到新环境跑一遍?
Schema migration 工具——按时间戳编号、记录应用历史、保证每次环境都按同样顺序应用同样脚本——是生产可控的唯一方法。
Rust 生态的选择:
- sqlx::migrate(本章主角)—— sqlx 内建、和 sqlx 其他部分无缝。
- refinery—— 独立迁移工具、支持更多 DB。
- diesel_migrations—— Diesel 自带的迁移。
sqlx::migrate 的定位:轻量、Rust-first、SQL 文件 based。不做花哨功能(无 schema-diff 自动生成、无复杂编程式迁移)——一件事做好:按顺序跑 SQL、记录 history、防篡改。
20.2 迁移文件的目录和命名
sqlx 约定 migrations/ 目录(可改)下放 SQL 文件:
migrations/
├── 20240101120000_create_users.sql
├── 20240102130000_add_email_to_users.sql
└── 20240103140000_create_posts_table.sql
命名格式:<version>_<description>.sql
- version:能 parse 成
i64的数字——通常时间戳(YYYYMMDDHHMMSS);sqlx migrate add --sequential则生成0001、0002这样的序号。resolve_blocking最后按version数值升序排序(sqlx-core/src/migrate/source.rs:142)。 - description:slug——下划线分隔的单词。给人看;解析时下划线会被换成空格存进
Migration::description(sqlx-core/src/migrate/source.rs:113-116),所以_sqlx_migrations.description里看到的是create users而不是create_users。
reversible 版本有两个文件:
migrations/
├── 20240101120000_create_users.up.sql
├── 20240101120000_create_users.down.sql
├── 20240102130000_add_email.up.sql
└── 20240102130000_add_email.down.sql
每个 *.up.sql 对应一个 *.down.sql——up 改 schema、down 撤销。
选 Simple 还是 Reversible?
- Simple:简单——脚本能还原就好(大多数迁移用 Simple)。
- Reversible:能回滚——但 down 脚本的维护增加一倍工作量。
sqlx 实战里大多数用 Simple——回滚几乎从不触发(生产事故用 restore backup 解决,不靠 migration down)。只有强审计要求的场景用 Reversible——每个迁移必须有对应的反向。
20.3 Migrator struct 和 migrate!() 宏
Migrator 是 sqlx 运行时的迁移执行器(sqlx-core/src/migrate/migrator.rs:14-26):
pub struct Migrator {
pub migrations: Cow<'static, [Migration]>,
pub ignore_missing: bool,
pub locking: bool,
pub no_tx: bool,
}
四个字段:
migrations: Cow<'static, [Migration]>——迁移列表。Cow让宏嵌入的 'static 切片和运行时 loaded 的 owned Vec 都能用同一字段。ignore_missing: bool——已应用迁移在当前 migrations 里找不到时是否忽略(默认 false 报错)。locking: bool——是否用 DB 锁防并发(默认 true)。no_tx: bool——默认 false。0.8.6 全树没有任何代码读取Migrator::no_tx;真正起作用的是每条Migration自己的no_tx字段(见 §20.7、§20.23)。
这四个字段都标了 #[doc(hidden)],源码注释写明它们不受 semver 保护、之所以公开只是为了让 migrate!() 能在可常量提升的上下文里直接构造 Migrator;用户代码应当走 set_ignore_missing / set_locking 两个方法。
两种构造 Migrator 的方法:
1. 运行时加载(Migrator::new)——
let migrator = Migrator::new(Path::new("./migrations")).await?;
migrator.run(&pool).await?;
运行时读 ./migrations 目录——部署时目录要跟着 binary 走。
2. 编译期嵌入(migrate!() 宏)——
let migrator = sqlx::migrate!(); // 或 sqlx::migrate!("./migrations")
migrator.run(&pool).await?;
宏展开时读 migrations/ 的所有文件、把内容 embed 到 binary 的 &'static [Migration]。部署只发 binary、不需要 migrations 目录。
20.3.1 migrate!() 宏的展开
migrate!() 宏(在 sqlx-macros-core/src/migrate.rs)展开成:
Migrator {
migrations: Cow::Borrowed(&[
Migration {
version: 20240101120000,
description: Cow::Borrowed("create users"),
migration_type: MigrationType::Simple,
sql: Cow::Borrowed(include_str!("/abs/path/migrations/20240101120000_create_users.sql")),
no_tx: false,
checksum: Cow::Borrowed(&[/* 编译期算好的 SHA-384 字节 */]),
},
// ... 更多 Migration
]),
..Migrator::DEFAULT
}
每个 Migration 的字段都是 Cow::Borrowed——'static 引用嵌入的字符串和字节。零运行时分配。checksum 编译期计算——避免运行时每次 hash。注意 sql 用的是 include_str!(sqlx-macros-core/src/migrate.rs:62-63)而不是把内容抄进 token stream——顺带让 rustc 把这个文件加进重编译依赖。
这条宏嵌入让部署极简——cargo build --release 后 target/release/myapp 就带有全部 migrations——scp 到生产跑起来立即能 migrator.run()。
20.4 _sqlx_migrations 表
sqlx 需要数据库侧的状态表记录"哪些迁移已应用"。每家驱动有自己的 DDL——Postgres 版(sqlx-postgres/src/migrate.rs:119-126):
CREATE TABLE IF NOT EXISTS _sqlx_migrations (
version BIGINT PRIMARY KEY,
description TEXT NOT NULL,
installed_on TIMESTAMPTZ NOT NULL DEFAULT now(),
success BOOLEAN NOT NULL,
checksum BYTEA NOT NULL,
execution_time BIGINT NOT NULL
);
六字段:
- version(BIGINT PK)——迁移版本号、主键防重复。
- description(TEXT)——描述、人读用。
- installed_on(TIMESTAMPTZ)——应用时间戳。
- success(BOOLEAN)——是否成功,用于 dirty version 检测。Postgres / SQLite 的
apply在事务里直接插TRUE,失败就整体回滚、不留行;只有 MySQL 先插FALSE、脚本成功后再UPDATE成TRUE(sqlx-mysql/src/migrate.rs:190-217),DDL 隐式提交后脚本失败,这行FALSE就留下了。 - checksum(BYTEA)—— 48 字节 SHA-384 hash。
- execution_time(BIGINT)——耗时纳秒(
elapsed.as_nanos() as i64)。监控用。注意apply先以-1插入这一行(execute_migration,sqlx-postgres/src/migrate.rs:285-295)、事务提交后再UPDATE回填真实耗时(sqlx-postgres/src/migrate.rs:234-244)——进程在这中间挂掉,这个值会永久停在 -1。
MySQL(sqlx-mysql/src/migrate.rs:83-90)/ SQLite(sqlx-sqlite/src/migrate.rs:72-79)的 schema 基本一致——两家都把 TIMESTAMPTZ ... DEFAULT now() 换成 TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP、BYTEA 换成 BLOB,其余六列完全相同。
ensure_migrations_table() 方法(sqlx-postgres/src/migrate.rs:114-133)在每次 run 开始时调 CREATE TABLE IF NOT EXISTS ——幂等,多次调安全。
20.4.1 为什么加前缀 _sqlx_
表名前缀 _sqlx_ 避开和用户表名冲突——以下划线开头、再带上 sqlx 字样,一眼可知是工具自己的状态表。
这个约定对迁移是关键——用户的 CREATE TABLE users ... DROP TABLE users 不可能误触 _sqlx_migrations。
_sqlx_migrations 不应该被用户直接改——是 sqlx 内部状态。如果用户手动 DELETE FROM _sqlx_migrations——下次 run 会重新跑所有迁移——大概率出问题(CREATE TABLE 重复 / DROP TABLE 不存在等)。约定是"用户不碰"——sqlx 依赖这条不变量。
20.5 Checksum SHA-384 防篡改
sqlx-core/src/migrate/migration.rs:25:
let checksum = Cow::Owned(Vec::from(Sha384::digest(sql.as_bytes()).as_slice()));
SHA-384 hash SQL 字节——48 字节校验和(sha2::Sha384)。源码没有解释为什么选 SHA-384 而不是更常见的 SHA-256——两者作为防误改的校验都绰绰有余。
checksum 在两处关键检查:
1. 应用时写入——migration 应用成功后、sqlx 把 checksum 存进 _sqlx_migrations 行。
2. run 时校验——每次 run 开始前、sqlx 遍历 migrations 列表和 _sqlx_migrations 表、对每个已应用的迁移:
// migrator.rs:173-182
match applied_migrations.get(&migration.version) {
Some(applied_migration) => {
if migration.checksum != applied_migration.checksum {
return Err(MigrateError::VersionMismatch(migration.version));
}
}
None => { conn.apply(migration).await?; }
}
如果当前文件的 checksum 和 DB 里记录的不一致 → VersionMismatch 错——停止运行。用 SQLite 实跑一遍:先用 migrate!() 应用两条迁移(库里 checksum 列长 48 字节,前 8 字节与 sha384sum 对该文件的输出一致),再在第一条文件末尾追加一行注释、用 Migrator::new 重跑,得到的是 migration 20240101120000 was previously applied but has been modified(Debug 为 VersionMismatch(20240101120000))——哪怕只多了一行注释,字节变了就算改动。删掉一个已应用的文件再跑,则是 migration 20240102130000 was previously applied but is missing in the resolved migrations(VersionMissing)。
这条检查防什么? ——防已应用的迁移被事后修改。典型场景:
- 开发者 A 提交了
20240101_init.sql、其他环境都跑过了。 - 开发者 B 觉得内容有问题、改了
20240101_init.sql再提交。 - B 部署到他自己机器——checksum 不一致——报错。
sqlx 的铁律:已应用的迁移永远不能改动——要改就写新的迁移。这个铁律由 checksum 机制强制。
20.5.1 checksum 不匹配的处理
如果真的需要改历史迁移(极少)——解决办法是手动修改 _sqlx_migrations 表的 checksum 列:
-- Postgres 的 bytea 十六进制写法;MySQL / SQLite 用 X'<hex>'
UPDATE _sqlx_migrations
SET checksum = '\x<new_sha384_hex>'
WHERE version = 20240101120000;
然后 sqlx run 会用新 checksum 对比、匹配通过。但这等于承认你改了历史——所有环境都要同步这条 UPDATE——容易出错。强烈不推荐。
正确做法:需要撤销老迁移效果时、写新的迁移做反向操作:
20240101_init.sql -- 老的、不动
20240115_fix_init_mistake.sql -- 新的、反向操作
这条规则让迁移历史append only——和 git log 一样——永远添加、不重写。
20.6 MigrationType:三种变体
sqlx-core/src/migrate/migration_type.rs:
pub enum MigrationType {
Simple, // .sql(无 up/down 区分)
ReversibleUp, // .up.sql
ReversibleDown, // .down.sql
}
判断逻辑(同文件):
.up.sql后缀 → ReversibleUp。.down.sql后缀 → ReversibleDown。- 其他(
.sql) → Simple。
is_up_migration() —— Simple + ReversibleUp 算 up、Down 不算。
is_down_migration() —— 只有 ReversibleDown 算 down。
Migrator::run 只跑 up migrations(Simple + ReversibleUp)。
Migrator::undo 只跑 down migrations(ReversibleDown)。
这条类型区分让 run 和 undo 互不干扰——即便同一 Migrator 里既有 up 又有 down、方向决定用哪些。
20.7 Migrator::run 完整流程
sqlx-core/src/migrate/migrator.rs:142-190 的 run_direct(简化):
pub async fn run_direct<C>(&self, conn: &mut C) -> Result<(), MigrateError>
where C: Migrate,
{
if self.locking { conn.lock().await?; } // 1. 取锁
conn.ensure_migrations_table().await?; // 2. 建表
let version = conn.dirty_version().await?;
if let Some(version) = version {
return Err(MigrateError::Dirty(version)); // 3. 脏 check
}
let applied_migrations = conn.list_applied_migrations().await?;
validate_applied_migrations(&applied_migrations, self)?; // 4. 校验一致性
let applied_migrations: HashMap<_, _> = applied_migrations
.into_iter().map(|m| (m.version, m)).collect();
for migration in self.iter() { // 5. 循环
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 => { conn.apply(migration).await?; } // 6. 应用
}
}
if self.locking { conn.unlock().await?; } // 7. 释放锁
Ok(())
}
七步流程:
- 获取锁(如果 locking = true)——防多实例同时跑迁移。
- ensure_migrations_table——幂等建
_sqlx_migrations表。 - dirty_version check——找
success = false的记录。有就报 Dirty 错。 - 校验已应用 vs 当前列表一致性——ignore_missing = false 时 DB 有的迁移必须在 migrations 列表里。
- 循环每个 up migration——跳过 down migrations。
- 已应用的检查 checksum、未应用的 apply。
- 释放锁。
apply 方法(sqlx-postgres/src/migrate.rs:206-249 和辅助函数 execute_migration,:275-298):
fn apply<'e: 'm, 'm>(&'e mut self, migration: &'m Migration)
-> BoxFuture<'m, Result<Duration, MigrateError>>
{
Box::pin(async move {
let start = Instant::now();
if migration.no_tx {
execute_migration(self, migration).await?;
} else {
// 迁移脚本 + 记账放在同一个事务里,保证不会执行两次。见 issue #1966。
let mut tx = self.begin().await?;
execute_migration(&mut tx, migration).await?;
tx.commit().await?;
}
let elapsed = start.elapsed();
// 事务里插的是 execution_time = -1,提交后再回填
query("UPDATE _sqlx_migrations SET execution_time = $1 WHERE version = $2")
.bind(elapsed.as_nanos() as i64)
.bind(migration.version)
.execute(self).await?;
Ok(elapsed)
})
}
async fn execute_migration(conn: &mut PgConnection, migration: &Migration)
-> Result<(), MigrateError>
{
conn.execute(&*migration.sql).await
.map_err(|e| MigrateError::ExecuteMigration(e, migration.version))?;
query(
"INSERT INTO _sqlx_migrations ( version, description, success, checksum, execution_time )
VALUES ( $1, $2, TRUE, $3, -1 )"
)
.bind(migration.version)
.bind(&*migration.description)
.bind(&*migration.checksum)
.execute(conn).await?;
Ok(())
}
每个迁移在一个事务里——SQL + INSERT 到 _sqlx_migrations 原子。失败会 rollback、不在 _sqlx_migrations 留半成品(这对 Postgres / SQLite 成立;MySQL 的 DDL 会隐式提交,见 §20.10)。用 SQLite 实测:一条迁移里连写两次 CREATE TABLE t1,run 返回 while executing migration 1: error returned from database: (code: 1) table t1 already exists(MigrateError::ExecuteMigration),_sqlx_migrations 里一行都没有。execution_time 只能在事务外测、所以先写 -1 再 UPDATE——源码注释直言这个值"只为数据溯源和调试存在,丢了也不要紧"。
no_tx = true 的迁移跳过事务包裹——适合 CREATE INDEX CONCURRENTLY 之类不能在事务内的 SQL。注意 0.8.6 里只有 Postgres 驱动读 no_tx 字段(sqlx-postgres/src/migrate.rs:214、:258),MySQL / SQLite 的 apply 无条件走事务。
20.8 Migrator::undo:回滚到指定版本
sqlx-core/src/migrate/migrator.rs:211-257:
pub async fn undo<'a, A>(&self, migrator: A, target: i64) -> Result<(), MigrateError> {
// 前置步骤同 run...
for migration in self.iter().rev()
.filter(|m| m.migration_type.is_down_migration())
.filter(|m| applied_migrations.contains_key(&m.version))
.filter(|m| m.version > target)
{
conn.revert(migration).await?;
}
// 释放锁...
Ok(())
}
倒序跑 down migrations——从最新的开始、反向撤销到 target version。例如:
applied: v1, v2, v3, v4, v5
undo(target=2):
revert v5
revert v4
revert v3
// v2 保留(v > 2 的都被撤销)
conn.revert(migration)(sqlx-postgres/src/migrate.rs:250-273)类似 apply 但跑 down 的 SQL + DELETE FROM _sqlx_migrations WHERE version = $1。
实际生产里 undo 几乎不用——重大 schema 错误通常靠restore backup 而不是靠跑 down migrations(down 可能本身就是错的)。undo 的实际价值在开发环境——local 调试时撤销上一条迁移重跑。
20.9 cargo sqlx migrate CLI
sqlx-cli 的 migrate 子命令提供完整的本地工作流:
cargo sqlx migrate add <name> —— 创建新迁移文件:
$ cargo sqlx migrate add create_users
Creating migrations/20240424160000_create_users.sql
自动用当前时间戳命名(UTC,%Y%m%d%H%M%S)。-r 生成 .up.sql + .down.sql 两个文件——这个参数只有短形式(sqlx-cli/src/opt.rs 里是 #[clap(short)]),没有 --reversible。还要注意类型是推断的:目录里已有迁移时,新文件沿用第一条迁移的类型,-r 会被忽略;只有目录为空时 -r 才决定类型(MigrationType::infer,sqlx-core/src/migrate/migration_type.rs:76-88)。
cargo sqlx migrate run —— 应用所有 pending migrations:
$ DATABASE_URL=... cargo sqlx migrate run
Applied 20240424160000/migrate create users (12.3ms)
Applied 20240425100000/migrate add email (5.1ms)
注意 CLI 的 run 没有直接调 Migrator::run——sqlx-cli/src/migrate.rs:274 起自己重写了一遍循环(:316 的 for 循环),为的是支持 --dry-run(打印 Can apply)、--target-version(打印 Skipped)和 --ignore-missing;也因此 CLI 路径不取 advisory lock(Migrator::run_direct 才取)。
cargo sqlx migrate revert —— 撤销最后一个已应用迁移(需要 reversible)。不带 --target-version 时一次只撤一条(sqlx-cli/src/migrate.rs:447-449 的 break);一条都没得撤时打印 No migrations available to revert。
cargo sqlx migrate info —— 看当前应用状态,格式是 {version}/{状态} {description}:
$ cargo sqlx migrate info
20240424160000/installed create users
20240425100000/pending add email
checksum 对不上时状态显示 installed (different checksum)、并额外打印 DB 里和本地的两个 checksum 十六进制串(sqlx-cli/src/migrate.rs:216-245)——开发 debug 常用。
另外还有 cargo sqlx migrate build-script —— 生成一个带 cargo:rerun-if-changed=migrations 的 build.rs,让 migrations/ 目录变动能触发重新编译。改已有的 SQL 文件本来就会触发重编译(include_str! 把它登记成了依赖);缺的是新增文件——实测往 migrations/ 里加一个新文件、不动 Rust 源码,cargo build 直接 Finished,程序里嵌入的仍是旧的迁移列表。
这套 CLI 的 UX 是 sqlx 的加分项——新手上手零障碍。对比 diesel 的 diesel migration 更简洁(sqlx 不需要 schema.rs 同步)。
20.10 dirty version 和并发保护
dirty version 机制——_sqlx_migrations 里只要有 success = false 的行,下次 run 开始时检测到这行、直接报错退出。0.8.6 里只有 MySQL 驱动会写出这种行:它先插一行 success = FALSE、再执行脚本、最后改成 TRUE(sqlx-mysql/src/migrate.rs:190-217),脚本里的 DDL 一旦隐式提交、后续语句又失败,FALSE 行就留在表里:
error: migration 20240424 is partially applied; fix and remove row from `_sqlx_migrations` table
(sqlx-cli 输出的样子;MigrateError::Dirty 的原文见 sqlx-core/src/migrate/error.rs。同一个文件里 VersionMismatch 的措辞是 "migration {0} was previously applied but has been modified"、VersionMissing 是 "... but is missing in the resolved migrations"。)
源码在 Dirty 变体上还留了一句注释:"this will only happen with a database that does not have transactional DDL (e.g., MySQL or Oracle)"——Postgres / SQLite 的 apply 直接插 success = TRUE,DDL 也在事务里,脚本失败会整体回滚、不会留下 success = false 的行;真正会脏的是 MySQL 这类 DDL 隐式提交的数据库。
用户要先手动修复(改 schema 到一致状态、删掉 _sqlx_migrations 里的 failure 行)才能继续。
为什么这么严? —— 半成品 schema 上再跑新迁移大概率错(比如 ALTER TABLE 引用的列可能没创建成功)——破坏更大。拒绝继续是失败爆炸的防御。
并发保护——conn.lock().await? 在 Migrator::run_direct 开始时取 DB 级锁:Postgres 用 SELECT pg_advisory_lock($1)、MySQL 用 SELECT GET_LOCK(?, -1)(两家的 lock id 都是 0x3d32ad9e * CRC32(数据库名),见各自 migrate.rs 的 generate_lock_id)。SQLite 的 lock() / unlock() 是空实现(sqlx-sqlite/src/migrate.rs:123-129,直接 Ok(()))——单文件数据库不做跨进程互斥。多个服务实例同时启动时,Postgres / MySQL 上只有一个能跑迁移、其他阻塞等待(GET_LOCK(?, -1) 的 -1 表示无限等)。
这条锁防双写入——如果两个 app 实例同时应用同一条迁移,至少一个会失败(CREATE TABLE 已存在或 _sqlx_migrations 主键冲突);在 Postgres / SQLite 上失败的那个事务整体回滚,在 MySQL 上已经隐式提交的 DDL 却回不去——脏了。
锁的释放要留意:run_direct 只在正常走完时调 unlock(),中途任何一步 ? 返回错误都会跳过它。Postgres 的 pg_advisory_lock 和 MySQL 的 GET_LOCK 都是会话级锁,要等这条连接关闭才自动释放。如果是 Migrator::run(&pool),出错后那条 PoolConnection 会照常归还池子、连接并没有关——锁也就一直挂在这条池内连接上,同一进程里换一条连接再跑迁移会卡在 lock(),直到这条连接被关闭。迁移失败后最稳妥的做法是让进程退出(按源码推断,本机无 Postgres / MySQL,未实跑)。
20.11 迁移系统的工作流
生产 sqlx 团队典型的迁移工作流:
Step 1(开发者 A):cargo sqlx migrate add add_user_phone —— 生成空文件。
Step 2:编辑 migrations/20240424_add_user_phone.sql:
ALTER TABLE users ADD COLUMN phone TEXT;
CREATE INDEX idx_users_phone ON users (phone);
Step 3:本地测试 cargo sqlx migrate run(环境变量 DATABASE_URL 指本地 dev DB)。
Step 4:提交到 git——迁移文件 + 业务代码改动一起 commit。Code review 时 reviewer 一起看 SQL 和 Rust 代码。
Step 5:CI 跑——cargo sqlx migrate run 在 CI 临时 DB 上验证迁移不出错。
Step 6:部署到 staging——启动时自动跑 migrate.run——_sqlx_migrations 表更新。
Step 7:部署到 prod——同上。
Step 8:监控——_sqlx_migrations.execution_time 字段记录每个迁移耗时——超长的(> 1 秒)值得检查。
这条工作流让schema 变更和代码变更同 PR——原子性好。
20.12 生产迁移的几条铁律
几条生产必须遵守的铁律:
1. 已应用的迁移不改动——用 checksum 机制强制。要改就写新迁移。
2. 大表的 ALTER TABLE 小心——Postgres 的 ALTER TABLE 多数形式会取 ACCESS EXCLUSIVE 锁,凡是触发全表重写或全表扫描的形式(改列类型会重写、SET NOT NULL 要扫表验证、Postgres 11 以前的 ADD COLUMN ... DEFAULT 会重写)在大表上都可能长时间阻塞读写。解法:分步(加可空列 → backfill → 加约束),并查阅你所用 Postgres 版本的 ALTER TABLE 文档确认哪些形式免重写。
3. 先部署向前兼容的迁移、再部署新代码——比如加列:
- Step 1:部署 "加列" 迁移(老代码不用新列)。
- Step 2:部署 "用新列" 的代码。
反过来会让 Step 2 部署失败(代码期望的列数据库里还没有)。
4. down migrations 是备选、不是首选——生产事故 prefer restore backup 而不是 revert migration。
5. 迁移必须幂等(理想状态)——IF NOT EXISTS / IF EXISTS 让迁移即使重跑也不炸。sqlx 的 checksum 机制避免重跑,但代码层面幂等更安全。
6. 生产部署前在 staging 跑过——staging 的 data volume / load 接近 prod。能跑通 staging 再碰 prod。
7. 监控 execution_time——一个迁移突然很慢(从 100ms 变 10s)值得警惕——可能是数据量增长导致 ALTER TABLE 代价暴增。
这七条铁律不是 sqlx 特有——任何 schema migration 系统都适用。sqlx 的价值是让遵守这些铁律变容易(checksum / dirty check / DB lock)。
20.13 sqlx::migrate::Migrate trait
让 sqlx 的 Migrator 跨 DB 工作的抽象是 Migrate trait(sqlx-core/src/migrate/migrate.rs):
pub trait Migrate {
fn ensure_migrations_table(&mut self) -> BoxFuture<'_, Result<(), MigrateError>>;
fn dirty_version(&mut self) -> BoxFuture<'_, Result<Option<i64>, MigrateError>>;
fn list_applied_migrations(&mut self) -> BoxFuture<'_, Result<Vec<AppliedMigration>, MigrateError>>;
fn lock(&mut self) -> BoxFuture<'_, Result<(), MigrateError>>;
fn unlock(&mut self) -> BoxFuture<'_, Result<(), MigrateError>>;
fn apply<'e: 'm, 'm>(&'e mut self, migration: &'m Migration)
-> BoxFuture<'m, Result<Duration, MigrateError>>;
fn revert<'e: 'm, 'm>(&'e mut self, migration: &'m Migration)
-> BoxFuture<'m, Result<Duration, MigrateError>>;
}
每家驱动实现 Migrate —— Postgres 的实现在 sqlx-postgres/src/migrate.rs:113、MySQL 和 SQLite 同理。Any 也实现(sqlx-core/src/any/migrate.rs,委托给底层 backend 的 as_migrate())。同文件里还有一个 MigrateDatabase trait —— create_database / database_exists / drop_database / force_drop_database —— 那是 sqlx database create|drop 用的,和 Migrate 分开。
关键观察——迁移逻辑在 sqlx-core 层、SQL 方言在驱动层。lock() 的实现:
- Postgres(
sqlx-postgres/src/migrate.rs:170-189):SELECT pg_advisory_lock($1),lock id 是generate_lock_id(当前数据库名)=0x3d32ad9e * CRC32(database_name)(源码注释:0x3d32ad9e chosen by fair dice roll)。 - MySQL(
sqlx-mysql/src/migrate.rs:146):SELECT GET_LOCK(?, -1),锁名是同一个公式算出来的十六进制字符串。 - SQLite(
sqlx-sqlite/src/migrate.rs:123-129):空实现,lock()和unlock()都直接Ok(())。
三家锁机制不同、sqlx 在 Migrate trait 层统一——用户代码零感知;但要知道 SQLite 上这层保护实际不存在。
20.14 迁移系统的执行流程图
用 mermaid 画 Migrator::run 的完整流程:
flowchart TD
Start["Migrator::run 调用"]
Lock["conn.lock() 取 DB 锁"]
Table["ensure_migrations_table 建 _sqlx_migrations"]
Dirty{"dirty_version 有失败记录?"}
List["list_applied_migrations 读已应用"]
Validate["校验已应用 vs 当前 migrations 列表一致性"]
Loop{"遍历 up migrations"}
Applied{"已应用?"}
Check{"checksum 匹配?"}
Apply["apply: 事务内跑 SQL + INSERT _sqlx_migrations"]
Next["下一条"]
Unlock["conn.unlock 释放锁"]
Ok["成功返回"]
ErrDirty["Err MigrateError::Dirty"]
ErrMismatch["Err VersionMismatch"]
ErrMissing["Err VersionMissing"]
Start --> Lock --> Table --> Dirty
Dirty -->|有| ErrDirty
Dirty -->|无| List --> Validate
Validate -->|DB 有而列表缺失| ErrMissing
Validate -->|通过| Loop
Loop --> Applied
Applied -->|是| Check
Applied -->|否| Apply
Check -->|匹配| Next
Check -->|不匹配| ErrMismatch
Apply --> Next
Next --> Loop
Loop -->|完成| Unlock --> Ok
关键节点:
- Dirty check 在 table 建好后立即做——不让脏状态蔓延。
- validate 确保"DB 有的迁移必须在当前列表" —— 防开发者误删 migrations/ 里的文件。
- 未应用的迁移不比 checksum、直接 apply——没应用过的还没有 baseline 可比。
遍历每条迁移的决策树:
- 已应用 + checksum 匹配 → skip。
- 已应用 + checksum 不匹配 → 报错(铁律保护)。
- 未应用 → apply(事务里跑)。
这条决策树让 run 幂等 —— 重复调用不会重复跑同一迁移。
20.15 和其他迁移工具的对比
Rust 生态和其他语言的主要迁移工具对比:
| 工具 | 语言 | 形态 | Diff 自动生成 | Down 迁移 |
|---|---|---|---|---|
| sqlx::migrate | Rust | SQL 文件 | 否 | 可选 |
| refinery | Rust | SQL 文件 | 否 | 不支持(写新迁移撤销) |
| Diesel migrations | Rust | SQL 文件 | 部分(--diff-schema) |
默认生成 down.sql,--no-down 可省 |
| Rails ActiveRecord | Ruby | Ruby DSL | 否(generator 按命令行参数生成骨架) | change 可自动反推 |
| Django | Python | Python DSL | 是 | 可选 |
| Flyway | Java | SQL 文件 | 否 | 商业版有 |
| Prisma Migrate | TypeScript | schema + 生成 SQL | 是 | 可选 |
| golang-migrate | Go | SQL 文件 | 否 | 可选 |
几条观察:
- Rust 生态偏 SQL 文件 + 用户手写——和 Go / Java 一样的务实派。
- Ruby / Python 偏 DSL(Django 还能从模型定义 diff 出迁移)——更 magic 但调试 magic 代价高。
- sqlx 和 refinery 的定位最像——都是"小工具"派。
sqlx::migrate 的独特价值是和 sqlx 的 type-safe query 生态无缝整合——一个 crate 搞定所有——不需要混用工具。
和同为 Rust 的 Diesel(2.3.9)对照,有两处实质差别。一是防篡改:Diesel 的 __diesel_schema_migrations 表只有 version VARCHAR(50) 和 run_on TIMESTAMP 两列(diesel/src/migration/setup_migration_table.sql),没有 checksum,已应用的迁移被改了它察觉不到;sqlx 的 VersionMismatch 正是补这一块。二是免事务标记:Diesel 在每个迁移目录里用 metadata.toml 的 run_in_transaction = false 声明,由通用的 MigrationHarness 执行(diesel_migrations/src/migration_harness.rs:191、:212),对所有后端都生效;sqlx 的 -- no-transaction 在 0.8.6 只有 Postgres 驱动认。另外 Diesel 的迁移是「一个目录 + up.sql / down.sql」,sqlx 是平铺的单文件或 .up.sql / .down.sql 文件对。
20.16 迁移系统的常见踩坑
生产里 sqlx 迁移的常见坑:
1. 忘了在服务启动时跑 migrate
#[tokio::main]
async fn main() {
let pool = PgPool::connect(&url).await.unwrap();
// ← 忘了 sqlx::migrate!().run(&pool).await!
axum::serve(listener, app).await.unwrap();
}
新 schema 没应用、业务代码报 column not found。修复:启动时加 migrate.run。
2. DATABASE_URL 指错库
# .env 里是 dev DB
DATABASE_URL=postgres://localhost/app_dev
# 但命令跑在 prod shell、.env 未 load
cargo sqlx migrate run # 迁移应用到错误的 DB
解法:显式指定 DATABASE_URL=postgres://prod/app_prod cargo sqlx migrate run。
3. 开发时改历史迁移 → 忘了重建 DB
# 改了已 applied 的 20240101_init.sql
cargo sqlx migrate run
# error: migration 20240101 was previously applied but has been modified
解法:local dev DB drop + recreate + migrate run;或不改历史(写新迁移)。
4. ALTER TABLE ADD COLUMN ... DEFAULT ... 锁大表
Postgres 11 以前,加列带默认值会重写全表、全程持 ACCESS EXCLUSIVE 锁——大表上会长时间卡住读写(11 起非 volatile 默认值只改元数据;volatile 默认值如 clock_timestamp() 仍会重写)。解法:分步(加列 allow NULL → backfill → 加 NOT NULL)。
5. Reversible migration 的 down 没测
开发者写了 .up.sql 没真跑过对应的 .down.sql——事故时 revert 发现 down 不对。解法:CI 里强制测双向——migrate run + migrate revert + migrate run——确保可逆。
20.17 一条实战:零停机迁移大表
最复杂的迁移场景——大表改 schema 不停机。典型需求:"给 users 表加 phone 列、10 亿行"。
错误做法(在 Postgres 11 以前会重写全表、长时间持有 ACCESS EXCLUSIVE 锁、服务瘫痪;11 起常量默认值只改元数据,但换成 volatile 默认值或需要按业务逻辑回填真实数据时,问题依旧):
ALTER TABLE users ADD COLUMN phone TEXT NOT NULL DEFAULT '';
正确做法——分多步迁移:
Migration 1(20240424_add_phone_nullable.sql):
-- 加可空列、立即完成(DDL 元数据改动、不扫表)
ALTER TABLE users ADD COLUMN phone TEXT;
Migration 2(20240425_backfill_phone.sql)——不用 sqlx migrate(太慢)、而是另写独立 job:
// 独立 batch job、分片 backfill、可中断
for batch in (0..total).step_by(10_000) {
sqlx::query("UPDATE users SET phone = '' WHERE id BETWEEN $1 AND $2 AND phone IS NULL")
.bind(batch).bind(batch + 10_000).execute(&pool).await?;
tokio::time::sleep(Duration::from_millis(100)).await; // 节流
}
Migration 3(20240501_add_phone_not_null.sql)——backfill 完再加 NOT NULL:
-- Postgres 下这步要持 ACCESS EXCLUSIVE 锁扫全表验证;
-- Postgres 12 起,若事先加好并 VALIDATE 了 CHECK (phone IS NOT NULL),可跳过这次扫描
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;
Migration 4(20240502_add_phone_default.sql):
ALTER TABLE users ALTER COLUMN phone SET DEFAULT '';
整个过程跨多个 release + 多条迁移——每一步都尽量短、可中断。sqlx 的 migrate 只管 schema DDL——数据级 backfill 必须独立的 Rust 程序。
这条实战说明 sqlx migrate 的边界——它是 schema migration 工具、不是zero-downtime migration 框架。后者需要业务层配合。
20.18 迁移和 Any 驱动的协同
第 19 章讲过 sqlx-cli 用 Any 驱动支持任意 DB——这对迁移尤其重要。cargo sqlx migrate run --database-url $URL 按 URL scheme 自动选 backend:
// sqlx-cli 内部简化(sqlx-cli/src/lib.rs:137、:153;sqlx-cli/src/migrate.rs:281)
sqlx::any::install_default_drivers();
let mut conn: AnyConnection = AnyConnection::connect(&database_url).await?;
let migrator = Migrator::new(Path::new("migrations")).await?;
// CLI 自己遍历 migrator.iter() 并调 conn.apply(...),以支持 --dry-run / --target-version
(sqlx-cli 用的是单条 AnyConnection 而不是 AnyPool——迁移是串行的,没必要开池。)
一条命令适配三家 DB——Postgres / MySQL / SQLite 的 migrate 实现各自不同(lock 机制 / DDL 语法等)、但通过 Any 层统一暴露。
这让 sqlx-cli 的迁移工具成为跨 DB 通用的——团队从 SQLite 迁到 Postgres 时 CLI 不变——只是 DATABASE_URL 换(迁移 SQL 本身的方言差异仍要自己改)。
20.19 迁移系统的版本演进
按 CHANGELOG 可核实的节点:
- 0.4.0-beta.1(2020-07-27):首次引入——"SQL migrations, both with a CLI tool and programmatically loading migrations at runtime"。
- 0.5.6(2021-08-16):加入
sqlx migrate build-script,生成build.rs监视migrations/变化。 - 0.5.12 / 0.5.13(2022-04):[#1675] 加入 undo migrations 的能力。
- 0.6.2(2022-09-14):[#2063] 迁移期间的数据库加锁可定制(即
Migrator::set_locking);[#1991] 保证 Postgres / MySQL / SQLite 的迁移进度不丢失。 - 0.7.2(2023-09-25):[#2538] CLI 的
migrate run/revert加--target-version;[#2602] 支持顺序号命名(--sequential);[#2664] 自动推断 migration type。 - 0.8.0(2024-07-22):[#3181] no-tx migration——
-- no-transaction开头的脚本跳过事务,仅 Postgres;[#3130] CLI 迁移失败时带上版本信息(MigrateError有 breaking change)。 - 0.8.3(2025-01-03):[#3528] 让 down migration 也遵守
no-transaction标记。
sqlx 迁移系统的稳定性让团队敢放心使用——现有的 migrations/ 目录跨小版本升级通常只需改 Cargo.toml 的版本号。这是成熟库的标志——用户的 migrations 是长期资产、必须保证向后兼容。
20.20 dirty_version 查询与自举建表
dirty_version 查询(sqlx-postgres/src/migrate.rs:138-140):
SELECT version FROM _sqlx_migrations WHERE success = false ORDER BY version LIMIT 1
一行 SQL 判断是否有未完成的迁移——有就拒绝继续 run——半成品 schema 保护。
IF NOT EXISTS 的必要性—— 第一次 Migrator::run() 时这个表还不存在—— sqlx 每次 run 无条件跑一遍 CREATE TABLE——等于 "确保表存在"——幂等。sqlx 完全自举—— 零初始步骤。
20.21 MigrationSource trait:文件系统和编译期 embed 的双入口
Migrator::new() 接受任何实现 MigrationSource trait 的东西作为输入(sqlx-core/src/migrate/source.rs:25-27):
pub trait MigrationSource<'s>: Debug {
fn resolve(self) -> BoxFuture<'s, Result<Vec<Migration>, BoxDynError>>;
}
两个主要实现:
&Path/PathBuf(sqlx-core/src/migrate/source.rs:29-46)—— 运行时读文件系统—— 内部用spawn_blocking包装同步std::fs—— 返回Vec<Migration>。migrate!()宏—— 编译期就把Migrator { migrations: Cow::Borrowed(&[...]), ..Migrator::DEFAULT }整个字面量展开出来(sqlx-macros-core/src/migrate.rs:122-128)—— 根本不走MigrationSource、也不经 I/O。有意思的是宏在编译期调的正是同一个resolve_blocking(:106),源码注释写着 "Use the same code path to resolve migrations at compile time and runtime"。
为什么做成 trait—— 用户可以自己实现——比如从 S3 / 网络获取迁移列表——sqlx 不假设来源—— 开放式扩展。0.8.6 里 sqlx 自带的实现只有 &Path 和 PathBuf 两个。
运行时路径的一个细节——spawn_blocking 至关重要:std::fs::read_to_string 是同步阻塞—— 在 Tokio 异步上下文直接调用会阻塞 worker 线程——用 spawn_blocking 扔到专属线程池—— async 正确。sqlx 源码这个调用(sqlx-core/src/migrate/source.rs:34 的 crate::rt::spawn_blocking)是每一个 Rust 异步库都要注意的同步 I/O 包装模式。
resolve_blocking 的核心逻辑(sqlx-core/src/migrate/source.rs:57-145)—— 遍历目录、按第一个 _ 切成两半、要求后半段以 .sql 结尾、解析前半段为 i64(解析失败会报错而不是忽略,见 :104-108;实测 v1_foo.sql 让 Migrator::new 返回 while resolving migrations: error parsing migration filename "v1_foo.sql"; expected integer version prefix (e.g. 01_foo.sql))—— 不是文件、或不符合 <VERSION>_<DESCRIPTION>.sql 形状的条目silently ignored—— 这是用户友好(README.md 等杂文件不会报错;实测目录里放一个 README.md 照常运行)也是潜在坑(文件名写错了 sqlx 不告诉你、迁移没执行)—— 团队约定 cargo sqlx migrate info 定期核对。
20.22 一个完整的迁移模板
最后给一个可复制粘贴的迁移 PR 模板——新项目起步时直接用:
项目结构:
my-app/
├── Cargo.toml
├── migrations/
│ ├── 20240101_create_users.sql
│ ├── 20240102_create_posts.sql
│ └── 20240103_add_user_email_index.sql
├── src/
│ ├── main.rs
│ └── models/
│ └── user.rs
└── .env
Cargo.toml:
[dependencies]
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "macros", "migrate"] }
# ...
src/main.rs:
use sqlx::postgres::PgPoolOptions;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db_url = std::env::var("DATABASE_URL")?;
let pool = PgPoolOptions::new()
.max_connections(20)
.connect(&db_url).await?;
// 启动时跑迁移
sqlx::migrate!().run(&pool).await?;
tracing::info!("Migrations applied");
// ... 启动 HTTP server
Ok(())
}
开发工作流:
# 新迁移
cargo sqlx migrate add create_comments
# 编辑 migrations/2024..._create_comments.sql
vim migrations/2024*_create_comments.sql
# 本地测试
DATABASE_URL=postgres://localhost/my_app_dev cargo sqlx migrate run
# 确认 info
cargo sqlx migrate info
# commit
git add migrations/ && git commit
CI 配置(GitHub Actions 片段):
- name: Start Postgres
run: docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=test postgres:16
- name: Wait for Postgres
run: sleep 3
- name: Install sqlx-cli
run: cargo install sqlx-cli --version 0.8.6 --no-default-features --features postgres,rustls
- name: Run migrations
env:
DATABASE_URL: postgres://postgres:test@localhost/postgres
run: cargo sqlx migrate run
- name: Run tests
env:
DATABASE_URL: postgres://postgres:test@localhost/postgres
run: cargo test
生产部署:
- 容器镜像内
cargo build --release——binary 带 migrations。 - 启动时
sqlx::migrate!().run(&pool)自动应用。 - Pod 副本多个时 DB 锁保证只有一个 pod 真正跑、其他等。
sqlx-cli 连库时遇到 ConnectionRefused / ConnectionReset / ConnectionAborted 这几类 I/O 错误会按指数退避重试,上限是 --connect-timeout(默认 10 秒,sqlx-cli/src/lib.rs:145-179、sqlx-cli/src/opt.rs);但服务端已经接受连接、只是还在启动而返回的数据库错误不在重试之列,所以「等 Postgres 就绪」这一步仍然要留(用 pg_isready 轮询比固定 sleep 可靠)。
20.23 Migration struct 与 SHA-384 checksum 的源码细节
源码(sqlx-core/src/migrate/migration.rs:3-36):
use sha2::{Digest, Sha384};
pub struct Migration {
pub version: i64,
pub description: Cow<'static, str>,
pub migration_type: MigrationType,
pub sql: Cow<'static, str>,
pub checksum: Cow<'static, [u8]>,
pub no_tx: bool,
}
impl Migration {
pub fn new(
version: i64,
description: Cow<'static, str>,
migration_type: MigrationType,
sql: Cow<'static, str>,
no_tx: bool,
) -> Self {
let checksum = Cow::Owned(Vec::from(Sha384::digest(sql.as_bytes()).as_slice()));
Migration { version, description, migration_type, sql, checksum, no_tx }
}
}
三个字段的 Cow<'static, ...>—— migration 可以来自编译期(migrate!() 宏 embed 的 &'static str、Cow::Borrowed)或运行时(从磁盘读文件、Cow::Owned)—— Cow 统一两种来源、零拷贝共享 embed 路径。
Sha384::digest(sql.as_bytes())—— 一行计算 48 字节哈希—— sha2 crate 提供—— checksum 存到 _sqlx_migrations 表的 BYTEA 字段。
关于 SHA-384——它是 SHA-512 家族的截断变体(内部用 64 位字运算,而 SHA-256 用 32 位)。sqlx 源码没有解释选型理由,别把"因为更快"当成官方说法——对几 KB 的迁移脚本来说两者都不构成瓶颈。
no_tx 的识别(sqlx-core/src/migrate/source.rs:126-127):
let no_tx = sql.starts_with("-- no-transaction");
一行代码—— 检查 SQL 是否以 -- no-transaction 开头——就这么简单——用来标注 CREATE INDEX CONCURRENTLY 这种不能在事务里跑的语句。(0.8.6 里只有 Postgres 驱动会读这个字段。)
20.24 迁移系统的实战 Q&A
读者可能问的几个问题 + 答案:
Q1:多个服务用同一 DB、迁移怎么协调?
A:只让 one service 负责 schema 迁移——其他服务的代码假设 schema 已 ready。或者用 Kubernetes 的 Job 在所有服务启动前跑迁移。避免多 service 自己 migrate.run——可能竞争 lock 或 version 冲突。
Q2:迁移文件太多(500+)启动变慢怎么办?
A:migrate.run 只检查 _sqlx_migrations vs 当前列表 diff——已应用的不重跑。用 migrate!() 嵌入时连磁盘 I/O 都没有、只是内存里比 checksum,基本可忽略;Migrator::new(path) 则要读一遍目录。如果真慢、考虑合并老的迁移(把 2020 年的 50 条合成一条 "initial schema")——但老库要配套处理:被合并掉的 50 个版本号仍在 _sqlx_migrations 里,不开 set_ignore_missing(true)(CLI 是 --ignore-missing)就会报 VersionMissing;新的合并迁移在老库上又是"未应用",得手动往 _sqlx_migrations 插一行带正确 checksum 的记录,否则它会在老库上再跑一遍。
Q3:迁移里能调 Rust 代码吗?
A:sqlx::migrate 不支持——只跑 SQL。如果需要"复杂数据转换"(某列的值按逻辑 recompute)——单独写 Rust 程序、在迁移之后跑。sqlx 的迁移是schema DDL 工具、不是全能数据处理平台。
Q4:多环境(dev / staging / prod)如何保证 migrations 一致?
A:git 是 source of truth——迁移文件都 commit。所有环境部署同一个 git commit、跑同一个 binary、应用同样的迁移——一致性靠 git 保证。定期 cargo sqlx migrate info 对比各环境、确认无分叉。
Q5:revert 出错怎么办?
A:revert 应用 down SQL——如果 down 写错了、可能破坏更多。解法:从 backup 恢复、或手动 SQL 修复——sqlx 不保证 revert 的 correctness、那是你 down 脚本的责任。生产强烈建议不 revert、通过正向新迁移修复。
20.25 迁移系统的高级用法:no_tx 和 set_locking
两个开关值得单独提——每条 Migration 的 no_tx(由文件头标记决定)和 Migrator 的 locking(由 set_locking 设置)——解决特殊场景。(Migrator 上那个同名的 no_tx 字段在 0.8.6 里没有任何代码读取,见 §20.3。)
no_tx:某些 SQL 不能在事务内执行——典型是 Postgres 的 CREATE INDEX CONCURRENTLY:
-- no-transaction
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);
(文件名如 20240424_add_index_concurrent.sql。)sqlx 用 sql.starts_with("-- no-transaction") 判断,标记必须是文件的第一行、从第一个字节开始——前面再放一行文件名注释、空行或 BOM,标记就失效、迁移照旧进事务。命中后设 migration.no_tx = true——apply 时跳过事务包裹。注意 0.8.6 里只有 Postgres 驱动会读这个字段(CHANGELOG 的 [#3181] 也写明 "Postgres only")——MySQL / SQLite 的 apply / revert 无条件用事务。0.8.3 的 [#3528] 补上了 down migration 也遵守这个标记。用 SQLite 实测:文件内容是 -- no-transaction 加一行 VACUUM;,解析出的 no_tx 为 true,但 run 仍返回 while executing migration 1: error returned from database: (code: 1) cannot VACUUM from within a transaction。
为什么 CONCURRENTLY 不能事务内跑——Postgres 的 CREATE INDEX CONCURRENTLY 用非阻塞方式建索引、需要多个 heap scan、跨事务边界才能保证一致性——事务内直接报错。
其他 no_tx 场景:Postgres 的 VACUUM、CREATE DATABASE、ALTER TYPE ... ADD VALUE(旧版本 Postgres 不允许在事务内加枚举值)。用户按需加注释。
set_locking:Migrator::set_locking(false) 关闭 DB 层锁。源码文档给的唯一适用场景是数据库不支持加锁——例如说 Postgres 协议、却不支持 advisory lock 的 CockroachDB(sqlx-core/src/migrate/migrator.rs:90-97),并警告关锁后多个客户端同时迁移可能出错甚至丢数据。每个测试独占一个库的场景关掉也无妨,但省下的只是一次加锁往返。
生产保留 locking = true——默认值就是 true。
下一章第 21 章日志追踪——让 sqlx 服务可观测。