SQLx 源码深度解析

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

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

"A migration is a promise to the future—make it small, make it reversible, and make its checksum immutable." —— 所有管理过生产 DB 的工程师的共识

本章要点

  • sqlx 的迁移系统由两套组件组成——运行时的 Migratorsqlx-core/src/migrate/migrator.rs)+ 编译期的 migrate!() 宏(嵌入 SQL 到 binary)+ sqlx-climigrate 子命令(脚手架 + 本地运行)。
  • 迁移文件命名——migrations/<version>_<description>.sql<version>_<description>.{up,down}.sql(reversible)。version 是数字(通常时间戳)、description 是 slug。
  • _sqlx_migrations 表 schemasqlx-postgres/src/migrate.rs:118-124)——version (BIGINT PK) + description + installed_on + success + checksum (BYTEA) + execution_time。每家驱动略有差异但字段一致。
  • checksum = SHA-384(sql_bytes)sqlx-core/src/migrate/migration.rs:25)——迁移应用时记录 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 上叠加新迁移。

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:数字——通常时间戳(YYYYMMDDHHMMSS 或 Unix timestamp)。按字典序排就是时间序。
  • description:slug——下划线分隔的单词。给人看。

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)。

两种构造 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("CREATE TABLE users ..."),
            checksum: Cow::Borrowed(&[/* 预计算的 SHA-384 */]),
            no_tx: false,
        },
        // ... 更多 Migration
    ]),
    ..Migrator::DEFAULT
}

每个 Migration 的字段都是 Cow::Borrowed——'static 引用嵌入的字符串和字节。零运行时分配。checksum 编译期计算——避免运行时每次 hash。

这条宏嵌入让部署极简——cargo build --releasetarget/release/myapp 就带有全部 migrations——scp 到生产跑起来立即能 migrator.run()

20.4 _sqlx_migrations

sqlx 需要数据库侧的状态表记录"哪些迁移已应用"。每家驱动有自己的 DDL——Postgres 版(sqlx-postgres/src/migrate.rs:118-124):

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)——是否成功。失败的迁移也写进来、success=false——用于 dirty version 检测。
  • checksum(BYTEA)—— 48 字节 SHA-384 hash。
  • execution_time(BIGINT)——耗时纳秒。监控用。

MySQL / SQLite 的 schema 基本一致——字段类型按方言改(SQLite 用 INTEGER 代替 BIGINT / TIMESTAMPTZ)。

ensure_migrations_table() 方法(pg migrate.rs:114-131)在每次 run 开始时调 CREATE TABLE IF NOT EXISTS ——幂等,多次调安全。

20.4.1 为什么加前缀 _sqlx_

表名前缀 _sqlx_ 避开和用户表名冲突。下划线开头按 SQL 惯例是"系统表"(虽然 Postgres 不强制)。

这个约定对迁移是关键——用户的 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 字节校验和。为什么 SHA-384 而不是更常见的 SHA-256?—— 可能是作者偏好或历史遗留(sqlx 早期选择)——都够用、碰撞概率都天文数字小。

checksum 在两处关键检查

1. 应用时写入——migration 应用成功后、sqlx 把 checksum 存进 _sqlx_migrations 行。

2. run 时校验——每次 run 开始前、sqlx 遍历 migrations 列表和 _sqlx_migrations 表、对每个已应用的迁移:

// migrator.rs:174-180
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——停止运行。

这条检查防什么? ——防已应用的迁移被事后修改。典型场景:

  • 开发者 A 提交了 20240101_init.sql、其他环境都跑过了。
  • 开发者 B 觉得内容有问题、改了 20240101_init.sql 再提交。
  • B 部署到他自己机器——checksum 不一致——报错。

sqlx 的铁律已应用的迁移永远不能改动——要改就写新的迁移。这个铁律由 checksum 机制强制。

20.5.1 checksum 不匹配的处理

如果真的需要改历史迁移(极少)——解决办法是手动修改 _sqlx_migrations 表的 checksum 列

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)。

这条类型区分runundo 互不干扰——即便同一 Migrator 里既有 up 又有 down、方向决定用哪些。

20.7 Migrator::run 完整流程

sqlx-core/src/migrate/migrator.rs:142-192 的 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 方法pg migrate.rs:272-295 左右):

