RocketMQ实操避坑指南:那些年我们踩过的坑

94 阅读15分钟

RocketMQ实操避坑指南:那些年我们踩过的坑

写在前面

用RocketMQ也有七八年了,回头看看当初做的一些设计,真是有点哭笑不得。今天就来聊聊我们团队在使用RocketMQ时踩过的几个大坑,希望能帮大家少走点弯路。不是什么高大上的架构设计,就是真实的踩坑经历。

坑一:自己造了个Event组件,结果更乱了

当时怎么想的

我们当初的想法其实很朴素:RocketMQ消息发了就没了,万一要查历史消息或者重发怎么办?于是项目组就决定搞个Event组件,把所有消息都存一份到数据库里,方便管理和回溯。

听起来挺合理的对吧?但实际用起来被折腾惨了。

问题一个接一个

整个流程是这样的:

sequenceDiagram
    participant 业务代码
    participant Event组件
    participant MySQL
    participant RocketMQ
    
    业务代码->>Event组件: 发送消息
    Event组件->>MySQL: 1. 保存事件记录
    MySQL-->>Event组件: 保存成功
    Event组件->>RocketMQ: 2. 发送MQ消息
    RocketMQ-->>Event组件: 发送成功
    Event组件-->>业务代码: 返回成功
    
    Note over MySQL,RocketMQ: 致命问题:两个操作没有事务保证!

我们当时没做事务,就是简单地先往MySQL插一条记录,再往RocketMQ发消息。这就导致了各种神奇的数据不一致。

第一种情况:RocketMQ有消息,Event里没有

@Service
public class EventService {
    
    @Transactional
    public void sendEvent(EventMessage msg) {
        // 先存数据库
        eventMapper.insert(msg);
        
        // 做一些其他业务逻辑
        doSomeBusiness();
        
        // 再发RocketMQ
        rocketMQTemplate.send(topic, msg);
        
        // 问题来了:如果doSomeBusiness()抛异常,事务回滚
        // 但RocketMQ消息已经发出去了!
    }
}

这种情况特别常见。因为我们的数据库操作在Spring事务里,但RocketMQ的发送不在。结果就是消息发出去了,消费者开始处理了,但事务回滚了,Event表里根本没这条记录。

然后就开始各种对账,发现Event表少了消息,运维同学就得手动去补数据。我记得有一次月底对账,发现少了几百条记录,差点没把人逼疯。

第二种情况:Event里有消息,RocketMQ里没有

public void sendEvent(EventMessage msg) {
    // 先存数据库,成功了
    eventMapper.insert(msg);
    
    // 发RocketMQ的时候网络抖了一下
    try {
        rocketMQTemplate.send(topic, msg);
    } catch (Exception e) {
        // 失败了,但数据库已经提交了
        log.error("发送MQ失败", e);
    }
}

这时候Event管理后台就会显示"待发送"状态,需要手动点重发。但这个重发按钮,说实话我们都不太敢点:

  • 生产者代码里可能还有其他逻辑,比如修改订单状态、扣库存啥的,单纯重发消息可能逻辑就不完整了
  • 有的消费者不支持幂等(这个锅也得我们自己背),重发了可能会重复处理,数据就乱了
  • 根本不知道当时发送失败是因为什么,网络问题?Broker挂了?盲目重发风险太大

所以这个Event组件搞到最后特别鸡肋,既不敢随便重发,又要花时间去维护这个表,还经常需要对账补数据。

性能也扛不住

每次发消息都要写数据库,QPS上去之后数据库压力巨大。我们当时有个核心业务,发消息的TPS能到3000+,MySQL直接就扛不住了。

graph TD
    A[每秒3000+请求] --> B[Event组件]
    B --> C[写MySQL Event表]
    B --> D[发RocketMQ]
    C --> E[MySQL压力爆炸]
    E --> F[慢查询增多]
    F --> G[接口超时]
    G --> H[告警疯狂响]

后来不得不做了分库分表,但这又增加了维护成本。而且分库分表之后,查询历史消息更麻烦了,得去多个库里查。

后来我们怎么改的

其实RocketMQ自己就有事务消息的功能,根本不需要我们自己搞这一套。我当时研究了一下RocketMQ的事务消息,发现这才是正道。

