我用 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 的好处:
- 业务规则集中 :取消订单的所有前置条件都在
process里,不会散落在 Controller、Service、Repository 各处。 - 纯函数,好测试 :给一个
OrderState,断言返回的Event或Error,不需要 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 退回了共享表,以及背后的取舍逻辑。