async fn apply(&mut self, migration: &Migration) -> BoxFuture<...> {
    let start = Instant::now();

    // 执行 SQL(在事务里)
    let mut tx = self.begin().await?;
    tx.execute(&*migration.sql).await?;

    // 记录 _sqlx_migrations
    query(
        "INSERT INTO _sqlx_migrations ( version, description, success, checksum, execution_time )
         VALUES ( $1, $2, TRUE, $3, $4 )"
    )
    .bind(migration.version)
    .bind(&*migration.description)
    .bind(&*migration.checksum)
    .bind(start.elapsed().as_nanos() as i64)
    .execute(&mut *tx).await?;

    tx.commit().await?;
    Ok(())
}

每个迁移在一个事务里——SQL + INSERT 到 _sqlx_migrations 原子。失败会 rollback、不在 _sqlx_migrations 留半成品。

no_tx = true 的迁移跳过事务包裹——适合 CREATE DATABASEVACUUM 之类不能在事务内的 SQL。

20.8 Migrator::undo:回滚到指定版本

migrator.rs:211-260

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) 类似 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-climigrate 子命令提供完整的本地工作流:

cargo sqlx migrate add <name> —— 创建新迁移文件:

$ cargo sqlx migrate add create_users
Creating migrations/20240424160000_create_users.sql

自动用当前时间戳命名。--reversible flag 生成 .up.sql + .down.sql 两个文件。

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)

内部调 Migrator::run + 打印进度。

cargo sqlx migrate revert —— 撤销最后一个已应用迁移(需要 reversible)。

cargo sqlx migrate info —— 看当前应用状态:

$ cargo sqlx migrate info
20240424160000/create_users (applied 2024-04-24 16:00:12 UTC)
20240425100000/add_email (pending)

显示每个迁移的应用状态——开发 debug 常用。

这套 CLI 的 UX 是 sqlx 的加分项——新手上手零障碍。对比 diesel 的 diesel migration 更简洁(sqlx 不需要 schema.rs 同步)。

20.10 dirty version 和并发保护

dirty version 机制——如果迁移跑到一半失败(SQL 错 / 网络断),_sqlx_migrations 里会有一行 success = false。下次 run 开始时检测到这行、直接报错退出

Error: migration 20240424 was previously applied but is not success

用户要手动修复(改 schema 到一致状态、删掉 _sqlx_migrations 里的 failure 行)才能继续

为什么这么严? —— 半成品 schema 上再跑新迁移大概率错(比如 ALTER TABLE 引用的列可能没创建成功)——破坏更大。拒绝继续失败爆炸的防御。

并发保护——conn.lock().await? 在 migrator 开始时取 DB 级锁(Postgres 的 pg_advisory_lock、MySQL 的 GET_LOCK、SQLite 的文件锁)——多个服务实例同时启动时、只有一个能跑迁移、其他等或立即失败。

这条锁防双写入——如果两个 app 实例同时尝试 INSERT INTO _sqlx_migrations、大概率其中一个失败(PRIMARY KEY 冲突)、但 SQL 已经执行了部分——脏了。

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 ADD COLUMN ... DEFAULT ... 会锁表、大表可能锁几分钟导致服务不可用。解法:分步(加 column 无 default → backfill → 加 default → 强制 not null)。

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>(&'e mut self, migration: &'e Migration) -> BoxFuture<'e, Result<Duration, MigrateError>>;
    fn revert<'e>(&'e mut self, migration: &'e Migration) -> BoxFuture<'e, Result<Duration, MigrateError>>;
}

每家驱动实现 Migrate —— Postgres 的实现在 sqlx-postgres/src/migrate.rs、MySQL 和 SQLite 同理。Any 也实现(委托给底层 backend)。

关键观察——迁移逻辑在 sqlx-core 层、SQL 方言在驱动层lock() 的实现:

  • PostgresSELECT pg_advisory_lock(2091089589) (固定数字、sqlx 内部选的魔数)。
  • MySQLSELECT GET_LOCK('_sqlx_migrate_lock', -1)
  • SQLite:文件层面排他——sqlx 走 BEGIN EXCLUSIVE

三家锁机制不同、sqlx 在 Migrate trait 层统一——用户代码零感知。

20.14 本章小结

