sea-orm-migration
主线位置:迁移不在三条主线上,但它用的全是主线上的零件。
seaql_migrations表是一个DeriveEntityModel实体(定义主线),建这张表用的是下一页的Schema::create_table_from_entity,记一笔「已执行」是一次Entity::insert(..).exec(写入主线),读已执行列表是一次query_all(查询主线)。迁移体里的 DDL 由 SchemaManager 交给连接执行。
flowchart TB
upcall["Migrator::up"] --> inst["install 建记录表"]
inst --> sel["读 seaql_migrations"]
sel --> diff["按名字做集合差"]
diff --> each{"逐个待执行迁移"}
each --> usetx{"should_use_transaction"}
usetx -->|"是"| beg["BEGIN"]
beg --> upb["migration.up"]
upb --> rec["INSERT 记录"]
rec --> cmt["COMMIT"]
usetx -->|"否"| upn["直接在连接上 up"]
upn --> recn["INSERT 记录"]
cmt --> each
recn --> each
sea-orm-migration 2.0.4 的 src/ 一共 2 388 行,其中 schema.rs 的 623 行是 pk_auto、string_null 这类建列辅助函数,migrator/with_self.rs 的 392 行是同一套方法的 &self 版本。真正决定行为的是 migrator.rs(300 行)和 migrator/exec.rs(332 行)。sqlx 自带的迁移器在 sqlx 卷的迁移一页,本页最后有一张两者的对照表。
实验 crate 依赖 sea-orm =2.0.4(sqlx-postgres、runtime-tokio-rustls、macros)和 sea-orm-migration =2.0.4(sqlx-postgres、runtime-tokio-rustls、cli);对照组是 sea-orm 1.1.20 + sea-orm-migration 1.1.20,本站现在用的版本。平台 M2 Max、macOS、rustc 1.99.0、Postgres 16.14。wire> / wire< 行来自夹在 sqlx 和 Postgres 之间的 TCP 代理,和 ActiveModelEx 一页用的是同一个:每条执行的语句一行,加上 ReadyForQuery 事务状态的变化(I 空闲、T 事务中、E 事务已失败)。sqlx 的日志里看不到 BEGIN / COMMIT,所以这里用代理。
两个 trait
一个迁移是一个实现了 MigrationTrait 的类型:
pub trait MigrationName {
fn name(&self) -> &str;
}
/// The migration definition
#[async_trait::async_trait]
pub trait MigrationTrait: MigrationName + Send + Sync {
/// Define actions to perform when applying the migration
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr>;
/// Define actions to perform when rolling back the migration
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
Err(DbErr::Migration("We Don't Do That Here".to_owned()))
}
/// Control whether this migration runs inside a transaction.
///
/// - `None` (default): follow backend convention (Postgres = transaction, MySQL/SQLite = no transaction)
/// - `Some(true)`: force wrapping in a transaction on any backend
/// - `Some(false)`: disable automatic transaction wrapping (use `manager.begin()` for manual control)
fn use_transaction(&self) -> Option<bool> {
None
}
}
name()是迁移的身份,库里只记它。#[derive(DeriveMigrationName)]生成的实现是sea_orm_migration::util::get_file_stem(file!())(sea-orm-macros/src/derives/migration.rs),也就是源文件名去掉.rs。sea-orm-cli migrate generate生成的模板(fmt_migration_template)在 2.0 里改成手写一个impl MigrationName,返回"m<UTC 时间>_<名字>"字面量,文件挪了位置名字也不变(CHANGELOG 2.0.0-rc.41:「Generated migrations implementMigrationNameexplicitly, keeping names stable when the file/module layout is customized」,#2580)。down有默认实现,默认就是报错DbErr::Migration("We Don't Do That Here")。不写down的迁移不能回滚,reset/refresh/down跑到它就停下(后面有实验)。use_transaction是 2.0 新增的,1.1.20 的MigrationTrait只有up/down。
迁移器是 MigratorTrait:必须实现的只有 fn migrations() -> Vec<Box<dyn MigrationTrait>>,注释写着「Vector of migrations in time sequence」。其余 up、down、status、fresh、refresh、reset、uninstall、install 都是默认方法。表名可以用 migration_table_name() 改,默认是 seaql_migrations。2.0 还有一个收 &self 的 MigratorTraitSelf(rc.32,#2806),所有 MigratorTrait 都通过一个 blanket impl 自动实现它,CLI 收的就是这个版本。
哪些迁移「待执行」,由 get_migration_with_status 决定:
let migration_in_db: HashSet<String> = migration_models
.into_iter()
.map(|model| model.version)
.collect();
let migration_in_fs: HashSet<String> = migration_files
.iter()
.map(|file| file.migration.name().to_string())
.collect();
let pending_migrations = &migration_in_fs - &migration_in_db;
for migration_file in migration_files.iter_mut() {
if !pending_migrations.contains(migration_file.migration.name()) {
migration_file.status = MigrationStatus::Applied;
}
}
let missing_migrations_in_fs = &migration_in_db - &migration_in_fs;
let errors: Vec<String> = missing_migrations_in_fs
.iter()
.map(|missing_migration| {
format!("Migration file of version '{missing_migration}' is missing, this migration has been applied but its file is missing")
}).collect();
两个名字集合做差,状态写回 migrations() 返回的那个 Vec。所以执行顺序是 Vec 里的顺序,不是名字的字典序;名字里的时间戳只是约定,迁移器不看它。反过来,库里有、代码里没有的名字,是一个错误,整个 status / up / down 都不往下走。
seaql_migrations:只记名字
记录表是一个普通实体,seaql_migrations.rs:
#[sea_orm(table_name = "seaql_migrations")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub version: String,
pub applied_at: i64,
}
install 用 Schema::create_table_from_entity(seaql_migrations::Entity) 生成建表语句,再加 if_not_exists();insert_migration_record 写 version = name()、applied_at = SystemTime::now() 的 Unix 秒数(开 with-time feature 时改用 time crate,同样是秒)。发出去的建表语句(下面实验的代理输出):
CREATE TABLE IF NOT EXISTS "seaql_migrations" ( "version" varchar NOT NULL PRIMARY KEY, "applied_at" bigint NOT NULL )
两列,没有校验和,没有执行耗时,没有「是否成功」。迁移器判断一个迁移跑没跑过,只看这一行在不在。下面的程序先跑两个迁移,再换三种 Migrator 去跑同一个库:名字不变但 up 改了、第一个改了名、Vec 末尾加一个名字更「早」的迁移(src/bin/edit_rename.rs,Create(name, table) 的 up 建一张只有 id 的表):
migrator!(V1, [("m20260101_000001_a", "a"), ("m20260101_000002_b", "b")]);
// same names, m1 now creates "a_edited"
migrator!(Edited, [("m20260101_000001_a", "a_edited"), ("m20260101_000002_b", "b")]);
// m1 renamed
migrator!(Renamed, [("m20260101_000001_create_a", "a"), ("m20260101_000002_b", "b")]);
// an older-named migration appended at the end of the vec
migrator!(Older, [("m20260101_000001_a", "a"), ("m20260101_000002_b", "b"), ("m20251231_000001_old", "old")]);
V1::up(&db, None).await?;
Edited::status(&db).await?;
Edited::up(&db, None).await?;
// ... Renamed::status / Renamed::up, Older::up, 然后列出 seaql_migrations 的行
--- V1 up
Applying all pending migrations
Applying migration 'm20260101_000001_a'
Migration 'm20260101_000001_a' has been applied
Applying migration 'm20260101_000002_b'
Migration 'm20260101_000002_b' has been applied
--- Edited: status, up
Checking migration status
Migration 'm20260101_000001_a'... Applied
Migration 'm20260101_000002_b'... Applied
Applying all pending migrations
No pending migrations
tables: ["a", "b", "seaql_migrations"]
--- Renamed: status, up
Checking migration status
status: Err(Custom("Migration file of version 'm20260101_000001_a' is missing, this migration has been applied but its file is missing"))
up: Err(Custom("Migration file of version 'm20260101_000001_a' is missing, this migration has been applied but its file is missing"))
--- Older: up, then rows of seaql_migrations
Applying all pending migrations
Applying migration 'm20251231_000001_old'
Migration 'm20251231_000001_old' has been applied
row: m20251231_000001_old 1792844029
row: m20260101_000001_a 1792844029
row: m20260101_000002_b 1792844029
with_status: m20260101_000001_a Applied
with_status: m20260101_000002_b Applied
with_status: m20251231_000001_old Applied
(中间的 Applying ... 行是 sea-orm-migration 自己的 tracing::info!,订阅了 sea_orm_migration=info 才看得见。)
| 改动 | 迁移器的反应 |
|---|---|
改了已执行迁移的 up | 不知道,status 照样是 Applied,新的 up 永远不会跑;a_edited 没有建出来 |
给已执行迁移改名(或改了文件名而用的是 DeriveMigrationName) | 旧名字在库里、代码里没有:DbErr::Custom("Migration file of version '...' is missing, ..."),status 和 up 都失败,新名字也不会被当成待执行去跑 |
在 Vec 末尾加一个名字更早的迁移 | 照样执行;seaql_migrations 按 version 排序读出,但执行和 status 的顺序是 Vec 的顺序 |
sqlx 的迁移器在第一种情况下报 migration 1 was previously applied but has been modified(见 sqlx 卷),因为它的表里存着 SHA-384。sea-orm 的迁移是 Rust 代码,编译进二进制,没有一个「文件内容」可以算校验和,seaql_migrations 也就只有名字。
问: 改名会报错,那给一个已执行的迁移改名的正确做法是什么?
答: 改名之前先把库里那一行的 version 也改掉(UPDATE seaql_migrations SET version = '新名' WHERE version = '旧名'),两边一致就没有「missing」。或者干脆不改名:DeriveMigrationName 取的是文件名,挪文件就等于改名,2.0 的 migrate generate 改成写死字面量就是为了避免这一点。
exec_up_with:2.0 每个迁移一个事务
up 先 install,再取待执行列表,交给 exec_up_with:
for Migration { migration, .. } in pending_migrations {
if let Some(steps) = steps.as_mut() {
if steps == &0 {
break;
}
*steps -= 1;
}
let use_txn = should_use_transaction(migration.as_ref(), db.get_database_backend());
info!("Applying migration '{}'", migration.name());
if use_txn {
let transaction = db.begin().await?;
let txn_manager = SchemaManager::new(&transaction);
migration.up(&txn_manager).await?;
info!("Migration '{}' has been applied", migration.name());
insert_migration_record(&transaction, migration.name(), migration_table_name.clone())
.await?;
transaction.commit().await?;
} else {
migration.up(manager).await?;
info!("Migration '{}' has been applied", migration.name());
insert_migration_record(db, migration.name(), migration_table_name.clone()).await?;
}
}
should_use_transaction 是 use_transaction() 给了就用,没给就看后端:backend == DbBackend::Postgres。所以 Postgres 上默认每个迁移各开一个事务,迁移体和它的那条 INSERT 记录在同一个事务里;up 返回错误时 ? 直接返回,transaction 被 drop,回滚(drop 时怎么回滚见事务)。已经提交的前几个迁移不受影响。
1.1.20 的结构不一样。MigratorTrait::up 把整个 exec_up 交给 exec_with_connection:
match db.get_database_backend() {
DbBackend::Postgres => {
let transaction = db.begin().await?;
let manager = SchemaManager::new(&transaction);
f(&manager).await?;
transaction.commit().await
}
DbBackend::MySql | DbBackend::Sqlite => {
let manager = SchemaManager::new(db);
f(&manager).await
}
}
Postgres 上 install、读记录、所有待执行迁移和它们的记录,全在一个事务里。
实验:第三个迁移中途失败
三个迁移:m1 建表 a,m2 建表 b,m3 建表 c 之后执行 SELECT 1/0。空 schema 上 Migrator::up(&db, None),然后看剩下什么(src/bin/txn_up.rs,两个版本的代码只差 Alias::new)。2.0.4:
wire> CREATE TABLE IF NOT EXISTS "seaql_migrations" ( "version" varchar NOT NULL PRIMARY KEY, "applied_at" bigint NOT NULL )
wire> CREATE TABLE IF NOT EXISTS "seaql_migrations" ( "version" varchar NOT NULL PRIMARY KEY, "applied_at" bigint NOT NULL )
wire> CREATE TABLE IF NOT EXISTS "seaql_migrations" ( "version" varchar NOT NULL PRIMARY KEY, "applied_at" bigint NOT NULL )
wire> CREATE TABLE IF NOT EXISTS "seaql_migrations" ( "version" varchar NOT NULL PRIMARY KEY, "applied_at" bigint NOT NULL )
wire> SELECT "version", "applied_at" FROM "seaql_migrations" ORDER BY "version" ASC
wire> BEGIN
wire< ReadyForQuery(T)
wire> CREATE TABLE "a" ( "id" integer PRIMARY KEY )
wire> INSERT INTO "seaql_migrations" ("version", "applied_at") VALUES ($1, $2) RETURNING "version"
wire> COMMIT
wire< ReadyForQuery(I)
wire> BEGIN
wire< ReadyForQuery(T)
wire> CREATE TABLE "b" ( "id" integer PRIMARY KEY )
wire> INSERT INTO "seaql_migrations" ("version", "applied_at") VALUES ($1, $2) RETURNING "version"
wire> COMMIT
wire< ReadyForQuery(I)
wire> BEGIN
wire< ReadyForQuery(T)
wire> CREATE TABLE "c" ( "id" integer PRIMARY KEY )
wire> SELECT 1/0
wire< ErrorResponse 22012 division by zero
wire< ReadyForQuery(E)
wire> ROLLBACK
wire< ReadyForQuery(I)
up returned: Err(Exec(SqlxError(Database(PgDatabaseError { severity: Error, code: "22012", message: "division by zero", detail: None, hint: None, position: None, where: None, schema: None, table: None, column: None, data_type: None, constraint: None, file: Some("int.c"), line: Some(870), routine: Some("int4div") }))))
tables: ["a", "b", "seaql_migrations"]
applied: m1_create_a
applied: m2_create_b
1.1.20:
wire> BEGIN
wire< ReadyForQuery(T)
wire> CREATE TABLE IF NOT EXISTS "seaql_migrations" ( "version" varchar NOT NULL PRIMARY KEY, "applied_at" bigint NOT NULL )
wire> CREATE TABLE IF NOT EXISTS "seaql_migrations" ( "version" varchar NOT NULL PRIMARY KEY, "applied_at" bigint NOT NULL )
wire> CREATE TABLE IF NOT EXISTS "seaql_migrations" ( "version" varchar NOT NULL PRIMARY KEY, "applied_at" bigint NOT NULL )
wire> CREATE TABLE IF NOT EXISTS "seaql_migrations" ( "version" varchar NOT NULL PRIMARY KEY, "applied_at" bigint NOT NULL )
wire> SELECT "version", "applied_at" FROM "seaql_migrations" ORDER BY "version" ASC
wire> CREATE TABLE "a" ( "id" integer PRIMARY KEY )
wire> INSERT INTO "seaql_migrations" ("version", "applied_at") VALUES ($1, $2) RETURNING "version"
wire> CREATE TABLE "b" ( "id" integer PRIMARY KEY )
wire> INSERT INTO "seaql_migrations" ("version", "applied_at") VALUES ($1, $2) RETURNING "version"
wire> CREATE TABLE "c" ( "id" integer PRIMARY KEY )
wire> SELECT 1/0
wire< ErrorResponse 22012 division by zero
wire> ROLLBACK
wire< ReadyForQuery(E)
wire< ReadyForQuery(I)
up returned: Err(Exec(SqlxError(Database(PgDatabaseError { severity: Error, code: "22012", message: "division by zero", detail: None, hint: None, position: None, where: None, schema: None, table: None, column: None, data_type: None, constraint: None, file: Some("int.c"), line: Some(870), routine: Some("int4div") }))))
tables: []
(1.1.20 那段 ROLLBACK 印在 ReadyForQuery(E) 前面,是代理两个方向各自打印造成的;服务端先回了 E,客户端才发 ROLLBACK。)
| 2.0.4 | 1.1.20 | |
|---|---|---|
| 事务边界 | 每个迁移一个 BEGIN … COMMIT,install 和读记录在事务外 | 整次 up 一个事务,install 也在里面 |
m3 失败后 | a、b 和它们的记录留下,c 回滚 | 什么都没留下,连 seaql_migrations 都没有 |
下次 up | 从 m3 接着跑 | 从 m1 重新跑 |
全批一个事务的好处是要么全成、要么全不成;坏处是一个迁移失败,前面跑成功的也白跑了,而且没法跑必须在事务外执行的语句。2.0 选了每个迁移一个事务,失败的粒度变成一个迁移。
CHANGELOG 2.0.0-rc.36 记的是 #2980「Per-Migration Transaction Control」:「Previously, all Postgres migrations ran inside a single batch transaction, while MySQL and SQLite ran without one. This was an all-or-nothing approach with no way to opt out for individual migrations (e.g. CREATE INDEX CONCURRENTLY on Postgres requires running outside a transaction).」那一版 up / down 改成了上面的 exec_up_with,但 fresh / refresh / reset / uninstall 还套在一个叫 exec_with_connection! 的宏里,宏在 Postgres 上无条件先 db.begin()。于是 use_transaction() -> Some(false) 的迁移在 reset 时仍然跑在事务里,有人用它写 DROP DATABASE,回滚不了(issue #3001,用的是 rc.37)。PR #3002 删掉了这个宏:「Remove exec_with_connection! macro that unconditionally wrapped all Postgres operations in a transaction」,并且「Move the Postgres transaction for drop_everything into the function itself so atomicity is preserved」,CHANGELOG 记在 2.0.0-rc.38。所以现在的 drop_everything 自己在 Postgres 上开一个事务,fresh 的「删光」是原子的,之后的重新迁移又是每个迁移各一个事务。
use_transaction(false):自己管事务
有些语句不能在事务里执行,CREATE INDEX CONCURRENTLY 是最常见的。默认设置下写进迁移,和 use_transaction() -> Some(false) 加 manager.begin() 手动开事务的写法对比(src/bin/concurrently.rs):
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let m = if OPT_OUT.load(Ordering::SeqCst) { Some(manager.begin().await?) } else { None };
let mm = m.as_ref().unwrap_or(manager);
mm.create_table(Table::create().table("t").col(ColumnDef::new("id").integer().primary_key()).col(ColumnDef::new("name").text()).to_owned())
.await?;
if let Some(m) = m {
m.commit().await?;
}
manager.get_connection().execute_unprepared("CREATE INDEX CONCURRENTLY idx_t_name ON t (name)").await?;
Ok(())
}
fn use_transaction(&self) -> Option<bool> {
if OPT_OUT.load(Ordering::SeqCst) { Some(false) } else { None }
}
--- use_transaction = None
wire> CREATE TABLE IF NOT EXISTS "seaql_migrations" ( ... )
...
wire> BEGIN
wire< ReadyForQuery(T)
wire> CREATE TABLE "t" ( "id" integer PRIMARY KEY, "name" text )
wire> CREATE INDEX CONCURRENTLY idx_t_name ON t (name)
wire< ErrorResponse 25001 CREATE INDEX CONCURRENTLY cannot run inside a transaction block
wire> ROLLBACK
wire< ReadyForQuery(E)
wire< ReadyForQuery(I)
up: Err("Execution Error: error returned from database: CREATE INDEX CONCURRENTLY cannot run inside a transaction block at line 3497")
--- use_transaction = Some(false)
...
wire> BEGIN
wire< ReadyForQuery(T)
wire> CREATE TABLE "t" ( "id" integer PRIMARY KEY, "name" text )
wire> COMMIT
wire< ReadyForQuery(I)
wire> CREATE INDEX CONCURRENTLY idx_t_name ON t (name)
wire> INSERT INTO "seaql_migrations" ("version", "applied_at") VALUES ($1, $2) RETURNING "version"
up: Ok(())
(at line 3497 是 sqlx 0.9 的 Display 加的服务端源码行号,见 sqlx 卷的错误。)
SchemaManager::begin 返回一个自己持有事务的 SchemaManager<'static>(DatabaseExecutor::OwnedTransaction),commit(self) 提交它;在不是 begin 得来的 manager 上调 commit 报 Cannot commit: SchemaManager does not own a transaction。代价是:use_transaction 为 false 时,迁移体和那条记录不在一个事务里。迁移体中途失败,前面已经执行的语句留在库里,而 seaql_migrations 没有这一行,下次 up 会从头再跑这个迁移。这个坑单独成页:迁移中途失败留下一半。
没有锁:两个实例同时迁移
sqlx 的 Migrator::run 开头先拿一把 pg_advisory_lock(sqlx 卷)。sea-orm-migration 的源码里没有任何锁:up 就是 install → 读记录 → 逐个执行。两个进程同时启动、都在启动时调 Migrator::up,会怎样?
下面的程序用两个独立的 DatabaseConnection(两个池,相当于两个实例),tokio::join! 同时跑 Migrator::up,每轮先清空 schema。三种起点:空 schema;seaql_migrations 已经存在;已经存在并且迁移写了 IF NOT EXISTS。另有 stagger 模式:迁移体先 SELECT pg_sleep(0.5) 再建表,第二个实例晚 100 毫秒启动(src/bin/concurrent.rs,两个迁移各建一张表):
let db1 = connect(SCHEMA, false).await?;
let db2 = connect(SCHEMA, false).await?;
if mode != "empty" {
Migrator::install(&db1).await?;
}
let (r1, r2) = tokio::join!(up_maybe_locked(&db1, locked), async {
if stagger {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
up_maybe_locked(&db2, locked).await
});
2.0.4:
mode = empty, stagger = false
20 x Execution Error: error returned from database: duplicate key value violates unique constraint "pg_type_typname_nsp_index" at line 666
| Ok
=> tables ["a", "b", "seaql_migrations"], applied ["m1_create_a", "m2_create_b"]
mode = installed, stagger = false
20 x Execution Error: error returned from database: duplicate key value violates unique constraint "pg_type_typname_nsp_index" at line 666
| Ok
=> tables ["a", "b", "seaql_migrations"], applied ["m1_create_a", "m2_create_b"]
mode = idempotent, stagger = false
20 x Execution Error: error returned from database: duplicate key value violates unique constraint "pg_type_typname_nsp_index" at line 666
| Ok
=> tables ["a", "b", "seaql_migrations"], applied ["m1_create_a", "m2_create_b"]
mode = installed, stagger = true
5 x Execution Error: error returned from database: relation "a" already exists at line 1164
| Ok
=> tables ["a", "b", "seaql_migrations"], applied ["m1_create_a", "m2_create_b"]
mode = idempotent, stagger = true
5 x Ok
| Query Error: error returned from database: duplicate key value violates unique constraint "seaql_migrations_pkey" at line 666
=> tables ["a", "b", "seaql_migrations"], applied ["m1_create_a", "m2_create_b"]
1.1.20(同样的程序):
mode = empty, stagger = false
20 x Execution Error: error returned from database: duplicate key value violates unique constraint "pg_type_typname_nsp_index"
| Ok
=> tables ["a", "b", "seaql_migrations"], applied ["m1_create_a", "m2_create_b"]
mode = installed, stagger = false
19 x Execution Error: error returned from database: duplicate key value violates unique constraint "pg_type_typname_nsp_index"
| Ok
=> tables ["a", "b", "seaql_migrations"], applied ["m1_create_a", "m2_create_b"]
1 x Ok
| Ok
=> tables ["a", "b", "seaql_migrations"], applied ["m1_create_a", "m2_create_b"]
mode = idempotent, stagger = false
20 x Execution Error: error returned from database: duplicate key value violates unique constraint "pg_type_typname_nsp_index"
| Ok
=> tables ["a", "b", "seaql_migrations"], applied ["m1_create_a", "m2_create_b"]
mode = installed, stagger = true
5 x Execution Error: error returned from database: duplicate key value violates unique constraint "pg_type_typname_nsp_index"
| Ok
=> tables ["a", "b", "seaql_migrations"], applied ["m1_create_a", "m2_create_b"]
mode = idempotent, stagger = true
5 x Execution Error: error returned from database: duplicate key value violates unique constraint "pg_type_typname_nsp_index"
| Ok
=> tables ["a", "b", "seaql_migrations"], applied ["m1_create_a", "m2_create_b"]
报什么错取决于两个实例在哪一步撞上:
- 两边几乎同时
CREATE TABLE同一张表(不管带不带IF NOT EXISTS):第二个会等第一个的事务结束,然后在系统表pg_type的唯一索引上冲突,报23505 pg_type_typname_nsp_index。1.1.20 整批一个事务,第一个实例一直持有未提交的建表,第二个几乎总是这个错(20 轮里有 1 轮第二个实例开始时第一个已经全部跑完,两边都Ok)。 - 第二个实例读到「待执行」时第一个还没提交,等它真正建表时第一个已经提交:
42P07 relation "a" already exists。 - 迁移写成幂等的(
IF NOT EXISTS):建表这一步过去了,撞在记录上,23505 seaql_migrations_pkey。
撞在哪一步,打开 sea_orm_migration=info 跑一轮就能看出来(每个实例的 info! 交错打印)。空 schema 时失败的一方连 Applying all pending migrations 都没打出来,死在 install;seaql_migrations 已存在时,两边都打了 Applying migration 'm1_create_a',一方死在 m1 的建表上:
mode = empty, stagger = false, lock = false
Applying all pending migrations
Applying migration 'm1_create_a'
Migration 'm1_create_a' has been applied
Applying migration 'm2_create_b'
Migration 'm2_create_b' has been applied
1 x Execution Error: error returned from database: duplicate key value violates unique constraint "pg_type_typname_nsp_index" at line 666
| Ok
=> tables ["a", "b", "seaql_migrations"], applied ["m1_create_a", "m2_create_b"]
mode = installed, stagger = false, lock = false
Applying all pending migrations
Applying migration 'm1_create_a'
Applying all pending migrations
Applying migration 'm1_create_a'
Migration 'm1_create_a' has been applied
Applying migration 'm2_create_b'
Migration 'm2_create_b' has been applied
1 x Execution Error: error returned from database: duplicate key value violates unique constraint "pg_type_typname_nsp_index" at line 666
| Ok
=> tables ["a", "b", "seaql_migrations"], applied ["m1_create_a", "m2_create_b"]
所有情况下库的最终状态都是对的,只是有一个实例的 up 返回 Err;如果启动代码是 Migrator::up(&db, None).await?,这个实例就起不来,重启之后没有待执行的迁移,正常启动。真正危险的是迁移体有副作用、而又不在同一个事务里。下面的数据迁移只做一条 INSERT,两个实例错开 100 毫秒(src/bin/concurrent_seed.rs):
use_transaction = Some(true)
instance 1: Ok(())
instance 2: Err("Query Error: error returned from database: duplicate key value violates unique constraint \"seaql_migrations_pkey\" at line 666")
seed_log rows: 1
use_transaction = Some(false)
instance 1: Ok(())
instance 2: Err("Query Error: error returned from database: duplicate key value violates unique constraint \"seaql_migrations_pkey\" at line 666")
seed_log rows: 2
在事务里,第二个实例的 INSERT 记录撞了主键,连同它的数据 INSERT 一起回滚,seaql_migrations 的主键起了一把「事后的锁」的作用。use_transaction 为 false 时没有这层保护,数据插了两遍。
不想让实例互相撞,要么只在一个地方跑迁移(部署流程里单独一步,而不是每个实例启动时),要么自己加锁。下面在 Migrator::up 外面套一把会话级 advisory lock,锁放在一个单独的连接上(concurrent.rs 的 lock 模式):
let lock_conn = connect(SCHEMA, false).await?;
lock_conn.execute_unprepared("SELECT pg_advisory_lock(7001)").await?;
let r = Migrator::up(db, None).await;
lock_conn.execute_unprepared("SELECT pg_advisory_unlock(7001)").await?;
mode = empty, stagger = false, lock = true
20 x Ok
| Ok
=> tables ["a", "b", "seaql_migrations"], applied ["m1_create_a", "m2_create_b"]
mode = installed, stagger = true, lock = true
5 x Ok
| Ok
=> tables ["a", "b", "seaql_migrations"], applied ["m1_create_a", "m2_create_b"]
报错原文、复现和检查方法单独成页:两个实例同时迁移。
status、down、refresh、reset、fresh
其余几个方法都是 exec_up / exec_down 的组合。exec_down_with 和 exec_up_with 对称:取已执行的迁移,倒序逐个 down,每个迁移一个事务(同样看 should_use_transaction),删掉它的记录。
| 方法 | 做什么 | 2.0.4 源码 |
|---|---|---|
status(db) | install,然后对每个迁移打一行 info!:Migration '...'... Applied / Pending;不返回列表 | status |
up(db, steps) | 跑待执行迁移,Some(n) 只跑前 n 个 | up |
down(db, steps) | 倒序回滚已执行迁移,None 是全部 | down |
refresh(db) | down 全部,再 up 全部 | refresh |
reset(db) | down 全部,再删掉 seaql_migrations 表 | reset |
uninstall(db) | 只删 seaql_migrations(DROP TABLE IF EXISTS ... CASCADE),别的不动 | uninstall |
fresh(db) | 删掉当前 schema 里所有的表和自定义类型,再 up 全部;不调用任何 down | exec_fresh |
get_pending_migrations_read_only 等三个 | 不 install,表不存在就当全部待执行,给没有 DDL 权限的账号用(2.0.1,#3144) | get_migration_with_status_read_only |
除了 *_read_only,每个方法都先 install,所以「只是看看状态」也会建 seaql_migrations 表。reset 在 1.1.20 里只做 down,不删记录表;2.0 改成连表一起删,并新增了 uninstall(PR #2787:「updates the reset() method of the MigratorTrait to drop the seaql_migrations table in addition to rolling back already applied migrations」,仓库里最早包含它的 tag 是 2.0.0-rc.28)。
fresh 删什么由 drop_everything_impl 决定:用 sea-schema 的 query_tables 列出 CURRENT_SCHEMA() 里的每一张表,逐个 DROP TABLE IF EXISTS ... CASCADE,再删掉这个 schema 里不属于扩展的类型。它不区分表是不是迁移建的。下面的 schema 里多放了一张迁移不认识的 keep_me(src/bin/lifecycle.rs,m1、m2 各建一张表并实现了 down):
--- up(Some(1))
Applying 1 pending migrations
Applying migration 'm1_create_a'
Migration 'm1_create_a' has been applied
[after up 1] tables = ["a", "keep_me", "seaql_migrations"]
--- status
Checking migration status
Migration 'm1_create_a'... Applied
Migration 'm2_create_b'... Pending
--- up(None)
Applying all pending migrations
Applying migration 'm2_create_b'
Migration 'm2_create_b' has been applied
--- down(Some(1))
Rolling back 1 applied migrations
Rolling back migration 'm2_create_b'
Migration 'm2_create_b' has been rolled back
[after down 1] tables = ["a", "keep_me", "seaql_migrations"]
--- refresh
Rolling back all applied migrations
Rolling back migration 'm1_create_a'
Migration 'm1_create_a' has been rolled back
Applying all pending migrations
Applying migration 'm1_create_a'
Migration 'm1_create_a' has been applied
Applying migration 'm2_create_b'
Migration 'm2_create_b' has been applied
[after refresh] tables = ["a", "b", "keep_me", "seaql_migrations"]
--- reset
Rolling back all applied migrations
Rolling back migration 'm2_create_b'
Migration 'm2_create_b' has been rolled back
Rolling back migration 'm1_create_a'
Migration 'm1_create_a' has been rolled back
[after reset] tables = ["keep_me"]
--- fresh
Dropping table 'keep_me'
Table 'keep_me' has been dropped
Dropping table 'seaql_migrations'
Table 'seaql_migrations' has been dropped
Dropping all types
Applying all pending migrations
Applying migration 'm1_create_a'
Migration 'm1_create_a' has been applied
Applying migration 'm2_create_b'
Migration 'm2_create_b' has been applied
[after fresh] tables = ["a", "b", "seaql_migrations"]
--- WithNoDown: up, then down(None)
Applying all pending migrations
Applying migration 'm3_no_down'
Migration 'm3_no_down' has been applied
Rolling back all applied migrations
Rolling back migration 'm3_no_down'
down: Err(Migration("We Don't Do That Here"))
[after failed down] tables = ["a", "b", "seaql_migrations"]
reset 之后 keep_me 还在、seaql_migrations 没了;fresh 连 keep_me 一起删掉。fresh 只适合开发库,连到一个和别的程序共用的 schema 上会删掉别人的表。最后一段:第三个迁移没写 down,down(None) 倒序回滚时第一个碰到的就是它,直接失败,a、b 也没回滚。
命令行
sea-orm-cli migrate init 生成的迁移 crate,main 是 cli::run_cli(migrator::Migrator).await(需要 cli feature)。run_cli_with_connection 读 .env、解析参数、连库,再按子命令分派到上面那些方法(run_migrate_inner):
match command {
Some(MigrateSubcommands::Fresh) => migrator.fresh(db).await?,
Some(MigrateSubcommands::Refresh) => migrator.refresh(db).await?,
Some(MigrateSubcommands::Reset) => migrator.reset(db).await?,
Some(MigrateSubcommands::Status) => migrator.status(db).await?,
Some(MigrateSubcommands::Up { num }) => migrator.up(db, num).await?,
Some(MigrateSubcommands::Down { num }) => migrator.down(db, Some(num)).await?,
_ => migrator.up(db, None).await?,
};
几处容易想错的地方:
不带子命令就是
up。down的-n默认是 1(MigrateSubcommands里default_value = "1"),而库函数Migrator::down(db, None)是全部回滚。-s/DATABASE_SCHEMA没给时,CLI 用"public"调set_schema_search_path(源码),--help里也写着「For PostgreSQL, this argument is optional with default value 'public'」。这条SET search_path在每条连接建立后执行(DatabaseConnection 与 ConnectOptions),会覆盖连接串里的options=-c search_path=...。用ConnectOptions直接验证(src/bin/search_path.rs,连接串里写search_path=so_mig_cli):set_schema_search_path(None) => current_schema() = so_mig_cli set_schema_search_path(Some("so_mig")) => current_schema() = so_mig所以靠连接串指定 schema 的项目,用 CLI 时不加
-s就会把迁移跑进public。sea-orm-cli migrate up本身不连库,它拼出cargo run --manifest-path ./migration/Cargo.toml -- up,把DATABASE_URL/DATABASE_SCHEMA放进环境变量去跑你的迁移 crate(run_migrate_command)。
用实验 crate 里的 src/bin/cli.rs(main 只有 cli::run_cli(Migrator).await)跑一遍,$URL 是 postgres://lab:lab@192.168.64.106/lab。schema 不存在时先报错,建好 schema 之后:
$ cli -u postgres://lab:lab@192.168.64.106/lab -s so_mig_cli status
Execution Error: error returned from database: no schema has been selected to create in at line 500
exit=1
$ cli -u $URL -s so_mig_cli status
Checking migration status
Migration 'm20260101_000001_create_a'... Pending
Migration 'm20260101_000002_create_b'... Pending
exit=0
$ cli -u $URL -s so_mig_cli up -n 1
Applying 1 pending migrations
Applying migration 'm20260101_000001_create_a'
Migration 'm20260101_000001_create_a' has been applied
exit=0
$ cli -u $URL -s so_mig_cli up
Applying all pending migrations
Applying migration 'm20260101_000002_create_b'
Migration 'm20260101_000002_create_b' has been applied
exit=0
$ cli -u $URL -s so_mig_cli down
Rolling back 1 applied migrations
Rolling back migration 'm20260101_000002_create_b'
Migration 'm20260101_000002_create_b' has been rolled back
exit=0
$ cli -u $URL -s so_mig_cli status
Checking migration status
Migration 'm20260101_000001_create_a'... Applied
Migration 'm20260101_000002_create_b'... Pending
exit=0
第一段是 -s 指向一个还不存在的 schema:search_path 设成了它,install 的 CREATE TABLE 找不到可建的 schema(3F000)。CLI 的错误处理是打印 Display 再 exit(1)。
和 sqlx 的迁移器对照
| sea-orm-migration 2.0.4 | sqlx 0.9.0 Migrator | |
|---|---|---|
| 迁移是什么 | 编译进二进制的 Rust 代码(MigrationTrait) | .sql 文件,migrate! 编译期嵌入 |
| 记录表 | seaql_migrations(version, applied_at) | _sqlx_migrations,带 checksum、success、execution_time |
| 改了已执行的迁移 | 发现不了 | 校验和不符,报错停下 |
| 事务 | Postgres 默认每个迁移一个,use_transaction 可关 | 每个迁移一个,-- no-transaction 可关 |
| 并发 | 没有锁 | pg_advisory_lock |
| 回滚 | down 方法,默认报错 | .down.sql 文件 |
| 顺序 | migrations() 的 Vec 顺序 | 版本号 |
空 schema 上调一次 Migrator::up(&db, None),有两个待执行迁移。CREATE TABLE IF NOT EXISTS "seaql_migrations" 会发几次?提示:数一数 exec_up → get_pending_migrations → get_migration_with_status → get_migration_models 这条调用链上有几个 Self::install(db).await?。
动脑筋答案
四次。上面「第三个迁移中途失败」实验的代理输出开头就是四条一模一样的语句,然后才是唯一一次 SELECT:
wire> CREATE TABLE IF NOT EXISTS "seaql_migrations" ( "version" varchar NOT NULL PRIMARY KEY, "applied_at" bigint NOT NULL )
wire> CREATE TABLE IF NOT EXISTS "seaql_migrations" ( "version" varchar NOT NULL PRIMARY KEY, "applied_at" bigint NOT NULL )
wire> CREATE TABLE IF NOT EXISTS "seaql_migrations" ( "version" varchar NOT NULL PRIMARY KEY, "applied_at" bigint NOT NULL )
wire> CREATE TABLE IF NOT EXISTS "seaql_migrations" ( "version" varchar NOT NULL PRIMARY KEY, "applied_at" bigint NOT NULL )
wire> SELECT "version", "applied_at" FROM "seaql_migrations" ORDER BY "version" ASC
exec_up 先 M::install(db)(第 1 次),再调 M::get_pending_migrations(db),它的默认实现开头 Self::install(db)(第 2 次),接着调 get_migration_with_status,又一个 Self::install(db)(第 3 次),后者再调 get_migration_models,还是先 Self::install(db)(第 4 次),最后才发 SELECT。每个默认方法都假设自己可能被单独调用,所以各自保证表存在。表已经存在时 IF NOT EXISTS 只是一个 NOTICE,代价是三次多余的往返;1.1.20 也是四次(同一个实验的输出)。
小结
- 一个迁移的身份只有
name();seaql_migrations只有version和applied_at两列,没有校验和。改了已执行迁移的内容不会被发现,改名会让status/up报Migration file of version '...' is missing。 - 执行顺序是
migrations()返回的Vec的顺序,待执行列表是两个名字集合的差。 - 2.0:Postgres 上默认每个迁移一个事务,迁移体和记录一起提交或回滚;
use_transaction() -> Some(false)关掉,配合manager.begin()/commit()手动分段。1.1.20:整次up一个事务,一个失败全部回滚。 - 没有锁。两个实例同时
up,总有一个报pg_type_typname_nsp_index、relation ... already exists或seaql_migrations_pkey;库的最终状态是对的,但不在事务里的数据迁移会执行两遍。只在一处跑迁移,或者自己套 advisory lock。 fresh删掉当前 schema 里所有的表,不管是谁建的;reset在 2.0 里连seaql_migrations一起删;没写down的迁移挡住所有回滚。- CLI 不带子命令是
up,down默认只回滚一个,-s默认public并覆盖连接串里的search_path。