事件驱动架构:解耦的收益与最终一致性

事件驱动(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」的消息顺序章节。

事件契约演进

事件一旦发出就撤不回来,契约必须向后兼容:

  1. 只加不改:新增字段必须可选,不改已有字段的含义与类型;
  2. 命名用过去时:order.created 而不是 createOrder,语义上就宣告了不可变;
  3. 消费者容忍未知字段:反序列化时忽略不认识的字段,别开严格模式;
  4. 版本共存:破坏性变更发新类型(order.created.v2)或新字段,双发一段时间再停旧的;
  5. schema 要有登记处:格式校验、兼容性检查、字段归属(谁可以加字段)写进流程,而不是靠口口相传;
  6. 不塞大对象与敏感数据:大文本、二进制、C 端隐私信息放引用或对象存储,事件体保持可审计。

失败模式

  • 把事件当查询用:消费者收到通知必然反查生产者,等于把异步改回同步,还多了一份压力;
  • 消费者不幂等:一次重复投递造成扣款两次,这是最贵的事故之一;
  • 没有死信队列(Dead Letter Queue):一条毒消息卡住整个分区,后面全部停摆;
  • 事件体里塞实体快照:把整个 Order 序列化进去,模型一变所有消费者跟着重建;
  • 没有重放能力:事件只保留 7 天,副本坏了却要重建三个月的数据;
  • 默认上事件溯源:为了「先进」引入事件溯源,结果每次业务调整都要写事件迁移。

小结:事件驱动的收益来自生产者不再认识消费者,代价是系统里出现了一段无人等待的时间窗,必须由状态机与产品一起消化。形态选择从事件通知起步,需要副本时才升级到携带状态,只有审计合规这类必须回答历史状态的场景才用事件溯源。工程上守住三件事:消费者幂等、按聚合分区保序、契约只加不改。

笔记加载中…