1. 事务消息在SpringBoot里的完整链路:半消息到底"半"在哪
很多人第一次在SpringBoot里接RocketMQ事务消息,都是被"事务"两个字带偏的。以为发了消息就等同于数据库事务,出问题能自动回滚。实际跑通一次之后才发现,它能给的只是生产者本地事务和消息投递之间的一致,消费端成不成功它管不着,中间件也不负责帮你回滚订单表。我见过的翻车现场,十次里有七八次不是代码写错,而是对这条链路的边界理解错了。
这一篇要聊的是更靠后的一步:当你的应用里不止一个业务需要发事务消息时,SpringBoot这边该怎么摆。这个标题看着像是基础整合,实际上"多事务消息"四个字才是真正的难点,也是rocketmq-spring这个starter设计上最别扭的地方。先把底层链路捋清楚,后面配置多个生产者时才知道自己到底在动哪颗螺丝。
1.1 半消息不是"发一半",是先扣在Broker的暂存区
半消息(Half Message)这个名字起得很有迷惑性。它不是把消息体切一半发出去,而是整条消息完整写进Broker,但被藏进一个系统内部Topic里,消费端在这个阶段完全看不见。
整个动作拆开是四步:
- 生产者把消息发给Broker,Broker不投递给任何消费者,而是把它写到
RMQ_SYS_TRANS_HALF_TOPIC这个系统Topic下,然后给生产者回一个"我收到了"。 - 生产者收到这个确认后,才开始执行本地事务,也就是你那段操作数据库的代码。
- 本地事务有结果了,生产者再向Broker发一次"结束事务"的请求,告诉它Commit还是Rollback。
- Broker收到Commit,把之前扣住的那条消息从暂存区里"还原"到真正的业务Topic,此时消费端才能拉到;收到Rollback就直接丢弃并记一笔操作日志。
这个设计的关键在于:第一步和第二步之间那道确认,是整个方案能成立的地基。如果生产者刚发出去就执行本地事务,那消息丢没丢、Broker收没收到都不知道,本地事务提交了消息却没落地,一致性直接崩。反过来先让Broker表态"我已经安全存下了",生产者再动数据库,最坏情况也只是消息多存了一条待定的,不会少。
提醒一句:半消息落在磁盘上是有成本的。一次事务消息,网络往返至少两次,写入至少两次(暂存一次、还原一次),吞吐天然比普通消息低一截。对账类、订单类业务可以接受,秒杀扣库存那种高并发热点路径就别往上套了。
1.2 回查不是兜底,而是主流程的一个分支
绝大多数人把回查(Check Local Transaction)当成异常兜底,觉得"正常情况下用不到"。这个认知偏差会直接影响你的表设计,后面写回查逻辑时就会开始糊弄。
真实情况是:生产者在执行完本地事务后,向Broker发确认这一步本身可能失败。网络抖一下、进程刚好被发布重启、机器被摘流,都会导致Broker迟迟等不到那个Commit/Rollback。这时候Broker不会傻等,它会启一个定时任务,主动反查生产者:"刚才那条消息,你那边到底成了没成?"
反查的默认节奏大致是这样的:半消息落盘后约6秒还没收到二次确认,就进入待回查队列;之后每隔约60秒回查一次,最多回查15次。超过15次还没拿到明确答复,Broker会认定这条消息失败了,直接丢弃并记日志。
这就意味着,回查走的是一条完全独立的网络请求,它拿不到你发送时传的业务参数对象。很多人在这里踩坑:发送时把订单对象作为arg传给了sendMessageInTransaction,本地事务里用得好好的,回查一进来发现arg是 null,直接NPE。因为回查是Broker发起的,它只能把消息体原封不动送回来,arg这个只在生产者本地内存里存在的参数,跨进程传不过去。
所以一条铁律:消息体里必须带上能唯一定位本地事务状态的业务ID,比如订单号、流水号。回查时只能靠它去数据库里捞状态。
1.3 rocketmq-spring替你封了什么,又没封什么
rocketmq-spring-boot-starter这个包,把原生的TransactionMQProducer包装成了RocketMQTemplate,你只要在配置文件里写上nameserver地址和生产者组,再实现一个RocketMQLocalTransactionListener,加个注解,就能用sendMessageInTransaction一把梭。开箱体验确实好,但它封装的边界值得说清楚。
它帮你做的:自动创建生产者实例并启动、把TransactionListener注册进去、维护回查线程池、处理发送结果和异常包装、跟Spring生命周期挂钩做优雅关闭。
它没帮你做的,也是坑最多的部分:
- 它不理解你的本地事务。
executeLocalTransaction返回什么状态,完全取决于你代码怎么写,中间件不会去校验你有没有真的提交数据库。 - 它不保证消费成功。Commit之后消息进了业务Topic,消费端失败重试、重试耗尽进死信,这套流程跟事务消息毫无关系。
- 它不处理消息重复。Broker在极端情况下(还原消息后生产者没收到响应)会重投,消费端必须自己做幂等。
- 它的一个Template只对应一个事务监听器。这一条就是"多事务消息"卡壳的直接原因,下一节细说。
另外补一句版本相关的经验:rocketmq-spring-boot-starter2.0.2 及更早的版本,@RocketMQTransactionListener注解上还有个txProducerGroup属性,用来区分不同的监听器。到了 2.0.3 这个属性开始被弱化,2.1.0 之后干脆移除了。如果你在网上抄到的示例代码里出现了txProducerGroup,基本可以判断那是好几年前的版本,直接套到新版本上编译都过不去。
2. 多事务消息卡壳的根因:一个Template只认一个监听器
业务一旦长起来,一个SpringBoot应用里同时存在订单创建、积分发放、库存预占、账单生成这些需要保证一致性的场景,太正常了。每个场景的事务边界不一样,本地事务表不一样,回查逻辑更不一样,你不可能把它们塞进同一个监听器里用 if-else 分流——那样代码会烂掉,而且回查时连消息属于哪类业务都判断不出来。
但如果你直接建两个类都加上@RocketMQTransactionListener,启动阶段大概率会挂。
2.1 版本差异带来的假象
在比较老的版本里(2.0.2及之前),靠txProducerGroup属性是可以同时挂多个监听器的:每个监听器声明自己归属哪个生产者组,RocketMQTemplate发送时指定组名,就能路由到对应的监听器。这套机制在当时的写法大概长这样:
@RocketMQTransactionListener(txProducerGroup = "order_tx_group") public class OrderTxListener implements RocketMQLocalTransactionListener { ... }到了 2.1.0 之后,txProducerGroup被移除,RocketMQTemplate内部只维护一个TransactionListener引用。容器里如果同时存在多个RocketMQLocalTransactionListener类型的Bean,启动时会直接抛异常,日志大意是当前版本只允许一个事务监听器(具体异常文案各小版本略有出入,但意思一致)。
于是网上出现了一堆"降版本到2.0.2就好了"的偏方。这招在短期能跑通,代价是锁死在老版本上,后面的安全补丁、协议兼容都跟你没关系了。长期看不是办法。
2.2 两种"多"的诉求要分开看
在动手改代码之前,先问清楚自己到底要的是哪一种"多",因为这两种诉求的解法完全不同。
| 诉求类型 | 具体表现 | 解法方向 |
|---|---|---|
| 多生产者 | 一个应用里同时有订单、积分、库存等多套事务消息场景,各有独立的事务表和回查逻辑 | 建多个RocketMQTemplate,每个挂独立的TransactionMQProducer和监听器 |
| 多消息 | 一次业务操作要发多条事务消息(比如下单后同时通知库存和积分) | 收敛为一条消息多Tag,或统一事务日志+补偿任务 |
第一种是结构性问题,靠配置类和Bean隔离解决。第二种是一致性问题,靠业务设计解决,配多少个生产者都没用——两个事务消息之间没有任何原子性保证,第一条Commit了第二条Rollback了,业务就半截了。
很多人把两者混为一谈,以为"多配几个生产者组就能一次发多条了",这是南辕北辙。下面先解决第一种。
3. 手搓多个TransactionMQProducer的完整配置
思路很直白:绕过starter的自动装配,手动new出多个TransactionMQProducer,每个绑定不同的生产者组和不同的监听器,再用RocketMQTemplate包一层,注册成不同的Bean。这样每个Template都是独立的,互不干扰,回查请求进来时Broker会带上生产者组,能精确路由回对应的监听器。
3.1 公共参数抽出来,group单独定
先把公共的连接参数抽到配置文件里,避免每个Template重复写死:
rocketmq: name-server: 10.0.0.11:9876;10.0.0.12:9876 tx: order: group: order_tx_producer_group send-timeout: 3000 retry-times: 2 point: group: point_tx_producer_group send-timeout: 3000 retry-times: 2这里有个细节值得展开:send-timeout指的是发送半消息的超时,不是本地事务的超时。本地事务执行多久,中间件完全不管,你写个30秒的慢SQL它也只能干等着,等不到二次确认就进入回查队列。所以本地事务一定要短,超过几秒的操作应该拆成异步。这是我第一次上线时吃的亏:本地事务里同步调了一个外部接口,对方偶尔卡顿七八秒,结果大量半消息进入回查,回查线程池被打满,整个生产者堵死。
3.2 配置类代码
下面是完整可运行的配置类,两个业务各一套:
@Configuration public class MultiTxProducerConfig { @Value("${rocketmq.name-server}") private String nameServer; @Bean(destroyMethod = "destroy") public RocketMQTemplate orderTxTemplate(OrderTxListener orderTxListener) { return buildTxTemplate("order_tx_producer_group", orderTxListener); } @Bean(destroyMethod = "destroy") public RocketMQTemplate pointTxTemplate(PointTxListener pointTxListener) { return buildTxTemplate("point_tx_producer_group", pointTxListener); } private RocketMQTemplate buildTxTemplate(String group, RocketMQLocalTransactionListener listener) { TransactionMQProducer producer = new TransactionMQProducer(group); producer.setNamesrvAddr(nameServer); producer.setSendMsgTimeout(3000); producer.setRetryTimesWhenSendFailed(2); producer.setTransactionListener(listener); // 回查线程池必须自己指定,原因见 3.3 ThreadPoolExecutor checkPool = new ThreadPoolExecutor( 2, 8, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(2000), r -> { Thread t = new Thread(r, group + "-check-" + r.hashCode()); t.setDaemon(true); return t; }, new ThreadPoolExecutor.AbortPolicy()); producer.setExecutorService(checkPool); RocketMQTemplate template = new RocketMQTemplate(); template.setProducer(producer); return template; } }@Bean(destroyMethod = "destroy")这个别省。RocketMQTemplate实现了销毁回调,不显式声明的话,应用优雅停机时生产者没有shutdown,Broker那边还认为连接活着,回查请求会打到已经死掉的客户端上,日志里一片连接超时。
RocketMQTemplate本身实现了初始化回调接口,被Spring容器创建时会自动调afterPropertiesSet()触发producer.start(),不需要你手动调。这一点如果搞错了会重复start,启动时直接抛状态异常。
3.3 回查线程池为什么必须自己给
TransactionMQProducer如果不设置executorService,内部会用一个默认线程池,核心线程数和最大线程数都是1,队列长度2000。单线程意味着回查请求是串行处理的。
这个默认值在业务量小的时候看不出问题,一旦进入回查的量上来,比如某次发布导致几百条半消息集中待回查,单线程一条条处理,每条回查里还要查数据库,200毫秒一条,200条就是40秒。后面的回查请求全堆在队列里,队列满了之后新的回查直接拒绝,Broker那边判定回查失败,继续等待下一轮,最后15次耗尽,消息被丢弃。
所以自建线程池不是优化项,是必需品。核心线程给2到4,最大给8到16,队列别开太大(2000偏大了,队列长意味着延迟高且不易察觉),关键是要配拒绝策略。默认的AbortPolicy会抛异常,回查失败进入下一轮,比你用CallerRunsPolicy把线程卡死要好。
线程名前缀带上group名,排查日志时能一眼看出是哪个业务的回查在堵。
3.4 注入RocketMQTemplate时的Bean冲突
多个RocketMQTemplate类型的Bean同时存在,如果你在业务代码里直接@Autowired RocketMQTemplate template,启动会报NoUniqueBeanDefinitionException,Spring不知道你要哪个。
正确做法是配合限定符:
@Service public class OrderService { private final RocketMQTemplate orderTxTemplate; public OrderService(@Qualifier("orderTxTemplate") RocketMQTemplate orderTxTemplate) { this.orderTxTemplate = orderTxTemplate; } }或者用@Resource(name = "orderTxTemplate"),按名字注入,效果一样但少一层@Qualifier,看着清爽点。我个人偏爱构造器注入加@Qualifier,依赖关系在构造参数上就写明了,不会有人误用。
还有一个隐蔽的坑:如果你同时保留了rocketmq.producer.group这行配置,starter的自动装配仍然会创建一个默认的RocketMQTemplate(Bean名固定为rocketMQTemplate),跟你的两个自建Template一起变成三个。既然走自建路线,就把rocketmq.producer.group这条配置删掉,否则多一个不受控的生产者实例白占连接。消费者侧的rocketmq.consumer.group不受影响,正常保留。
4. 本地事务表:回查能不能扛住全看这张表
配置搞定了,真正决定稳定性的是这张事务表。回查进来的那一刻,你只有消息体里的业务ID,能不能在几十毫秒内给出准确答复,全靠表结构和状态机的设计。
4.1 表结构与状态机
一个够用的表长这样:
CREATE TABLE tx_message_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_id VARCHAR(64) NOT NULL COMMENT '业务唯一ID,与消息体一致', biz_type VARCHAR(32) NOT NULL COMMENT '业务类型,区分不同事务场景', tx_status TINYINT NOT NULL DEFAULT 0 COMMENT '0待执行 1已提交 2已回滚 3处理中', payload TEXT COMMENT '消息体快照,用于补偿重发', check_count INT NOT NULL DEFAULT 0 COMMENT '被回查次数', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_biz (biz_id, biz_type), KEY idx_status_time (tx_status, create_time) );几个设计要点解释一下:
biz_id和biz_type建联合唯一索引,不是单列。因为不同业务场景的订单号可能撞号(比如一个用了自增ID,一个用了雪花ID,理论上不撞但保不齐),加上业务类型隔离更稳。
tx_status里留了一个"处理中"状态。为什么需要?因为本地事务执行过程中如果进程挂了,数据库里留下一条"待执行"的记录,回查时你无法判断这条业务到底是还没开始还是执行到一半。有了"处理中",执行前先抢占式更新成这样,回查看到这个状态就返回UNKNOWN,等下一轮再审。这个状态不要省,省了之后回查会误判。
check_count是自检用的。回查次数异常增长的记录,说明你的本地事务经常拿不到确定结果,值得单独拉出来看。
idx_status_time这个索引是给补偿任务用的,扫"待处理且超过N分钟"的记录,后面第5节会用到。
4.2 executeLocalTransaction的执行顺序
顺序错了,一致性就废了。正确顺序是:
- 插入事务日志,
tx_status = 3(处理中),用INSERT IGNORE或捕获唯一键冲突保证幂等。 - 执行真正的业务逻辑(扣款、下单、改库存)。
- 业务成功则更新
tx_status = 1,返回COMMIT。 - 业务明确失败则更新
tx_status = 2,返回ROLLBACK。 - 业务结果不确定(超时、依赖服务异常、抛了非业务异常)则保持
3,返回UNKNOWN。
注意第5条:不要图省事把异常都catch了返回ROLLBACK。ROLLBACK是不可逆的,消息一旦回滚就彻底丢了。如果那次异常其实只是网络抖动,业务在数据库里已经提交了,你返回ROLLBACK就等于消息永久丢失,业务数据改了但下游不知道,这才是最麻烦的不一致。拿不准就返回UNKNOWN,让Broker回查,回查时会重新读表,多一次机会总比直接丢强。
另外,executeLocalTransaction里的业务逻辑必须幂等。因为发送半消息失败重试时,同一笔业务可能被调用两次。用biz_id做唯一约束,重复插入直接忽略,是成本最低的幂等手段。
4.3 checkLocalTransaction的三种返回值与数据注意事项
回查逻辑本身很简单,查表按状态映射:
@Override public RocketMQLocalTransactionState checkLocalTransaction(Message msg) { String bizId = extractBizId(msg); TxMessageLog log = txMessageLogMapper.selectByBizId(bizId); if (log == null) { // 记录不存在:可能本地事务还没落库,也可能压根没执行 return RocketMQLocalTransactionState.UNKNOWN; } if (log.getCheckCount() > 0) { txMessageLogMapper.incrCheckCount(bizId); } switch (log.getTxStatus()) { case 1: return RocketMQLocalTransactionState.COMMIT; case 2: return RocketMQLocalTransactionState.ROLLBACK; default: return RocketMQLocalTransactionState.UNKNOWN; } }三个返回值各自的后果得背下来:
COMMIT—— Broker把暂存区的消息还原到业务Topic,消费端开始消费。这是不可撤销的。
ROLLBACK—— Broker丢弃消息,业务Topic永远收不到。同样不可撤销。
UNKNOWN—— Broker保持原样,等到下一个回查周期再来问。如果连续15次都是UNKNOWN,Broker最终按失败处理,丢弃消息,日志里记一笔。
这里有个极其容易踩的坑:记录不存在时该返回什么。有人写return null,有人写死ROLLBACK。返回null直接NPE;返回ROLLBACK则是把可能还没执行完的业务判了死刑。稳妥的做法是返回UNKNOWN。但要注意,executeLocalTransaction的第一步就是插记录,正常情况下记录不存在只说明半消息发出去了、本地事务一行还没执行,这时候UNKNOWN是合理的,下一轮回查大概率就能查到。代价是极端情况会多耗几轮回查次数,可以接受。
还有一个数据上的细节:回查时消息体的反序列化。checkLocalTransaction(Message msg)拿到的Message是Broker原样返回的,payload字节数组也在,但如果你发送时用的是Spring的MessageBuilder包装的自定义对象,回查时要自己按相同方式反序列化一次。类型对不上会抛MessageConversionException。建议消息体统一用JSON字符串或Protobuf字节,别依赖具体的序列化器实现,跨版本升级时少一层麻烦。
5. 一次业务下发多条事务消息:一致性怎么收口
配置多个生产者只是解决了"不同场景各用各的",还有一种更隐蔽的需求:同一笔业务,要通知好几个下游。比如下单成功,既要通知库存预占,又要通知积分冻结,还要通知风控留痕。
有同学会想:我配了三个Template,挨个发三次事务消息不就完了。这条路走不通,原因很直接:三条事务消息之间不存在任何原子性。第一条Commit了,第二条Broker没收到半消息确认,第三条本地事务炸了返回ROLLBACK,结果库存扣了、积分没冻、风控没记。你在业务层想回滚?这时候第一条消息已经被消费端处理了,回滚链条会变得极其复杂。
5.1 拆多条事务消息还是一条消息多Tag
对比一下两种落法:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 多条事务消息 | 每个下游发一条独立的tx消息 | 下游解耦彻底,各自消费 | 无原子性,故障时状态散乱,补偿复杂 |
| 一条消息多Tag | 发一条tx消息,多个消费组用不同Tag订阅 | 原子性天然保证,只有一次ACK | 消息体较大,下游要按Tag过滤 |
在绝大多数场景下,第二条路明显更优。一条事务消息Commit之后,所有订阅它的消费组都会收到,各自处理各自的部分。库存消费组按Tag过滤只处理库存相关字段,积分消费组只处理积分字段,风控消费组只做留痕。生产侧只需要保证一次本地事务和一次消息投递的一致性,复杂度直接降一个量级。
Tag的设计建议是带上业务动作,比如ORDER_CREATED、ORDER_PAID,消费端用consumer.subscribe("order_topic", "ORDER_CREATED || ORDER_PAID")这种表达式过滤。一个topic下的Tag别超过10个,多了之后消费端的过滤逻辑会失控。
如果下游的消费吞吐差异极大(比如风控组要跑重计算,库存组要毫秒级响应),一个topic会互相拖累。这种情况下可以拆topic,但生产侧仍然发一条消息,由消费端做"扇出"——第一个消费组收到后,用普通消息(非事务)转发给其他topic。这里的事务性要求就降级成"普通消息可靠投递",用本地消息表加定时补偿就能兜住。
5.2 统一事务日志 + 补偿扫描
即使收敛成一条消息,仍然存在一个窗口:本地事务提交成功了,但executeLocalTransaction在返回COMMIT之前进程被kill,Broker收不到确认,进入回查。这时候回查能查到tx_status = 1,返回COMMIT,消息就出去了,这条路径是安全的。
真正需要补偿的是另一种情况:Broker回查15次全部失败(比如回查期间生产者一直不可用),消息被丢弃,但本地业务数据已经改了。这条数据永远不会被下游知道。
补一个扫描任务兜住这个缺口:
@Scheduled(fixedDelay = 60_000) public void compensate() { // 捞取"已提交但超过5分钟未被确认消费"的记录 List<TxMessageLog> list = txMessageLogMapper.selectNeedCompensate( Duration.ofMinutes(5), 200); for (TxMessageLog log : list) { try { // 用普通消息重投,消费端靠biz_id幂等 rocketMQTemplate.syncSend( "order_topic:ORDER_CREATED", log.getPayload()); txMessageLogMapper.markCompensated(log.getId()); } catch (Exception e) { log.warn("补偿重发失败, id={}", log.getId(), e); } } }这个任务的关键在于消费端必须按biz_id做幂等,否则补偿重发会让下游重复处理。幂等的实现方式最朴素的就是下游也存一张消费记录表,biz_id唯一键,插入冲突就跳过。别指望Redis的setnx,那玩意儿有丢失风险,涉及钱的场景还是得落库。
扫描的批次别开太大,200条一次比较稳,每批之间留点间隔,避免把数据库和Broker同时打满。
6. 实测踩坑与排查链路
配置和表都齐了,剩下的是线上跑起来才会暴露的问题。挑四个我实际遇到过的,把排查过程写出来,遇到类似现象时能少绕几圈。
6.1 回查压根没进来
现象:应用日志里executeLocalTransaction有输出,但checkLocalTransaction从来没被调用过,半消息堆积持续增长。
排查链路:
第一步,确认监听器有没有真正注册到生产者上。自建Template时如果template.setProducer(producer)之前忘了producer.setTransactionListener(listener),发送照样能成,但回查永远进不来。这个错误特别隐蔽,因为发送日志一切正常。在启动日志里搜 "transaction listener" 相关的注册信息,或者打个断点在setTransactionListener上确认执行了。
第二步,检查生产者组是否和Broker侧记录的一致。半消息在Broker上会记录生产者组,回查时按组发请求。如果发送时用的组名和配置的Bean里的组名对不上(比如一个带了下划线一个没带),回查请求会找不到目标客户端。
第三步,看Broker日志里有没有回查请求的记录。如果Broker侧压根没发起回查,问题在Broker配置:transactionCheckInterval是不是被调得极大,或者事务消息处理线程池被占满了。
第四步,检查客户端的instanceName。同一台机器上多个生产者实例,如果instanceName相同但连接了不同的nameserver,客户端实例会被复用,导致注册关系混乱。稳妥做法是给每个生产者设一个独立的instanceName,比如group + "-" + pid。
6.2 半消息堆积
现象:RMQ_SYS_TRANS_HALF_TOPIC的消息量持续上涨,消费延迟也跟着涨。
排查链路:先看是不是回查返回大量UNKNOWN。把check_count拉出来按业务类型分组统计,如果某一类的平均值明显偏高,说明那个业务的事务状态判定逻辑有问题,可能一直返回UNKNOWN。常见原因是事务日志没能及时更新——比如业务代码在更新tx_status之前就抛异常了,记录永远停在"处理中"。
再看回查线程池的队列深度。前面说过默认单线程,如果队列堆到几百,说明回查处理速度跟不上。这时候要么扩线程,要么检查回查里的SQL有没有走索引。回查是高频短请求,一次慢查询就能拖垮整条链路。
还有一种情况是Broker的transactionTimeOut配得太小,半消息刚写完就被拉去回查了,而本地事务其实还在跑,回查必然返回UNKNOWN。这个参数默认6秒,如果你的本地事务平均耗时超过3秒,建议往上调,否则会白白浪费回查次数。
6.3 UNKNOWN之后消息"消失"
现象:某笔业务日志显示返回了UNKNOWN,之后既没有Commit也没有Rollback,下游一直没收到。
根因往往是回查次数耗尽。默认最多15次,每次间隔60秒,理论上15分钟内会被反复问到。如果这15分钟里生产者一直重启或者回查持续异常,消息就被按失败丢弃了。这个"丢弃"不会给你发告警,只在Broker日志里有一行。
规避手段是把check_count用起来:超过10次还是UNKNOWN的记录,主动打一条告警日志出来,别等它被默默丢掉。同时补偿任务要覆盖这种记录,靠定时扫描重新投递一次普通消息,用消费端幂等兜住重复。
6.4 事务消息和延迟、批量不兼容
这个坑纯粹是文档没写清楚。事务消息的发送接口只接受单条消息,批量接口sendMessageInTransaction压根没有。至于延迟级别,实测下来在半消息阶段设置不生效,Commit还原之后也不会带上延迟属性。
所以如果你的业务需要"下单30分钟后自动关单",正确姿势是:事务消息负责可靠投递下单成功事件,消费端收到之后再发一条普通延迟消息用于超时关单。别想着一步到位。
还有一个容易忽略的:事务消息不支持顺序发送。半消息的还原是Broker内部异步完成的,还原后的队列选择不一定和发送时的队列一致。要求严格顺序的场景,这条链路得另做设计。
7. 版本、参数与容量:清理几个必须记住的数字
把散落在各处的关键参数汇总成一张表,调优和排障时对着看:
| 参数 | 位置 | 默认值 | 调优建议 |
|---|---|---|---|
| transactionTimeOut | Broker | 6000ms | 本地事务P99超过3秒时上调至10000~15000 |
| transactionCheckMax | Broker | 15 | 不建议下调,下调等于缩短容错窗口 |
| transactionCheckInterval | Broker | 60s | 单次回查耗时高时可上调,减少无效回查 |
| 回查线程池coreSize | 生产者 | 1 | 按回查QPS给,2~8 |
| 回查线程池queue | 生产者 | 2000 | 建议降到200~500,配合拒绝策略 |
| sendMsgTimeout | 生产者 | 3000ms | 跨机房时适当放大 |
| retryTimesWhenSendFailed | 生产者 | 2 | 保持默认,事务消息重试代价高 |
版本上还有一条经验:rocketmq-spring-boot-starter2.2.x 对应的客户端是4.9.x,2.3.x 才对齐到5.x。如果你的broker是4.x,别硬上2.3.x的starter,协议层虽然大体兼容,但事务消息的回查协议在5.x里做过调整,混搭时出问题极难排查。升级路径要么整套升,要么老老实实锁在2.2.x。
最后提一下监控。事务消息这条链路上至少有四个指标值得盯:半消息堆积量、回查调用QPS、回查UNKNOWN占比、tx_message_log里状态为"处理中"且超过10分钟的记录数。前两个看整体健康度,第三个看业务判定逻辑是否合理,第四个看补偿任务有没有漏。前面三个上中间件的监控面板就能拿到,最后一个得自己写个定时统计打点到日志里,成本很低,但救过我好几次。
我在实际项目里最大的体会是,事务消息这东西的价值不在于"它能保证一致性",而在于"它把不一致暴露在了可观测的位置"。半消息堆积、回查次数、UNKNOWN比例,这些都是非常直接的信号,比业务层自己埋一堆对账逻辑要清晰得多。真正省事的做法不是把代码写得多聪明,而是把事务日志表当成核心资产来设计——字段留足、索引建对、状态机管好,剩下的交给中间件和补偿任务,出问题的概率会低很多。