🎯 RocketMQ的顺序消息和事务消息:让消息听话排好队!

10 阅读20分钟

📖 开场:银行转账的故事

想象你要给朋友转账 💰:

操作序列:
1. 检查余额(有1000元) ✅
2. 扣除你的1000元 ✅
3. 增加朋友的1000元 ✅

如果顺序乱了会怎样? 😱

错误序列:
1. 增加朋友的1000元 ✅  ← 钱凭空出现!
2. 检查余额(有1000元) ✅
3. 扣除你的1000元 ✅

结果:朋友多了1000,你少了1000,但总金额不对了!💀

这就是为什么需要顺序消息和事务消息!

  • 顺序消息:保证消息按顺序处理 🔢
  • 事务消息:保证操作的原子性(要么全成功,要么全失败)⚛️

🎯 Part 1: 顺序消息(Ordered Message)

🤔 什么是顺序消息?

定义:消息按照发送的顺序被消费

示例

发送顺序:消息1 → 消息2 → 消息3
消费顺序:消息1 → 消息2 → 消息3 ✅

不能是:消息2 → 消息1 → 消息3 ❌

🏢 业务场景

场景1:订单状态流转 📦

订单状态变化:
1. 创建订单
2. 支付订单
3. 发货
4. 确认收货

必须按顺序!不能先发货再支付!😱

场景2:数据库Binlog同步 🗄️

MySQL操作:
1. INSERT user (id=1, name='张三')
2. UPDATE user SET name='李四' WHERE id=1
3. DELETE user WHERE id=1

同步到ES时必须按顺序!
否则:先DELETEINSERT,数据就错了!💀

场景3:股票交易 📈

交易记录:
1. 买入100股,价格10元
2. 卖出50股,价格12元
3. 买入30股,价格11元

必须按时间顺序处理,否则计算盈亏就错了!

📊 RocketMQ的三种顺序级别

1️⃣ 全局顺序(Global Ordering)⭐

定义:整个Topic的所有消息全局有序

实现1个Topic只有1个Queue

Topic: global-order-topic
└─ Queue-0  ← 只有一个队列!

发送:M1 → M2 → M3 → M4 → M5
Queue: [M1][M2][M3][M4][M5]
消费:M1 → M2 → M3 → M4 → M5 ✅

生活比喻
银行只有一个窗口,所有人排一个队,绝对有序!👥

优点

  • ✅ 100%保证顺序
  • ✅ 实现简单

缺点

  • ❌ 性能极差(只能串行处理)
  • ❌ 无法并发
  • ❌ 吞吐量低

适用场景

  • 顺序要求极高且数据量很小的场景
  • 一般不推荐使用!

2️⃣ 分区顺序(Partition Ordering)⭐⭐⭐⭐⭐

定义:同一个分区(Queue)内的消息有序

实现多个Queue,相同业务ID的消息路由到同一个Queue

Topic: order-topic
├─ Queue-0: [订单1-M1][订单1-M2][订单1-M3]  ← 订单1的消息
├─ Queue-1: [订单2-M1][订单2-M2][订单2-M3]  ← 订单2的消息
└─ Queue-2: [订单3-M1][订单3-M2][订单3-M3]  ← 订单3的消息

保证:
- 订单1的消息内部有序 ✅
- 订单2的消息内部有序 ✅
- 订单3的消息内部有序 ✅
- 但订单之间无需有序 😊

生活比喻
银行有3个窗口,同一个客户的所有业务在同一个窗口办理!🏦

优点

  • ✅ 保证了需要的顺序性
  • ✅ 支持并发(不同订单可并行处理)
  • ✅ 吞吐量高
  • ✅ **最常用的方案!**⭐⭐⭐⭐⭐

缺点

  • ❌ 实现稍复杂
  • ❌ 需要合理选择分区Key

3️⃣ 普通消息(无顺序)

定义:消息随机分配到各个Queue,不保证顺序

Topic: normal-topic
├─ Queue-0: [M3][M1][M5]
├─ Queue-1: [M2][M6]
└─ Queue-2: [M4]

完全无序,谁快谁先处理!

适用场景

  • 不需要顺序的场景(日志收集、监控数据)

💻 顺序消息代码实现

生产者:发送顺序消息

import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.MessageQueueSelector;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.common.message.MessageQueue;
import java.util.List;

public class OrderProducer {
    
