RocketMQ本地消息表:生产环境下的分布式事务最佳实践

RocketMQ本地消息表:生产环境下的分布式事务最佳实践

前言

在微服务架构下,分布式事务一直是个让人头疼的问题。RocketMQ虽然提供了事务消息机制,但在某些场景下,本地消息表方案反而更加稳定可靠。这篇文章会从实战角度,和大家聊聊我在生产环境中使用本地消息表的经验,包括踩过的坑和总结的最佳实践。

一、为什么需要本地消息表?

1.1 RocketMQ事务消息的局限性

先说说RocketMQ自带的事务消息。它的流程是这样的:

sequenceDiagram participant 业务系统 participant MQ生产者 participant MQ服务器 participant MQ消费者 业务系统->>MQ生产者: 1.发送半消息 MQ生产者->>MQ服务器: 2.半消息(Send OK) MQ服务器-->>MQ生产者: 3.半消息已存储 MQ生产者->>业务系统: 4.执行本地事务 alt 本地事务成功 业务系统->>MQ生产者: 5.Commit MQ生产者->>MQ服务器: 6.提交消息 MQ服务器->>MQ消费者: 7.投递消息 else 本地事务失败 业务系统->>MQ生产者: 5.Rollback MQ生产者->>MQ服务器: 6.回滚消息 end Note over MQ服务器,MQ生产者: 回查机制:如果长时间未收到确认 MQ服务器->>MQ生产者: 7.回查事务状态 MQ生产者->>业务系统: 8.查询本地事务 业务系统-->>MQ生产者: 9.返回事务状态

看起来挺完美,但实际用下来有几个问题:

问题一:回查机制不够灵活

我们有个订单系统,下单后需要发消息给库存系统扣减库存。用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 本地消息表的优势

基于这些问题,我们转向了本地消息表方案。核心思路很简单:

graph LR A[业务操作] -->|同一事务| B[插入消息表] B --> C[后台任务扫描] C --> D[发送到MQ] D --> E[更新消息状态] style A fill:#e1f5ff style B fill:#e1f5ff style C fill:#fff4e1 style D fill:#fff4e1 style E fill:#e8f5e9

把消息发送和业务操作放在同一个本地事务里,利用数据库的ACID特性保证一致性

相比RocketMQ事务消息,它有这些好处:

  1. 事务保证更彻底:业务数据和消息数据要么一起成功,要么一起失败
  2. 回查逻辑简单:直接查消息表状态就行,不需要反推业务状态
  3. 支持消息补偿:消息表可以记录详细的发送历史,方便排查和重试
  4. 降低MQ依赖:MQ暂时不可用时,消息会积压在表里,不影响业务主流程

二、本地消息表的核心原理

2.1 整体架构

graph TB subgraph 服务层 A[订单服务] end subgraph 数据库 B[业务表
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 消息状态流转

stateDiagram-v2 [*] --> 待发送: 创建消息 待发送 --> 发送中: 取出发送 发送中 --> 已发送: 发送成功 发送中 --> 待发送: 发送失败(重试) 待发送 --> 发送失败: 超过最大重试次数 发送失败 --> 待发送: 人工介入重试 已发送 --> [*] note right of 待发送 status=0 可以被定时任务扫描 end note note right of 已发送 status=1 最终状态 end note note right of 发送失败 status=2 需要人工排查 end note

三、核心代码实现

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 '事务消息表';

几个设计要点:

  1. status字段:用TINYINT节省空间,0/1/2三个状态足够
  2. retry_count:记录重试次数,避免无限重试
  3. next_retry_time:采用延迟递增策略(0秒、5秒、10秒、25秒...)
  4. 索引设计(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 (...);

虽然有点冗余,但换来了事务的强一致性,这个代价是值得的。

最佳实践

  1. 消息表必须和业务表在同一个数据库
  2. 如果实在要跨库,建议用Spring的ChainedTransactionManager,但要注意它不是真正的两阶段提交
  3. 或者改用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就没了

解决方案

  1. 改用有界队列
java 复制代码
// 设置队列上限
private BlockingQueue<Msg> msgQueue = 
    new LinkedBlockingQueue<>(10000);

// 队列满时的处理策略
if (!msgQueue.offer(msg, 100, TimeUnit.MILLISECONDS)) {
    log.warn("消息队列已满,消息将由定时任务补偿 msgId={}", msg.getId());
    // 不放入队列,靠定时任务兜底
}
  1. 监控队列积压
java 复制代码
@Scheduled(fixedRate = 60000) // 每分钟检查
public void monitorQueueSize() {
    int size = msgQueue.size();
    if (size > 8000) { // 80%阈值告警
        log.error("消息队列积压严重 size={}", size);
        // 发送告警
        alertService.send("MQ队列积压", size);
    }
}
  1. 增加消费线程
java 复制代码
// 根据队列积压情况动态调整线程数
private ThreadPoolExecutor executor = new ThreadPoolExecutor(
    5,  // 核心线程数
    20, // 最大线程数
    60, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(100),
    new ThreadPoolExecutor.CallerRunsPolicy() // 队列满时调用者执行
);

最佳实践

  1. 内存队列必须设置上限,推荐值:1万 ~ 5万
  2. 设置告警阈值,及时发现问题
  3. 队列满时允许丢弃,依赖定时任务补偿
  4. 重要消息可以考虑持久化队列(如磁盘队列)

坑3:消息重复发送

场景复现

有个用户投诉说收到了5条重复的优惠券,查日志发现消息确实发了5次。

原因分析

java 复制代码
// 问题代码
public void processMsg(Msg msg) {
    // 1. 发送到MQ
    producer.send(mqMsg);
    
    // 2. 更新消息状态
    msgMapper.updateStatus(msg.getId(), 1);
    
    // 问题:如果第1步成功,第2步失败(网络超时、数据库宕机)
    // 定时任务会认为消息没发送,再次发送
}

时序图

sequenceDiagram participant 发送线程 participant MQ participant 数据库 participant 定时任务 发送线程->>MQ: 1. 发送消息 MQ-->>发送线程: 2. 发送成功 发送线程->>数据库: 3. 更新状态 Note over 数据库: 数据库假死,更新失败 定时任务->>数据库: 4. 查询status=0的消息 数据库-->>定时任务: 5. 返回该消息 定时任务->>MQ: 6. 再次发送(重复)

解决方案

方案一:消费端做幂等(推荐)

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);
}

