SeaORM 迁移还要手动跑?我在 Rust CRM 里把 20 个 migration 直接塞进 `main.rs`
做全栈项目时,我越来越不喜欢一种部署体验:
发版
启动服务
发现表没建
再补一条 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));
这里的顺序是刻意设计的:
- 先连读模型数据库
- 再执行 SeaORM migration
- 再启动 CQRS 基础设施
- 最后才绑定 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:把email、phone_number从“商户内唯一”调整成“全局唯一”000020:插入系统配置分类和默认项
也就是说,migration 在这里承担了三类职责:
- 初始化核心表结构
- 演进已有约束
- 补齐系统默认数据
这才是我理解里更真实的 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_categories 和 system_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.nameplatform.default_trial_daysnotification.sms_providerstorage.driver
很多项目喜欢把这类默认值散落在:
- 后端常量
- 管理后台初始化按钮
- 一次性的 SQL 文件
结果就是环境一多,数据一致性全靠记忆。
我更倾向于把“系统要有这些初始配置才能正常工作”这件事,直接写进 migration。
原因很简单:
如果它是系统启动的前置条件,它就应该和表结构一起被版本化。
六、自动迁移不是银弹,边界也要说清楚
写到这里,也得把话说完整。
我并不是认为所有项目都应该把 migration 塞进 main.rs。
这套方案更适合下面这类场景:
- 项目还在快速演进
- 部署方式偏单体或少量实例
- 目标是降低手工步骤
- 迁移耗时可控
- 团队接受“启动失败优于脏运行”
如果你的场景是:
- 大规模多副本同时滚动发布
- migration 会跑很久
- 涉及复杂锁表操作
- 需要专门的发布窗口和 DBA 审核
那把 migration 独立成发布阶段任务,会更稳妥。
所以这里的关键不是“自动迁移永远最好”,而是:
在当前项目阶段,哪种方案让系统更一致、部署更少犯错。
对 Pico-CRM 来说,答案就是现在这套。
七、最后总结一下
Pico-CRM 这条启动链路里,Migrator::up(...) 的意义不只是省一条命令,而是把“数据库必须先准备好”这件事写成了程序行为。
它带来的几个直接结果是:
- 服务启动和数据库结构同步绑定
- 20 个 migration 的演进历史有明确落点
- 默认配置 seed 不再散落在脚本和常量里
- 单二进制部署更完整
- 共享表多租户方案下,迁移成本与商户数量解耦
如果你现在的项目还在靠“发版时顺手跑一下 migration”,那我建议你认真想一遍:
这一步到底是部署备注,还是系统启动契约。
在我的项目里,它显然属于后者。
如果你也在用 Rust + SeaORM 做业务系统,欢迎聊聊你是把 migration 放在 CI、入口进程,还是单独 worker 里。不同阶段的答案不一样,但把这个问题想清楚,能少踩很多运维层面的坑。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)