本章讲完 sqlx 的迁移系统:

  1. 为什么要 schema migration 工具(§20.1)—— 跨环境同步、团队协作、回滚、bootstrap 新环境。
  2. 文件命名约定(§20.2)—— <version>_<description>.sql 或 reversible 的 .up/.down.sql
  3. Migrator struct + migrate! 宏(§20.3)—— 运行时 vs 编译期嵌入两种构造方式。
  4. _sqlx_migrations 表 schema(§20.4)—— version / description / installed_on / success / checksum / execution_time 六字段。_sqlx_ 前缀避撞用户表。
  5. SHA-384 checksum(§20.5)—— 防篡改、"已应用迁移不能改"铁律。VersionMismatch 报错。
  6. MigrationType 三变体(§20.6)—— Simple / ReversibleUp / ReversibleDown。文件后缀判断。
  7. run_direct 七步流程(§20.7)—— lock / ensure table / dirty check / validate / loop / apply / unlock。
  8. undo 回滚(§20.8)—— 倒序跑 down migrations 到指定版本。生产少用、开发常用。
  9. cargo sqlx migrate CLI(§20.9)—— add / run / revert / info 四子命令。UX 友好。
  10. dirty version + DB lock(§20.10)—— 半成品 schema 拒绝继续、并发 run 互斥。
  11. 典型工作流(§20.11)—— 8 步从 add 到 prod 监控。
  12. 七条生产铁律(§20.12)—— 不改历史、ALTER TABLE 小心、迁移先于代码、幂等、staging、监控。
  13. Migrate trait 跨 DB 抽象(§20.13)—— 七方法、每家驱动实现各自方言。

下一章第 21 章日志与追踪——sqlx 如何和 log/tracing 集成、slow query 怎么抓、span 怎么传。

20.15 迁移系统的执行流程图

用 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"]
    Start --> Lock --> Table --> Dirty
    Dirty -->|有| ErrDirty
    Dirty -->|无| List --> 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——没应用过的不需要 checksum(还没有 baseline)。

遍历每条迁移的决策树

  • 已应用 + checksum 匹配 → skip。
  • 已应用 + checksum 不匹配 → 报错(铁律保护)。
  • 未应用 → apply(事务里跑)。

这条决策树让 run 幂等 —— 重复调用不会重复跑同一迁移。

20.16 迁移系统的设计哲学

读完本章、sqlx 迁移系统的几条设计哲学:

1. SQL 优先、不引入 DSL——直接用 SQL 文件、不像 Rails ActiveRecord 或 Django 的 Python DSL。SQL 是描述 schema 最准确的语言——迁移工具只需管理 SQL 文件的应用顺序。

2. 不自动生成——sqlx 不做 schema diff 自动生成迁移(像 Prisma 或 EF Core 那样)。用户手写 SQL——虽然麻烦、但用户对每一行 SQL 的影响清楚。

3. 已应用不改动——checksum 机制强制。历史 append only——和 git / event sourcing 一致。

4. 轻量 + 嵌入式——migrate!() 宏让迁移嵌进 binary、部署零额外依赖。

5. 不提供数据迁移 DSL——sqlx 迁移是 SQL 级别、不管 "把 users.phone 的数据从 string 变 json" 这种数据级迁移(那要用户自己写 SQL 或 Rust 代码)。

这五条让 sqlx::migrate 小而专——不做 Rails migration 那种全能、但把做的事做到简单可靠。

20.17 和其他迁移工具的对比

Rust 生态和其他语言的主要迁移工具对比:

工具 语言 形态 Diff 自动生成 Down 强制
sqlx::migrate Rust SQL 文件 可选
refinery Rust SQL 文件 可选
Diesel migrations Rust SQL 文件 部分 默认有
Rails ActiveRecord Ruby Ruby DSL 强制
Django Python Python DSL 可选
Flyway Java SQL 文件 商业版有
Prisma Migrate TypeScript schema + 生成 SQL 可选
golang-migrate Go SQL 文件 可选

几条观察

  • Rust 生态偏 SQL 文件 + 用户手写——和 Go / Java 一样的务实派。
  • Ruby / Python 偏 DSL + diff——更 magic 但调试 magic 代价高
  • sqlx 和 refinery 的定位最像——都是"小工具"派。

sqlx::migrate 的独特价值是和 sqlx 的 type-safe query 生态无缝整合——一个 crate 搞定所有——不需要混用工具。

20.18 迁移系统的常见踩坑

生产里 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: VersionMismatch

解法:local dev DB drop + recreate + migrate run;或不改历史(写新迁移)。

4. ALTER TABLE ADD COLUMN ... DEFAULT ... 锁大表

