1. 为什么不直接用RocketMQ客户端,而是再套一层Stream
1.1 没有Stream时的常见痛点:从一个订单积分的需求说起
之前做了一个订单支付成功后的积分发放需求,业务链路本身并不复杂:支付回调成功,先写订单表,再发一条MQ消息通知下游系统增加积分、发短信、更新库存。刚开始图省事,直接在Service里注入RocketMQTemplate往topic里塞消息,接口调通了就上线了。结果跑了两个月,问题陆续冒出来:支付回调重复触发导致重复发消息、业务事务回滚了但消息已经发出去了、下游消费成功了但ACK丢失导致消息重复投递。最麻烦的是,组里几个服务各自用不同的方式写MQ相关的代码,有的直接new Producer,有的封装了一层工具类,有的用注解监听,风格完全不统一,换个topic都要翻半天代码。
后来把消息链路重构成SpringCloud Stream + RocketMQ,才真正体会到Stream这层抽象的价值。Stream做的事情不复杂,但很关键:把消息中间件的差异屏蔽在Binder层,业务代码面对的不再是RocketMQ的Producer、Consumer这些API,而是统一的MessageChannel和@StreamListener。也就是说,哪怕未来把RocketMQ换成Kafka,业务代码几乎不用动,只需要换一个Binder依赖,改一下配置。
1.2 Stream的三大抽象:Destination、Binder、Binding到底各管什么
Stream这套抽象可以拆成三个核心概念:Destination、Binder、Binding。理解这三个东西,很多配置就不会觉得玄乎了。
- Destination:消息通道的目标地址,翻译成RocketMQ就是topic名。配置里写的destination: order-topic,本质上就是在声明一个逻辑地址。
- Binder:对接具体消息中间件的适配层。RocketMQ有RocketMQBinder,Kafka有KafkaBinder。它负责把Stream的统一操作翻译成RocketMQ的底层调用。
- Binding:把生产者和消费者与某个Destination连接起来的绑定关系。一个Binding包含input或output两种方向,input对应消费者,output对应生产者,同时会把group、并发消费、重试策略等参数全部挂在这个Binding上。
用生活化的方式类比:Destination是门牌号,Binder是帮你找到门牌号的房屋中介,Binding是你和中介签的合同。合同里规定了你看房子的频率(并发数)、钥匙丢了怎么办(重试)、房租到期后要不要续(消费组)。平时你只需要知道门牌号,不需要关心中介具体怎么找到房子。
这套抽象带来的直接好处是:业务代码从“面向具体的MQ API编程”变成“面向消息通道编程”。我在实际项目中感受最深的一点是,测试阶段可以引入一个内存Binder,单元测试里不需要真的启动RocketMQ就能验证消息收发逻辑,这对CI流水线来说太重要了。
1.3 事务消息和Stream的关系:抽象层做不到的事情
Stream的抽象虽然方便,但有一个前提:它默认处理的是普通消息,也就是发送后不管成功失败、不保证和本地事务一致的那种消息。如果业务需要“本地数据库操作和发消息要么一起成功、要么一起失败”,Stream的MessageChannel本身并没有提供这个能力,因为这个语义必须由消息中间件底层来支撑。
RocketMQ从4.3.0版本开始支持事务消息,核心机制是半消息加回查。SpringCloud Stream的RocketMQ Binder虽然暴露了Binding层的抽象,但事务消息的完整控制仍然需要直接使用RocketMQTemplate,通过它注册RocketMQLocalTransactionListener,让本地事务的提交或回滚状态能够传回Broker。所以实际项目里的通用做法是:普通消息走Stream的通道,事务消息走RocketMQTemplate,二者共存,各干各擅长的活。我在下文落地代码里会把这个结构完整展示出来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. RocketMQ事务消息的执行全过程:半消息、回查与状态提交
2.1 先写库还是先发消息:一个经典的两难问题
没有事务消息之前,谈到“可靠消息”,最常见的两个方案都有明显的缺陷。
先发消息再写库:消息发出去了,下游消费者立即处理,结果查订单发现订单还没写入,导致下游处理失败。如果本地数据库操作因为某种原因回滚了,消息已经无法撤回,下游已经基于错误的事实执行了业务动作,这就是典型的账对不上的问题。
先写库再发消息:数据库事务提交后,MQ调用如果超时或者网络抖动,消息就丢了,下游永远感知不到这笔订单。虽然可以在业务表里加一个“消息发送状态”字段,用定时任务扫表补偿,但这会引入额外的表、调度任务和人工干预。
事务消息的思路是把这个两难问题交给Broker来解决:业务方先发送一条“半消息”,这条消息暂时不投递给消费者;然后执行本地事务;根据本地事务的执行结果,再通知Broker是Commit还是Rollback。整个过程看起来就像消息中间件和业务数据库在做一个分布式事务协调。
2.2 半消息是什么:对消费者不可见,对生产者可见
半消息是理解RocketMQ事务消息的钥匙。它的本质是:消息已经发送到了Broker,但Broker把它存放在一个特殊主题下,而不是直接放到业务主题。这个特殊主题在RocketMQ源码里叫RMQ_SYS_TRANS_HALF_TOPIC,可以通俗理解为“事务消息暂存区”。在这种状态下,普通消费者在业务topic上收不到这条消息,因为Broker不会让半消息进入正常消费链路。
为什么要这样设计?关键是要保证“本地事务执行结果”和“消息是否可见”两个状态同步。如果消息一开始就可见,消费者可能立刻处理,而这时本地事务还没提交,就会出现脏读;如果消息一开始不可见,等本地事务提交后再变成可见,消费者看到的数据一定来自已经提交的本地事务,逻辑上才自洽。
在实际编码时,生产者发送半消息后不能直接发普通消息。RocketMQTemplate里有一个专门的方法:sendMessageInTransaction。这个方法内部会先发送半消息,再回调executeLocalTransaction执行本地事务,然后根据返回值决定提交还是回滚。对于调用方来说,这个流程看起来就是一个同步调用:事务成功了消息就发出去,失败了消息就消失,不需要自己拼状态。
2.3 回查机制的触发条件与执行逻辑
回查机制是事务消息可靠性的保障,也是很多人觉得难的地方。回查到底在解决什么问题?简单说:生产者向Broker提交半消息之后,Broker得到的最终状态可能因为网络异常、生产者进程崩溃等原因丢失,此时半消息会一直滞留在暂存区。Broker没法一直等下去,所以在一定时间后主动去问生产者:“你那条本地事务到底提交了没有?”
这个“主动询问”的过程,在代码里对应的是Rock
