Rust 微服务用事件驱动,最怕的是事件语义不清
系统里到处都是
UserUpdated、OrderChanged、PaymentDone,每个消费者理解都不一样。事件越多,系统越乱。
事件驱动不是“把同步调用改成发 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
UserChanged、OrderUpdated 这种事件很方便,但语义太弱。
消费者不知道变了什么,也不知道该不该处理。
更好的事件是具体事实:
#[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、类型、版本和聚合标识;消费者必须幂等;顺序优先按业务对象保证;事件结构演进要向前兼容。
事件写清楚,服务之间是在共享事实;事件写含糊,服务之间只是在传递困惑。