生产大表加列带默认值会锁——分钟级卡顿。解法:分步(加列 allow NULL → backfill → 加 NOT NULL)。

5. Reversible migration 的 down 没测

开发者写了 .up.sql 没真跑过对应的 .down.sql——事故时 revert 发现 down 不对。解法:CI 里强制测双向——migrate run + migrate revert + migrate run——确保可逆。

这五条坑覆盖 sqlx migrate 生产用户的常见失误——记住它们避免 80% 的事故。

20.19 一条实战:零停机迁移大表

最复杂的迁移场景——大表改 schema 不停机。典型需求:"给 users 表加 phone 列、10 亿行"。

错误做法(会锁 5-30 分钟、服务瘫痪):

ALTER TABLE users ADD COLUMN phone TEXT;

正确做法——分多步迁移

Migration 120240424_add_phone_nullable.sql):

-- 加可空列、立即完成(DDL 元数据改动、不扫表)
ALTER TABLE users ADD COLUMN phone TEXT;

Migration 220240425_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 320240501_add_phone_not_null.sql)——backfill 完再加 NOT NULL:

-- Postgres 下这步也需要扫全表验证、但比 ADD DEFAULT 影响小
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;

Migration 420240502_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.20 本章的核心要义

用三条浓缩:

  1. sqlx migrate 是"简单的 SQL 文件管理器"——按版本跑、记录历史、checksum 防改——不做 schema diff / 不做 DSL / 不做数据迁移。

2. migrate!() 宏 + cargo sqlx migrate CLI 是黄金组合——开发时用 CLI 管文件、部署时用宏嵌入 binary——零额外依赖部署。

3. 生产迁移靠约定而非工具——七条铁律(§20.12)是工具外的纪律——遵守才能零事故。

读完本章你能独立在生产 Rust 项目里使用 sqlx 迁移系统——从本地 add 到 prod 部署的完整工作流。这是 sqlx 生产用户最核心的运维能力之一。

20.21 迁移和 Any 驱动的协同

第 19 章讲过 sqlx-cli 用 Any 驱动支持任意 DB——这对迁移尤其重要。cargo sqlx migrate run --database-url $URL 按 URL scheme 自动选 backend:

// sqlx-cli 内部简化
sqlx::any::install_default_drivers();
let pool = AnyPool::connect(&database_url).await?;

let migrator = Migrator::new(Path::new("migrations")).await?;
migrator.run(&pool).await?;

一条命令适配三家 DB——Postgres / MySQL / SQLite 的 migrate 实现各自不同(lock 机制 / DDL 语法等)、但通过 Any 层统一暴露。

这让 sqlx-cli 的迁移工具成为跨 DB 通用的——团队从 SQLite 迁到 Postgres 时 CLI 不变——只是 DATABASE_URL 换。迁移工具的稳定性让迁移团队生产环境时更安心。

20.22 迁移系统的版本演进

sqlx 迁移系统从 0.3 到 0.8 的演进:

  • 0.3(2020):首次引入、基本的 SQL 文件运行。
  • 0.4:checksum 机制正式化;_sqlx_migrations 表 schema 标准化。
  • 0.5sqlx-climigrate 子命令完善;migrate!() 宏加入。
  • 0.6:reversible migrations 支持(.up.sql / .down.sql)。
  • 0.7:GAT 重构简化内部代码;Any 驱动全面支持 migrate。
  • 0.8(本专栏版本):no_tx migrations 支持(-- no-transaction 注释声明)、set_locking 可关;细节修复。

0.9-alpha 可能的演进——execution_time 改成 Duration、错误报告改进——但核心机制不变。

sqlx 迁移系统的稳定性让团队敢放心使用——几年里没有 breaking change、现有的 migrations/ 目录跨版本迁移只需改 Cargo.toml 的版本号。这是成熟库的标志——用户的 migrations 是长期资产、必须保证向后兼容。

20.23 _sqlx_migrations 表:每家驱动的 schema

各家驱动在 sqlx-postgres/src/migrate.rssqlx-mysql/src/migrate.rssqlx-sqlite/src/migrate.rs 里各自定义 _sqlx_migrations 表的 schema——字段语义相同、类型语法不同

Postgressqlx-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
);

MySQL 版本用 DATETIME DEFAULT CURRENT_TIMESTAMP + LONGBLOBSQLite 版本用 TIMESTAMP + BLOB—— 本质都是六列。

