7AA7 Blog

事件驱动架构:事件溯源与 CQRS 的适用场景

Byte 吴 UID:126
2026-09-26 23:29
70 2 1

服务之间用事件通信,看起来只是把同步调用换成异步消息。真正的差别在于:事件里带回多少信息、谁来使用它、以及系统的真实状态到底存在哪里。把这三件事想清楚,才知道该选事件通知、事件携带状态转移还是事件溯源,以及是否需要 CQRS。

三种事件模式的差异

事件通知是最小的一类。事件只描述”发生了什么”,几乎不带数据,订阅方要主动回查。

{ "eventType": "OrderPaid", "orderId": "20261021-8891", "occurredAt": "2026-10-21T10:12:31Z" }

它实现简单、耦合低,代价是订阅方要多一次查询,源服务会承受额外的读压力。变更频繁、结构复杂的实体适合这种方式。

事件携带状态转移把订阅方关心的字段一起放进事件。订阅方不需要回查,可以在本地落一份只读副本。

{
  "eventType": "OrderPaid",
  "orderId": "20261021-8891",
  "amountInCents": 129900,
  "buyerId": "U-33412",
  "status": "PAID",
  "occurredAt": "2026-10-21T10:12:31Z"
}

省掉了查询,但事件从”通知”变成了”契约”。字段改动影响所有订阅方,schema 演进从此变成必须管理的事情。

事件溯源不再把当前状态当作主数据,而是把不可变的事件序列当作唯一事实来源,当前状态由事件重放得出。它与前两者的差别是方向性的:前两者里事件是副产品,这里事件是本体。

事件溯源的核心与代价

事件溯源的账本是 append-only 的事件流:

OrderCreated   (amount=129900, items=[A×1])
OrderLineAdded (item=B×2, delta=+40000)
OrderPaid      (paymentMethod=..., delta=0)
OrderShipped   (trackingNo=..., delta=0)

把前三条重放就得到订单的当前状态。这样做的好处很具体:完整审计天然具备,可以回答”10 月 8 日下午三点这张单的状态是什么”;还能做时间旅行式的调试与回放式压测;历史状态不必依赖冗余表去推测,变更轨迹天然完整。

代价同样具体,而且会持续产生:

查询困难。不能直接 SELECT * FROM order WHERE buyer_id=?,因为”买家”可能只存在于 OrderCreated 事件的载荷里。解决办法是维护读侧投影——关系表、检索索引、列存都只是某个视角的物化视图,由事件异步驱动。代价是查询延迟从”立即”变成”最终”。

版本演进。老事件一旦写入就无法改写,因此改业务语义只有三种做法:向上转换(读取时把旧版本补上默认值转成新版)、新旧并存(保留旧 handler,同时上线新版逻辑)、或者一次性迁移到新的事件流。向上转换通常是推荐项,因为它完全不碰历史数据:

@Override
public DomainEvent upgrade(EventEnvelope raw) {
    if (raw.getSchemaVersion() == 1) {
        OrderPaidV1 old = deserialize(raw, OrderPaidV1.class);
        // v2 增加了 channel 字段,老事件按当时的默认渠道补齐
        return new OrderPaidV2(old.orderId(), old.amountInCents(), Channel.WEB);
    }
    return deserialize(raw, currentVersion(raw));
}

无法真正删除。合规场景要求删除个人数据时,不可变日志没有删除操作。常见做法是加密粉碎(crypto-shredding):把个人字段加密后入事件,删除时只销毁对应的密钥。它必须在设计之初就引入,事后补救要重写全部历史数据。

重放开销。事件积累到千万级后,从零重放一个聚合可能要几秒。为此引入快照:每积累 N 个事件就写一次聚合快照,之后重放只需要取快照之后的事件。快照必须可以安全丢弃,它只是缓存,不能成为新的真实来源。

CQRS 与投影

CQRS 把写模型和读模型分开:写侧维护不变式并产出事件,读侧订阅事件构建适合查询的结构。事件溯源几乎总伴随 CQRS,因为没有它就无法查询;但 CQRS 本身并不要求事件溯源——读写分离也可以用普通的双写或 CDC 实现。

