做全栈项目时,我越来越不喜欢一种部署体验:

发版
启动服务
发现表没建
再补一条 migrate 命令
然后祈祷同事下次别忘

这套流程在团队很大、平台很成熟的时候也许能靠规范兜住,但对个人项目或者轻量 SaaS 来说,它就是纯粹的额外心智负担。

所以我在 Pico-CRM 里做了一个很直接的选择:

服务启动时,先跑数据库迁移;迁移成功后,再继续启动 CQRS 和 HTTP 服务。

不是额外脚本,不是手动命令,就是 main.rs 里的一步。

一、先看真实代码:迁移就在启动链路里

Pico-CRM 的服务入口在 server/src/main.rs

核心代码就是这几行:

let db = Database::new().await;

Migrator::up(db.get_connection(), None)
    .await
    .unwrap_or_else(|err| panic!("执行数据库迁移失败: {}", err));

bootstrap_cqrs(db.connection.clone())
    .await
    .unwrap_or_else(|err| panic!("启动 CQRS 基础设施失败: {}", err));

这里的顺序是刻意设计的:

  1. 先连读模型数据库
  2. 再执行 SeaORM migration
  3. 再启动 CQRS 基础设施
  4. 最后才绑定 HTTP 端口

这个顺序的含义很简单:

库没准备好,服务就别假装自己能提供能力。

尤其这个项目不是纯 CRUD。后面还有事件存储初始化、投影监听器启动、Leptos SSR 路由注册。如果表结构都没到位,越往后启动,问题只会越隐蔽。

二、20 个 migration 不是口号,项目里现在就是 20 个

迁移列表定义在 migration/src/lib.rs

当前 Migrator 里注册了 20 个 migration:

vec![
    Box::new(m20260501_000001_create_table_merchants::Migration),
    Box::new(m20260501_000002_create_table_admin_users::Migration),
    Box::new(m20260501_000003_create_table_audit_logs::Migration),
    // ...
    Box::new(m20260501_000018_make_user_name_globally_unique::Migration),
    Box::new(m20260501_000019_make_user_contact_globally_unique::Migration),
    Box::new(m20260501_000020_seed_system_config_defaults::Migration),
]

这 20 个里不只是“建表”。

后面三条就很能说明问题:

  • 000018:把 users.user_name 从“商户内唯一”调整成“全局唯一”
  • 000019:把 emailphone_number 从“商户内唯一”调整成“全局唯一”
  • 000020:插入系统配置分类和默认项

也就是说,migration 在这里承担了三类职责:

  1. 初始化核心表结构
  2. 演进已有约束
  3. 补齐系统默认数据

这才是我理解里更真实的 migration。它不是一次性建库脚本,而是业务结构的时间线

三、为什么我不想把 migration 留给部署脚本

很多人会说,迁移不是应该交给 CI/CD、k8s Job 或运维脚本吗?

这话没错,但要看项目阶段。

Pico-CRM 现在的目标很明确:一个 Rust 单二进制,把家政 CRM 的 MVP 跑起来。

这种情况下,把迁移写进启动流程,收益比代价大得多。

3.1 单二进制部署更完整

项目本身就已经在往“启动即可用”收敛了:

  • .env.{APP_ENV} 自动加载
  • Database::new() 自动连接数据库
  • Migrator::up(...) 自动跑迁移
  • bootstrap_cqrs(...) 自动初始化事件存储和投影
  • Axum + Leptos 自动挂完整路由

这套链路的好处是,部署动作非常朴素:

把二进制放上去
配置好 DATABASE_URL / ES_DATABASE_URL
启动服务

你不需要再额外记一条“哦对,还得先跑 migrate”。

3.2 错误暴露更早

如果 migration 失败,当前实现会直接 panic!

unwrap_or_else(|err| panic!("执行数据库迁移失败: {}", err));

这个做法看起来激进,但我认为在启动阶段是合理的。

因为它把问题暴露在最前面:

  • SQL 写错了,直接启动失败
  • 索引冲突了,直接启动失败
  • 默认数据插入不兼容,直接启动失败

而不是服务已经对外监听了,用户请求进来以后你才发现某张表根本不存在。

3.3 共享表多租户更适合这么做

Pico-CRM 的租户隔离方案是:

单 PostgreSQL 数据库,共享表,通过 merchant_id 做租户范围隔离。

这会让启动迁移这件事变得更划算。

如果你走的是“每个租户一个 schema”或者“每个租户一个库”,迁移执行复杂度会随着租户数量上升。

