Appearance
第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 的迁移系统由两套组件组成——运行时的
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: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 --release 后 target/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)。
这条类型区分让 run 和 undo 互不干扰——即便同一 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(())
}七步流程:
- 获取锁(如果 locking = true)——防多实例同时跑迁移。
- ensure_migrations_table——幂等建
_sqlx_migrations表。 - dirty_version check——找
success = false的记录。有就报 Dirty 错。 - 校验已应用 vs 当前列表一致性——ignore_missing = false 时 DB 有的迁移必须在 migrations 列表里。
- 循环每个 up migration——跳过 down migrations。
- 已应用的检查 checksum、未应用的 apply。
- 释放锁。
apply 方法(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 DATABASE 或 VACUUM 之类不能在事务内的 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-cli 的 migrate 子命令提供完整的本地工作流:
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() 的实现:
- Postgres:
SELECT pg_advisory_lock(2091089589)(固定数字、sqlx 内部选的魔数)。 - MySQL:
SELECT GET_LOCK('_sqlx_migrate_lock', -1)。 - SQLite:文件层面排他——sqlx 走
BEGIN EXCLUSIVE。
三家锁机制不同、sqlx 在 Migrate trait 层统一——用户代码零感知。
20.14 本章小结
本章讲完 sqlx 的迁移系统:
- 为什么要 schema migration 工具(§20.1)—— 跨环境同步、团队协作、回滚、bootstrap 新环境。
- 文件命名约定(§20.2)——
<version>_<description>.sql或 reversible 的.up/.down.sql。 - Migrator struct + migrate! 宏(§20.3)—— 运行时 vs 编译期嵌入两种构造方式。
- _sqlx_migrations 表 schema(§20.4)—— version / description / installed_on / success / checksum / execution_time 六字段。
_sqlx_前缀避撞用户表。 - SHA-384 checksum(§20.5)—— 防篡改、"已应用迁移不能改"铁律。VersionMismatch 报错。
- MigrationType 三变体(§20.6)—— Simple / ReversibleUp / ReversibleDown。文件后缀判断。
- run_direct 七步流程(§20.7)—— lock / ensure table / dirty check / validate / loop / apply / unlock。
- undo 回滚(§20.8)—— 倒序跑 down migrations 到指定版本。生产少用、开发常用。
- cargo sqlx migrate CLI(§20.9)—— add / run / revert / info 四子命令。UX 友好。
- dirty version + DB lock(§20.10)—— 半成品 schema 拒绝继续、并发 run 互斥。
- 典型工作流(§20.11)—— 8 步从 add 到 prod 监控。
- 七条生产铁律(§20.12)—— 不改历史、
ALTER TABLE小心、迁移先于代码、幂等、staging、监控。 - 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::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
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 1(20240424_add_phone_nullable.sql):
sql
-- 加可空列、立即完成(DDL 元数据改动、不扫表)
ALTER TABLE users ADD COLUMN phone TEXT;Migration 2(20240425_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 3(20240501_add_phone_not_null.sql)——backfill 完再加 NOT NULL:
sql
-- Postgres 下这步也需要扫全表验证、但比 ADD DEFAULT 影响小
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;Migration 4(20240502_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.5:
sqlx-cli的migrate子命令完善;migrate!()宏加入。 - 0.6:reversible migrations 支持(
.up.sql/.down.sql)。 - 0.7:GAT 重构简化内部代码;Any 驱动全面支持 migrate。
- 0.8(本书版本):
no_txmigrations 支持(-- 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.rs、sqlx-mysql/src/migrate.rs、sqlx-sqlite/src/migrate.rs 里各自定义 _sqlx_migrations 表的 schema——字段语义相同、类型语法不同。
Postgres(sqlx-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 + LONGBLOB、SQLite 版本用 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(())
}六个步骤:
conn.lock()—— 获取 DB advisory lock—— 避免多进程同时 migrate。ensure_migrations_table—— 幂等创建_sqlx_migrations表。dirty_version—— 检查是否有 success=false 残留—— 有就MigrateError::Dirty立即返回。list_applied_migrations+validate_applied_migrations—— 读已应用列表、和 binary 里的迁移对比—— 检测"应用过但 binary 里没有"的情况。- 循环比对—— 每个迁移:已应用 → 比 checksum(不等则
VersionMismatch失败);未应用 →conn.apply(migration)。 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/PathBuf(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 源码这个调用(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
└── .envCargo.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 commitCI 配置(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 str、Cow::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+)启动变慢怎么办?
A:migrate.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 一致?
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、通过正向新迁移修复。
这五个 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_tx 和 locking——解决特殊场景。
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_locking:Migrator::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 服务可观测。