RocketMQ本地消息表:生产环境下的分布式事务最佳实践
前言
在微服务架构下,分布式事务一直是个让人头疼的问题。RocketMQ虽然提供了事务消息机制,但在某些场景下,本地消息表方案反而更加稳定可靠。这篇文章会从实战角度,和大家聊聊我在生产环境中使用本地消息表的经验,包括踩过的坑和总结的最佳实践。
一、为什么需要本地消息表?
1.1 RocketMQ事务消息的局限性
先说说RocketMQ自带的事务消息。它的流程是这样的:
看起来挺完美,但实际用下来有几个问题:
问题一:回查机制不够灵活
我们有个订单系统,下单后需要发消息给库存系统扣减库存。用RocketMQ事务消息时,遇到了一个尴尬的场景:
java
// 这种情况下回查会很麻烦
@Transactional
public void createOrder(OrderDTO dto) {
// 1. 插入订单
orderMapper.insert(order);
// 2. 插入订单明细(可能有多条)
orderDetailMapper.batchInsert(details);
// 3. 扣减优惠券
couponService.deduct(dto.getCouponId());
// 4. 发送消息
// 问题来了:回查时怎么判断这一堆操作都成功了?
}
回查函数需要判断订单、明细、优惠券都处理完了,写起来很别扭。
问题二:HALF消息主题替换,导致业务逻辑混乱
RocketMQ的HALF消息会把原topic替换成RMQ_SYS_TRANS_HALF_TOPIC,这在某些监控场景下会很麻烦。我们的APM系统是按topic统计消息量的,HALF消息会被统计到系统topic下,业务topic的数据就对不上了。
问题三:OP消息只记录不重新投递
OP消息(操作消息)在Commit后会重新投递,但Rollback时只记录日志,不会重试。这意味着如果你的业务需要支持消息补偿,RocketMQ事务消息帮不上忙。
1.2 本地消息表的优势
基于这些问题,我们转向了本地消息表方案。核心思路很简单:
把消息发送和业务操作放在同一个本地事务里,利用数据库的ACID特性保证一致性。
相比RocketMQ事务消息,它有这些好处:
- 事务保证更彻底:业务数据和消息数据要么一起成功,要么一起失败
- 回查逻辑简单:直接查消息表状态就行,不需要反推业务状态
- 支持消息补偿:消息表可以记录详细的发送历史,方便排查和重试
- 降低MQ依赖:MQ暂时不可用时,消息会积压在表里,不影响业务主流程
二、本地消息表的核心原理
2.1 整体架构
orders] C[消息表
mq_messages] end subgraph 消息发送层 D[消息发送队列
内存队列] E[消息处理器
MsgProcessor] end subgraph MQ集群 F[RocketMQ
Broker] end subgraph 补偿机制 G[定时任务
扫描超时消息] end A -->|1.写入| B A -->|2.写入| C C -->|3.放入| D D -->|4.异步发送| E E -->|5.发送| F F -->|6.响应| E E -->|7.更新状态| C G -->|8.扫描| C G -->|9.重新发送| D style A fill:#e3f2fd style B fill:#fff3e0 style C fill:#fff3e0 style D fill:#f3e5f5 style E fill:#f3e5f5 style F fill:#e8f5e9 style G fill:#fce4ec
整个流程分为三个阶段:
阶段一:业务+消息写入(同步)
java
@Transactional
public Long createOrder(OrderDTO dto) {
// 1. 写业务表
Order order = buildOrder(dto);
orderMapper.insert(order);
// 2. 写消息表(同一事务)
MqMessage msg = new MqMessage();
msg.setTopic("order_created");
msg.setContent(JSON.toJSONString(order));
msg.setStatus(0); // 待发送
mqMessageMapper.insert(msg);
// 3. 放入内存队列(不阻塞主流程)
msgQueue.offer(msg);
return order.getId();
}
阶段二:异步发送MQ
java
// 后台线程不断从队列取消息发送
while (true) {
MqMessage msg = msgQueue.poll(1, TimeUnit.SECONDS);
if (msg != null) {
try {
producer.send(msg);
// 发送成功,更新状态
mqMessageMapper.updateStatus(msg.getId(), 1);
} catch (Exception e) {
// 发送失败,增加重试次数
mqMessageMapper.incrRetryCount(msg.getId());
}
}
}
阶段三:兜底补偿(定时任务)
java
@Scheduled(cron = "0 */5 * * * ?") // 每5分钟执行
public void retryFailedMessages() {
// 查询状态=0且超过5分钟的消息
List<MqMessage> list = mqMessageMapper.selectTimeout(5);
for (MqMessage msg : list) {
msgQueue.offer(msg); // 重新放入队列
}
}
2.2 消息状态流转
三、核心代码实现
3.1 消息表设计
sql
CREATE TABLE mq_messages (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
content VARCHAR(255) NOT NULL COMMENT '消息内容',
topic CHAR(64) NOT NULL COMMENT '消息主题',
tag CHAR(64) COMMENT '消息标签',
status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-待发送 1-已发送 2-发送失败',
retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
max_retry INT NOT NULL DEFAULT 3 COMMENT '最大重试次数',
next_retry_time TIMESTAMP NULL COMMENT '下次重试时间',
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id),
INDEX idx_status_next_retry (status, next_retry_time)
) COMMENT '事务消息表';
几个设计要点:
- status字段:用TINYINT节省空间,0/1/2三个状态足够
- retry_count:记录重试次数,避免无限重试
- next_retry_time:采用延迟递增策略(0秒、5秒、10秒、25秒...)
- 索引设计 :
(status, next_retry_time)联合索引,定时任务扫描效率高
3.2 消息客户端封装
java
@Component
public class MybatisTransactionMsgClient extends TransactionMsgClient {
@Autowired
private SqlSessionTemplate sessionTemplate;
/**
* 发送消息(核心方法)
* 1. 检查事务状态
* 2. 插入消息表
* 3. 发送到MQ
*/
@Override
public Long sendMsg(Connection con, String content,
String topic, String tag) throws Exception {
Long id = null;
// 1. 校验事务状态
if (!con.getAutoCommit()) {
throw new RuntimeException("连接不在事务中");
}
// 2. 校验参数
if (content == null || content.isEmpty() ||
topic == null || topic.isEmpty()) {
throw new Exception("消息内容或主题为空");
}
try {
// 3. 插入消息记录
Map<Long, String> idUrlPair =
MsgStorage.insertMsg(con, content, topic, tag);
id = idUrlPair.getKey();
// 4. 放入内存队列(非阻塞)
Msg msg = new Msg(id, idUrlPair.getValue());
putMsg(msg);
return id;
} catch (Exception e) {
log.error("发送消息失败 topic={} tag={}", topic, tag, e);
throw e;
}
}
/**
* 消息处理线程
*/
private class MsgProcessorRunnable implements Runnable {
@Override
public void run() {
while (true) {
try {
// 从队列取消息(带超时)
Msg msg = msgQueue.poll(
getTimeoutMs(), TimeUnit.MILLISECONDS);
if (msg == null) {
continue;
}
// 查询消息详情
MsgInfo msgInfo = MsgStorage.getMsgById(msg);
if (msgInfo == null) {
log.warn("消息不存在 msgId={}", msg.getIdUrlPair());
continue;
}
// 构建MQ消息
Message mqMsg = buildMsg(msgInfo);
// 发送到MQ
SendResult result = producer.send(mqMsg);
// 根据结果更新状态
if (result != null &&
result.getSendStatus() == SendStatus.SEND_OK) {
// 成功,更新为已发送
MsgStorage.updateMsgStatus(msg);
} else {
// 失败,计算下次重试时间
handleSendFailure(msg, msgInfo);
}
} catch (InterruptedException e) {
log.error("消息处理线程被中断", e);
break;
} catch (Exception e) {
log.error("处理消息异常", e);
}
}
}
}
/**
* 处理发送失败的消息
*/
private void handleSendFailure(Msg msg, MsgInfo msgInfo) {
int dealedTime = msgInfo.getHaveDealtimes() + 1;
if (dealedTime < MAX_DEAL_TIME) {
// 还没超过最大重试次数,计算下次重试时间
long nextExpireTime = System.currentTimeMillis() +
timeOutData[dealedTime] * 1000;
msgInfo.setNextExpireTime(nextExpireTime);
msgInfo.setHaveDealtimes(dealedTime);
// 放入时间轮等待
timewheelQueue.put(msg);
} else {
// 超过最大重试次数,标记为失败
log.error("消息发送失败超过最大次数 msgId={}",
msg.getIdUrlPair());
MsgStorage.updateMsgStatus(msg, 2); // 状态改为失败
}
}
}
3.3 时间轮实现(延迟重试)
java
/**
* 时间轮线程
* 负责扫描需要重试的消息,并放回发送队列
*/
private class MsgTimewheelRunnable implements Runnable {
@Override
public void run() {
try {
if (!state.get().equals(State.RUNNING)) {
return;
}
long currentTime = System.currentTimeMillis();
Msg msg = timewheelQueue.peek();
// 检查消息是否到达重试时间
while (msg != null &&
msg.getNextExpireTime() <= currentTime) {
msg = timewheelQueue.poll();
log.trace("时间轮取出消息 msgId={}", msg);
// 重新放入发送队列
msgQueue.put(msg);
msg = timewheelQueue.peek();
}
} catch (Exception ex) {
log.error("时间轮处理异常", ex);
}
}
}
3.4 业务代码集成
java
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private MybatisTransactionMsgClient transactionMsgClient;
/**
* 创建订单
* 业务数据和消息在同一事务中
*/
@Transactional(rollbackFor = Exception.class)
public Long createOrder(OrderDTO dto) throws Exception {
// 1. 插入订单(业务数据)
Order order = new Order();
order.setContent(dto.getContent());
Long orderId = orderMapper.insertOrder(order);
// 2. 发送消息(同一事务)
String msgContent = JSON.toJSONString(order);
Long msgId = transactionMsgClient.sendMsg(
msgContent,
"order_created_topic",
null
);
log.info("订单创建成功 orderId={} msgId={}", orderId, msgId);
return orderId;
}
}
四、生产环境踩过的坑
坑1:消息表和业务表不在同一数据库
场景复现
最开始我们是这样设计的:
- 订单库:
order_db - 消息表:
common_db.mq_messages(放在公共库)
想法很美好:所有业务共用一张消息表,统一管理。结果上线第一天就出事了:
java
@Transactional
public void createOrder(OrderDTO dto) {
// 1. 写订单库
orderMapper.insert(order); // 操作 order_db
// 2. 写消息表
msgMapper.insert(msg); // 操作 common_db
// 问题:两个库的操作无法保证原子性!
}
表现:
- 订单创建成功,但消息没发出去
- 或者消息发了,但订单回滚了
根本原因:
Spring的@Transactional默认只能管理单个数据源的事务。跨库操作需要分布式事务(XA、Seata),但那样又把架构搞复杂了。
解决方案:
把消息表和业务表放到同一个数据库:
sql
-- 每个业务库都建一张消息表
CREATE TABLE order_db.mq_messages (...);
CREATE TABLE inventory_db.mq_messages (...);
CREATE TABLE payment_db.mq_messages (...);
虽然有点冗余,但换来了事务的强一致性,这个代价是值得的。
最佳实践:
- 消息表必须和业务表在同一个数据库
- 如果实在要跨库,建议用Spring的
ChainedTransactionManager,但要注意它不是真正的两阶段提交 - 或者改用Seata的AT模式,但会增加系统复杂度
坑2:内存队列OOM
场景复现
我们的订单系统在大促期间挂了,看日志发现是OOM:
makefile
java.lang.OutOfMemoryError: Java heap space
at java.util.concurrent.LinkedBlockingQueue.offer
原因分析:
java
// 内存队列无界,消息积压导致OOM
private BlockingQueue<Msg> msgQueue = new LinkedBlockingQueue<>();
// 大促期间:
// 1. 订单量暴增:10万/分钟
// 2. MQ发送变慢:网络抖动
// 3. 队列积压:几十万条消息
// 4. 内存爆了:每条消息1KB,几百MB就没了
解决方案:
- 改用有界队列
java
// 设置队列上限
private BlockingQueue<Msg> msgQueue =
new LinkedBlockingQueue<>(10000);
// 队列满时的处理策略
if (!msgQueue.offer(msg, 100, TimeUnit.MILLISECONDS)) {
log.warn("消息队列已满,消息将由定时任务补偿 msgId={}", msg.getId());
// 不放入队列,靠定时任务兜底
}
- 监控队列积压
java
@Scheduled(fixedRate = 60000) // 每分钟检查
public void monitorQueueSize() {
int size = msgQueue.size();
if (size > 8000) { // 80%阈值告警
log.error("消息队列积压严重 size={}", size);
// 发送告警
alertService.send("MQ队列积压", size);
}
}
- 增加消费线程
java
// 根据队列积压情况动态调整线程数
private ThreadPoolExecutor executor = new ThreadPoolExecutor(
5, // 核心线程数
20, // 最大线程数
60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy() // 队列满时调用者执行
);
最佳实践:
- 内存队列必须设置上限,推荐值:
1万 ~ 5万 - 设置告警阈值,及时发现问题
- 队列满时允许丢弃,依赖定时任务补偿
- 重要消息可以考虑持久化队列(如磁盘队列)
坑3:消息重复发送
场景复现
有个用户投诉说收到了5条重复的优惠券,查日志发现消息确实发了5次。
原因分析:
java
// 问题代码
public void processMsg(Msg msg) {
// 1. 发送到MQ
producer.send(mqMsg);
// 2. 更新消息状态
msgMapper.updateStatus(msg.getId(), 1);
// 问题:如果第1步成功,第2步失败(网络超时、数据库宕机)
// 定时任务会认为消息没发送,再次发送
}
时序图:
解决方案:
方案一:消费端做幂等(推荐)
java
@Service
public class CouponConsumer {
@Autowired
private RedisTemplate redisTemplate;
@RocketMQMessageListener(
topic = "coupon_issue",
consumerGroup = "coupon_consumer"
)
public void onMessage(String msgId, String content) {
// 1. 检查是否已处理
String key = "coupon:processed:" + msgId;
Boolean isProcessed = redisTemplate.opsForValue()
.setIfAbsent(key, "1", 7, TimeUnit.DAYS);
if (Boolean.FALSE.equals(isProcessed)) {
log.warn("消息已处理,跳过 msgId={}", msgId);
return;
}
// 2. 业务处理
try {
couponService.issue(content);
} catch (Exception e) {
// 处理失败,删除标记,允许重试
redisTemplate.delete(key);
throw e;
}
}
}
方案二:发送前先查状态
java
public void processMsg(Msg msg) {
// 1. 先查询消息状态
MsgInfo info = msgMapper.selectById(msg.getId());
if (info.getStatus() == 1) {
log.info("消息已发送,跳过 msgId={}", msg.getId());
return;
}
// 2. 发送到MQ
producer.send(mqMsg);
// 3. 更新状态
msgMapper.updateStatus(msg.getId(), 1);
}
最佳实践:
- 消费端做幂等是王道,用Redis或数据库唯一约束
- 发送端尽量避免重复,但不强求100%去重
- 业务上要容忍少量重复,设计时考虑幂等性
坑4:定时任务扫描压力大
场景复现
上线一个月后,DBA找我说数据库慢查询告警,定位到是定时任务的扫描SQL:
sql
-- 每5分钟执行一次
SELECT * FROM mq_messages
WHERE status = 0
AND create_time < DATE_SUB(NOW(), INTERVAL 5 MINUTE)
LIMIT 1000;
表里有300万条消息,这个查询扫描了几十万行,数据库CPU飙到80%。
原因分析:
- 消息表数据量太大,历史消息没清理
create_time字段没加索引- 扫描范围太大
解决方案:
方案一:优化索引
sql
-- 添加联合索引
ALTER TABLE mq_messages
ADD INDEX idx_status_time (status, create_time);
-- 查询改写
SELECT * FROM mq_messages
WHERE status = 0
AND next_retry_time <= NOW() -- 改用 next_retry_time
ORDER BY next_retry_time
LIMIT 1000;
方案二:分区表
sql
-- 按月分区
CREATE TABLE mq_messages (
...
) PARTITION BY RANGE (TO_DAYS(create_time)) (
PARTITION p202401 VALUES LESS THAN (TO_DAYS('2024-02-01')),
PARTITION p202402 VALUES LESS THAN (TO_DAYS('2024-03-01')),
...
);
-- 定时删除旧分区
ALTER TABLE mq_messages DROP PARTITION p202401;
方案三:定时归档
java
@Scheduled(cron = "0 0 3 * * ?") // 每天凌晨3点
public void archiveMessages() {
// 1. 归档7天前的已发送消息
int count = msgMapper.archiveOldMessages(7);
log.info("归档消息 count={}", count);
// 2. 删除30天前的失败消息(人工介入后)
msgMapper.deleteOldFailedMessages(30);
}
最佳实践:
- 消息表按
status + next_retry_time建索引 - 已发送的消息定期归档或删除
- 失败消息超过一定时间后人工介入
- 表数据量控制在100万以内
坑5:大消息存储问题
场景复现
有个业务场景需要发送商品详情,包含十几张图片的URL,消息体有50KB。一开始没注意,后来发现:
- 数据库写入变慢:
VARCHAR(255)存不下,改成TEXT后行格式变大 - 内存队列占用高:1万条消息就占了500MB
- MQ发送超时:RocketMQ单消息限制4MB,虽然没超,但网络传输慢
解决方案:
方案一:消息瘦身
java
// 不要发送完整对象
// ❌ 错误
String content = JSON.toJSONString(product);
// ✅ 正确:只发ID,消费端自己查
String content = JSON.toJSONString(
Map.of("productId", product.getId())
);
方案二:大消息分片
java
public void sendLargeMsg(String content) {
if (content.length() > 10000) { // 超过10KB
// 1. 上传到OSS
String url = ossService.upload(content);
// 2. 发送OSS地址
String miniMsg = JSON.toJSONString(
Map.of("type", "oss", "url", url)
);
transactionMsgClient.sendMsg(miniMsg, topic, tag);
} else {
// 直接发送
transactionMsgClient.sendMsg(content, topic, tag);
}
}
最佳实践:
- 消息体控制在1KB以内,最多不超过10KB
- 大数据用OSS等外部存储,消息只传地址
- 数据库字段用
VARCHAR(1000),够用就好 - 定期检查消息体大小分布,发现异常及时处理
五、与RocketMQ事务消息对比
5.1 功能对比
| 维度 | 本地消息表 | RocketMQ事务消息 |
|---|---|---|
| 事务保证 | ✅ 强一致(依赖DB事务) | ⚠️ 最终一致(依赖回查) |
| 实现复杂度 | 🟡 中等(需要写代码) | 🟢 简单(SDK封装好) |
| MQ依赖 | 🟢 低(MQ宕机不影响写入) | 🔴 高(MQ宕机无法发送半消息) |
| 回查逻辑 | 🟢 简单(查消息表状态) | 🔴 复杂(需要反推业务状态) |
| 消息补偿 | ✅ 支持(消息表可重试) | ❌ 不支持(Rollback不重试) |
| 性能 | 🟡 中等(多一次DB写入) | 🟢 高(直接发MQ) |
| 监控 | 🟢 容易(查消息表) | 🟡 一般(需要看MQ控制台) |
5.2 适用场景
选择本地消息表的场景:
-
业务逻辑复杂:多表操作,难以通过回查判断状态
java// 示例:订单系统 - 插入订单主表 - 插入订单明细表(多条) - 更新库存表 - 扣减优惠券 - 发送消息 // 回查时很难判断这一堆操作都成功了 -
需要消息补偿:业务要求失败的消息能重试
java// 示例:支付回调通知 - 通知失败需要不断重试 - 直到商户返回成功 -
对一致性要求高:不允许消息丢失
java// 示例:资金流水 - 每一笔资金变动必须有对应的消息
选择RocketMQ事务消息的场景:
-
业务逻辑简单:单表操作,回查容易
java// 示例:用户注册送积分 @Transactional public void register(User user) { userMapper.insert(user); // 回查:查用户表是否存在 } -
性能要求高:不想增加DB写入开销
-
团队经验丰富:熟悉RocketMQ事务消息的各种坑
5.3 混合方案
在实际项目中,我们采用了混合方案:
java
// 核心业务用本地消息表
@Service
public class OrderService {
public void createOrder() {
// 订单、库存、优惠券...复杂逻辑
// 用本地消息表保证强一致
}
}
// 简单业务用RocketMQ事务消息
@Service
public class UserService {
public void register(User user) {
// 单表插入用户
// 用RocketMQ事务消息,性能更好
}
}
六、最佳实践总结
6.1 架构设计
-
消息表必须和业务表在同一个数据库
- 利用数据库事务保证强一致性
- 避免分布式事务的复杂性
-
内存队列设置合理上限
- 推荐值:1万 ~ 5万
- 防止OOM
- 依赖定时任务兜底
-
时间轮实现延迟重试
- 失败消息延迟递增:5s、10s、25s、50s、100s
- 避免频繁重试打垮系统
-
定时任务作为兜底机制
- 扫描超时未发送的消息
- 间隔建议:5分钟
- 注意扫描性能
6.2 数据库设计
-
表结构设计
sql-- 核心字段 status TINYINT -- 状态 retry_count INT -- 重试次数 next_retry_time TIMESTAMP -- 下次重试时间 -- 索引 INDEX idx_status_retry (status, next_retry_time) -
消息体大小
- 控制在1KB以内
- 最多不超过10KB
- 大消息用OSS存储
-
数据归档
- 已发送消息定期归档(7天)
- 失败消息人工介入后删除(30天)
- 表数据量控制在100万以内
6.3 代码实现
-
事务管理
java@Transactional(rollbackFor = Exception.class) public void businessMethod() { // 1. 业务操作 bizMapper.insert(...); // 2. 发送消息(同一事务) msgClient.sendMsg(...); } -
消息发送
java// 1. 同步写入消息表 // 2. 异步放入内存队列 // 3. 后台线程发送到MQ // 4. 定时任务兜底补偿 -
消费端幂等
java// 用Redis或数据库唯一约束 String key = "msg:processed:" + msgId; Boolean success = redis.setIfAbsent(key, "1", 7, TimeUnit.DAYS); if (Boolean.FALSE.equals(success)) { return; // 已处理,跳过 }
6.4 监控告警
-
消息积压告警
java// 队列大小超过80%告警 if (msgQueue.size() > 8000) { alert("消息队列积压"); } -
发送失败告警
java// 失败消息超过阈值告警 int failCount = msgMapper.countFailedMsg(); if (failCount > 100) { alert("消息发送失败过多"); } -
处理延迟告警
java// 消息处理时长超过5分钟告警 long delay = now - msg.getCreateTime(); if (delay > 300000) { alert("消息处理延迟"); }
6.5 容量规划
-
数据库
- 消息表行数:< 100万
- 单行大小:< 1KB
- 总大小:< 1GB
-
内存队列
- 队列长度:1万 ~ 5万
- 单条消息:< 1KB
- 总内存:< 50MB
-
MQ集群
- TPS:按业务峰值 × 1.5
- 存储:按消息量 × 7天
七、总结
本地消息表是一种经过生产验证的分布式事务解决方案。相比RocketMQ事务消息,它在复杂业务场景下更加稳定可靠。
核心要点回顾:
- 强一致性:利用数据库事务保证业务和消息的原子性
- 简单回查:直接查消息表状态,不需要反推业务状态
- 消息补偿:支持失败消息重试,可靠性更高
- 降低耦合:MQ暂时不可用时不影响业务主流程
当然,它也有一些代价:
- 需要额外的表存储和代码维护
- 多一次数据库写入,性能略低
- 需要定时任务兜底,增加了系统复杂度
在实际项目中,建议根据业务场景灵活选择:
- 复杂业务、高一致性要求 → 本地消息表
- 简单业务、高性能要求 → RocketMQ事务消息
- 混合使用,各取所长
希望这篇文章能帮到正在做分布式事务选型的你。如果有任何问题,欢迎留言交流!
参考资料:
- RocketMQ官方文档:rocketmq.apache.org/zh/docs/