《Java 消息中间件·事件驱动架构》序:别把消息队列当 RPC

把消息队列当成更可靠的 HTTP,是事件驱动架构最常见也最贵的误用。本序拆开三种典型误用——用 MQ 做同步调用、削峰却不管积压、用事件掩盖领域边界——说明它们各自的失效方式与代价,给出本卷的五条立场和五个自检问题:事件是事实的记录,不是调用的替代品。

一个很常见的起点

大多数团队接触事件驱动,不是因为读了一本架构书,而是因为遇上了一件具体的事:一个下单请求要调五个服务,每个服务都要重试、都要熔断、都要设超时,链路一长,整体可用性就乘起来了。五个 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 还是反复出现?通常是三个原因叠在一起:

  1. 团队的心智模型是 RPC。 大多数人先学会的是「调用一个方法,拿返回值」,事件驱动的思维方式需要重新建立。
  2. 框架提供了现成的封装。 一些客户端库把「发请求 + 等回复」做成了几行配置,用起来太顺手,以至于没人去问它背后的语义代价。
  3. 需求里确实有「同步」两个字。 产品说「用户点击后要立刻看到结果」,工程师就把整条链路做成同步的,哪怕真正需要同步的只有最后一跳。

第三条最值得警惕。用户要的是「立刻看到结果」,不是「所有服务同步完成」。 下单后立刻返回「已受理」,页面轮询或者推送后续状态,用户体验可能比等 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 转发过度设计合适

这张表不是绝对的,但它能挡住大部分「为了架构而架构」的冲动。真正的判断标准是:这个场景里,时间解耦和空间解耦,是不是业务真正需要的? 需要,事件驱动;不需要,同步调用更简单。

本卷的立场

基于上面的判断,这一卷有五条明确的立场:

  1. 事件是事实的记录,不是调用的替代品。 事件用过去时命名,描述「已经发生了什么」,不描述「请做什么」。命令可以走消息,但它不是事件,两者要分开建模。
  2. 架构的价值在边界,不在中间件。 用 Kafka 还是用别的,是次要问题;聚合边界划在哪、事件粒度多粗、谁有权改契约,才是主要问题。
  3. 每个架构决策都要能算出代价。 「用事件解耦」不是一个免费的选项,它换来的是最终一致性和运维复杂度。卷三的每一章都会给出代价,而不只是好处。
  4. 可验证优先于可描述。 每节至少要有一种可验证物:可运行的代码、可重放的拓扑、可对照的表、可执行的清单。纯观点的段落不超过一节。
  5. 不重复前两卷。 机制问题归卷一,可靠性配置归卷二。需要展开 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 的行为与客户端的开销
卷二怎么做到不丢、不重、可恢复把可靠性落到配置、代码与演练上
卷三怎么用事件设计系统把业务需求翻译成契约、读模型与治理规则

卷二结束在「一条链路能不能上线」。卷三从那里接手,问的是另一个问题:几十条链路、几百个消费者、跨多个集群时,事件流本身怎么治理。

卷二留下的四个缺口——跨集群语义、业务幂等的容量模型、契约治理的组织方式、长尾故障的观测——会在这一卷的不同章节里展开。它们不是「以后再说」的待办,而是架构设计时必须先回答的输入条件。

一句提醒

事件驱动架构最大的风险不是技术选型错,是用了事件却没有改变思维方式。

同步调用的思维是「我调用你,你告诉我结果」;事件驱动的思维是「事情发生了,我记录下来,关心它的人自己去处理」。前者关心控制流,后者关心事实与状态。代码可以改,中间件可以换,但如果团队还在用「等回复」的心态设计异步链路,那这套架构只会把问题从「调用失败」变成「消息不知道去哪了」。

后面十四章,讲的都是怎么把后一种思维落到可以运行、可以验证、可以运维的东西上。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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