SQLx 源码深度解析

第20章 迁移系统:Migrator、checksum 与 migrate!

作者 杨艺韬 · 9,571 字 · 发布于 · 更新于

迁移系统由运行时的 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(())
}

七步流程:

  1. 获取锁(如果 locking = true)——防多实例同时跑迁移。
  2. ensure_migrations_table——幂等建 _sqlx_migrations 表。
  3. dirty_version check——找 success = false 的记录。有就报 Dirty 错。
  4. 校验已应用 vs 当前列表一致性——ignore_missing = false 时 DB 有的迁移必须在 migrations 列表里。
  5. 循环每个 up migration——跳过 down migrations。
  6. 已应用的检查 checksum、未应用的 apply。
  7. 释放锁。

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 服务可观测。