    public static void main(String[] args) throws Exception {
        // 1. 创建生产者
        DefaultMQProducer producer = new DefaultMQProducer("order_producer_group");
        producer.setNamesrvAddr("localhost:9876");
        producer.start();
        
        // 2. 模拟订单数据
        List<OrderStep> orderSteps = buildOrders();
        
        // 3. 发送顺序消息
        for (OrderStep orderStep : orderSteps) {
            Message msg = new Message(
                "OrderTopic",  // Topic
                orderStep.toString().getBytes()
            );
            
            // ⭐ 关键:使用MessageQueueSelector,根据订单ID选择队列
            SendResult sendResult = producer.send(
                msg,
                new MessageQueueSelector() {
                    @Override
                    public MessageQueue select(
                        List<MessageQueue> mqs,
                        Message msg,
                        Object arg  // 这里是订单ID
                    ) {
                        // 根据订单ID选择队列
                        Long orderId = (Long) arg;
                        int index = (int) (orderId % mqs.size());
                        return mqs.get(index);  // 相同订单ID → 相同队列
                    }
                },
                orderStep.getOrderId()  // ⭐ 传入订单ID作为分区Key
            );
            
            System.out.printf("订单%d-%s 发送到 Queue-%d%n",
                orderStep.getOrderId(),
                orderStep.getDesc(),
                sendResult.getMessageQueue().getQueueId());
        }
        
        producer.shutdown();
    }
    
    /**
     * 构造订单数据
     */
    private static List<OrderStep> buildOrders() {
        List<OrderStep> orderSteps = new ArrayList<>();
        
        // 订单1的流程
        orderSteps.add(new OrderStep(1L, "创建订单"));
        orderSteps.add(new OrderStep(1L, "支付"));
        orderSteps.add(new OrderStep(1L, "发货"));
        orderSteps.add(new OrderStep(1L, "确认收货"));
        
        // 订单2的流程
        orderSteps.add(new OrderStep(2L, "创建订单"));
        orderSteps.add(new OrderStep(2L, "支付"));
        orderSteps.add(new OrderStep(2L, "发货"));
        orderSteps.add(new OrderStep(2L, "确认收货"));
        
        // 订单3的流程
        orderSteps.add(new OrderStep(3L, "创建订单"));
        orderSteps.add(new OrderStep(3L, "支付"));
        orderSteps.add(new OrderStep(3L, "发货"));
        orderSteps.add(new OrderStep(3L, "确认收货"));
        
        return orderSteps;
    }
}

/**
 * 订单步骤实体
 */
@Data
@AllArgsConstructor
class OrderStep {
    private Long orderId;    // 订单ID
    private String desc;     // 操作描述
}

运行结果

订单1-创建订单 发送到 Queue-1
订单1-支付 发送到 Queue-1      ← 相同订单,相同队列!
订单1-发货 发送到 Queue-1
订单1-确认收货 发送到 Queue-1

订单2-创建订单 发送到 Queue-2
订单2-支付 发送到 Queue-2
订单2-发货 发送到 Queue-2
订单2-确认收货 发送到 Queue-2

订单3-创建订单 发送到 Queue-0
订单3-支付 发送到 Queue-0
订单3-发货 发送到 Queue-0
订单3-确认收货 发送到 Queue-0

相同订单ID的消息,都进入了同一个队列!


消费者:顺序消费消息

import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.*;
import org.apache.rocketmq.common.message.MessageExt;
import java.util.List;

public class OrderConsumer {
    