投影的职责是把流翻译成表。实现上有两个必须处理的细节:

一是顺序。同一个聚合的事件必须按序处理,跨聚合则不需要乱序保护。常见做法是按聚合 ID 分区,保证分区内顺序,跨分区并行。

二是重复与幂等。投递语义通常是至少一次,同一个事件可能处理两次。投影处理器需要用已处理事件序号做去重,写入侧用 upsert 而不是 insert。

-- 幂等写入:以事件版本为条件,重复回放不会覆盖新数据
INSERT INTO order_read_model (order_id, status, amount_cents, last_event_version)
VALUES (?, ?, ?, ?)
ON CONFLICT (order_id) DO UPDATE
SET status = EXCLUDED.status,
    amount_cents = EXCLUDED.amount_cents,
    last_event_version = EXCLUDED.last_event_version
WHERE order_read_model.last_event_version < EXCLUDED.last_event_version;

这套写法让”重放全部历史重建读库”变成一个安全操作,而不是需要小心翼翼的手工流程。

最终一致性的实际影响

读侧落后于写侧通常是几十到几百毫秒,重建投影时可能落后数分钟。业务上必须接受的三件事:

读己之所写。用户提交后立即刷新,可能看到旧数据。常见处理是在写操作返回后短时间内把请求定向到主库,或者前端先本地渲染已提交结果。

业务校验不能只读投影。扣库存前查到的可用量可能是过期的。需要在写模型侧重新校验,或者用预留的方式把并发冲突推迟到最终确认环节处理。

外部系统看到的中间态。发出去的 OrderPaid 之后又发生退款,订阅方会先看到已支付再看到已退款。订阅方的状态机必须能接受这种来回,不能假设单调。

把这些影响摊到前端,UI 上的常见做法是乐观更新加一句”处理中”的提示,而不是让用户对着没变化的数据反复点。

事件 schema 演进规则

事件是长期契约,schema 演化必须提前约定规则:

变更类型 是否兼容 处理方式
增加可选字段(有默认值) 兼容 消费者忽略未知字段即可
增加必填字段 不兼容 先发默认值的版本,灰度后再要求
删除字段 不兼容 先停止写入,观察无消费者后再清 schema
改字段语义不变名 最危险 改用新字段名,旧字段保留一段时间
改枚举值域 视情况 新增值算兼容,消费者需处理未知值

配套的消费端契约同样重要:消费端对未知枚举值不应崩溃,而应落入”未知”分支并报警;反序列化时对未知字段应宽容。这条规则不写进代码规范,schema 演进迟早会在某次上线时集中爆发成一个故障。

什么团队不该上

事件溯源的调试体验明显变差:无法直接看一行记录知道现状,得翻阅事件序列或者重建投影。排障时需要额外的工具(事件浏览器、投影滞后监控、重放开关),这些工具本身也是要维护的代码。

运维复杂度也上升一个量级:消息积压、投影滞后告警、重复投递、分区扩容、schema 版本管理,每一项都需要有人长期负责。

明确的劝退条件:团队规模小,无力承担配套工具的建设;查询以即席的统计分析为主,直接把变更数据同步进数仓更省事;读写延迟要求压到毫秒级且不允许中间态;业务本身 CRUD 为主,没有复杂的不变式流转。

折中做法是只在核心领域的一两个聚合上做事件溯源,其余照旧。它满足了审计诉求,也把爆炸半径收住了。一致性、复杂度和团队规模之间只能做交换,选择更重的方案时要先想清楚,这笔额外复杂度每天的利息由谁来付。

1.5kViews
70Likes
2Comments
2Participants
10 minread time

Comments (2)

  • 硅屿 · 2026-09-27 01:53

    这个我信,因为我也这么干过,然后被骂了。

    Reply
  • 顾清池 · 2026-09-27 10:46

    有点疑惑,这个指标到底怎么算才合理?

    Reply