返回文章列表

Rust 微服务用事件驱动,最怕的是事件语义不清

175·2 分钟阅读
Rust微服务

系统里到处都是 UserUpdatedOrderChangedPaymentDone,每个消费者理解都不一样。事件越多,系统越乱。

事件驱动不是“把同步调用改成发 MQ”。

真正的事件驱动要先回答:这个事件代表一个事实,还是一条命令?消费者能不能依赖它?它会不会重复?顺序怎么保证?字段改了怎么办?

事件不是消息格式,它是服务之间的事实契约。


事件要表达已经发生的事实

我不喜欢这种事件名:

ProcessOrder
DoPayment
UpdateUser

它们更像命令,不像事件。

事件应该表达已经发生的事实:

#[derive(Debug, Clone, PartialEq, Eq)]
enum DomainEvent {
    OrderCreated { order_id: u64 },
    PaymentSucceeded { order_id: u64 },
    OrderCancelled { order_id: u64 },
}

OrderCreated 的语义是:订单已经创建。

消费者看到这个事件,可以做自己的后续动作。但它不能假设生产者还会同步等它完成。

我的偏好是:事件用过去式,命令用祈使式,不要混在一起。

命名清楚以后,系统边界会清楚很多。


事件不要叫 Changed

UserChangedOrderUpdated 这种事件很方便,但语义太弱。

消费者不知道变了什么,也不知道该不该处理。

更好的事件是具体事实:

#[derive(Debug, Clone, PartialEq, Eq)]
enum UserEvent {
    UserRegistered { user_id: u64 },
    UserEmailChanged { user_id: u64 },
    UserDisabled { user_id: u64 },
}

这样消费者能明确订阅自己关心的事件。

发送一个大而全的 UserUpdated,会让所有消费者都开始解析 payload、猜测字段差异,系统耦合反而更重。

事件越具体,消费者越轻松。


事件至少要有这些元数据

业务 payload 之外,事件还需要元数据:

#[derive(Debug, Clone)]
struct EventEnvelope<E> {
    event_id: String,
    aggregate_id: String,
    event_type: &'static str,
    version: u32,
    payload: E,
}

这些字段分别解决问题:

  • event_id:消费者去重
  • aggregate_id:按业务对象定位
  • event_type:路由和反序列化
  • version:事件结构演进

不要只发一坨 JSON。短期省事,长期每个消费者都会痛苦。

事件一旦跨服务,就是 API。API 需要版本和契约。


至少一次投递要求消费者幂等

MQ 和 Outbox 常见语义是至少一次投递。

这意味着消费者必须接受重复事件:

#[derive(Debug, PartialEq, Eq)]
enum ConsumeDecision {
    Process,
    SkipDuplicate,
}
 
fn decide_consume(already_processed: bool) -> ConsumeDecision {
    if already_processed {
        ConsumeDecision::SkipDuplicate
    } else {
        ConsumeDecision::Process
    }
}

真实实现里,消费者会把 event_id 写入 processed_events 表。插入成功才执行业务,唯一冲突就跳过。

不要把“MQ 不会重复”当假设。只要网络会失败,确认消息就可能失败,重复就一定要处理。


顺序保证要按聚合根看

很多人会问:事件顺序怎么保证?

先问另一个问题:你需要保证什么范围的顺序?

全局顺序通常代价很高,也很少真的需要。大多数业务只需要同一个订单的事件有序。

#[derive(Debug)]
struct OrderedEvent<E> {
    aggregate_id: String,
    sequence: u64,
    payload: E,
}
 
fn is_next(expected: u64, event: &OrderedEvent<DomainEvent>) -> bool {
    event.sequence == expected
}

aggregate_id 可以作为分区 key,让同一个订单的事件进入同一个分区。

如果消费者发现 sequence 跳了,就应该暂停处理、等待缺失事件,或者进入修复流程。

先保证单个业务对象内有序,不要轻易追求全局有序。


事件版本只能向前兼容

事件一旦发出去,就会被多个消费者依赖。

字段不能随便删,含义不能悄悄改。

兼容演进的方向通常是:

  • 新增可选字段
  • 保留旧字段一段时间
  • 新事件类型替代旧事件类型
  • 消费者先兼容,生产者再切换

用版本表达结构:

#[derive(Debug)]
struct PaymentSucceededV1 {
    order_id: u64,
    payment_id: String,
}
 
#[derive(Debug)]
struct PaymentSucceededV2 {
    order_id: u64,
    payment_id: String,
    paid_cents: u64,
}

事件版本治理和 API 版本治理一样,不能靠口头约定。


结论

事件驱动的难点不在 MQ,也不在 Rust 序列化,而在事件语义是否清楚。

事件表达已经发生的事实;名字要具体,不要滥用 Changed;事件信封要有 id、类型、版本和聚合标识;消费者必须幂等;顺序优先按业务对象保证;事件结构演进要向前兼容。

事件写清楚,服务之间是在共享事实;事件写含糊,服务之间只是在传递困惑。