    public static void main(String[] args) throws Exception {
        // 1. 创建消费者
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("order_consumer_group");
        consumer.setNamesrvAddr("localhost:9876");
        
        // 2. 订阅Topic
        consumer.subscribe("OrderTopic", "*");
        
        // 3. ⭐ 注册顺序消息监听器(MessageListenerOrderly)
        consumer.registerMessageListener(new MessageListenerOrderly() {
            @Override
            public ConsumeOrderlyStatus consumeMessage(
                List<MessageExt> msgs,
                ConsumeOrderlyContext context
            ) {
                // ⭐ 这里的消息是有序的!
                for (MessageExt msg : msgs) {
                    String body = new String(msg.getBody());
                    System.out.printf("线程[%s] 消费消息: %s, Queue=%d%n",
                        Thread.currentThread().getName(),
                        body,
                        msg.getQueueId());
                    
                    // 模拟业务处理
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                
                // 返回成功
                return ConsumeOrderlyStatus.SUCCESS;
            }
        });
        
        // 4. 启动消费者
        consumer.start();
        System.out.println("顺序消费者启动成功!");
    }
}

运行结果

线程[Thread-1] 消费消息: 订单1-创建订单, Queue=1
线程[Thread-1] 消费消息: 订单1-支付, Queue=1      ← 顺序正确!
线程[Thread-1] 消费消息: 订单1-发货, Queue=1
线程[Thread-1] 消费消息: 订单1-确认收货, Queue=1

线程[Thread-2] 消费消息: 订单2-创建订单, Queue=2
线程[Thread-2] 消费消息: 订单2-支付, Queue=2
线程[Thread-2] 消费消息: 订单2-发货, Queue=2
线程[Thread-2] 消费消息: 订单2-确认收货, Queue=2

线程[Thread-3] 消费消息: 订单3-创建订单, Queue=0
线程[Thread-3] 消费消息: 订单3-支付, Queue=0
线程[Thread-3] 消费消息: 订单3-发货, Queue=0
线程[Thread-3] 消费消息: 订单3-确认收货, Queue=0

每个订单的消息按顺序消费!不同订单可以并行消费!


🔐 顺序消息的底层原理

生产者端

1. 选择Queue
   orderId % queueSize = queueId
   
   相同orderId → 相同queueId
   
2. 发送到指定Queue
   相同Queue内的消息是有序的(先进先出)

图示

                Producer
                    │
         ┌──────────┼──────────┐
         │          │          │
      Order1     Order2     Order3
      (orderId=1) (orderId=2) (orderId=3)
         │          │          │
         │          │          │
    hash(1)%3=1  hash(2)%3=2  hash(3)%3=0
         │          │          │
         ↓          ↓          ↓
      Queue-1    Queue-2    Queue-0
    [M1][M2]    [M1][M2]   [M1][M2]
    [M3][M4]    [M3][M4]   [M3][M4]

消费者端

关键:Queue级别的锁 🔒

1. 消费者拉取消息前,先对Queue加锁
2. 同一时刻,一个Queue只能被一个线程消费
3. 消费完成后,释放锁
4. 其他线程才能消费这个Queue的下一条消息

对比

类型监听器锁机制顺序性性能
顺序消费MessageListenerOrderlyQueue级别锁✅ 有序😐 中等
并发消费MessageListenerConcurrently无锁❌ 无序⚡ 高

图示

MessageListenerOrderly(顺序消费):

Queue-0: [M1][M2][M3]
            ↓
         🔒 加锁
            ↓
        Thread-1 消费 M1
            ↓
        Thread-1 消费 M2  ← 同一个线程,顺序处理
            ↓
        Thread-1 消费 M3
            ↓
         🔓 解锁


MessageListenerConcurrently(并发消费):

Queue-0: [M1][M2][M3]
            ↓
      并发消费(无锁)
     ↙      ↓      ↘
Thread-1  Thread-2  Thread-3
消费M1    消费M2    消费M3    ← 同时消费,可能M2先处理完

⚠️ 顺序消息的注意事项

1. 消费失败的处理

问题

Queue-0: [M1][M2][M3]

M1消费成功 ✅
M2消费失败 ❌ ← 怎么办?
M3还没消费 ⏳

处理逻辑

@Override
public ConsumeOrderlyStatus consumeMessage(
    List<MessageExt> msgs,
    ConsumeOrderlyContext context
) {
    for (MessageExt msg : msgs) {
        try {
            processMessage(msg);
            return ConsumeOrderlyStatus.SUCCESS;
        } catch (Exception e) {
            log.error("消费失败", e);
            
            // ⭐ 返回 SUSPEND_CURRENT_QUEUE_A_MOMENT
            // 效果:暂停消费这个Queue一段时间,然后重试
            return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
        }
    }
}

重试机制

M2消费失败 → 暂停Queue-0消费1秒 → 重试M2
           → 失败 → 暂停Queue-0消费2秒 → 重试M2
           → 失败 → 暂停Queue-0消费4秒 → 重试M2
           → ...
           → 失败次数过多 → 发送到死信队列

影响

