我用 Rust 给订单系统上了事件溯源

我用 Rust 给订单系统上了事件溯源,审计日志都不用写了

一、缘起:被一个 status 字段坑惨了

事情是这样的。

之前写家政 CRM 的订单模块,就一个 orders 表,订单状态用 status 字段存。从"待确认"改成"已确认"------一行 UPDATE 的事,旧值直接覆盖。

然后问题就来了。

客户投诉说"我明明没确认过这个订单",运营说是系统 bug,客服说查不到记录。我一个 SELECT 下去------只看到当前值是 confirmed。谁改的?什么时候改的?之前是什么值?对不起,查不到。

这就是典型的状态机丢失历史的问题。在业务系统里,尤其是订单、工单、审批流这类场景,"当前状态"只是冰山一角,"怎么变成这样的"才是真正有价值的信息。

于是我把订单模块整个重构成了事件溯源(Event Sourcing)。

提前声明:本文分享的是个人实践,不构成架构建议。事件溯源不是银弹,本文会同时讲踩坑和收益。

二、概念:不存状态,存"发生了什么"

传统做法:你存的是结果

sql 复制代码
-- 传统做法:直接覆盖
UPDATE orders SET status = 'confirmed', updated_at = NOW() WHERE id = $1;

事件溯源的做法:你存的是事件序列

复制代码
OrderCreated → OrderConfirmed → OrderDispatched → OrderInService → OrderCompleted

一个订单从创建到完成,数据库里不是一条记录,而是五条事件。每条事件都带着操作人 UUID、时间戳和事件类型。想知道三周前谁把"服务中"点成了"已完成"?直接查事件流,精确到秒。

在我看来,事件溯源的核心可以总结成一句话:不存当前状态,存导致状态变化的事件序列。当前状态是事件流折叠(fold)出来的投影。

三、代码实战:用 Rust 的 disintegrate 实现

技术栈:Rust 全栈,后端 Axum,前端 Leptos,事件存储用 PostgreSQL + disintegrate crate。

3.1 定义事件

先从事件定义开始。每种订单操作对应一个事件变体:

rust 复制代码
// backend/src/domain/crm/order/es/events.rs

#[derive(Debug, Clone, PartialEq, Eq, Event, Serialize, Deserialize)]
#[stream(
    OrderEvent,
    [
        OrderCreated,
        OrderDetailsUpdated,
        OrderStatusChanged,
        OrderCancelled,
        OrderAssignmentUpdated,
        OrderSettlementUpdated
    ]
)]
pub enum OrderEventEnvelope {
    OrderCreated {
        #[id]
        merchant_id: String,     // 多租户隔离键
        #[id]
        order_uuid: String,      // 聚合根 ID
        operator_uuid: Option<String>,  // 谁操作的
        customer_uuid: Option<String>,
        status: String,
        amount_cents: i64,
        inserted_at: DateTime<Utc>,
        updated_at: DateTime<Utc>,
        // ... 其他字段
    },
    OrderStatusChanged {
        #[id] merchant_id: String,
        #[id] order_uuid: String,
        operator_uuid: Option<String>,
        status: String,
        completed_at: Option<DateTime<Utc>>,
        updated_at: DateTime<Utc>,
    },
    OrderCancelled {
        #[id] merchant_id: String,
        #[id] order_uuid: String,
        operator_uuid: Option<String>,
        cancellation_reason: String,
        updated_at: DateTime<Utc>,
    },
    // ... OrderDetailsUpdated, OrderAssignmentUpdated, OrderSettlementUpdated
}

注意 #[id] 标注的字段:merchant_id + order_uuid 一起组成事件流的唯一标识。多租户隔离天然内建在事件流标识里,这个后面第二篇文章会展开讲。

3.2 核心模式:Decision(决策)

这是整个设计里我最满意的一层。

请求进来不直接写事件存储,而是先过 Decision 校验。Decision 是一个纯函数------接收当前状态和预期操作,返回"该不该产生事件"以及"产生什么事件"。

举个例子,取消订单的 Decision:

rust 复制代码
// backend/src/domain/crm/order/es/decisions.rs

pub struct CancelOrderDecision {
    merchant_id: String,
    order_uuid: String,
    reason: String,
    updated_at: DateTime<Utc>,
    operator_uuid: Option<String>,
}