但共享表方案里,migration 只需要跑一次,全体商户共享最新结构。

新商户开通不需要重新跑整套建表逻辑,只需要正常插业务数据即可。

四、这套实现里最值得学的,不是 up(),而是时机

很多文章写自动迁移,会停留在“SeaORM 有 Migrator::up() 可以调用”。

这句话本身没什么价值,文档里也能看到。

真正有意思的是:你把它放在什么时候执行。

Pico-CRM 里它放在 bootstrap_cqrs() 之前,这一点很关键。

因为后面的 CQRS 基础设施依赖数据库状态稳定:

  • 读模型表要存在
  • 审计日志表要存在
  • 系统配置表要存在
  • 约束和索引要已经就位

举个例子。

m20260501_000020_seed_system_config_defaults.rs 里会初始化 system_config_categoriessystem_config_items 的默认数据,而且用了 ON CONFLICT DO NOTHING

INSERT INTO system_config_categories (...) VALUES (...)
ON CONFLICT (code) DO NOTHING

这意味着它不仅是第一次建库要跑,后续实例重启时再跑也能保持幂等。

这就是启动自动迁移能成立的前提之一:

迁移本身必须尽量可重复、安全、增量。

五、再看两个真实例子:为什么 migration 不只是 create table

5.1 全局唯一约束的修正

migration/src/m20260501_000018_make_user_name_globally_unique.rs 做的不是新建表,而是替换唯一索引:

manager
    .get_connection()
    .execute_unprepared(r#"DROP INDEX IF EXISTS "idx_users_merchant_username_unique""#)
    .await?;

manager
    .get_connection()
    .execute_unprepared(
        r#"CREATE UNIQUE INDEX IF NOT EXISTS "idx_users_username_unique" ON "users" ("user_name")"#,
    )
    .await?;

这类 migration 很典型。

前期你可能觉得“用户名在商户内唯一就够了”,后面业务一改,发现登录体系、账号识别、平台管理都更适合全局唯一,那就只能演进。

migration 的价值,恰恰就在这里。

它把“数据库设计的后悔药”变成一条可追踪的变更记录。

5.2 默认配置 seed 也应该纳入迁移时间线

migration/src/m20260501_000020_seed_system_config_defaults.rs 里除了表数据,还塞了真实业务配置,比如:

  • platform.name
  • platform.default_trial_days
  • notification.sms_provider
  • storage.driver

很多项目喜欢把这类默认值散落在:

  • 后端常量
  • 管理后台初始化按钮
  • 一次性的 SQL 文件

结果就是环境一多,数据一致性全靠记忆。

我更倾向于把“系统要有这些初始配置才能正常工作”这件事,直接写进 migration。

原因很简单:

如果它是系统启动的前置条件,它就应该和表结构一起被版本化。

六、自动迁移不是银弹,边界也要说清楚

写到这里,也得把话说完整。

我并不是认为所有项目都应该把 migration 塞进 main.rs

这套方案更适合下面这类场景:

  • 项目还在快速演进
  • 部署方式偏单体或少量实例
  • 目标是降低手工步骤
  • 迁移耗时可控
  • 团队接受“启动失败优于脏运行”

如果你的场景是:

  • 大规模多副本同时滚动发布
  • migration 会跑很久
  • 涉及复杂锁表操作
  • 需要专门的发布窗口和 DBA 审核

那把 migration 独立成发布阶段任务,会更稳妥。

所以这里的关键不是“自动迁移永远最好”,而是:

在当前项目阶段,哪种方案让系统更一致、部署更少犯错。

对 Pico-CRM 来说,答案就是现在这套。

七、最后总结一下

Pico-CRM 这条启动链路里,Migrator::up(...) 的意义不只是省一条命令,而是把“数据库必须先准备好”这件事写成了程序行为。

它带来的几个直接结果是:

  • 服务启动和数据库结构同步绑定
  • 20 个 migration 的演进历史有明确落点
  • 默认配置 seed 不再散落在脚本和常量里
  • 单二进制部署更完整
  • 共享表多租户方案下,迁移成本与商户数量解耦

如果你现在的项目还在靠“发版时顺手跑一下 migration”,那我建议你认真想一遍:

这一步到底是部署备注,还是系统启动契约。

在我的项目里,它显然属于后者。

如果你也在用 Rust + SeaORM 做业务系统,欢迎聊聊你是把 migration 放在 CI、入口进程,还是单独 worker 里。不同阶段的答案不一样,但把这个问题想清楚,能少踩很多运维层面的坑。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