  • ⚠️ Queue被阻塞,M3也无法消费
  • ⚠️ 其他Queue不受影响

2. 消费者数量要≤Queue数量

4个Queue,5个消费者:

Queue-0 → Consumer-1
Queue-1 → Consumer-2
Queue-2 → Consumer-3
Queue-3 → Consumer-4
          Consumer-5 (闲置) ❌

原因:一个Queue只能被一个消费者消费(顺序性要求)

3. 不要在消费代码中加锁

// ❌ 错误:不要自己加锁
synchronized (lock) {
    processMessage(msg);
}

// ✅ 正确:RocketMQ已经帮你加锁了(Queue级别)
processMessage(msg);

⚛️ Part 2: 事务消息(Transactional Message)

🤔 什么是事务消息?

定义:保证本地事务发送消息的原子性

问题场景

业务流程:下单 + 扣减库存

try {
    // 1. 本地事务:扣减库存
    db.execute("UPDATE inventory SET stock = stock - 1 WHERE id = 1");
    
    // 2. 发送消息:通知发货
    mq.send("通知发货");
    
    db.commit();
} catch (Exception e) {
    db.rollback();
}

可能的问题

问题1:先提交事务,再发消息

1. 提交数据库事务 ✅
2. 发送消息  ❌ 失败(网络故障)

结果:库存扣减了,但发货消息丢失!💀

问题2:先发消息,再提交事务

1. 发送消息 ✅
2. 提交数据库事务 ❌ 失败(违反约束)

结果:发货消息发出了,但库存没扣减!💀

两难境地

先提交事务 → 消息可能丢失 💀
先发消息 → 事务可能失败 💀

事务消息的解决方案

保证:
- 本地事务成功 → 消息一定发送 ✅
- 本地事务失败 → 消息一定不发送 ✅
- 要么都成功,要么都失败!

🎯 事务消息的业务场景

场景1:订单和库存 🛒

用户下单:
1. 创建订单(本地事务)
2. 扣减库存(发送消息到库存服务)

要求:订单创建成功,库存消息一定要发出

场景2:积分和优惠券 🎁

用户支付成功:
1. 增加积分(本地事务)
2. 发放优惠券(发送消息)

要求:积分增加了,优惠券消息一定要发出

场景3:账户和账单 💰

转账:
1. A账户扣款(本地事务)
2. 生成转账记录(发送消息)

要求:扣款成功,转账记录消息一定要发出

🔄 事务消息的执行流程

完整流程图

Producer                    Broker                  Consumer
   │                          │                         │
   │ 1. 发送半消息(Half)       │                         │
   ├─────────────────────────>│                         │
   │                          │                         │
   │ 2. 半消息存储成功          │                         │
   │<─────────────────────────┤                         │
   │                          │                         │
   │ 3. 执行本地事务            │                         │
   ├──┐                       │                         │
   │  │ try {                 │                         │
   │  │   db.update(...)      │                         │
   │  │   db.commit()         │                         │
   │  │ }                     │                         │
   │<─┘                       │                         │
   │                          │                         │
   │ 4. 提交/回滚事务消息       │                         │
   ├─────────────────────────>│                         │
   │   COMMIT_MESSAGE ✅       │                         │
   │   or                     │                         │
   │   ROLLBACK_MESSAGE ❌     │                         │
   │                          │                         │
   │                          │ 5. 消息可见(如果提交)    │
   │                          ├────────────────────────>│
   │                          │                         │
   │                          │                  6. 消费消息
   │                          │                         ├──┐
   │                          │                         │  │ processMessage()
   │                          │                         │<─┘
   │                          │                         │
   │                          │<────────────────────────┤
   │                          │    7. ACK              │
   │                          │                         │

如果生产者宕机了怎么办? 🤔

Producer在执行本地事务时宕机 💀

Broker发现半消息长时间没有确认(超时)

Broker主动回查Producer:事务到底成功还是失败?

Producer查询本地事务状态,告诉Broker:
- COMMIT_MESSAGE:提交 ✅
- ROLLBACK_MESSAGE:回滚 ❌
- UNKNOWN:不确定(继续回查)🤔

💻 事务消息代码实现

生产者:发送事务消息

import org.apache.rocketmq.client.producer.LocalTransactionState;
import org.apache.rocketmq.client.producer.TransactionListener;
import org.apache.rocketmq.client.producer.TransactionMQProducer;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.common.message.MessageExt;

public class TransactionProducer {
    