字段逐一

  • version—— migration 版本号(时间戳或序号)。
  • description—— 文件名 slug。
  • installed_on—— 应用时间、DB 端 now()(不是客户端时间、避免时钟偏移)。
  • success—— 成功标志、未 commit 的 migration 留 false——下次 run 检查 dirty。
  • checksum—— 48 字节 SHA-384 哈希——防改。
  • execution_time—— 应用耗时(us)——诊断用。

dirty_version 查询:139):

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——等于 "确保表存在"——幂等。老 migration 工具常在这里出错(把 CREATE TABLE 放到第一个迁移里、导致用户必须手动 bootstrap)——sqlx 完全自举—— 零初始步骤。

20.24 Migrator::run_direct:六步执行流水

Migrator::run() 内部调 run_direct()sqlx-core/src/migrate/migrator.rs:142-192)—— 50 行代码精确体现 sqlx 迁移的执行纪律:

pub async fn run_direct<C>(&self, conn: &mut C) -> Result<(), MigrateError>
where C: Migrate,
{
    if self.locking { conn.lock().await?; }
    conn.ensure_migrations_table().await?;
    let version = conn.dirty_version().await?;
    if let Some(version) = version {
        return Err(MigrateError::Dirty(version));
    }
    let applied_migrations = conn.list_applied_migrations().await?;
    validate_applied_migrations(&applied_migrations, self)?;
    let applied_migrations: HashMap<_, _> = applied_migrations
        .into_iter().map(|m| (m.version, m)).collect();
    for migration in self.iter() {
        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?; }
        }
    }
    if self.locking { conn.unlock().await?; }
    Ok(())
}

六个步骤

  1. conn.lock() —— 获取 DB advisory lock—— 避免多进程同时 migrate。
  2. ensure_migrations_table—— 幂等创建 _sqlx_migrations 表。
  3. dirty_version—— 检查是否有 success=false 残留—— 有就 MigrateError::Dirty 立即返回。
  4. list_applied_migrations + validate_applied_migrations—— 读已应用列表、和 binary 里的迁移对比—— 检测"应用过但 binary 里没有"的情况。
  5. 循环比对—— 每个迁移:已应用 → 比 checksum(不等则 VersionMismatch 失败);未应用 → conn.apply(migration)
  6. conn.unlock()—— 释放 advisory lock。

两条设计原则

  • 应用前先检查—— step 3 + 4 检查完所有不变式、再开始改动—— "fail fast"。
  • advisory lock 成对—— step 1 和 6 必须配对—— 中途 ? 错误会让 lock 不释放—— 但 DB session 结束时 lock 自动清理—— Postgres 和 MySQL 的 advisory lock 都是 session-scoped—— sqlx 没显式 catch-unlock 也没关系。

这段代码把"迁移怎么应用"的所有决策都集中在一处—— 读懂它就读懂 sqlx 迁移的全部语义。

20.25 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 / PathBufsqlx-core/src/migrate/source.rs:29-46)—— 运行时读文件系统—— 内部用 spawn_blocking 包装同步 std::fs—— 返回 Vec<Migration>
  • &'static [Migration]—— 由 migrate!() 宏在编译期生成—— 直接返回不经 I/O。

为什么做成 trait—— 用户可以自己实现——比如从 S3 / embedded binary 资源 / 网络获取迁移列表——sqlx 不假设来源—— 开放式扩展

运行时路径的血泪史——spawn_blocking 至关重要:std::fs::read_to_string同步阻塞—— 在 Tokio 异步上下文直接调用会阻塞 worker 线程——用 spawn_blocking 扔到专属线程池—— async 正确。sqlx 源码这个调用(sqlx-core/src/migrate/source.rs:33)是每一个 Rust 异步库都要注意的同步 I/O 包装模式。

resolve_blocking 的核心逻辑(同文件)—— 遍历目录、匹配 <VERSION>_<DESCRIPTION>.sql 命名、解析 VERSION 为 i64—— 不匹配的文件silently ignored—— 这是用户友好(README.md 等杂文件不会报错)也是潜在坑(文件名写错了 sqlx 不告诉你、迁移没执行)—— 团队约定 cargo sqlx migrate info 定期核对。

20.26 一个完整的迁移模板

最后给一个可复制粘贴的迁移 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: 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 真正跑、其他等。

这套60 行配置 + 一行代码让 Rust 项目拥有企业级 schema 管理——sqlx 迁移系统的生产价值兑现。