impl Decision for CancelOrderDecision {
    type Event = OrderEventEnvelope;
    type StateQuery = OrderState;
    type Error = String;

    fn state_query(&self) -> Self::StateQuery {
        // 查询订单当前投影状态
        OrderState::new(&self.merchant_id, &self.order_uuid)
    }

    fn process(&self, state: &Self::StateQuery) -> Result<Vec<Self::Event>, Self::Error> {
        // 从事件流重建出 Domain Model
        let mut order = state.to_domain()?;

        // 业务规则:已完成订单不可取消
        order.cancel(self.reason.clone())?;

        // 返回应该写入的事件
        Ok(vec![OrderEventEnvelope::OrderCancelled {
            merchant_id: self.merchant_id.clone(),
            order_uuid: self.order_uuid.clone(),
            operator_uuid: self.operator_uuid.clone(),
            cancellation_reason: order.cancellation_reason.unwrap_or_default(),
            updated_at: self.updated_at,
        }])
    }
}

Decision 的好处

  1. 业务规则集中 :取消订单的所有前置条件都在 process 里,不会散落在 Controller、Service、Repository 各处。
  2. 纯函数,好测试 :给一个 OrderState,断言返回的 EventError,不需要 mock 数据库。下面就是测试用例:
rust 复制代码
// 已完成订单拒绝取消
#[test]
fn it_rejects_assignment_updates_for_completed_orders() {
    TestHarness::given([
        // Given: 订单已创建
        seed_created_event("merchant-1", &sample_order(), None),
        // Given: 订单已完成
        OrderEventEnvelope::OrderStatusChanged {
            merchant_id: "merchant-1".to_string(),
            order_uuid: "order-1".to_string(),
            status: "completed".to_string(),
            completed_at: Some(ts(2)),
            updated_at: ts(2),
            ..
        },
    ])
    .when(UpdateOrderAssignmentDecision::new(
        "merchant-1", "order-1",
        assignment_update,
        ts(3), None,
    ))
    .then_err(
        "schedule assignment can only be updated in planned status (current: done)"
    );
}

不用连数据库,不用 mock,Given 事件流 → When 调 Decision → Then 断言结果,秒级反馈。

3.3 状态投影:折叠事件流

有了事件,怎么拿到"当前订单"给用户看?通过 State 投影:

rust 复制代码
// backend/src/domain/crm/order/es/state.rs

impl StateMutate for OrderState {
    fn mutate(&mut self, event: Self::Event) {
        match event {
            OrderEvent::OrderCreated { status, amount_cents, notes, inserted_at, .. } => {
                self.exists = true;
                self.status = Some(status);
                self.amount_cents = amount_cents;
                self.notes = notes;
                self.inserted_at = Some(inserted_at);
                // ... 映射所有字段
            }
            OrderEvent::OrderStatusChanged { status, completed_at, .. } => {
                self.status = Some(status);
                self.completed_at = completed_at;
            }
            OrderEvent::OrderCancelled { cancellation_reason, .. } => {
                self.status = Some("cancelled".to_string());
                self.cancellation_reason = Some(cancellation_reason);
                self.completed_at = None;
            }
            // ... 其他事件类型
        }
    }
}

折叠过程 :从 OrderCreated 开始,每条事件依次调 mutate,最终得到一个完整 OrderState。需要 Domain Model?调 state.to_domain() 直接拿到带所有业务方法的 Order 结构体。

3.4 Repository 层:串联 Decision 与 Event Store

rust 复制代码
// backend/src/infrastructure/repositories/crm/order_repository_impl.rs

impl OrderRepository for SeaOrmOrderRepository {
    fn cancel_order(
        &self, uuid: String, reason: String, operator_uuid: Option<String>,
    ) -> impl Future<Output = Result<Order, String>> + Send {
        let merchant_id = self.merchant_id.clone();
        async move {
            let event_store = event_store().await?;
            let decision_maker = disintegrate_postgres::decision_maker(event_store, NoSnapshot);

            // 调 Decision,内部会校验状态、检查业务规则
            decision_maker
                .make(CancelOrderDecision::new(
                    merchant_id.clone(), uuid.clone(),
                    reason, Utc::now(), operator_uuid,
                ))
                .await?;

            // 重新从事件流加载最新投影
            load_order_from_events(&merchant_id, &uuid).await?
                .ok_or_else(|| format!("order not found after cancellation"))
        }
    }
}