    public static void main(String[] args) throws Exception {
        // 1. 创建事务消息生产者
        TransactionMQProducer producer = new TransactionMQProducer("transaction_producer_group");
        producer.setNamesrvAddr("localhost:9876");
        
        // 2. ⭐ 设置事务监听器
        producer.setTransactionListener(new TransactionListener() {
            
            /**
             * 执行本地事务
             * 半消息发送成功后,Broker会回调这个方法
             */
            @Override
            public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
                System.out.println("⭐ 开始执行本地事务...");
                
                try {
                    // ⭐ 执行本地事务(比如:扣减库存)
                    String orderId = new String(msg.getBody());
                    boolean success = executeLocalBusiness(orderId);
                    
                    if (success) {
                        System.out.println("✅ 本地事务执行成功,提交消息");
                        return LocalTransactionState.COMMIT_MESSAGE;  // 提交消息
                    } else {
                        System.out.println("❌ 本地事务执行失败,回滚消息");
                        return LocalTransactionState.ROLLBACK_MESSAGE;  // 回滚消息
                    }
                    
                } catch (Exception e) {
                    System.out.println("⚠️ 本地事务异常,回滚消息");
                    e.printStackTrace();
                    return LocalTransactionState.ROLLBACK_MESSAGE;
                }
            }
            
            /**
             * 检查本地事务状态
             * 如果Producer宕机,Broker会回调这个方法进行回查
             */
            @Override
            public LocalTransactionState checkLocalTransaction(MessageExt msg) {
                System.out.println("🔍 Broker回查本地事务状态...");
                
                try {
                    // ⭐ 查询本地事务状态(从数据库查询)
                    String orderId = new String(msg.getBody());
                    boolean exists = checkLocalBusinessStatus(orderId);
                    
                    if (exists) {
                        System.out.println("✅ 回查结果:事务已提交");
                        return LocalTransactionState.COMMIT_MESSAGE;
                    } else {
                        System.out.println("❌ 回查结果:事务已回滚");
                        return LocalTransactionState.ROLLBACK_MESSAGE;
                    }
                    
                } catch (Exception e) {
                    System.out.println("⚠️ 回查异常,返回UNKNOW,稍后继续回查");
                    e.printStackTrace();
                    return LocalTransactionState.UNKNOW;  // 未知状态,继续回查
                }
            }
        });
        
        // 3. 启动生产者
        producer.start();
        
        // 4. 发送事务消息
        String orderId = "ORDER_" + System.currentTimeMillis();
        Message msg = new Message(
            "TransactionTopic",
            "TagA",
            orderId.getBytes()
        );
        
        System.out.println("📤 发送事务消息: " + orderId);
        producer.sendMessageInTransaction(msg, null);
        
        // 等待一段时间
        Thread.sleep(Integer.MAX_VALUE);
        
        producer.shutdown();
    }
    
    /**
     * 执行本地业务(模拟)
     */
    private static boolean executeLocalBusiness(String orderId) {
        System.out.println("💼 执行本地业务:扣减库存,订单ID=" + orderId);
        
        // 模拟数据库操作
        try {
            // jdbc.execute("UPDATE inventory SET stock = stock - 1");
            // jdbc.execute("INSERT INTO orders VALUES (...)");
            // jdbc.commit();
            
            Thread.sleep(1000);  // 模拟耗时
            return true;  // 模拟成功
            
        } catch (Exception e) {
            // jdbc.rollback();
            return false;
        }
    }
    
    /**
     * 检查本地业务状态(模拟)
     */
    private static boolean checkLocalBusinessStatus(String orderId) {
        System.out.println("🔍 查询本地业务状态:订单ID=" + orderId);
        
        // 从数据库查询订单是否存在
        // SELECT COUNT(*) FROM orders WHERE order_id = ?
        return true;  // 模拟:订单已存在
    }
}

消费者:消费事务消息

import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.*;
import org.apache.rocketmq.common.message.MessageExt;
import java.util.List;

public class TransactionConsumer {
    
    public static void main(String[] args) throws Exception {
        // 创建消费者
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("transaction_consumer_group");
        consumer.setNamesrvAddr("localhost:9876");
        consumer.subscribe("TransactionTopic", "*");
        
        // 注册消息监听器
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(
                List<MessageExt> msgs,
                ConsumeConcurrentlyContext context
            ) {
                for (MessageExt msg : msgs) {
                    String orderId = new String(msg.getBody());
                    System.out.println("✅ 消费事务消息: " + orderId);
                    
                    // 执行业务逻辑(比如:通知发货)
                    notifyWarehouse(orderId);
                }
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
        
        consumer.start();
        System.out.println("事务消费者启动成功!");
    }
    
    private static void notifyWarehouse(String orderId) {
        System.out.println("📦 通知仓库发货: " + orderId);
    }
}

🔄 事务消息的执行流程(详细版)

正常流程(事务成功)✅

1️⃣ Producer发送半消息
   Producer → Broker: "我要发送一条事务消息"
   Broker: "好的,我先存储为半消息(Half Message)"
   