最佳实践

  1. 消费端做幂等是王道,用Redis或数据库唯一约束
  2. 发送端尽量避免重复,但不强求100%去重
  3. 业务上要容忍少量重复,设计时考虑幂等性

坑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%。

原因分析

  1. 消息表数据量太大,历史消息没清理
  2. create_time字段没加索引
  3. 扫描范围太大

解决方案

方案一:优化索引

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);
}

最佳实践

  1. 消息表按status + next_retry_time建索引
  2. 已发送的消息定期归档或删除
  3. 失败消息超过一定时间后人工介入
  4. 表数据量控制在100万以内

坑5:大消息存储问题

场景复现

有个业务场景需要发送商品详情,包含十几张图片的URL,消息体有50KB。一开始没注意,后来发现:

  1. 数据库写入变慢:VARCHAR(255) 存不下,改成 TEXT 后行格式变大
  2. 内存队列占用高:1万条消息就占了500MB
  3. 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);
    }
}

最佳实践

  1. 消息体控制在1KB以内,最多不超过10KB
  2. 大数据用OSS等外部存储,消息只传地址
  3. 数据库字段用 VARCHAR(1000),够用就好
  4. 定期检查消息体大小分布,发现异常及时处理

五、与RocketMQ事务消息对比

5.1 功能对比

维度 本地消息表 RocketMQ事务消息
事务保证 ✅ 强一致(依赖DB事务) ⚠️ 最终一致(依赖回查)
实现复杂度 🟡 中等(需要写代码) 🟢 简单(SDK封装好)
MQ依赖 🟢 低(MQ宕机不影响写入) 🔴 高(MQ宕机无法发送半消息)
回查逻辑 🟢 简单(查消息表状态) 🔴 复杂(需要反推业务状态)
消息补偿 ✅ 支持(消息表可重试) ❌ 不支持(Rollback不重试)
性能 🟡 中等(多一次DB写入) 🟢 高(直接发MQ)
监控 🟢 容易(查消息表) 🟡 一般(需要看MQ控制台)

5.2 适用场景

选择本地消息表的场景

  1. 业务逻辑复杂:多表操作,难以通过回查判断状态

    java 复制代码
    // 示例:订单系统
    - 插入订单主表
    - 插入订单明细表(多条)
    - 更新库存表
    - 扣减优惠券
    - 发送消息
    // 回查时很难判断这一堆操作都成功了
  2. 需要消息补偿:业务要求失败的消息能重试

    java 复制代码
    // 示例:支付回调通知
    - 通知失败需要不断重试
    - 直到商户返回成功
  3. 对一致性要求高:不允许消息丢失

    java 复制代码
    // 示例:资金流水
    - 每一笔资金变动必须有对应的消息

选择RocketMQ事务消息的场景

  1. 业务逻辑简单:单表操作,回查容易

    java 复制代码
    // 示例:用户注册送积分
    @Transactional
    public void register(User user) {
        userMapper.insert(user);
        // 回查:查用户表是否存在
    }
  2. 性能要求高:不想增加DB写入开销

  3. 团队经验丰富:熟悉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. 消息表必须和业务表在同一个数据库

    • 利用数据库事务保证强一致性
    • 避免分布式事务的复杂性
  2. 内存队列设置合理上限

    • 推荐值:1万 ~ 5万
    • 防止OOM
    • 依赖定时任务兜底
  3. 时间轮实现延迟重试

    • 失败消息延迟递增:5s、10s、25s、50s、100s
    • 避免频繁重试打垮系统
  4. 定时任务作为兜底机制

    • 扫描超时未发送的消息
    • 间隔建议:5分钟
    • 注意扫描性能