每条操作都是这个流程:加载状态 → Decision 校验 → 写入事件 → 重新加载投影返回

四、踩坑实录

4.1 时序问题:事件写入了但投影还没同步

这是最大的坑。

场景:用户在订单列表页创完订单,点进去详情------404。

原因是事件写入了 PostgreSQL 的事件表,但查询订单列表的投影表(projection/materialized view)还没同步完。前后就差几十毫秒,但足够让用户看到不一致的数据。

解决方案:乐观重试。查不到时等 200ms 再查,最多 3 次。不优雅,但确实管用。

后续计划:引入 Outbox 模式或改用 LISTEN/NOTIFY 机制触发 Projection 同步,彻底消灭这个时序问题。

4.2 调试变复杂

以前出问题直接 SELECT * FROM orders WHERE id = $1,一条记录一眼看完。

现在要查事件表 → 手动折叠状态 → 对比 Projection 表,多了至少两个步骤。排查一次数据不一致,至少要开三个 SQL 窗口。

于是给每个订单详情页加了变更日志 Tab------直接展示事件流的可读版本,谁在什么时候做了什么,省得每次都开数据库查。

五、意外收获

5.1 审计日志天然自带

这一点真的是意外之喜 。事件流本身就是完整的时间线,不需要额外记 changelog 表。

做纠纷回溯的时候,传统系统可能需要一个专门的审计模块,每个关键操作前后手动写 change_log。事件溯源里,事件流就是审计日志。区别只是展示的时候翻译一下字段名。

rust 复制代码
// 订单详情可以直接查变更记录
pub async fn fetch_order_change_logs(
    &self, uuid: String,
) -> Result<Vec<OrderChangeLogDto>, String> {
    // 查询事件流 → 映射为人可读的变更记录
    self.query.list_order_change_logs(uuid).await
}

5.2 业务规则可测试性飞跃

以前写单元测试:mock(OrderRepository).expect("find_order").returning(...),写三行 mock 只为了验证一行业务逻辑。

有了 Decision 模式后,测试变成 Given 事件序列 → When Decision → Then 断言,逻辑清晰,反馈快速。项目里订单模块的测试覆盖了状态流转规则、拒绝路径、结算校验等核心路径,跑完全部测试不到 1 秒。

六、总结

事件溯源不适合所有场景。如果你的系统只是简单的增删改查、没有复杂状态流转、不需要审计追溯,那传统的 CRUD 完全够用------别为了架构而架构。

但对订单、工单、审批流这类"状态变更即业务价值"的场景,事件溯源可能比一个 status 字段更值得一试。

几个核心心得以一句话总结:

  • Decision 模式让业务规则集中、可测试
  • 事件流天然等于审计日志,不需要单独维护 changelog
  • 时序问题是真实存在的坑,乐观重试是最小成本的止血方案
  • 调试复杂度增加是真实代价,变更日志展示可以部分弥补

完整的代码见 GitHub 仓库(Pico-CRM),Rust 全栈,还在持续迭代中。如果你也在用事件溯源或者踩过类似的坑,欢迎在评论区聊聊你的方案。


下一篇预告:同样在这个 CRM 项目里,我做的多租户架构选择------为什么从独立 Schema 退回了共享表,以及背后的取舍逻辑。

相关推荐
木雷坞1 小时前
NAS Docker 服务恢复排查:卷权限、端口和反代
后端
牛奶2 小时前
1秒下单10万次,服务器是怎么扛住的?
大数据·服务器·后端
小强19882 小时前
为什么小程序中不能使用 window、document 或 jQuery?
后端
楼田莉子2 小时前
仿Muduo的高并发服务器:LoopThread模块及其ThreadPool模块
linux·服务器·c++·后端·学习
二月龙2 小时前
微信小程序页面栈限制解析与突破方案
后端
Rust研习社2 小时前
你为什么总是入门 Rust 失败
开发语言·后端·rust
SamDeepThinking2 小时前
批评下属不如当场展示解决方案
后端·程序员·团队管理
AskHarries2 小时前
GPT-Image-2(img2)到底能做什么?
后端
Leinwin3 小时前
GPT-5.5 Instant API接入教程:免费额度、速率限制与最佳实践
后端·python·flask