《Java 消息中间件·事件驱动架构》1.2 事件驱动 vs 请求驱动

事件驱动不是默认答案。先用四个信号判断什么时候该用事件,再用一张决策树把请求驱动、命令、查询、事件分开;接着用 libraryd 的两个反例说明用错位置的代价:借书前异步查库存导致超卖,借完立刻查列表却读不到。文末附串行调用与扇出的对照实验,并说明延迟收益为何不是主要理由。

默认答案不是事件

一个团队刚把 Kafka 接进系统时,最常见的动作是「把所有调用都改成发消息」。半年后他们通常会得到三个结果:链路变长了,问题难查了,一致性要靠人肉对账了。这不是 Kafka 的问题,是把「异步」当成了「先进」。

事件驱动是一种逐用例的决策,不是一种全局架构风格。同一个系统里,借书要走命令加事件,查借阅列表要走同步查询,算逾期罚金可能只是一个本地方法。这一节给出判断顺序:先看四个信号,再走一棵决策树,最后用两个反例说明走错的代价。

本节有一个可运行的对照实验(串行调用 vs 扇出),但它的作用是把「扇出的收益来自哪里」量出来,不是证明事件一定更快。实验代码只用 JDK 25 标准库。

请求驱动假设了什么

同步调用能成立,靠的是四个隐含假设:

假设含义不成立时
调用方知道被调方目标在编码时就确定新增消费者要改生产者
调用方现在就要答案同一个请求内需要结果只能阻塞等待或改为异步
失败可以同步传播抛异常、返回错误码需要重试、死信、补偿
动作只有一个所有者谁负责执行是明确的可能重复执行

这四个假设在大量场景里是成立的:读者登录、查询借阅列表、计算罚金、校验参数。成立的时候,请求驱动更简单、更好测、更好排查,没有任何理由为了「架构先进」把它换掉。

事件驱动的价值,恰恰来自这四个假设不成立的地方。

四个该用事件的信号

信号一:扇出,而且消费者数量在写代码时未知。 借阅创建之后,库存要减、通知要发、积分要加、报表要记。今天四个,明年可能八个。如果生产者要逐个调用,每加一个消费者就得改一次生产者、发一次版。用事件,生产者只知道「借阅创建了」,谁关心谁来订阅。

信号二:时间解耦。 通知服务升级的 10 分钟里,借书不应该失败。事件把「发生」和「处理」在时间上分开:生产者写完日志就返回,消费者什么时候起来什么时候读。代价是消费者可能滞后,业务要能接受这个窗口。

信号三:审计与重放。 事件日志本身就是「发生了什么」的记录。出了问题时可以重置位移重放,补一个新投影,或者回填历史报表。请求驱动的调用日志通常只记录了「调用成功/失败」,没有记录业务事实。

信号四:异步长流程。 借书本身是毫秒级的,但「逾期提醒 → 冻结借阅权限 → 人工申诉 → 解冻」可能跨几天、跨多个系统、需要人工介入。这种流程用同步调用串不起来,必须用事件加编排(Saga,第 4 章展开)。

四个信号不需要全部满足。扇出加时间解耦就足以让事件成立;反过来,只有一个消费者、又要求同步结果时,硬上事件只会把简单问题复杂化。

四个信号的强度并不一样,判断时可以先看最强的那个:

信号强度单独出现时是否足够主要代价
消费者未知或会增长强足够,这是事件最本质的理由契约要稳定
审计与重放强足够,日志本身就是需求存储与保留策略
时间解耦中通常足够,但要确认业务能接受滞后积压与最终一致
异步长流程中足够,但需要编排与补偿状态机复杂度

「解耦」是最容易被滥用的一个词。它不是一个独立信号,而是前面几个信号的结果:因为消费者未知,所以生产者与消费者解耦;因为时间上分开,所以可用性解耦。把「解耦」本身当理由,往往会得到一个既有 broker 又有同步等待的混合体。

决策树

把上面的判断固化成顺序问题,可以避免「凭感觉选架构」:

一个业务动作需要被处理
├─ 1. 它只是读取,不改变状态?
│  └─ 是 → 查询(同步返回,不要包成事件)
├─ 2. 调用方必须在本次请求内知道成功或失败?
│  ├─ 是
│  │  ├─ 只有一个明确的下游 → 请求驱动(同步调用 / 命令)
│  │  └─ 多个下游且必须一起成功 → 分布式事务或编排(第 4 章)
│  └─ 否,可以「发完就走」 → 继续
├─ 3. 需要审计、重放或回填历史?
│  └─ 是 → 事件
├─ 4. 流程跨小时、跨系统或需要人工介入?
│  └─ 是 → 事件 + 编排(Saga)
├─ 5. 消费者数量未知,或未来会增加?
│  ├─ 是 → 事件
│  └─ 否 → 继续
└─ 6. 只有一个已知消费者,且不会增加
   └─ 命令(点对点),不是事件

树里有两个容易走错的岔口。

第一,第 2 问回答「是」时,不要用事件伪装同步调用。常见做法是发一条消息,然后在一个临时主题上等回复,再加超时和重试。这实际上是把 RPC 拆成了两段,还丢了错误栈、调用链和背压,复杂度却翻倍。需要同步结果就用同步调用;需要异步但又要结果,用 CompletableFuture 或明确的请求-响应协议,不要用业务事件拼。

第二,第 5 问回答「否」时,不要为了「解耦」上事件。只有一个消费者、未来也不会增加,事件带来的只是多一个 broker、多一段延迟、多一套幂等和死信。直接调用更诚实。

除了这两个岔口,实际评审里还有三种高频误判:

误判表面理由实际问题纠正方式
「以后可能会有多个消费者」预留扩展性用一个假想的未来换今天的复杂度等第二个消费者真实出现再拆
「异步就是高可用」消费者挂了不影响生产者积压、重复、乱序都转移给了下游把「消费失败怎么办」写进设计
「事件比调用松耦合」生产者不认识消费者事件契约仍然是耦合,只是从编译期挪到运行期用事件目录和兼容性测试管理契约

这三种误判的共同点是:把「耦合消失了」当成结论。实际上耦合没有消失,只是从「代码依赖」变成了「数据契约依赖」。后者更隐蔽,因为编译器不会报错,只有消费者读不懂字段时才会暴露。

反例一:借书前异步查库存

这是本书构造的 libraryd 场景,不是任何公司的线上事故。

库存表里 B-1 只剩 1 本。两个读者几乎同时点击借阅。借阅服务把「检查库存」做成了异步消息:发一条 CheckStock,库存服务查完再回一条 StockAvailable。

时刻借阅服务库存服务库存值
t0M-1 发 CheckStock(B-1)—1
t1M-2 发 CheckStock(B-1)—1
t2—处理 M-1,读到 1,回「有货」1
t3—处理 M-2,读到 1,回「有货」1
t4M-1 创建借阅扣减,写 00
t5M-2 创建借阅扣减,写 -1-1

两次检查都发生在任何一次扣减之前,所以两次都通过。这不是消息丢了,也不是消费者重复消费,而是跨异步边界的 read-then-write 没有原子性。把 CheckStock 改成事件、改成命令、加重试,都解决不了:只要「读」和「写」之间隔着一次网络往返和一次排队,窗口就存在。

正确的做法有三种,代价不同:

方案做法代价
同步条件更新在创建借阅的同一事务里执行 UPDATE inventory SET available = available - 1 WHERE book_id = ? AND available > 0,受影响行数为 0 就拒绝借阅服务与库存库耦合,但语义最强
单写者预占每本书一个分区/聚合,预占请求串行化,先写 reserved 再确认需要处理预占超时与补偿,UI 要有「处理中」状态
乐观并发带版本号更新,冲突则重试或拒绝高冲突下重试放大,仍要处理最终失败

