事件驱动架构:解耦的收益与最终一致性
事件驱动(Event-Driven Architecture)最诱人的地方是「加一个消费者不用改生产者」,最容易被低估的地方是「用户会看到自己刚做的操作没有生效」。收益和代价来自同一处:生产者不再知道消费者,于是系统里多了一段没人等待的时间窗。
三种基本形态
| 形态 | 事件里装什么 | 消费者做什么 | 典型用途 | 主要风险 |
|---|---|---|---|---|
| 事件通知(Event Notification) | 只有 ID 与少量标识 | 回查生产者接口拿详情 | 缓存失效、触发重新计算 | 消费端反查造成耦合与压力回流 |
| 事件携带状态转移(Event-Carried State Transfer) | 变化后的完整状态或该聚合的必要字段 | 直接更新本地副本 | 跨服务读模型、搜索索引 | 事件体变大、字段语义必须稳定 |
| 事件溯源(Event Sourcing) | 状态变化本身即事实,事件是唯一事实来源 | 重放事件重建状态 | 审计、账务、合规 | 学习与运维成本高,改事件即改历史 |
选择顺序建议:能用通知就别用携带状态,能用携带状态就别用溯源。事件溯源适合「必须回答某个时点的状态是什么」的场景,不适合当默认架构。
收益:解耦到底解了什么
- 空间解耦:生产者不知道消费者,消费者数量可以自由增长;
- 时间解耦:生产者不必等消费者处理完,峰值由队列吸收;
- 可重放:修好 bug 后重放事件即可重建副本(前提是消费者幂等、事件有保留期);
- 故障隔离:消费者宕机只造成堆积,不会让生产者失败。
收益的前提是事件是事实,不是需求。事件描述「已经发生了什么」(order.paid),命令描述「请做什么」(payOrder)。把命令包装成事件,只是把同步 RPC 换成异步 RPC,耦合度没降,还多了一层不好观测的队列。
最终一致性的用户可见后果
这是事件驱动最需要产品一起决策的部分:
| 场景 | 用户看到的现象 | 处理方式 |
|---|---|---|
| 下单成功后立刻进订单列表 | 列表里没有这一单 | 前端乐观展示,本地先落一笔「处理中」 |
| 修改昵称后刷新资料页 | 昵称还是旧的(读模型未刷新) | 读己之写(Read-Your-Writes):写后一段时间走权威库,或前端本地覆盖 |
| 支付成功后余额未变 | 用户重复点击支付 | 状态机挡住重复支付,页面显示「到账处理中」 |
| 消费端积压 | 数据越来越旧 | 监控消费延迟(Lag),超阈值告警并降级提示 |
读己之写要专门处理:读写路径分开后,用户提交完立刻读到旧值,是最容易被投诉的 bug。常见做法是写入成功后把版本号带回前端,读取时要求「至少这个版本」;或对该用户短时间内的读请求打回权威库。读写模型分离的完整形态见第 13 章。
幂等消费与顺序
投递语义是至少一次(At-Least-Once),重复是必然,不是异常。
-- 消费去重表:与业务更新放在同一个本地事务里,保证「只处理一次」
CREATE TABLE consumed_event (
consumer VARCHAR(64) NOT NULL, -- 消费者名,同一事件可能被多个消费者处理
event_id VARCHAR(64) NOT NULL, -- 事件自身 ID
handled_at DATETIME NOT NULL,
PRIMARY KEY (consumer, event_id)
);
顺序只能在单个聚合内保证:把 aggregate_id(订单 ID、会话 ID、用户 ID)作为分区键(Partition Key),同一聚合的事件进同一分区即有序,跨聚合不保证也不需要保证。分区内仍可能因重投或并行消费乱序,消费端要自己挡:
// 用版本号挡住过期事件:事件携带 aggregate_version,只接受更大的版本
func (h *Handler) Apply(evt OrderEvent) error {
cur, err := h.repo.LoadVersion(evt.OrderID)
if err != nil {
return err
}
if evt.AggregateVersion <= cur {
return nil // 重复或乱序的旧事件,丢弃并打点计数
}
if !h.sm.Allows(evt.Type, cur, evt.AggregateVersion) {
return ErrIllegalTransition // 状态机不允许的跳变进人工队列,不静默丢弃
}
return h.repo.ApplyAndAdvance(evt)
}
消息顺序性与幂等消费的更多工程做法见「system-design」教程,会话级有序的取舍可参考「im-system」的消息顺序章节。
事件契约演进
事件一旦发出就撤不回来,契约必须向后兼容:
- 只加不改:新增字段必须可选,不改已有字段的含义与类型;
- 命名用过去时:
order.created而不是createOrder,语义上就宣告了不可变; - 消费者容忍未知字段:反序列化时忽略不认识的字段,别开严格模式;
- 版本共存:破坏性变更发新类型(
order.created.v2)或新字段,双发一段时间再停旧的; - schema 要有登记处:格式校验、兼容性检查、字段归属(谁可以加字段)写进流程,而不是靠口口相传;
- 不塞大对象与敏感数据:大文本、二进制、C 端隐私信息放引用或对象存储,事件体保持可审计。
失败模式
- 把事件当查询用:消费者收到通知必然反查生产者,等于把异步改回同步,还多了一份压力;
- 消费者不幂等:一次重复投递造成扣款两次,这是最贵的事故之一;
- 没有死信队列(Dead Letter Queue):一条毒消息卡住整个分区,后面全部停摆;
- 事件体里塞实体快照:把整个
Order序列化进去,模型一变所有消费者跟着重建; - 没有重放能力:事件只保留 7 天,副本坏了却要重建三个月的数据;
- 默认上事件溯源:为了「先进」引入事件溯源,结果每次业务调整都要写事件迁移。
小结:事件驱动的收益来自生产者不再认识消费者,代价是系统里出现了一段无人等待的时间窗,必须由状态机与产品一起消化。形态选择从事件通知起步,需要副本时才升级到携带状态,只有审计合规这类必须回答历史状态的场景才用事件溯源。工程上守住三件事:消费者幂等、按聚合分区保序、契约只加不改。