RocketMQ事务消息的原理是这样的:

sequenceDiagram
    participant Producer
    participant Broker
    participant Consumer
    
    Producer->>Broker: 1. 发送Half消息(对消费者不可见)
    Broker-->>Producer: 2. Half消息发送成功
    Producer->>Producer: 3. 执行本地事务
    
    alt 本地事务成功
        Producer->>Broker: 4. 发送Commit
        Broker->>Broker: 消息变为可消费
        Broker->>Consumer: 5. 投递消息给消费者
    else 本地事务失败
        Producer->>Broker: 4. 发送Rollback
        Broker->>Broker: 删除Half消息
        Note over Consumer: 消费者永远不会收到
    else 超时未响应
        Broker->>Producer: 回查本地事务状态
        Producer->>Producer: 检查业务数据
        Producer-->>Broker: 返回Commit/Rollback
    end

改造后的代码:

@Service
public class OrderService {
    
    @Autowired
    private RocketMQTemplate rocketMQTemplate;
    
    @Autowired
    private OrderMapper orderMapper;
    
    // 发送事务消息
    public void createOrder(OrderDTO orderDTO) {
        OrderMessage msg = convertToMessage(orderDTO);
        
        rocketMQTemplate.sendMessageInTransaction(
            "order_topic",
            MessageBuilder.withPayload(msg).build(),
            orderDTO  // 这个参数会传给本地事务方法
        );
    }
}

// 事务监听器
@RocketMQTransactionListener
public class OrderTransactionListener implements RocketMQLocalTransactionListener {
    
    @Autowired
    private OrderMapper orderMapper;
    
    // 执行本地事务
    @Override
    public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        OrderDTO orderDTO = (OrderDTO) arg;
        
        try {
            // 执行本地业务逻辑
            Order order = new Order();
            order.setOrderId(orderDTO.getOrderId());
            order.setStatus("CREATED");
            orderMapper.insert(order);
            
            // 本地事务成功,提交消息
            return RocketMQLocalTransactionState.COMMIT;
            
        } catch (Exception e) {
            log.error("创建订单失败", e);
            // 本地事务失败,回滚消息
            return RocketMQLocalTransactionState.ROLLBACK;
        }
    }
    
    // 事务状态回查
    @Override
    public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
        // Broker会定期回查事务状态
        String orderId = msg.getHeaders().get("orderId").toString();
        Order order = orderMapper.selectById(orderId);
        
        if (order != null && "CREATED".equals(order.getStatus())) {
            // 订单已创建,消息应该投递
            return RocketMQLocalTransactionState.COMMIT;
        } else {
            // 订单不存在或状态不对,回滚消息
            return RocketMQLocalTransactionState.ROLLBACK;
        }
    }
}

这样改造之后,Event组件直接废掉了,省了一堆代码和维护成本。消息的可靠性由RocketMQ保证,我们只需要关注业务逻辑就行。

至于消息回溯的需求,RocketMQ的Console就能做,虽然功能简陋点,但至少靠谱。如果真需要更强大的查询功能,可以考虑上RocketMQ的数据集成到Elasticsearch,这个后面有机会再聊。

坑二:为了保证顺序,创建了100个Topic

这个操作真的很迷

我们有个业务场景需要保证消息的顺序性,比如订单的状态变更必须按顺序处理:创建订单 -> 支付 -> 发货 -> 完成。如果乱序了,可能支付消息还没处理,发货消息就来了,这就乱套了。

然后不知道是哪位大神想出来的主意,说不用RocketMQ的顺序消息功能,而是创建100个Topic,根据订单ID取模分发到不同的Topic。理由是这样可以提高并发度,避免单个Topic成为瓶颈。

// 当时的代码,现在看起来都想笑
@Service
public class OrderMessageService {
    
    private static final int TOPIC_COUNT = 100;
    
    public void sendOrderMessage(OrderMessage msg) {
        Long orderId = msg.getOrderId();
        int topicIndex = (int) (orderId % TOPIC_COUNT);
        String topic = "order_topic_" + topicIndex;
        
        rocketMQTemplate.send(topic, msg);
    }
}

