一、缘起:启动一下要做 6 件事,但代码只有 78 行
大家好,我是 Pico-CRM 的作者。
之前三篇分别讲了事件溯源、多租户架构、DDD 分层,有读者在评论区问了一个很实在的问题:
"你这套东西启动的时候是怎么串起来的?不会要开 5 个进程吧?"
不会。整个 Pico-CRM------从数据库建表到事件存储初始化,从 CQRS 投影监听到 HTTP 服务绑定------都在一个 main.rs 的 78 行里完成。启动一个命令,一个二进制跑所有东西。
提前声明:本文分享的是个人项目的启动链路设计,不构成生产部署标准。你的项目可能有 Kubernetes、Docker Compose 或其他编排方式,思路可以借鉴,实现不用照搬。
二、启动链路全景图
整个 main.rs 干了 6 件事,按顺序来:
markdown
1. 加载 .env 配置
2. 连接数据库(SeaORM)
3. 跑 Migration(20 个迁移文件)
4. 启动 CQRS 基础设施(事件存储 + 投影监听)
5. 注册 Leptos SSR 路由 + 认证中间件
6. 绑定 TCP 端口,启动 HTTP 服务
翻译成代码就是:
rust
// server/src/main.rs
#[tokio::main]
async fn main() {
// 1. 加载配置
let env_file = format!(".env.{}", env::var("APP_ENV").unwrap_or_else(|_| "dev".to_string()));
dotenvy::from_filename(&env_file).unwrap();
// 2. 连接数据库
let db = Database::new().await;
// 3. 跑 Migration
Migrator::up(db.get_connection(), None).await.unwrap();
// 4. 启动 CQRS(事件存储 + 投影监听 + PG 选主)
bootstrap_cqrs(db.connection.clone()).await.unwrap();
// 5. 注册路由 + 中间件
let app = Router::new()
.leptos_routes_with_context(&leptos_options, routes, || provide_context(db.clone()), || shell(leptos_options.clone()))
.layer(from_fn_with_state(db_clone, global_api_auth_middleware))
.fallback(leptos_axum::file_and_error_handler(shell))
.with_state(leptos_options);
// 6. 启动 HTTP 服务
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app.into_make_service()).await.unwrap();
}
看起来挺简洁的对吧?但每一步背后都有设计取舍,下面逐个拆解。
三、Migration 层:20 个迁移文件一次性同步跑完
3.1 为什么不用异步 migration
大部分 ORM 的 migration 是命令行跑的:sea-orm-cli migrate up。这在开发环境没问题,但部署时多了一步手工操作------你部署完二进制,还得记得执行 migration,忘了就炸。
我的选择是直接在 main 里同步跑:
rust
Migrator::up(db.get_connection(), None)
.await
.unwrap_or_else(|err| panic!("执行数据库迁移失败: {}", err));
启动即建表。部署一个新实例,两条命令:
bash
$ cargo-leptos build --release # 构建单个二进制
$ ./target/server/pico-crm # 启动,migration 自动跑
不用额外的 init 脚本,不用 Kubernetes Job 单独跑 migration。一个二进制包学一切。
3.2 Migration 是怎么组织成列表的
SeaORM 的 MigratorTrait 需要你实现 migrations() 方法,返回一个 Vec<Box<dyn MigrationTrait>>:
rust
// migration/src/lib.rs
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
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),
// ... 20 个 migration,按顺序排列
Box::new(m20260501_000020_seed_system_config_defaults::Migration),
]
}
}
SeaORM 会在内部查 seaql_migrations 表,跳过已执行的文件,只跑新的。所以每次启动只会执行增量的那部分,不会重复执行。
3.3 单库共享表的 migration 优势
还记得第二篇讲的多租户从独立 Schema 退回共享表吗?migration 这块的优势在启动时体现得特别明显:
ini
独立 Schema 方案:N 个商户 × 20 个 migration = 启动跑 N×20 次
共享表方案:20 个 migration,所有商户共用,跑一次就完事
新商户开通不需要 CREATE SCHEMA + 重跑 migration,插一条 merchants 表记录就搞定。启动速度跟商户数量解耦。
四、CQRS 启动:事件存储 + 投影监听 + PG 选主
这是整个启动最核心的一步,也是最有「Rust 味儿」的一步。
4.1 bootstrap_cqrs 只干三件事
rust
// backend/src/infrastructure.rs
pub async fn bootstrap_cqrs(read_model_db: DatabaseConnection) -> Result<(), String> {
// 第一步:初始化事件存储(建 event 表 + domain_id 列 + 索引)
event_store::initialize().await?;
// 第二步:抢投影 Leader 锁
if !event_store::hold_projection_leader_lock().await? {
eprintln!("projection leader lock is already held, skipping");
return Ok(());
}
// 第三步:启动投影监听器
projections::spawn_all_listeners(read_model_db).await?;
Ok(())
}
每一步都是阻塞启动,后一步依赖前一步。事件存储没初始化好,投影监听器连事件表都找不到。这种启动时暴露问题的策略是刻意的------宁可启动失败立即告警,也不要静默运行然后数据不一致。
4.2 事件存储初始化:用 disintegrate_postgres 自动建 schema
event_store::initialize() 里面做了几件事:
rust
// backend/src/infrastructure/event_store/mod.rs
pub async fn initialize() -> Result<(), String> {
let pool = event_store_pool().await?;
EVENT_STORE_INIT
.get_or_try_init(|| async move {
// 1. 为每个事件类型注册 schema(建 event 表 + domain_id 列)
initialize_registered_event_schemas(pool.clone()).await?;
// 2. 初始化 Listener 基础设施(PG NOTIFY 通道)
initialize_listener_infra(pool.clone()).await?;
// 3. 回填历史数据(字段迁移兼容)
backfill_schedule_event_order_uuid(pool).await?;
Ok::<(), String>(())
})
.await?;
Ok(())
}
注意 OnceCell 的使用------事件存储只初始化一次。如果多实例部署(水平扩展),每个实例都会调 initialize(),但 OnceCell 保证每个进程内只做一次实际初始化。
事件类型注册是泛型驱动的。新增一个事件溯源聚合,只需在这里加一行:
rust
async fn initialize_registered_event_schemas(pool: sqlx::PgPool) -> Result<(), String> {
initialize_event_schema::<ServiceRequestEventEnvelope>(pool.clone(), "service request").await?;
initialize_event_schema::<OrderEventEnvelope>(pool.clone(), "order").await?;
initialize_event_schema::<ScheduleEventEnvelope>(pool.clone(), "schedule").await?;
Ok(())
}
每条 initialize_event_schema 调一次 PgEventStore::try_new,disintegrate 会在 PostgreSQL 里自动创建 event 表,并为事件枚举里标记了 #[id] 的字段建索引。你不需要手动写事件表的 migration。
4.3 PG Advisory Lock 选主:不用 Redis 的分布式锁
投影监听器有一个问题:如果你部署了 3 个实例,3 个进程同时消费事件、同时更新投影表------数据就全乱了。
传统的方案是引入 Redis 分布式锁,或者单独起一个 projection worker 进程。但 Pico-CRM 只用了一个部署实例(至少目前是),我不想为了一个锁加一个 Redis 依赖。
于是用了 PostgreSQL 的 Advisory Lock:
rust
pub async fn hold_projection_leader_lock() -> Result<bool, String> {
let pool = event_store_pool().await?;
let mut conn = pool.acquire().await?;
// 抢锁:pg_try_advisory_lock 是非阻塞的,抢不到直接返回 false
let acquired: bool = sqlx::query_scalar("SELECT pg_try_advisory_lock($1)")
.bind(PROJECTION_LEADER_LOCK_KEY)
.fetch_one(&mut *conn)
.await?;
if !acquired {
return Ok(false); // 没抢到,这个实例不当 Leader,不启动投影监听
}
// 抢到了,把连接 hold 住(锁跟连接生命周期绑定)
tokio::spawn(async move {
let _projection_lock_conn = conn;
pending::<()>().await; // 永远不释放
});
Ok(true)
}
核心逻辑:
pg_try_advisory_lock是非阻塞的,抢不到立刻返回 false,不阻塞启动- 锁的生命周期跟 PostgreSQL 连接绑定------连接断开,锁自动释放
- 用一个固定整数
0x5049_434f_4351_5253(PICO-CQRS 的 hex)作为锁 key - 抢到锁的 connection 被丢到一个
tokio::spawn里pending::<()>().await,永远不释放
这个方案的优点是零额外依赖------不需要 Redis、不需要 ZooKeeper、不需要 etcd。缺点是只适合单实例部署的场景,水平扩展需要额外的选主机制。
4.4 投影监听:三种聚合各自独立消费
抢到 Leader 锁后,spawn_all_listeners 启动三种聚合的投影监听:
rust
// backend/src/infrastructure/projections/crm/mod.rs
pub async fn spawn_crm_listeners(read_model_db: DatabaseConnection) -> Result<(), String> {
service_request_projection::spawn_service_request_listener(read_model_db.clone()).await?;
order_projection::spawn_order_listener(read_model_db.clone()).await?;
schedule_projection::spawn_schedule_listener(read_model_db).await?;
Ok(())
}
每个投影监听是一个 tokio::spawn 出来的异步任务,独立消费各自的事件流:
rust
// order_projection.rs
pub async fn spawn_order_listener(read_model_db: DatabaseConnection) -> Result<(), String> {
let listener_event_store = event_store().await?;
let projection = OrderProjection::new(read_model_db.clone()).await?;
tokio::spawn(async move {
PgEventListener::builder(listener_event_store)
.register_listener(
projection,
PgEventListenerConfig::poller(Duration::from_millis(250)) // 250ms 轮询
.with_notifier() // PG NOTIFY 推送
.with_retry(|err, attempts| { // 指数退避重试
projection_listener_retry("order", err, attempts)
}),
)
.start()
.await
});
Ok(())
}
三个关键参数:
- 250ms 轮询:默认每 250ms 查一次事件表有无新事件
- PG NOTIFY 通知:有事件写入时数据库主动推送通知,不用等到下个轮询周期
- 指数退避重试:出错了从 200ms 开始退避,最多重试 10 次,10 次后 Abort 不再重试
投影处理的 handle 方法里有一个重要的细节------事件幂等判断:
rust
// 每条事件处理前,检查 event_id
if model.event_id >= event_id {
return Ok(()); // 已处理过,跳过
}
// ... 处理逻辑
active.event_id = Set(event_id); // 处理完记录 event_id
如果同一个事件被重复投递,投影表里记录的 event_id >= 当前事件 id,直接跳过。保证至少一次投递 + 幂等处理 = 最终一致性。
五、路由注册:SSR 页面 + API 认证同在一个 Router
数据库和 CQRS 就绪后,注册 HTTP 路由并绑定端口:
rust
let app = Router::new()
.leptos_routes_with_context(
&leptos_options, routes,
move || { provide_context(db.clone()); }, // 注入 Database 到 Leptos Context
move || shell(leptos_options.clone()), // SSR Shell
)
.layer(from_fn_with_state(db_clone, global_api_auth_middleware)) // API 认证层
.fallback(leptos_axum::file_and_error_handler(shell))
.with_state(leptos_options);
这里做对了两件事:
- SSR 渲染和 API 认证同进程:Leptos 的 SSR 路由和 Axum 的 API 中间件共用同一个 Router,不用开两个端口
- Database 注入 Context :
provide_context(db.clone())把数据库连接注入 Leptos 的 Context 系统,前端 Handler 里expect_context::<Database>()就能拿到,不需要全局变量或 lazy_static
六、部署体验:一个二进制拷过去就能用
最终构建出来的是一个单独的可执行文件:
bash
$ cargo-leptos build --release
# 产物:
# target/server/pico-crm ← 后端二进制(包含 SSR + WASM + 静态文件)
# target/site/ ← 纯前端资源(可选)
部署到一个新 VPS 上:
bash
# 1. 拷二进制
scp target/server/pico-crm user@vps:/opt/pico-crm/
# 2. 写环境变量
cat > /opt/pico-crm/.env.prod <<EOF
DATABASE_URL=postgres://...
ES_DATABASE_URL=postgres://...
EOF
# 3. 启动
APP_ENV=prod /opt/pico-crm/pico-crm
就这样。没有 Docker Compose 编排多个服务,没有 Nginx 反代多个端口,没有 npm run dev 起前端开发服务器。一个二进制 + 一个数据库 = 整个 SaaS 跑起来。
七、总结
一个 main.rs 里跑完 migration + CQRS + 投影监听 + HTTP 服务,不是偷懒,是刻意设计的部署简化。
几个要点回顾:
- Migration 启动时同步执行------部署不需要额外脚本,SeaORM 内部跳过已执行的
- 事件存储 schema 由 disintegrate 自动管理 ------枚举里
#[id]字段直接映射到 domain_id 列 - PG Advisory Lock 做投影 Leader 选举------零额外依赖,连接断开自动释放
- 投影监听并发独立消费------三种聚合各跑各的 tokio::task,250ms 轮询 + PG NOTIFY 双通道
- 幂等处理保证最终一致性 ------
event_id检查避免重复投递造成数据覆盖
当然这个方案不是银弹。水平扩展到多实例时,PG Advisory Lock 只能保证一个 Leader------但如果你跟我一样还在 MVP 阶段、一个 VPS 够用,这套启动链路可能比你想象的更省心。
完整的代码在 GitHub 仓库 Pico-CRM,Rust 全栈(Axum + Leptos + SeaORM + disintegrate),欢迎 Star 和交流。
你的项目启动链路是什么样的?migration 是自动跑还是手动跑?投影监听有没有什么踩坑经历?欢迎在评论区聊聊。
上一篇拆解了 DDD 三层架构在 Rust 里的落地,下一篇计划写跨聚合编排------一个 Application Service 如何协调 4 个聚合完成一个复杂业务流程,敬请关注。