   半消息特点:
   - 存储在CommitLog中
   - 消费者看不到(不可见)
   - 标记为"预提交"状态

2️⃣ Broker确认半消息
   Broker → Producer: "半消息已存储,你可以执行本地事务了"

3️⃣ Producer执行本地事务
   try {
       db.update("UPDATE inventory SET stock = stock - 1");
       db.commit();  ✅ 成功
   }

4️⃣ Producer提交消息
   Producer → Broker: "本地事务成功,请提交消息"
   
5️⃣ Broker标记消息可见
   Broker: "收到!我把消息标记为可见"
   消息从"半消息"变为"正常消息"

6️⃣ Consumer消费消息
   Consumer: "我看到消息了,开始消费"
   Consumer: "消费成功!"

异常流程(事务失败)❌

1️⃣ Producer发送半消息
   Producer → Broker: "我要发送一条事务消息"
   Broker: "好的,我先存储为半消息"

2️⃣ Producer执行本地事务
   try {
       db.update("UPDATE inventory SET stock = stock - 1");
       db.commit();  ❌ 失败(库存不足)
   } catch (Exception e) {
       // 事务失败
   }

3️⃣ Producer回滚消息
   Producer → Broker: "本地事务失败,请回滚消息"

4️⃣ Broker删除半消息
   Broker: "收到!我删除这条半消息"

5️⃣ Consumer永远看不到这条消息
   Consumer: "什么消息?我没看到啊!"

回查流程(Producer宕机)💀

1️⃣ Producer发送半消息
   Producer → Broker: "我要发送一条事务消息"
   Broker: "好的,我先存储为半消息"

2️⃣ Producer执行本地事务
   try {
       db.update("UPDATE inventory SET stock = stock - 1");
       db.commit();  ✅ 成功
   }

3️⃣ Producer准备提交消息时,宕机了!💀
   Producer: (宕机)💀
   Broker: "Producer怎么没反应了?"

4️⃣ Broker超时,开始回查
   Broker等待60秒(默认)
   Broker: "Producer还没确认,我主动问问他吧"
   
   Broker → Producer: "你的事务执行成功了吗?"

5️⃣ Producer恢复后,回查本地事务状态
   Producer: "让我查查数据库..."
   Producer: SELECT COUNT(*) FROM orders WHERE order_id = ?
   Producer: "查到了!事务已提交!"
   
   Producer → Broker: "事务已成功,请提交消息"

6️⃣ Broker提交消息
   Broker: "好的,我标记消息可见"

7️⃣ Consumer消费消息
   Consumer: "我看到消息了,开始消费"

回查机制的参数

# 回查间隔时间(默认60秒)
transactionCheckInterval=60000

# 最大回查次数(默认15次)
transactionCheckMax=15

# 第一次回查延迟时间(默认6秒)
transactionTimeout=6000

🎯 事务消息的底层原理

半消息(Half Message)的存储

RocketMQ的存储结构

CommitLog(所有消息的物理存储)
├─ 正常消息:Topic = 实际Topic,可见
├─ 半消息:Topic = RMQ_SYS_TRANS_HALF_TOPIC,不可见 ⭐
└─ Op消息:记录半消息的操作(提交/回滚)

流程

1. 发送半消息
   → 存储到CommitLog
   → Topic替换为 RMQ_SYS_TRANS_HALF_TOPIC
   → 消费者看不到(因为订阅的是原Topic)

2. 提交消息(COMMIT)
   → 从Half Topic读取原始消息
   → 恢复原始Topic
   → 构建索引,消息变为可见
   → 写入Op消息(标记已提交)

3. 回滚消息(ROLLBACK)
   → 写入Op消息(标记已回滚)
   → 半消息不会被构建索引,永远不可见

图示

┌────────────────── CommitLog ──────────────────┐
                                               