关键不在于选哪个,而在于承认:需要「检查后立即决定」的动作,不能把检查放到异步链路里。 事件适合传播「已经发生的事」,不适合承担「还没决定的判断」。

反例二:借完立刻查列表读不到

同一个 libraryd 场景。读者借完书,页面跳到「我的借阅」。这个页面读的是由 library.loan.created 投影出来的读模型。投影消费者正常,但有三五百毫秒的延迟。

读者看到空列表,以为没借上,又点了一次借阅。第二次请求创建了第二笔借阅——如果业务没有「同一本书不能重复借」的约束,这就成了一笔重复借阅。

这个问题的名字叫读己之写(read-your-writes)。它不是投影写错了,而是读路径没有声明自己需要什么一致性:

读路径一致性要求可行做法代价
读者刚操作完,看自己的结果读己之写直接读写模型,或按 loanId 查写库读路径分叉,代码变多
列表页,允许几百毫秒延迟最终一致读投影,UI 显示「同步中」需要产品接受
运营报表分钟级最终一致读投影或离线表延迟可接受
借阅前的库存判断强一致同步条件更新(见反例一)耦合

「用了 CQRS 就必须接受最终一致」是一句半真话。CQRS 允许你按读路径选择一致性:刚写完的那一条可以走写模型,列表可以走投影。把所有读都强行塞进投影,然后用「最终一致」解释用户的困惑,是把设计选择推给了用户。

3.1 到 3.3 节会给出投影、重建和读路径分流的完整实现。这一节只需要记住判断:读路径的一致性要求,要在接口设计时写清楚,而不是等用户投诉再补。

对照实验:串行调用 vs 扇出

用一段最小程序把两种拓扑的差别量出来(片段,省略 import 与类声明)。请求驱动版本依次调用三个下游,每个耗时 50 毫秒;事件版本发布一次,三个独立消费者并行处理同样的 50 毫秒:

