事件驱动与消息架构:从命令链到事件链
以「大促零点下单 1 万 QPS,同步链挂着积分、通知、风控、推荐五个下游,任何一个抖动全线超时」引入:命令与事件的语义分野、MQ 削峰填谷的算术账、三大 MQ 选型表、事件负载设计与顺序/幂等/演进三大工程问题、Spring Cloud Stream 取舍,附同步改事件与失败隔离实验。
前置知识
- Spring AMQP 与 RabbitMQ:会写 RabbitTemplate 发送与 @RabbitListener 消费、知道可靠性三问——没读过也能跟,但本篇的实验与落地复用它的环境,强烈建议先读;
- Spring Cloud 微服务开发:知道服务之间同步调用(Feign)意味着什么——没读过也能跟。
学习目标
读完本文你将能够:
- 用「命令与事件」的语义分野给每个跨服务调用分类,说清事件链为什么让上游彻底解耦;
- 算清削峰填谷的算术账:给定流入速率、消费速率与洪峰时长,推积水与排空时间;
- 按「业务消息、事务消息、日志流」三个定位给 RabbitMQ、RocketMQ、Kafka 选型;
- 把一条同步调用链改造成事件链:事务后可靠发布、事件负载快照与 id 的取舍、消费端各自订阅;
- 指出顺序性、重复消费、事件演进三大工程问题的方向性解法,并对 Spring Cloud Stream 做出诚实取舍。
预计 55 分钟。
1. 你现在要解决什么问题
大促零点,下单接口冲到 1 万 QPS。这条同步链是这样的:订单落库之后,Feign 串行调用积分服务加积分、通知服务发短信、风控服务过审核、推荐服务记偏好——四个下游,任何一个抖动,下单接口的超时率全线飙升;任何一个挂了,下单跟着失败。更糟的是秒杀场景:下单本身要同步扣库存、同步写多张表,洪峰直接击穿数据库的容量天花板——服务扩容能加机器,数据库的写能力不会因为你多起两个实例就变强。病根与 090 篇同源但角度不同:090 讲的是「跨系统写入的一致性」,本篇讲的是同步耦合的结构性问题——上游的可用性与性能被所有下游拉平,下游的数量每加一个,上游的脆弱就深一分。解法是换通信模型:把「你帮我做事」改成「我宣布发生了什么」,把洪峰从直冲数据库改成先排队。
2. 命令与事件:一词语义差,一整个架构差
跨服务通信的两种语义,先分清:
- 命令(command):「你必须做」。下单服务调库存服务「扣减」,发方点名收方、强期待结果、同步等回复——命令没被执行,流程就没法继续。Feign 调用天生是命令;
- 事件(event):「我发生了」。订单服务发布「订单已创建」,发布者不关心谁在听、几个在听、听没听到结果——它只负责把事实说出来。
把 1 节的同步链改写成语义上诚实的样子:加积分、发短信、记偏好,没有一个是「不结果就进行不下去」的调用——它们都是「对订单已创建这个事实的反应」。改成事件链后:
命令链(改前):订单 → 积分 → 通知 → 风控 → 推荐 串行等待,谁慢谁挂谁连坐
事件链(改后):订单 发布「订单已创建」
积分服务 订阅 → 加积分
通知服务 订阅 → 发短信
风控服务 订阅 → 过审核
推荐服务 订阅 → 记偏好结构变了,性质跟着变:上游只管发布,下游各订各的、各自失败、各自恢复。新增一个「优惠券发放」服务,订阅同一事件即可,上游一行不改——开闭原则从类设计级升到架构级。判断标准一句话:这个调用「拿不到结果就进行不下去」吗? 是,保持命令(同步);否,改事件(异步)。
也要给事件链立负面清单,三种情况别上事件:
- 实时查询链路:用户正等着看结果,队列一绕就是几十毫秒起步的额外延迟与不确定性;
- 拿不到结果真进行不下去的强依赖:支付扣款、实名核验——硬改成事件只会把「同步失败」变成「异步悬案」;
- 低流量的内部管理操作:同步调用一屏代码、断点可调;事件链引入中间件、最终一致与排障成本,换不来对等收益。为了架构先进性上 MQ,是事件驱动最常见的事故起点。
一个高频追问要先挡掉:「扣库存是命令还是事件?」看产品语义——秒杀预扣库存在下单主流程里,拿不到结果用户就下不了单,它是命令;但「下单成功后通知仓储拣货」就是事件。命令与事件不是技术的二选一,而是每一条边都要单独裁决;「必须成功但不必现在完成」的灰色地带,用 090 篇的本地消息表承接。
3. 削峰填谷:MQ 是蓄水池,账要会算
事件链里站着的 MQ,洪峰下的作用是蓄水池:生产端按自己的节奏灌,消费端按自己能消化的速度抽,洪峰先排队、不丢、不冲垮下游。
算一笔账。洪峰 1 万 QPS 进,下游只能稳定消化 2 千 QPS:
净积水速度 = 10000 − 2000 = 8000 条/秒
洪峰持续 5 分钟 = 300 秒 → 积水 = 8000 × 300 = 240 万条
洪峰过后进水归零,排空时间 = 2400000 ÷ 2000 = 1200 秒 = 20 分钟结论:积分最晚 20 分钟到账。这个延迟用户接不接受,是产品决策,不是技术拍板——削峰改造前先拿这道算术题去找产品对齐,是工程师的职责。同时看清两个代价:延迟从毫秒级变成分钟级(延迟可见性:队列深度、消息年龄要能被看见,积压告警是新运维对象,130 篇接手);队列容量与磁盘账要提前规划(RabbitMQ 经典队列积压多了会把消息换页出内存,240 万条量级的积压要么开惰性队列直接落盘,要么把这路流量交给 Kafka/RocketMQ——见下节选型)。
削峰的两个前提:每条消息晚处理无害(实时查询、支付回调这种等不起的,绝不进队列)与消费端幂等(重试与重投是常态)。缺一个,蓄水池就变成漏水的水库。
4. MQ 选型:按场景选,不按名气选
| MQ | 定位 | 强项 | 典型场景 |
|---|---|---|---|
| RabbitMQ | 业务消息邮局 | 路由灵活(四种交换机)、延迟低、管理台成熟 | 中小流量业务事件,团队已有 Spring AMQP 经验(140 篇) |
| RocketMQ | 业务消息重炮 | 事务消息、延迟消息、海量堆积、运维生态国内成熟 | 电商交易链、需要事务消息与延迟语义的场景 |
| Kafka | 日志流平台 | 极高吞吐、消费组水平扩展、可回溯重放 | 日志采集、埋点流水、大数据管道 |
三者的分野本质是「业务消息」与「数据流」的分野:RabbitMQ 与 RocketMQ 服务业务流程,Kafka 服务数据管道。选型三问:需要事务消息或丰富延迟档位吗(RocketMQ 的主场)?吞吐是不是到了百万级每秒(Kafka 的主场)?团队熟什么、量有多大(RabbitMQ 往往就是正解)?按名气选 Kafka 而业务只有每秒几百条,是给自行车装货运火车的运维负担。
5. 事件建模:名字、归属与边界
消息中间件选完只解决了「怎么传」,事件本身的建模质量决定这套架构能活多久。三条纪律:
- 名字用过去式:OrderCreated,不是 CreateOrder。事件是已经发生的事实,语法即语义——读到 Created 就知道「无需确认、无需征得我同意」,读到 Create 会误以为是命令。同理 OrderPaid、StockDeducted;真正的命令才用祈使句命名(DeductStockCommand),并且不该出现在 MQ 的广播频道里;
- 归属发布者的业务域:「订单已创建」由订单服务定义,字段是订单视角的事实快照;积分服务按自己的需要解读它,但无权反过来要求事件长成方便自己消费的样子——否则发布者被 N 个下游的需求撕碎,事件退化成 N 套私有 RPC 的合体。下游缺字段就回查或提需求走版本演进;
- 粒度对齐业务事实:一个事件一件事,别把「下单+支付」捏成一个巨型事件;也别碎片化到「订单地址改了」发一条、「订单备注改了」再发一条——粒度裁决的标准是「下游的反应是不是一件事」。
一份事件长什么样,用 JSON 钉一下结构:
{
"eventId": "b7c9d5e0-8f21-4c6a-9d3e-7a1b2c4d5e6f",
"eventName": "OrderCreated",
"eventVersion": 1,
"occurredAt": "2026-10-03T20:15:30+08:00",
"data": {
"orderId": 1001,
"userId": 8,
"sku": "P-42",
"quantity": 1,
"amount": 128.00
}
}三个信封字段的用途:eventId 是全局唯一标识——下游幂等去重就认它(重复投递时同一个 eventId 出现两次);eventVersion 让 schema 演进有处安放,消费端见到不认识的更高版本按自己的策略降级或告警;occurredAt 是事件时间,与消费时间之间那道差,就是第 3 节排队延迟在数据里的样子。
领域建模有一套专门的工作法叫事件风暴(Event Storming):把业务流程用一堆过去式事件贴在墙上、再倒推出命令与边界——知道有这个工具即可,深挖归领域驱动设计的书。
6. 落地主线:把 140 篇的同步链改造成事件链
140 篇教的是「可靠地发、可靠地收」(交换机、confirm、ack、死信),本篇不再重复可靠性三问,讲架构层的三件事。
第一件:可靠发布——事务提交后才发布。 直接在 @Transactional 方法里 convertAndSend 是经典事故:事务随后回滚了,消息已经飞出去,下游对着一条不存在的订单发了货。正确姿势是 090 篇的本地消息表:订单与「订单已创建事件」同一个事务落库,后台扫表再发 MQ。业务代码骨架:
@Service
public class OrderService {
private final OrderMapper orderMapper;
private final OutboxMapper outboxMapper; // 本地消息表(090 篇五步)
@Transactional
public Long create(OrderRequest req) {
Order order = orderMapper.insert(req.toOrder());
outboxMapper.insert(Outbox.of("order", order.getId(),
new OrderCreatedEvent(order.getId(), req.getUserId(),
req.getSku(), req.getQuantity(), req.getAmount())));
return order.getId(); // 发布交给扫表任务,不在事务内发 MQ
}
}可靠发布的闭环还差最后一个角:扫表任务自己挂了怎么办。答案是把它变成可观测对象——「消息表中 status=0 且超过 N 分钟」的条数就是发布健康的直读指标,给它配告警(130 篇的动线);扫描重试设上限,超限的置为异常状态转人工,别让一条毒事件堵死整条发布管道。
第二件:事件负载——带全量快照还是只带 id。 两条路:带快照(订单号、用户、商品、金额全塞进事件),下游零回查、处理自洽,代价是事件体积大、下游与事件的 schema 绑定;只带 id(下游拿到订单号回查订单服务),事件小而稳定,代价是回查接口成为下游的硬依赖、回查放大上游读流量。经验值:高频小事件带快照;负载大或含敏感字段(手机号、身份证)的事件带 id 加版本号。两边混用也正常——按字段敏感性与体积逐事件裁决,别搞一刀切。
第三件:消费端各订各的。 topic 交换机 + 每个下游自己的队列(140 篇的拓扑原样可用):
@Component
public class PointConsumer {
@RabbitListener(queues = "order.point.queue")
public void onOrderCreated(OrderCreatedEvent event) {
pointService.add(event.userId(), event.orderId(), event.amount());
}
}每个服务独享队列意味着失败天然隔离:积分消费失败只堵积分自己的队列,通知照发、推荐照记——这是 1 节「谁慢谁挂谁连坐」的直接解药,第 10 节实验眼见为实。
改造动手前,给一张七步清单收口本节:
1. 识别边界 画出同步链上的每个跨服务调用
2. 裁决语义 逐边判断:拿不到结果能继续吗?命令留下,事件改写
3. 定义契约 事件名(过去式)、负载(快照或 id)、版本策略
4. 可靠发布 事务内只写消息表,发布交给扫表任务
5. 声明拓扑 topic 交换机 + 各下游独享队列
6. 补幂等 唯一键或已处理表,重复消息不重复生效
7. 补可见性 队列深度与消息年龄可查,积压告警接入 130 篇7. 三大工程问题:方向要知道,深挖各有去处
事件链不是免费午餐,三个问题先立路标:
- 顺序性:同一订单「创建 → 支付 → 发货」事件乱序到达,下游状态机会错乱。RabbitMQ 的答案是单队列单消费者保序(吞吐受限);Kafka 的答案是按 orderId 做分区键、同键同分区天然有序。方向知道即可,深挖归 Kafka 语境;
- 重复消费:at least once 投递语义下重复是常态(确认丢失就重投),消费端幂等是必修课——唯一键约束、已处理表、状态机校验,110 篇(分布式锁与幂等)专讲;
- 事件版本演进:下游升级永远慢于上游,事件 schema 要向后兼容——只加字段不改语义、消费端容忍缺字段;伤筋动骨的变更发新事件名(OrderCreatedV2),新旧并行消费一段再下线旧的。
8. 消费拓扑:竞争消费与广播消费
同一份「订单已创建」事件,拓扑上有两种消费形态,别混:
- 竞争消费(负载均衡):积分服务起三个实例,三个消费者订同一个队列——每条消息只被其中一个实例处理。这是水平扩容消费能力的手段,队列是任务的分发点;
- 广播(扇出):积分、通知、推荐是不同的服务,各建各的队列绑到同一交换机——每个队列都收到完整一份事件,各自独立消费。这是事件链的默认形态,隔离性正来自于此。
两者的分界一句话:同一份代码的多个副本抢一个队列,不同代码各开各的队列。由此引出一条队列命名纪律:队列名必须带服务身份(如 point.order.queue),禁止两个服务共用一个队列名——否则新服务上线时「顺手」绑了别人的队列,一半消息被它抢走,原服务平白丢消费。这是事件架构里真实发生过无数次的经典事故,靠命名约定与拓扑评审堵住。
9. Spring Cloud Stream 要不要用:一个诚实的结论
Spring Cloud Stream 是 Spring 家族的消息抽象层:Binder 屏蔽 RabbitMQ/Kafka 差异,业务代码面向函数式模型写,理论上换 MQ 不改业务。价值与成本都要看全:多 MQ 共存的大组织、需要统一治理与可替换性的平台团队,抽象层物有所值;而代价是调试栈加深(错误藏在 Binder 之后)、各 MQ 的高级特性(RabbitMQ 的死信、RocketMQ 的事务消息)要么用不上要么绕路。诚实的结论:绑定单一 MQ 的团队,直用原生客户端(Spring AMQP、rocketmq-spring)更省心;除非「换 MQ」是真实路线图上的事,否则别为抽象付税。
10. 实验:失败隔离的眼见为实
环境复用 140 篇的 RabbitMQ:
docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 \
-e RABBITMQ_DEFAULT_USER=dev -e RABBITMQ_DEFAULT_PASS=dev123 \
rabbitmq:4-management
# 管理台 http://localhost:15672(dev / dev123)先跑同步链版本(订单服务 Feign 调积分)做基线:停掉积分服务,下单——
连接超时 / 503 ← 下游挂,上游陪葬再跑事件链版本(第 6 节的 Outbox + PointConsumer,topic 拓扑照 140 篇声明),重复同样的破坏:
curl 下单 → 200,订单号正常返回 ← 上游完全无感
管理台 order.point.queue 消息数:1 ← 事件安静地躺在队列里
重启积分服务 → 几秒后队列归零,积分到账同一个「下游挂了」的事故,两种架构两种结局——这就是本篇要的那个直觉。顺手验证可靠发布:把发件扫描任务停掉、只让订单事务跑,确认消息表有记录而 MQ 没消息;再启动扫表,消息补发、下游幂等消化。
最后看第 8 节的拓扑落地为吞吐:同时起两个积分服务实例(连同一个 RabbitMQ),连发十单,观察两个实例日志各消化约一半——同一队列的竞争消费把事件处理水平扩容;再起一个「优惠券服务」订同一个交换机,它在自己的新队列里收到全部十单,而积分队列的消息数分毫未动——广播与竞争,各走各的路,互不惊动。
11. 官方参考
- Spring AMQP 参考文档:https://docs.spring.io/spring-amqp/reference/
- Spring Cloud Stream:https://docs.spring.io/spring-cloud-stream/reference/
- RocketMQ 与 Kafka 官方文档:https://rocketmq.apache.org/docs/ 、 https://kafka.apache.org/documentation/
自检
不看上文,凭记忆回答。答不出的,回到对应小节重读。
- 命令与事件的语义分野是什么?各自解除了上游对下游的哪种绑定?
- 「拿不到结果就进行不下去」的调用该保持什么通信模型?灰色地带用什么承接?
- 削峰算术:流入 9000 QPS、消费 2000 QPS、洪峰 4 分钟——积水多少条、进水归零后多久排空?
- 削峰的两个前提是什么?哪类请求绝不能进队列?
- 三个 MQ 各一句话定位;事务消息与延迟消息是谁的主场?
- 为什么不能在 @Transactional 事务内直接发 MQ?事故链与正确姿势各是什么?
- 事件负载带快照还是带 id?各自的代价与裁决依据是什么?
- 顺序、重复、演进三大问题的方向性解法各一句;Spring Cloud Stream 什么时候才值得用?