20.27 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 strCow::Borrowed)或运行时(从磁盘读文件、Cow::Owned)—— Cow 统一两种来源、零拷贝共享 embed 路径。

Sha384::digest(sql.as_bytes())—— 一行计算 48 字节哈希—— sha2 crate 提供—— checksum 存到 _sqlx_migrations 表的 BYTEA 字段。

为什么 SHA-384 而不是 SHA-256——SHA-384 是 SHA-512 的截断版本—— 在64 位机器上比 SHA-256 快(SHA-512 内部用 64 位字运算、SHA-256 用 32 位)—— 大多数迁移系统用 MD5/SHA-1 的历史惯性被 sqlx 打破——选现代、快、安全的算法。

no_tx 的识别sqlx-core/src/migrate/source.rs:127):

let no_tx = sql.starts_with("-- no-transaction");

一行代码—— 检查 SQL 第一行是否 -- no-transaction 注释——就这么简单——识别 CREATE INDEX CONCURRENTLY 这种不能事务的语句。

这段代码体现 sqlx 的一致哲学—— 简单到极致、把复杂的判断留给用户显式标注——不假装能自动识别所有边界情形。

20.28 为什么 sqlx 的迁移系统被广泛采纳

sqlx 迁移系统是广泛采纳的——几乎每个 sqlx 用户都在用。原因思考:

1. 零学习成本——SQL 文件 + 时间戳命名——和几十种其他迁移工具一样——不需要学新概念。

2. 和 sqlx 生态无缝——同一个 Pool、同一个 Connection、同一套错误类型——不引入新库。

3. CLI UX 现代——add / run / revert / info 四命令直白——新手十分钟上手。

4. 生产问题保护——checksum 铁律 + dirty check + DB lock——避免 80% 的生产事故。

5. 编译期嵌入选项——migrate!() 宏让部署零额外文件——比"部署时必须带 migrations 目录"的其他工具好。

这五条"够用 + 易用 + 安全 + 集成 + 灵活"让 sqlx::migrate 成为 Rust 生态的默认选择——即便有 refinery 等替代品、sqlx 用户几乎不考虑换。"方便到不需要考虑替代" 是成熟工具的最高境界。

读完本章希望你对"什么样的迁移工具是好工具"有具体标准——下次评估 / 设计同类工具时有参照。

20.29 迁移系统的实战 Q&A

读者可能问的几个问题 + 答案:

Q1:多个服务用同一 DB、迁移怎么协调?

A:只让 one service 负责 schema 迁移——其他服务的代码假设 schema 已 ready。或者用 Kubernetes 的 Job 在所有服务启动前跑迁移。避免多 service 自己 migrate.run——可能竞争 lock 或 version 冲突。

Q2:迁移文件太多(500+)启动变慢怎么办?

Amigrate.run 只检查 _sqlx_migrations vs 当前列表 diff——已应用的不重跑。启动检查 500 条 migrations 大约几百毫秒——基本可忽略。如果真慢、考虑合并老的迁移(把 2020 年的 50 条合成一条 "initial schema")——不影响生产(反正老 DB 上这些都已应用、checksum 用新 hash 需手动 update)。

Q3:迁移里能调 Rust 代码吗?

A:sqlx::migrate 不支持——只跑 SQL。如果需要"复杂数据转换"(某列的值按逻辑 recompute)——单独写 Rust 程序、在迁移之后跑。sqlx 的迁移是schema DDL 工具、不是全能数据处理平台

Q4:多环境(dev / staging / prod)如何保证 migrations 一致?

Agit 是 source of truth——迁移文件都 commit。所有环境部署同一个 git commit、跑同一个 binary、应用同样的迁移——一致性靠 git 保证。定期 cargo sqlx migrate info 对比各环境、确认无分叉。

Q5:revert 出错怎么办?

Arevert 应用 down SQL——如果 down 写错了、可能破坏更多。解法:从 backup 恢复、或手动 SQL 修复——sqlx 不保证 revert 的 correctness、那是你 down 脚本的责任。生产强烈建议不 revert、通过正向新迁移修复。

这五个 Q&A 覆盖生产 sqlx 用户最常问的迁移问题——大多数答案是"sqlx 工具 + 团队纪律"的组合。工具本身不够、需要纪律配合。

20.30 迁移系统的 Rust 生态启示