static void work(String who) {
    try {
        Thread.sleep(50);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}

public static void main(String[] args) throws Exception {
    long t0 = System.nanoTime();
    work("inventory");
    work("notice");
    work("audit");
    long chainMs = (System.nanoTime() - t0) / 1_000_000;

    ExecutorService pool = Executors.newFixedThreadPool(3);
    long t1 = System.nanoTime();
    List<Future<?>> futures = List.of(
            pool.submit(() -> work("inventory")),
            pool.submit(() -> work("notice")),
            pool.submit(() -> work("audit")));
    for (Future<?> f : futures) {
        f.get();
    }
    long fanoutMs = (System.nanoTime() - t1) / 1_000_000;
    pool.shutdown();

    System.out.println("chain  = " + chainMs + " ms (3 calls x 50ms, sequential)");
    System.out.println("fanout = " + fanoutMs + " ms (3 consumers x 50ms, parallel)");
    System.out.println("ratio  = " + String.format("%.2f", (double) chainMs / fanoutMs));
    System.out.println("producer edits when adding a 4th consumer: chain=1, fanout=0");
}

本机实测(Temurin 25.0.5+8 / macOS / 2026-10-29),连续跑三次:

run 1
chain  = 160 ms (3 calls x 50ms, sequential)
fanout = 67 ms (3 consumers x 50ms, parallel)
ratio  = 2.39

run 2
chain  = 176 ms (3 calls x 50ms, sequential)
fanout = 71 ms (3 consumers x 50ms, parallel)
ratio  = 2.48

run 3
chain  = 166 ms (3 calls x 50ms, sequential)
fanout = 63 ms (3 consumers x 50ms, parallel)
ratio  = 2.63

三次比值在 2.39 到 2.63 之间。单次运行,随调度波动,不作基准;这里能看出的只是量级:三个 50 毫秒的独立工作,串行落在 160–176 毫秒,并行落在 63–71 毫秒(50 毫秒工作加线程池调度开销)。

但延迟不是事件驱动的主要收益。程序最后一行 producer edits when adding a 4th consumer: chain=1, fanout=0 是一句固定的对照说明,不是测量值,它想表达的是改动位置的数量:

请求驱动加第四个下游,生产者要改一处代码并重新发布;事件驱动加第四个消费者,生产者一行不用改。前者是编译期耦合,后者是运行期发现。延迟可以靠并行优化,耦合不能。

还要说清楚这个实验的边界:线程池模拟的是三个独立消费者,不是 Kafka 内部的处理。真实链路里,每个消费者有自己的消费组、自己的位移、自己的失败处理;跨消费者的并行是天然的,但单个消费者内部仍然受分区数限制。卷一讲过分区与消费组,这里不重复。

事件不是免费的异步

把请求换成事件,账单会在五个地方出现:

成本项具体表现什么时候会痛
运维多一套 broker、监控、容量规划团队没有平台能力时
排查链路跨进程,错误不回溯到调用方出事时找不到「谁该负责」
一致性最终一致窗口、重复投递、乱序业务要求强一致读时
测试要起 broker 或写内存替身,端到端更慢单元测试覆盖率下降
契约事件 schema 一旦发布就要兼容多年领域模型还在剧烈变化时

所以决策树里才有那么多「否」的出口。事件驱动的成熟度不是「用了多少事件」,而是「知道哪些地方不该用」。

还有三种「事件不一定更快」的机制,值得在设计时提前算进去:

  • 生产端攒批:为了吞吐,生产者可能等 linger.ms 再发;同步调用没有这一段。
  • 消费者轮询:消费者按 fetch 周期拉取,空闲时也有毫秒级到秒级的发现延迟。
  • 再平衡与投影延迟:消费者扩容、实例重启时,分区要重新分配;投影读模型还要额外追赶。

这三段延迟在低流量时最明显。高流量下攒批和并行会把它们摊薄,但不会消失。用事件换的是吞吐与解耦,不是单条延迟。

混合形态才是常态

真实系统不会整体切换到事件驱动。libraryd 的借阅路径就是混合的:

步骤交互形态理由
读者点击「借阅」同步命令需要立刻知道成功还是被拒绝
校验借阅上限本地调用数据就在借阅服务里
写借阅记录与 outbox本地事务状态变化必须原子提交
发布 library.loan.created事件库存、通知、积分、报表都要知道
页面跳转后查「我的借阅」同步查询读己之写,不能等投影
逾期提醒事件 + 定时扫描跨天流程,不能同步等待

一张表里同时出现同步命令、事件、同步查询,这不是「不纯粹」,而是正常。判断标准始终是那个用例的一致性要求和消费者结构,不是「我们是不是事件驱动架构」。

把决策记录下来

架构决策最怕的是「当时为什么这么选」丢失。每个用例至少记五行:

用例:借阅创建后的库存扣减
选择:事件(library.loan.created → inventory consumer)
理由:消费者数量会增长;库存服务可短暂离线;需要重放回填
放弃的方案:同步调用(库存服务故障会阻塞借书)
接受的代价:库存最多滞后 2 秒;重复投递由 bookId+loanId 幂等消化

这五行会在半年后救你一次:当有人问「为什么这里不用同步调用」,答案在文档里,不在某个人的记忆里。

小结

  • 请求驱动在四个假设成立时更简单:知道被调方、现在要答案、失败可同步传播、动作只有一个所有者。
  • 该用事件的四个信号:消费者未知或会增长、需要时间解耦、需要审计与重放、流程跨小时跨系统。
  • 决策树先排除「读取」和「必须同步知道结果」,再判断审计、长流程、扇出;只有一个已知消费者时用命令,不要用事件。
  • 反例一说明跨异步边界的 read-then-write 会超卖,需要同步条件更新或单写者预占;反例二说明读己之写要按读路径选一致性。
  • 扇出的收益不只是并行延迟,更是「加消费者不改生产者」;代价是运维、排查、一致性、测试、契约五项成本。

阅读导航:上一节 1.1 事件、命令与查询 | 下一节 1.3 事件驱动成熟度模型

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收