Rust in Everything
基于 sea-orm 2.0.4

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 implement MigrationName explicitly, 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 也就只有名字。

📝NOTE
没有蠢问题

问: 改名会报错,那给一个已执行的迁移改名的正确做法是什么?

答: 改名之前先把库里那一行的 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.41.1.20
事务边界每个迁移一个 BEGIN … COMMIT,install 和读记录在事务外整次 up 一个事务,install 也在里面
m3 失败后a、b 和它们的记录留下,c 回滚什么都没留下,连 seaql_migrations 都没有
下次 up从 m3 接着跑从 m1 重新跑

全批一个事务的好处是要么全成、要么全不成;坏处是一个迁移失败,前面跑成功的也白跑了,而且没法跑必须在事务外执行的语句。2.0 选了每个迁移一个事务,失败的粒度变成一个迁移。

📝NOTE
冷知识:rc.36 的「每个迁移一个事务」只改对了一半

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 全部;不调用任何 downexec_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.4sqlx 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 顺序版本号
❗IMPORTANT
动脑筋:一次 up,几条建表语句?

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

💬 关联讨论

发起讨论
加载中...