消费者也得订阅100个Topic:

@Service
public class OrderConsumerService {
    
    @Autowired
    private DefaultMQPushConsumer consumer;
    
    @PostConstruct
    public void init() throws Exception {
        // 订阅100个Topic,看着就头疼
        for (int i = 0; i < 100; i++) {
            String topic = "order_topic_" + i;
            consumer.subscribe(topic, "*");
        }
        consumer.start();
    }
}

带来的问题

运维噩梦

首先运维同学就不干了。管理后台里密密麻麻100个Topic,每个Topic还有多个队列,看着就头疼。想查个消息堆积情况,得翻好几页。而且Topic多了,Broker的元数据压力也大。

并没有解决顺序问题

更关键的是,这样搞其实并没有真正解决顺序问题:

123.jpg

graph TB
    A[订单1001: 创建] -->|orderId % 100 = 1| B[order_topic_1]
    C[订单1001: 支付] -->|orderId % 100 = 1| B
    D[订单1001: 发货] -->|orderId % 100 = 1| B
    
    B --> E[Queue 0]
    B --> F[Queue 1]
    B --> G[Queue 2]
    B --> H[Queue 3]
    
    E --> I[Consumer1]
    F --> I
    G --> J[Consumer2]
    H --> J
    
    I --> K{还是可能乱序!}
    J --> K

因为每个Topic还是有多个MessageQueue,如果不指定队列,同一个订单的消息可能被发到不同的队列,消费的时候还是会乱序。

我当时就提出过质疑,但架构师说已经上线了,业务跑得好好的,改起来风险大。后来我仔细看了下代码,发现之所以没出问题,是因为业务层做了很多补偿逻辑,比如状态机校验、重试机制等等,本质上是业务代码在兜底,而不是消息顺序真的保证了。

正确的做法

后来项目重构的时候,我们改用了RocketMQ原生的顺序消息。先来理解一下RocketMQ的队列模型:

graph LR
    A[Topic: order_topic] --> B[Queue 0]
    A --> C[Queue 1]
    A --> D[Queue 2]
    A --> E[Queue 3]
    
    B --> F[同一队列内保证顺序]
    C --> F
    D --> F
    E --> F
    
    F --> G[Consumer按顺序消费]

RocketMQ保证的是:同一个MessageQueue内的消息,消费时严格按照发送顺序。所以我们只需要把同一个订单的所有消息发到同一个队列就行了。

发送端改造:

@Service
public class OrderMessageService {
    
    @Autowired
    private RocketMQTemplate rocketMQTemplate;
    
    public void sendOrderMessage(OrderMessage msg) {
        // 使用orderId作为hash key,保证同一订单的消息发到同一个队列
        rocketMQTemplate.syncSendOrderly(
            "order_topic",
            msg,
            msg.getOrderId().toString()  // 这个就是hash key
        );
    }
}

原理是这样的:

sequenceDiagram
    participant Producer
    participant Broker
    participant Queue0
    participant Queue1
    
    Note over Producer: 订单1001的消息
    Producer->>Broker: hash(1001) = Queue0
    Broker->>Queue0: 创建、支付、发货按顺序入队
    
    Note over Producer: 订单1002的消息
    Producer->>Broker: hash(1002) = Queue1
    Broker->>Queue1: 创建、支付、发货按顺序入队
    
    Note over Queue0,Queue1: 不同订单可以并行处理

消费端改造:

@Component
@RocketMQMessageListener(
    topic = "order_topic",
    consumerGroup = "order_consumer_group",
    consumeMode = ConsumeMode.ORDERLY,  // 关键:顺序消费模式
    consumeThreadMax = 1  // 顺序消费建议用单线程
)
public class OrderMessageConsumer implements RocketMQListener<OrderMessage> {
    
    @Override
    public void onMessage(OrderMessage message) {
        log.info("收到订单消息: orderId={}, type={}", 
            message.getOrderId(), message.getType());
        
        // 处理业务逻辑
        processOrder(message);
    }
    
