默认答案不是事件
一个团队刚把 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。
| 时刻 | 借阅服务 | 库存服务 | 库存值 |
|---|---|---|---|
| t0 | M-1 发 CheckStock(B-1) | — | 1 |
| t1 | M-2 发 CheckStock(B-1) | — | 1 |
| t2 | — | 处理 M-1,读到 1,回「有货」 | 1 |
| t3 | — | 处理 M-2,读到 1,回「有货」 | 1 |
| t4 | M-1 创建借阅 | 扣减,写 0 | 0 |
| t5 | M-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 事件驱动成熟度模型
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。