一个很常见的起点
大多数团队接触事件驱动,不是因为读了一本架构书,而是因为遇上了一件具体的事:一个下单请求要调五个服务,每个服务都要重试、都要熔断、都要设超时,链路一长,整体可用性就乘起来了。五个 99.9% 的服务串起来,理论可用性不到 99.5%。
这时候有人说:不如把它们解开,下单成功后发一条消息,谁关心谁去消费。
这个方向是对的。但接下来的实现经常走偏——把「调用」从同步换成异步,却把调用的语义原封不动搬了过去:要等回复、要拿到错误码、要保证「处理完才返回」。于是消息队列被当成了一条更可靠、更慢、更难调试的 HTTP。
这一卷要讲的,就是这条岔路怎么走回去。
误用一:用消息队列做同步调用
最典型的样子是这样:
// 生产者侧:发请求,等回复
producer.send(new ProducerRecord<>("order.commands",
orderId, commandJson)).get();
ConsumerRecord<String, String> reply =
waitForReply(correlationId, Duration.ofSeconds(5));
消费者侧消费 order.commands,处理完再往 order.replies 发一条带 correlationId 的回复,生产者再订阅 order.replies 把它和自己等待的请求对上。
这套模式在理论上能跑通,JMS 时代就有标准做法(ReplyTo + JMSCorrelationID)。它的问题不在「能不能实现」,而在它把同步调用里由框架承担的四件事,全部交给了业务自己:
| 同步调用里框架做的事 | 搬到消息上之后谁做 |
|---|---|
| 超时 | 业务自己计时,还要处理「超时之后回复才到」 |
| 错误传播 | 业务自己定义错误消息格式,还要区分「处理失败」和「没收到」 |
| 背压 | 生产者无感,消费者积压到崩 |
| 调用链追踪 | 自己往 header 里塞 traceId,每一跳手动传 |
第一件事就足够致命。同步调用的超时是「连接断了,我知道它没成功」;消息的超时是「我没等到回复,但它可能成功、可能失败、可能还在队列里排着」。超时在同步语义里是一个确定的结果,在异步语义里是一个不确定的状态。
于是业务不得不加补偿、加对账、加幂等——这些本来是为了解决「消息可能重复」的机制,现在被用来解决「我不知道对面到底做没做」。系统复杂度上去了,可靠性却没有同步调用那么直观。
更麻烦的是容量。同步调用的并发上限由线程池和连接池决定,超了就排队或者拒绝,压力看得见;异步调用没有这个上限,生产者可以一直发,压力全部堆在消费者的 lag 上。而 lag 是个滞后指标——等你看见它涨起来,已经积压了几十万条。
为什么这条路总有人走
明知有这些问题,为什么 Request-Reply 还是反复出现?通常是三个原因叠在一起:
- 团队的心智模型是 RPC。 大多数人先学会的是「调用一个方法,拿返回值」,事件驱动的思维方式需要重新建立。
- 框架提供了现成的封装。 一些客户端库把「发请求 + 等回复」做成了几行配置,用起来太顺手,以至于没人去问它背后的语义代价。
- 需求里确实有「同步」两个字。 产品说「用户点击后要立刻看到结果」,工程师就把整条链路做成同步的,哪怕真正需要同步的只有最后一跳。
第三条最值得警惕。用户要的是「立刻看到结果」,不是「所有服务同步完成」。 下单后立刻返回「已受理」,页面轮询或者推送后续状态,用户体验可能比等 800 毫秒更好;而内部链路可以完全是异步的。把「用户感知的同步」和「系统内部的同步」分开,是事件驱动设计的第一课。
四个失效点的具体症状
如果一定要做 Request-Reply,至少要准备好面对这四种情况:
| 症状 | 表现 | 根因 |
|---|---|---|
| 迟到的回复 | 超时之后回复才到,生产者已经放弃 | 超时是本地判断,消息还在路上 |
| 无法区分的失败 | 分不清「处理失败」和「没收到」 | 没有同步调用的连接状态 |
| 堆积的等待表 | 记录 correlationId → 等待者 的 Map 持续增长 | 超时的条目没人清理 |
| 断掉的链路 | 日志里只有消费端,没有发起端 | traceId 要手动传递,漏一环就断 |
第一种最容易被低估。生产者的等待逻辑超时了,但消费者的处理还在继续——它可能已经把库存扣了,只是回复发得慢。这时候生产者重试,消费者再扣一次。你以为自己在做超时重试,实际上是在制造重复扣减。
这不是理论推演。卷二第 2 章把重复的四种来源逐一构造过,其中「生产重试」这一类在 Request-Reply 模式下会被放大:每一次超时重试都是一次新的请求,而消费者无法区分「重试」和「新请求」,除非请求本身带了幂等键。
所以结论不是「绝对不能做 Request-Reply」,而是:做了它,就必须同时做幂等、做超时后状态查询、做补偿。这三样加起来的工作量,通常超过当初想省下的那次同步调用。
误用二:削峰却不管积压
第二个误用听起来更正当:用消息队列削峰。
流量有尖峰,下游扛不住峰值,中间加一层队列,把尖峰摊平。这个思路本身没问题,问题在于很多人只做了前半句——加了队列,没算过「摊平需要多久」。
一个具体的算例。假设:
- 峰值写入 5000 条/秒,持续 10 分钟,共 300 万条;
- 下游稳态消费能力 2000 条/秒;
- 峰值结束后,写入回落到 500 条/秒。
积压从峰值开始累积,峰值结束时达到 (5000 − 2000) × 600 = 180 万条。之后净消化速度是 2000 − 500 = 1500 条/秒,需要 1800000 / 1500 = 1200 秒,也就是20 分钟才能追平。
如果峰值不是 10 分钟而是 30 分钟,积压 540 万条,追平需要 60 分钟。峰值时长每增加一分钟,恢复时间就增加一分多钟——积压是有利息的。
这还没算峰值期间下游可能变慢(数据库连接池打满、GC 变长),一旦消费能力掉到 1500 条/秒以下,积压就永远追不平,队列从「缓冲」变成「堆积场」。
把同样的公式套在三个量级上,差距会变得很直观:
| 峰值时长 | 积压峰值 | 追平时间(净消化 1500/秒) |
|---|---|---|
| 10 分钟 | 180 万条 | 20 分钟 |
| 30 分钟 | 540 万条 | 60 分钟 |
| 60 分钟 | 1080 万条 | 120 分钟 |
注意最后一行的含义:一个小时的峰值,需要两个小时才能恢复。如果这种峰值每天出现一次,系统就永远处在「刚追平又被打回」的状态,而监控面板上的 lag 永远不会归零。
更糟的是消费能力下降的情况。同样是 10 分钟峰值、180 万条积压,如果下游因为连接池打满而把消费能力从 2000 条/秒降到 600 条/秒,那么净消化速度变成 600 − 500 = 100 条/秒,追平需要 18000 秒——5 个小时。而这 5 个小时里,下游的负载一直高于稳态,很可能继续恶化。
卷二第 5 章讲过这套判断:能不能追上,看的是「消费能力 − 稳态写入」这个差值,而不是队列有多能存。但很多团队在架构评审时只问「队列能存多少」,没问「存满之前能不能消化完」。
队列不生产容量,它只是把容量缺口推迟暴露。
这句话可以再推一步:容量缺口的暴露时间,等于积压量除以净消化速度。 架构评审时如果有人能当场算出这个数,这个方案才是可以讨论的方案;如果算不出来,说明「削峰」还只是一个愿望。
误用三:用事件掩盖领域边界
第三个误用最隐蔽,因为它看起来像是「做得更彻底」。
典型表现是:不管什么变化,都发一条事件。用户改了个昵称发 user.updated,库存动了一下发 inventory.changed,订单状态变一下发 order.status.changed。所有事件塞进一个 business-events 主题,消费者按 eventType 过滤。
这种设计的问题不是性能,是语义。
user.updated 这条事件里该放什么?放全量用户对象,下游拿到的是快照,不是「发生了什么」;放变更字段列表,下游要自己推断新旧值;放新旧值,事件体积随字段数增长。而且「更新」这个动作本身没有业务含义——是改了手机号,还是注销了账号?两者的下游反应完全不同,但事件类型一样。
领域事件的定义是「领域里发生的一件有意义的事」,它的粒度由聚合根决定。UserPhoneChanged 和 UserDeactivated 是两个事件,不是一个 user.updated 的两种 payload。把事件做成「表变更通知」,等于放弃了领域建模,只是把数据库触发器搬到了消息总线上。
卷二第 8 章讲过事件体的取舍,卷三第 2 章会把「怎么找到正确的事件」讲完。这里先给一条判据:
如果一条事件的消费者需要回查生产者的数据库才能理解它,
这条事件就没有把事实说清楚。
把同一个业务动作按两种粒度写出来,差别一目了然:
| 场景 | 表变更式 | 领域事件式 | 消费者要做什么 |
|---|---|---|---|
| 用户改手机号 | user.updated + 全量用户对象 | UserPhoneChanged + 新旧号码 | 直接判断是否需要发验证短信 |
| 订单创建 | order.inserted + 全量订单 | OrderPlaced + 订单号、商品、金额 | 直接触发库存预占 |
| 库存调整 | inventory.update + 变更后数量 | StockAdjusted + 调整原因、差值 | 直接区分盘亏、退货、补货 |
表变更式的事件还有一个隐藏成本:它把生产者的表结构变成了公共契约。 一旦下游依赖了 user.updated 里的字段,上游改表就得通知所有下游,事件契约与数据库 schema 被绑死。而领域事件只承诺业务事实,表怎么改是生产者自己的事。
这就是「用事件掩盖领域边界」的代价:表面上解耦了服务,实际上把耦合从接口层转移到了数据层,而且更难发现。
事件驱动真正解决什么
把三个误用放一边,事件驱动擅长的是四件事,而且只擅长这四件:
扇出。 一个事实发生,N 个下游各自关心,且 N 会变。订单创建后要通知库存、积分、风控、推荐、报表——用调用要改 N 次代码,用事件只加消费者。
解耦时间。 生产者和消费者不需要同时在线。下游维护时上游照常写,恢复后补消费。
审计与重放。 事件日志本身就是事实记录。出了问题时可以重放,可以做对账,可以回答「三个月前这笔订单经历了什么」。
异步长流程。 一个流程跨越几分钟、几天,甚至需要人工介入。用同步调用要一直占着连接,用事件可以把等待外置。
反过来,四件事它都不擅长:强一致的读、低延迟的请求-响应、需要立刻知道结果的操作、以及「只是想把两个服务之间的调用解耦一下」这种轻量需求。
最后一条值得展开。如果两个服务之间就是一次简单的「扣库存,返回成功或失败」,而且调用方必须等结果才能继续,那么同步调用是正确选择,加一层消息只会让错误处理变复杂。事件驱动不是更高级的架构,它是另一种取舍。
把两个方向摊成一张表,选型会快很多:
| 需求特征 | 事件驱动 | 同步调用 |
|---|---|---|
| 调用方必须立刻拿到结果才能继续 | 不合适 | 合适 |
| 一个事实,N 个下游关心,且 N 会变 | 合适 | 每次都要改调用方 |
| 下游可能离线、维护、迟到上线 | 合适 | 需要重试与降级 |
| 端到端延迟要求低于 10 毫秒 | 通常不合适 | 合适 |
| 流程持续几分钟到几天 | 合适 | 占连接,不现实 |
| 需要审计与重放 | 合适 | 要额外写日志 |
| 一次简单的 CRUD 转发 | 过度设计 | 合适 |
这张表不是绝对的,但它能挡住大部分「为了架构而架构」的冲动。真正的判断标准是:这个场景里,时间解耦和空间解耦,是不是业务真正需要的? 需要,事件驱动;不需要,同步调用更简单。
本卷的立场
基于上面的判断,这一卷有五条明确的立场:
- 事件是事实的记录,不是调用的替代品。 事件用过去时命名,描述「已经发生了什么」,不描述「请做什么」。命令可以走消息,但它不是事件,两者要分开建模。
- 架构的价值在边界,不在中间件。 用 Kafka 还是用别的,是次要问题;聚合边界划在哪、事件粒度多粗、谁有权改契约,才是主要问题。
- 每个架构决策都要能算出代价。 「用事件解耦」不是一个免费的选项,它换来的是最终一致性和运维复杂度。卷三的每一章都会给出代价,而不只是好处。
- 可验证优先于可描述。 每节至少要有一种可验证物:可运行的代码、可重放的拓扑、可对照的表、可执行的清单。纯观点的段落不超过一节。
- 不重复前两卷。 机制问题归卷一,可靠性配置归卷二。需要展开
acks、ISR、位移细节时,一句话引用,不重写。
五个自检问题
在进入具体章节之前,先用五个问题检查一下:你现在的系统,是在用事件,还是只是把调用换了个通道?
问题一:你的事件名是过去时还是动词?
CreateOrder 是命令,OrderCreated 是事件。如果主题名、事件类名里全是动词,说明设计时想的是「让对方做什么」,不是「记录发生了什么」。这不是命名问题,是模型问题。
问题二:一个消费者处理失败,会不会影响其他消费者?
如果所有消费者共用一个消费组,一个失败就会阻塞整个分区的进度,其他人被牵连。如果要做到彼此独立,每个下游应该有自己的消费组。这一条检查的是「扇出是不是真的扇出了」。
问题三:新增一个下游,需要改生产者的代码吗?
需要改,说明生产者和消费者之间还有编译期依赖。真正的解耦是:生产者只认事件契约,新下游自己订阅、自己消费、自己负责。
问题四:能不能重放三个月前的事件,重建一个新的读模型?
不能,说明事件保留了多久、保留的粒度是否足够。这一条直接决定了 CQRS 的重建能力(第 3.3 节)和事件溯源的可能性(卷二第 7.3 节)。
问题五:出了问题时,能不能回答「这个状态是怎么变成现在这样的」?
如果只能看到最终状态,答不出来,说明事件没有承担「审计」的职责。事件驱动的一个隐含收益是可追溯性,如果这个收益没有兑现,那用事件只是在增加复杂度。
五个问题里如果有三个以上答「否」,那么这套系统大概率还处在「用消息队列做异步调用」的阶段。这不可耻,很多系统都是这么起步的;但要知道自己在哪一级,才知道下一步该往哪走(第 1.3 节会给出成熟度模型)。
这一卷怎么读
全书分五部分:
- 范式(第 1–3 章):事件、命令、查询的区别,事件怎么建模,CQRS 的边界。
- 一致性与演进(第 4–6 章):分布式事务的三种落地、顺序与并发、契约演进。
- 流处理(第 7–9 章):Kafka Streams 的拓扑、流表模型、作业运维。
- 可观测性与治理(第 10–12 章):追踪、延迟度量、血缘、平台治理、迁移方法。
- 案例与验收(第 13–15 章):
libraryd全链路、场景对照、上线判据。
三条阅读路径:
| 角色 | 路径 | 重点 |
|---|---|---|
| 架构师 | 序 → 1–3 章 → 13–15 章 | 先建立判断,再看完整案例与验收 |
| 开发 | 前言 → 2–4 章 → 7–9 章 | 事件契约、投影、Streams 拓扑的可运行代码 |
| 评审 | 目录 → 10–12 章 → 14–15 章 | 场景对照表与上线判据当作检查表 |
如果你只读一章,读第 1 章——它决定了后面十四章的讨论有没有共同语言。
与卷一、卷二的关系
三卷的分工是一条直线:
| 卷 | 问题 | 读完能做什么 |
|---|---|---|
| 卷一 | 一条消息从 send() 到落盘发生了什么 | 看懂 broker 的行为与客户端的开销 |
| 卷二 | 怎么做到不丢、不重、可恢复 | 把可靠性落到配置、代码与演练上 |
| 卷三 | 怎么用事件设计系统 | 把业务需求翻译成契约、读模型与治理规则 |
卷二结束在「一条链路能不能上线」。卷三从那里接手,问的是另一个问题:几十条链路、几百个消费者、跨多个集群时,事件流本身怎么治理。
卷二留下的四个缺口——跨集群语义、业务幂等的容量模型、契约治理的组织方式、长尾故障的观测——会在这一卷的不同章节里展开。它们不是「以后再说」的待办,而是架构设计时必须先回答的输入条件。
一句提醒
事件驱动架构最大的风险不是技术选型错,是用了事件却没有改变思维方式。
同步调用的思维是「我调用你,你告诉我结果」;事件驱动的思维是「事情发生了,我记录下来,关心它的人自己去处理」。前者关心控制流,后者关心事实与状态。代码可以改,中间件可以换,但如果团队还在用「等回复」的心态设计异步链路,那这套架构只会把问题从「调用失败」变成「消息不知道去哪了」。
后面十四章,讲的都是怎么把后一种思维落到可以运行、可以验证、可以运维的东西上。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。