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的元数据压力也大。
并没有解决顺序问题
更关键的是,这样搞其实并没有真正解决顺序问题:
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的你,那就太好了。
最后提醒一句:生产环境无小事,改动之前多思考,上线之后多观察。
共勉。