    private void processOrder(OrderMessage message) {
        // 同一个订单的消息会严格按顺序到达这里
        switch (message.getType()) {
            case "CREATE":
                handleCreate(message);
                break;
            case "PAY":
                handlePay(message);
                break;
            case "SHIP":
                handleShip(message);
                break;
        }
    }
}

需要注意的几个地方:

1. 顺序消费会牺牲一些性能

因为同一个队列的消息必须串行处理,所以吞吐量会比并发消费低。但这是值得的,毕竟数据准确性比性能重要。如果想提高性能,可以增加MessageQueue的数量。

2. 消费失败会阻塞队列

如果某条消息消费失败,RocketMQ会不断重试,这期间队列里后面的消息都会被阻塞。所以消费端一定要做好异常处理和兜底逻辑:

@Override
public void onMessage(OrderMessage message) {
    try {
        processOrder(message);
    } catch (BizException e) {
        // 业务异常,比如订单状态不对,这种不应该重试
        log.error("订单状态异常,跳过该消息: {}", message, e);
        // 可以把消息存到失败表,人工处理
        saveToFailTable(message, e);
        // 返回成功,让RocketMQ不要重试
        
    } catch (Exception e) {
        // 系统异常,比如数据库连接失败,这种可以重试
        log.error("系统异常,等待重试: {}", message, e);
        throw e;  // 抛出异常,RocketMQ会重试
    }
}

3. 不是所有场景都需要顺序消息

我们后来review了一下所有使用消息的地方,发现其实只有20%的场景真的需要顺序性。很多地方当初要求顺序,只是因为"感觉应该有序",但实际上业务逻辑里已经做了幂等和状态校验,乱序也不会有问题。

所以不要过度设计,真正需要顺序的才用顺序消息,其他的用普通消息就行,性能还更好。

坑三:延迟消息的神操作 - 30分钟+5分钟

需求背景

我们有个场景:用户下单后如果35分钟内没支付,就自动取消订单。这种场景用延迟消息是最合适的,下单的时候发一条35分钟后的延迟消息,消息到了之后检查订单状态,如果还是未支付就取消。

问题来了

但是RocketMQ的延迟消息有个限制:只支持固定的18个延迟等级,分别是:1s、5s、10s、30s、1m、2m、3m、4m、5m、6m、7m、8m、9m、10m、20m、30m、1h、2h。

你看,有30分钟,有1小时,就是没有35分钟。那怎么办呢?

项目组的某位天才想出了一个办法:先发一条30分钟的延迟消息,消息到了之后再发一条5分钟的延迟消息

// 下单时发送30分钟延迟消息
@Service
public class OrderService {
    
    public void createOrder(Order order) {
        // 保存订单
        orderMapper.insert(order);
        
        // 发送30分钟延迟消息
        CancelOrderMessage msg = new CancelOrderMessage();
        msg.setOrderId(order.getOrderId());
        msg.setStage(1);  // 第一阶段
        
        Message<CancelOrderMessage> message = MessageBuilder
            .withPayload(msg)
            .build();
            
        rocketMQTemplate.syncSend(
            "order_cancel_topic",
            message,
            3000,
            16  // 延迟等级16 = 30分钟
        );
    }
}

// 消费者:收到30分钟消息后,再发5分钟消息
@Component
@RocketMQMessageListener(
    topic = "order_cancel_topic",
    consumerGroup = "order_cancel_consumer"
)
public class OrderCancelConsumer implements RocketMQListener<CancelOrderMessage> {
    
    @Override
    public void onMessage(CancelOrderMessage msg) {
        if (msg.getStage() == 1) {
            // 第一阶段,再发5分钟延迟消息
            msg.setStage(2);
            
            Message<CancelOrderMessage> message = MessageBuilder
                .withPayload(msg)
                .build();
                
            rocketMQTemplate.syncSend(
                "order_cancel_topic",
                message,
                3000,
                12  // 延迟等级12 = 5分钟
            );
            
        } else if (msg.getStage() == 2) {
            // 第二阶段,真正取消订单
            Order order = orderMapper.selectById(msg.getOrderId());
            if ("UNPAID".equals(order.getStatus())) {
                order.setStatus("CANCELLED");
                orderMapper.updateById(order);
            }
        }
    }
}

整个流程是这样的:

