Rust 全栈一个 main.rs 搞定启动:migration + CQRS + 投影监听,部署只需一个二进制

一、缘起:启动一下要做 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::spawnpending::<()>().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(())
}

三个关键参数:

  1. 250ms 轮询:默认每 250ms 查一次事件表有无新事件
  2. PG NOTIFY 通知:有事件写入时数据库主动推送通知,不用等到下个轮询周期
  3. 指数退避重试:出错了从 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);

这里做对了两件事:

  1. SSR 渲染和 API 认证同进程:Leptos 的 SSR 路由和 Axum 的 API 中间件共用同一个 Router,不用开两个端口
  2. Database 注入 Contextprovide_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 个聚合完成一个复杂业务流程,敬请关注。

相关推荐
Penge6661 小时前
一文理清 Mac/Linux 终端配置文件(.bash_profile, .bashrc, .zshrc)
后端
Rust研习社2 小时前
Rust 性能陷阱:那些看起来很优雅但很慢的写法(上)
后端·rust·编程语言
万亿少女的梦1682 小时前
基于SpringBoot的在线考试管理系统设计与实现
java·spring boot·后端
DianSan_ERP2 小时前
京东订单接口集成中如何处理消费者敏感信息的安全与合规问题?
前端·数据库·后端·团队开发·运维开发
web守墓人2 小时前
【go语言】go语言实现go-torch, 完成Lenet-5的搭建,训练,以及pth和onnx模型导出
开发语言·后端·golang
平凡但不平庸的码农2 小时前
Go 语言常用标准库详解
开发语言·后端·golang
码云数智-园园3 小时前
Spring循环依赖:三级缓存到底解决了什么,没解决什么?
java·后端·spring
lilihuigz3 小时前
AI内容管理系统全面解析:核心功能、关键技术与架构应用指南 - WP站长
人工智能·搜索引擎·架构
Shadow(⊙o⊙)3 小时前
初识Qt+经典方式实现hello world!的交互
开发语言·c++·后端·qt·学习