6.2 数据库设计

  1. 表结构设计

    sql 复制代码
    -- 核心字段
    status TINYINT          -- 状态
    retry_count INT         -- 重试次数
    next_retry_time TIMESTAMP  -- 下次重试时间
    
    -- 索引
    INDEX idx_status_retry (status, next_retry_time)
  2. 消息体大小

    • 控制在1KB以内
    • 最多不超过10KB
    • 大消息用OSS存储
  3. 数据归档

    • 已发送消息定期归档(7天)
    • 失败消息人工介入后删除(30天)
    • 表数据量控制在100万以内

6.3 代码实现

  1. 事务管理

    java 复制代码
    @Transactional(rollbackFor = Exception.class)
    public void businessMethod() {
        // 1. 业务操作
        bizMapper.insert(...);
        
        // 2. 发送消息(同一事务)
        msgClient.sendMsg(...);
    }
  2. 消息发送

    java 复制代码
    // 1. 同步写入消息表
    // 2. 异步放入内存队列
    // 3. 后台线程发送到MQ
    // 4. 定时任务兜底补偿
  3. 消费端幂等

    java 复制代码
    // 用Redis或数据库唯一约束
    String key = "msg:processed:" + msgId;
    Boolean success = redis.setIfAbsent(key, "1", 7, TimeUnit.DAYS);
    if (Boolean.FALSE.equals(success)) {
        return; // 已处理,跳过
    }

6.4 监控告警

  1. 消息积压告警

    java 复制代码
    // 队列大小超过80%告警
    if (msgQueue.size() > 8000) {
        alert("消息队列积压");
    }
  2. 发送失败告警

    java 复制代码
    // 失败消息超过阈值告警
    int failCount = msgMapper.countFailedMsg();
    if (failCount > 100) {
        alert("消息发送失败过多");
    }
  3. 处理延迟告警

    java 复制代码
    // 消息处理时长超过5分钟告警
    long delay = now - msg.getCreateTime();
    if (delay > 300000) {
        alert("消息处理延迟");
    }

6.5 容量规划

  1. 数据库

    • 消息表行数:< 100万
    • 单行大小:< 1KB
    • 总大小:< 1GB
  2. 内存队列

    • 队列长度:1万 ~ 5万
    • 单条消息:< 1KB
    • 总内存:< 50MB
  3. MQ集群

    • TPS:按业务峰值 × 1.5
    • 存储:按消息量 × 7天

七、总结

本地消息表是一种经过生产验证的分布式事务解决方案。相比RocketMQ事务消息,它在复杂业务场景下更加稳定可靠。

核心要点回顾:

  1. 强一致性:利用数据库事务保证业务和消息的原子性
  2. 简单回查:直接查消息表状态,不需要反推业务状态
  3. 消息补偿:支持失败消息重试,可靠性更高
  4. 降低耦合:MQ暂时不可用时不影响业务主流程

当然,它也有一些代价:

  1. 需要额外的表存储和代码维护
  2. 多一次数据库写入,性能略低
  3. 需要定时任务兜底,增加了系统复杂度

在实际项目中,建议根据业务场景灵活选择:

  • 复杂业务、高一致性要求 → 本地消息表
  • 简单业务、高性能要求 → RocketMQ事务消息
  • 混合使用,各取所长

希望这篇文章能帮到正在做分布式事务选型的你。如果有任何问题,欢迎留言交流!


参考资料

  1. RocketMQ官方文档:rocketmq.apache.org/zh/docs/
相关推荐
踏浪无痕1 天前
深入理解集群消费与广播消费的进度管理策略
rocketmq
程序员三明治9 天前
选 Redis Stream 还是传统 MQ?队列选型全攻略(适用场景、优缺点与实践建议)
java·redis·后端·缓存·rocketmq·stream·队列
稚辉君.MCA_P8_Java10 天前
RocketMQ 是什么?它的架构是怎么样的?和 Kafka 又有什么区别?
后端·架构·kafka·kubernetes·rocketmq
JimmtButler13 天前
RocketMQ本地编译
后端·rocketmq
JimmtButler13 天前
Namesrv解析
后端·rocketmq
阿里云云原生14 天前
阿里云两大 AI 原生实践荣获 2025 年度 OSCAR “开源+”典型案例
apache·rocketmq
阿里云云原生14 天前
PalmPay 携手阿里云 RocketMQ,共建非洲普惠金融“高速通道”
rocketmq
阿里云云原生16 天前
Apache RocketMQ × AI:面向 Multi-Agent 的事件驱动架构
apache·rocketmq
周杰伦_Jay16 天前
【 RocketMQ 全解析】分布式消息队列的架构、消息转发与快速实践、事务消息
分布式·算法·架构·rocketmq·1024程序员节