sequenceDiagram
    participant 用户
    participant 订单服务
    participant RocketMQ
    participant 消费者
    
    用户->>订单服务: 下单
    订单服务->>RocketMQ: 发送30分钟延迟消息(stage=1)
    订单服务-->>用户: 下单成功
    
    Note over RocketMQ: 等待30分钟...
    
    RocketMQ->>消费者: 投递消息(stage=1)
    消费者->>消费者: 检查stage=1
    消费者->>RocketMQ: 再发送5分钟延迟消息(stage=2)
    
    Note over RocketMQ: 再等待5分钟...
    
    RocketMQ->>消费者: 投递消息(stage=2)
    消费者->>消费者: 检查stage=2
    消费者->>订单服务: 取消未支付订单

这样搞的问题

复杂度暴增

首先代码变复杂了,要维护stage状态,还要在消费者里再发一次消息。如果以后要改成40分钟、45分钟,是不是要发三次消息?

可靠性问题

如果第一次消费失败了怎么办?如果消费者重启了怎么办?如果第二次发送消息失败了怎么办?每一步都可能出问题,而且出问题后很难排查。

我记得有一次就出了事故:某天凌晨消费者服务器重启,正好有一批30分钟的消息到了,但是没来得及发第二次5分钟的消息,结果导致一批订单没有被取消。第二天用户投诉,说明明超时了为啥不取消,我们查日志查了半天才发现是这个问题。

监控困难

你怎么监控这个流程?30分钟到了吗?5分钟发了吗?每一步都要埋点监控,否则出问题根本不知道哪里卡住了。

更好的办法

后来我研究了一下,发现这个需求其实不应该用延迟消息。RocketMQ的延迟消息更适合那种固定延迟的场景,比如"5分钟后发送短信提醒"。

对于这种任意时间的延迟任务,更好的方案有几个:

方案一:定时任务扫表

最简单粗暴的方法,每分钟扫描一次订单表,把超时未支付的订单取消掉:

@Component
public class OrderTimeoutJob {
    
    @Autowired
    private OrderMapper orderMapper;
    
    // 每分钟执行一次
    @Scheduled(cron = "0 * * * * ?")
    public void cancelTimeoutOrders() {
        // 查询35分钟前创建的未支付订单
        Date timeoutTime = DateUtils.addMinutes(new Date(), -35);
        List<Order> timeoutOrders = orderMapper.selectTimeoutOrders(timeoutTime);
        
        for (Order order : timeoutOrders) {
            try {
                cancelOrder(order);
            } catch (Exception e) {
                log.error("取消订单失败: orderId={}", order.getOrderId(), e);
            }
        }
    }
    
    private void cancelOrder(Order order) {
        // 取消订单逻辑
        order.setStatus("CANCELLED");
        orderMapper.updateById(order);
        
        log.info("订单已取消: orderId={}", order.getOrderId());
    }
}

这种方案的好处是简单可靠,出了问题容易排查。缺点是有一定的延迟(最多1分钟),而且如果订单量大,扫表会有性能问题。

但对于我们这个场景,1分钟的延迟完全可以接受,用户也不会在意35分钟还是36分钟取消。性能问题可以通过加索引、分库分表来解决。

方案二:使用专门的延迟队列

如果真的需要精确的延迟时间,可以考虑用Redis的sorted set或者Redisson的DelayedQueue:

@Service
public class OrderDelayService {
    
    @Autowired
    private RedissonClient redissonClient;
    
    // 添加延迟任务
    public void addDelayTask(Long orderId, long delayMinutes) {
        RDelayedQueue<Long> delayedQueue = redissonClient.getDelayedQueue(
            redissonClient.getQueue("order_cancel_queue")
        );
        
        delayedQueue.offer(
            orderId,
            delayMinutes,
            TimeUnit.MINUTES
        );
    }
    
    // 消费延迟任务
    @PostConstruct
    public void startConsumer() {
        RQueue<Long> queue = redissonClient.getQueue("order_cancel_queue");
        
        new Thread(() -> {
            while (true) {
                try {
                    Long orderId = queue.take();  // 阻塞获取
                    processTimeoutOrder(orderId);
                } catch (Exception e) {
                    log.error("处理延迟任务失败", e);
                }
            }
        }).start();
    }
    
