Skip to content

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

"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):

rust
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)——

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

运行时读 ./migrations 目录——部署时目录要跟着 binary 走

2. 编译期嵌入migrate!() 宏)——

rust
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)展开成:

rust
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):

sql
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

rust
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 表、对每个已应用的迁移:

rust
// 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 列

sql
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

rust
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 完整流程

migrator.rs:142-192 的 run_direct(简化):

rust
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 左右):

rust
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

rust
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> —— 创建新迁移文件:

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

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

cargo sqlx migrate run —— 应用所有 pending migrations:

bash
$ 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 —— 看当前应用状态:

bash
$ 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

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

rust
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 的完整流程:

关键节点

  • 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::migrateRustSQL 文件可选
refineryRustSQL 文件可选
Diesel migrationsRustSQL 文件部分默认有
Rails ActiveRecordRubyRuby DSL强制
DjangoPythonPython DSL可选
FlywayJavaSQL 文件商业版有
Prisma MigrateTypeScriptschema + 生成 SQL可选
golang-migrateGoSQL 文件可选

几条观察

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

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

20.18 迁移系统的常见踩坑

生产里 sqlx 迁移的常见坑

1. 忘了在服务启动时跑 migrate

rust
#[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 指错库

bash
# .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

bash
# 改了已 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 分钟、服务瘫痪):

sql
ALTER TABLE users ADD COLUMN phone TEXT;

正确做法——分多步迁移

Migration 120240424_add_phone_nullable.sql):

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

Migration 220240425_backfill_phone.sql)——不用 sqlx migrate(太慢)、而是另写独立 job

rust
// 独立 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:

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

Migration 420240502_add_phone_default.sql):

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:

rust
// 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.22a _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):

sql
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):

sql
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.22b Migrator::run_direct:六步执行流水

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

rust
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.22c MigrationSource trait:文件系统和编译期 embed 的双入口

Migrator::new() 接受任何实现 MigrationSource trait 的东西作为输入(sqlx-core/src/migrate/source.rs:25-27):

rust
pub trait MigrationSource<'s>: Debug {
    fn resolve(self) -> BoxFuture<'s, Result<Vec<Migration>, BoxDynError>>;
}

两个主要实现

  • &Path / PathBufsource.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 源码这个调用(source.rs:33)是每一个 Rust 异步库都要注意的同步 I/O 包装模式。

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

20.23 一个完整的迁移模板

最后给一个可复制粘贴的迁移 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

toml
[dependencies]
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "macros", "migrate"] }
# ...

src/main.rs

rust
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(())
}

开发工作流

bash
# 新迁移
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 片段):

yaml
- 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.24 Migration struct 与 SHA-384 checksum 的源码细节

源码sqlx-core/src/migrate/migration.rs:7-36):

rust
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):

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

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

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

20.25 为什么 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.26 迁移系统的实战 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.27 迁移系统的 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.28 迁移系统的高级用法:no_tx 和 set_locking

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

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

sql
-- 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.29 迁移系统的深层思考

最后一点深层思考——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 服务可观测

基于 VitePress 构建