  Offset  Topic                     Visible    
  ------  -----                     -------    
  1000    OrderTopic                YES        
  1001    RMQ_SYS_TRANS_HALF_TOPIC  NO    半消息
  1002    OrderTopic                YES        
  1003    RMQ_SYS_TRANS_OP_HALF     -     Op消息
  1004    OrderTopic                YES        
                                               
└───────────────────────────────────────────────┘

消费者订阅 OrderTopic,只能看到 Offset 1000, 1002, 1004
看不到 Offset 1001(半消息)

⚠️ 事务消息的注意事项

1. 本地事务要幂等

场景

Producer执行本地事务 → 成功
Producer准备提交消息 → 网络故障,提交失败
Broker回查 → Producer再次执行本地事务 → 重复执行!💀

解决

@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
    // ⭐ 不要再次执行事务,只查询事务状态!
    boolean exists = checkTransactionStatus(msg);  // 查询数据库
    return exists ? COMMIT_MESSAGE : ROLLBACK_MESSAGE;
}

private boolean checkTransactionStatus(MessageExt msg) {
    String orderId = new String(msg.getBody());
    // ⭐ 查询数据库,判断订单是否已存在
    return orderRepository.existsByOrderId(orderId);
}

2. 回查要快速返回

❌ 错误做法

@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
    // 再次执行业务逻辑(错误!)
    executeLocalBusiness(msg);  // 可能很慢!
    return COMMIT_MESSAGE;
}

✅ 正确做法

@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
    // 快速查询状态
    boolean committed = isTransactionCommitted(msg);  // 查数据库,很快
    return committed ? COMMIT_MESSAGE : ROLLBACK_MESSAGE;
}

原因

  • Broker有超时时间(默认6秒)
  • 如果回查太慢,Broker会继续回查
  • 导致大量重复回查,浪费资源

3. 回查次数有限制

配置

# 最大回查次数:15次
transactionCheckMax=15

超过15次还是UNKNOW怎么办?

→ 消息被自动回滚(删除)
→ 相当于消息丢失!💀

解决

  • 确保回查逻辑正确
  • 确保数据库可用
  • 监控回查失败次数

4. 事务消息不支持延迟和批量

限制

// ❌ 不支持延迟
msg.setDelayTimeLevel(3);  // 无效!

// ❌ 不支持批量
producer.send(Arrays.asList(msg1, msg2, msg3));  // 无效!

// ✅ 只能单条发送
producer.sendMessageInTransaction(msg, null);

📊 顺序消息 vs 事务消息

特性顺序消息事务消息
解决的问题消息顺序性本地事务 + 发送消息的原子性
实现方式相同Key进同一个Queue半消息 + 事务回查
性能中等(Queue级别锁)较低(需要回查)
可靠性非常高
适用场景订单状态流转、Binlog同步订单和库存、积分和优惠券
监听器MessageListenerOrderlyTransactionListener
是否支持批量
是否支持延迟

🎓 面试题速答

Q1: RocketMQ如何保证消息顺序性?

A:

  1. 生产者:使用MessageQueueSelector,相同业务ID的消息路由到同一个Queue
  2. Broker:同一个Queue内的消息FIFO(先进先出)
  3. 消费者:使用MessageListenerOrderly,Queue级别加锁,顺序消费

核心:相同业务的消息进同一个Queue,Queue内有序!


Q2: 全局顺序和分区顺序的区别?

A:

  • 全局顺序:整个Topic只有1个Queue,所有消息全局有序

    • 优点:100%有序
    • 缺点:性能差,无法并发
  • 分区顺序:多个Queue,相同Key的消息进同一个Queue

    • 优点:性能高,支持并发
    • 缺点:实现稍复杂
    • **推荐使用!**⭐

Q3: 顺序消息消费失败怎么办?

A: 返回SUSPEND_CURRENT_QUEUE_A_MOMENT

  • 暂停这个Queue的消费
  • 等待一段时间后重试
  • 失败次数过多,发送到死信队列

影响

  • ⚠️ 这个Queue被阻塞,后续消息也无法消费
  • ✅ 其他Queue不受影响

Q4: 什么是事务消息?

A: 保证本地事务发送消息的原子性:

  • 本地事务成功 → 消息一定发送 ✅
  • 本地事务失败 → 消息一定不发送 ✅

实现

  1. 发送半消息(Half Message)
  2. 执行本地事务
  3. 提交/回滚消息

回查机制

  • Producer宕机 → Broker主动回查事务状态
  • 保证消息不丢失

Q5: 事务消息的流程?

A:

1. Producer发送半消息到Broker
2. Broker存储半消息(消费者看不到)
3. Producer执行本地事务
4. 成功 → Producer提交消息 → 消费者可见
5. 失败 → Producer回滚消息 → 消息删除
6. 超时 → Broker回查Producer → 决定提交还是回滚

Q6: 事务消息的回查机制?

A: 触发条件:Producer长时间没有确认消息状态

流程

  1. Broker主动回调Producer的checkLocalTransaction()
  2. Producer查询本地事务状态(从数据库查询)
  3. 返回COMMIT/ROLLBACK/UNKNOW
  4. UNKNOW会继续回查,最多15次

注意

  • 回查要快(不要重新执行事务)
  • 回查要准(查询数据库状态)
  • 超过15次UNKNOW,消息自动回滚

🎯 最佳实践总结

顺序消息 ✅

1. 合理选择分区Key
   - 同一业务的消息用相同Key
   - 避免热点Key(某个Key消息量特别大)

2. 消费者数量 ≤ Queue数量
   - 推荐:消费者数 = Queue数

3. 异常处理
   - 失败重试有限次数
   - 超过次数发死信队列
   - 不要阻塞太久

4. 监控告警
   - 监控消费延迟
   - 监控重试次数
   - 监控死信队列

事务消息 ✅

1. 本地事务要幂等
   - 使用唯一ID防止重复

2. 回查要快速
   - 只查询状态,不重新执行
   - 从数据库查询

3. 回查要准确
   - 明确判断事务是否成功
   - 避免返回UNKNOW

4. 监控告警
   - 监控回查次数
   - 监控回查失败率
   - 监控事务超时

5. 降级方案
   - 回查失败后的补偿
   - 人工介入机制

🎬 总结:一张图看懂顺序消息和事务消息

┌─────────────────────────────────────────────────────┐
                    顺序消息                         
                                                     
  问题:消息乱序                                      
  解决:相同Key  同一Queue  顺序消费               
                                                     
  Producer:                                          
    ├─ 使用MessageQueueSelector                     
    └─ 相同orderId  同一Queue                       
                                                     
  Consumer:                                          
    ├─ 使用MessageListenerOrderly                   
    └─ Queue级别锁,顺序消费                         
                                                     
  适用场景:                                         
    ├─ 订单状态流转                                  
    ├─ Binlog同步                                   
    └─ 需要严格顺序的场景                            
└─────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────┐
                    事务消息                         
                                                     
  问题:本地事务和消息发送不一致                      
  解决:半消息 + 事务回查                            
                                                     
  流程:                                             
    1. 发送半消息(消费者看不到)                    
    2. 执行本地事务                                  
    3. 提交/回滚消息                                 
    4. 如果超时,Broker主动回查                      
                                                     
  适用场景:                                         
    ├─ 订单和库存                                    
    ├─ 积分和优惠券                                  
    └─ 需要强一致性的场景                            
└─────────────────────────────────────────────────────┘

🎉 恭喜你!

你已经掌握了RocketMQ的顺序消息和事务消息!🎊

核心要点

  1. 顺序消息:相同Key进同一Queue,Queue内有序
  2. 事务消息:半消息 + 本地事务 + 回查机制

下次面试,这样回答

"RocketMQ的顺序消息通过MessageQueueSelector实现,相同业务ID的消息路由到同一个Queue,然后使用MessageListenerOrderly顺序消费。

事务消息通过半消息机制实现,先发送半消息(消费者不可见),然后执行本地事务,成功就提交消息,失败就回滚。如果Producer宕机,Broker会主动回查事务状态,保证消息一定能发送或一定不发送。

我们在订单系统中使用了顺序消息保证订单状态流转的顺序性,在库存系统中使用了事务消息保证本地事务和消息发送的一致性。"

面试官:👍 "非常专业!你对RocketMQ理解很深!"


🎈 表情包时间 🎈

       学完RocketMQ顺序消息和事务消息后:

              之前:
         😵 "顺序乱了怎么办?事务和消息不一致怎么办?"
         
              现在:
         😎 "顺序消息!事务消息!轻松搞定!"
         
              面试官:
         😲 "这小子可以!"

本文完 🎬

记得点赞👍 收藏⭐ 分享🔗

上一篇: 184-Kafka的消息积压如何处理.md
下一篇: 186-RocketMQ的刷盘机制和主从同步.md


作者注:写完这篇,感觉可以开一家"消息中间件咨询公司"了!📮
如果这篇文章对你有帮助,请给我一个Star⭐!

版权声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。