    private void processTimeoutOrder(Long orderId) {
        Order order = orderMapper.selectById(orderId);
        if ("UNPAID".equals(order.getStatus())) {
            order.setStatus("CANCELLED");
            orderMapper.updateById(order);
        }
    }
}

这种方案支持任意延迟时间,而且性能不错。缺点是引入了Redis依赖,而且要自己保证消费者的高可用。

方案三:使用RocketMQ 5.0的定时消息

这块我还没有深入研究,但听说RocketMQ 5.0版本支持了任意时间的定时消息,不再受限于18个延迟等级。如果项目组能升级到5.0,这应该是最优雅的方案。

// RocketMQ 5.0的定时消息(理论上,我还没实际用过)
public void sendTimerMessage(Order order) {
    Message<CancelOrderMessage> message = MessageBuilder
        .withPayload(new CancelOrderMessage(order.getOrderId()))
        .build();
    
    long deliverTime = System.currentTimeMillis() + 35 * 60 * 1000;  // 35分钟后
    
    rocketMQTemplate.syncSendDeliverTimeMills(
        "order_cancel_topic",
        message,
        deliverTime
    );
}

但因为我们现在用的还是4.x版本,还没来得及升级和测试,所以这块不敢多说,怕误导大家。

我们最后的选择

经过评估,我们最后选择了定时任务扫表的方案。虽然不够优雅,但足够简单可靠。我们加了个索引,定时任务每分钟只需要扫几千条数据,完全没有性能问题。

-- 加个复合索引
CREATE INDEX idx_status_createtime ON orders(status, create_time);

-- 扫表的SQL
SELECT * FROM orders 
WHERE status = 'UNPAID' 
  AND create_time < DATE_SUB(NOW(), INTERVAL 35 MINUTE)
LIMIT 1000;

上线后运行很稳定,也没出过什么问题。有时候简单的方案反而是最好的方案。

几个踩坑后的经验

不要过度设计

像Event组件这种,出发点是好的,但实际上RocketMQ已经提供了更好的方案。有时候我们太想搞一些"通用组件"、"中台系统",反而把简单问题复杂化了。

能用现成功能就用现成功能,不要什么都想自己造轮子。当然,如果是为了学习和理解原理,造轮子是好事,但在生产环境里,还是稳妥为好。

多画图,多思考

很多设计问题,如果提前画个时序图或者流程图,就能发现问题。比如Event组件的数据一致性问题,如果当初画个时序图,肯定能看出来两个操作之间没有事务保证。

我现在养成了一个习惯:每次要设计一个功能,先用Mermaid或者PlantUML画个图,自己推演一遍各种异常情况,能避免很多坑。

敬畏生产环境

100个Topic那个案例,如果当初做个压测,或者咨询一下有经验的人,就不会搞出这么奇葩的设计。但当时可能是为了赶进度,或者架构师太自信,直接就上了。

生产环境不是试验田,改一个东西要考虑方方面面:性能、可维护性、可观测性、故障恢复等等。宁可多花点时间论证,也不要留下技术债。

文档很重要

我们踩过这些坑之后,专门整理了一份《RocketMQ使用规范》,把这些经验教训都写进去了。新人来了必须先看这个文档,能避免重复踩坑。

而且每次遇到新问题,解决之后也会更新到文档里。现在这份文档已经有几十页了,成了我们团队最宝贵的资产之一。

保持学习

技术在不断进步,RocketMQ也在不断演进。比如我前面提到的5.0版本的定时消息功能,就很值得关注。还有一些新的特性,比如多租户、流式处理等等,有时间都应该研究一下。

不要守着老版本的知识不放,新技术可能已经解决了你现在遇到的问题。

写在最后

踩坑是成长的必经之路,重要的是踩完之后要总结反思,不要在同一个地方摔倒两次。

这篇文章记录的都是我们团队真实踩过的坑,可能有些地方理解得不够深入,也欢迎大家指正和补充。如果这些经验能帮到正在使用RocketMQ的你,那就太好了。

最后提醒一句:生产环境无小事,改动之前多思考,上线之后多观察

共勉。