最后从迁移系统这个具体工具升华到Rust 工具设计的通用启示:

  1. "小工具"胜过"大框架"——sqlx 迁移做一件事(跑 SQL 文件)做好、不试图覆盖所有 schema 管理需求。Rust 社区偏好这种哲学——每个工具聚焦、组合使用。

2. 依赖已成熟的设计约定——sqlx 沿用 Flyway / golang-migrate 的"SQL 文件 + 时间戳命名"约定——不发明新习惯。站在前人肩膀上降低用户学习成本。

3. 用类型系统表达约束——Migrator 的 Cow<'static, [Migration]> 让编译期嵌入和运行时加载统一;MigrationType enum 让 up/down 差异类型化——问题通过类型显式而不是隐式 convention。

4. 提供工具 + 留逃生舱——sqlx 迁移"一般用法"简单、"特殊需求"(no_tx、set_locking)通过字段暴露控制——用户先走主流、特殊情况回旋

5. CLI 和 library 并存——sqlx-cli 是 CLI 入口、sqlx::migrate!() 是 lib 入口——两条路径覆盖开发和部署的不同阶段。单一路径的工具(只有 lib 或只有 CLI)往往 UX 差。

这五条启示放在 Rust 工具设计里都有共鸣——tokio / serde / hyper 都遵循类似哲学。读 sqlx 迁移系统读到的不只是"怎么管 schema"、还有"怎么设计小而好用的 Rust 工具"。

20.31 迁移系统的高级用法:no_tx 和 set_locking

Migrator struct 有两个高级字段值得单独提——no_txlocking——解决特殊场景。

no_tx:某些 SQL 不能在事务内执行——典型是 Postgres 的 CREATE INDEX CONCURRENTLY

-- 20240424_add_index_concurrent.sql
-- no-transaction  ← sqlx 识别这行注释、设 no_tx = true
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);

sqlx 解析 SQL 开头的 -- no-transaction 注释设 migration.no_tx = true——apply 时跳过事务包裹。

为什么 CONCURRENTLY 不能事务内跑——Postgres 的 CREATE INDEX CONCURRENTLY 用非阻塞方式建索引、需要多个 heap scan、跨事务边界才能保证一致性——事务内直接报错。

其他 no_tx 场景:Postgres 的 VACUUM、某些 ALTER TABLE 的快速路径、CREATE DATABASE。用户按需加注释。

set_lockingMigrator::set_locking(false) 关闭 DB 层锁。场景:

  • 开发环境——本地 dev 跑 migrate 不怕并发,关锁省几毫秒。
  • 测试——每个测试独占 DB、不需要跨进程锁。
  • DB 不支持 advisory lock——某些 Postgres fork(CockroachDB)协议兼容但没 pg_advisory_lock。

生产永远保留 locking = true——默认值就是 true——除非你非常清楚不需要。

这两个字段的存在让 sqlx 迁移系统能适应边界场景——"好的工具主流简单、边界灵活"的设计典范。

20.32 迁移系统的深层思考

最后一点深层思考——schema migration 的工具层和纪律层

sqlx 迁移工具层提供的:

  • 文件组织(migrations/ 目录)。
  • 命名约定(版本号 + 描述)。
  • 状态跟踪(_sqlx_migrations 表)。
  • checksum 防改。
  • 锁保护。

纪律层用户自己负责的:

  • "已应用的不改"——工具警告但不强制阻止。
  • "先加列再用列"——工具不做 deployment ordering。
  • "大表 ALTER 分步做"——工具不检查 SQL 性能影响。
  • "每次迁移幂等"——IF NOT EXISTS 等约束靠 SQL 作者。

好的迁移工具工具层 + 纪律层都要好——sqlx 工具层做到位、纪律层文档里说清楚——用户的纪律培养是用好 sqlx 的关键。

这条"工具不管的地方靠纪律"的边界同样适用于 git / docker 等生产工具—— git 不阻止 force push、docker 不阻止 root 容器—— 工具给强力、纪律给底线—— 两条都到位的团队才跑得稳。读完本章你应该清楚哪些是 sqlx 替你守的、哪些必须你自己守。

这条"工具和纪律共同决定质量"的观察在很多领域适用——git 的工具层完美、但不阻止你 force push 毁掉历史——纪律来自 code review + branch protection。sqlx 迁移类似——工具给你盘子、不保证你手稳

读完本章希望你对"何时靠工具、何时靠纪律"有更清楚的认识——这是工程师成熟的标志。

下一章第 21 章日志追踪——让 sqlx 服务可观测