消息队列的坑我都替你踩过了:从小白到达人的队列设计血泪史
那年我接手一个订单系统,老员工拍着胸脯说:"用了消息队列,解耦神器,性能翻倍,稳得很!"结果上线第一天,1000个订单凭空消失,财务对账对到凌晨三点,老板的眼神比Redis宕机还可怕。
从那天起我就明白了:消息队列是这个行业最大的谎言之一。它不是什么"解耦神器",它是"事故放大器"。用得好,系统稳如老狗;用得不好,让你知道什么叫一夜回到解放前。
先说清楚:消息队列到底解决什么问题?
很多人嘴里挂着"解耦"和"异步",但问他具体解决了什么问题,十个有八个说不清楚。
消息队列解决的是生产速度和消费速度不匹配的问题。比如:
- 用户下单QPS 1000,但下游库存系统最多扛200。不排队?直接炸。
- 发送10000条短信,HTTP调用要10秒。用户等不及,UI直接超时。
- 日志收集,每秒10万条,写数据库扛不住。得先排队慢慢消化。
本质上,队列就是个缓冲区。但就是这个缓冲区,坑多得能埋人。
坑一:消息丢了,你都不知道
这是最经典的问题。生产者发了一条消息,心想"稳稳当当进队列了",结果消费者压根没收到。
问题出在哪?发送代码大概率是这样的:
// 经典错误写法
public void sendOrder(Order order) {
rabbitTemplate.convertAndSend("order.exchange", "order.create", order);
// 发完就当成功了
log.info("订单消息发送成功");
}
如果这条消息还在内存里,还没落到磁盘,RabbitMQ重启了,消息就没了。你以为发了,实际上在排队等投胎。
正确的做法是用发布确认(Publisher Confirm)机制:
// 正确写法:等待broker确认
public void sendOrder(Order order) {
rabbitTemplate.invokeConfirm((correlationData, ack, cause) -> {
if (!ack) {
// 消息没到broker,重试!
log.error("消息发送失败: {}", cause);
retrySend(order);
}
}, new CorrelationData(order.getId()));
}
当然,很多项目连Confirm都没配,直接原因是——懒。或者是"反正生产环境不会挂"的迷之自信。
坑二:重复消费,让你多发了一万条短信
消息丢了很可怕,但更可怕的是消息重复了。
为什么重复?网络抖动、超时重试、消费者重启没ACK……各种原因都会导致同一条消息被消费两次。
我曾经遇到一个场景:活动推送,用户收到了同一条短信发了三条。用户直接打电话投诉,运营还以为系统被黑客攻击了。
解决方案只有一条:做幂等。每条消息带一个唯一ID,消费前先查库或者Redis,看这笔处理过没有:
public void consumeOrder(String msgId, Order order) {
// 幂等key,存在说明处理过了
if (redis.setIfAbsent("order:processed:" + msgId, "1", 24h)) {
// 没处理过,执行
doProcess(order);
} else {
log.info("消息 {} 已处理过,跳过", msgId);
}
}
这条规则没有例外。只要你的消息队列支持重试,你就必须做幂等。
坑三:顺序乱了,用户收到的时间线是乱的
有些业务对顺序敏感。比如:用户点赞又取消了,结果取消的消息先到,点赞后到,用户的最终状态是"已点赞"——用户:???
消息队列里,同一个queue的消息是保序的,但问题是:
- 多消费者并发消费,同一个queue的消息可能被不同消费者处理,处理顺序就不保证了。
- 多实例部署,每个实例消费不同partition/queue,顺序更乱。
解法一:单消费者。牺牲性能,换顺序。但基本没人愿意这么干。
解法二:按用户ID分桶。同一个用户的操作都进同一个队列,这样全局顺序虽然不保证,但单个用户的操作顺序是保证的。
// 用用户ID做routing key,保证同一用户的消息进同一个队列
rabbitTemplate.convertAndSend("order.exchange",
"order." + order.getUserId().hashCode() % 10, // 10个队列分桶
order);
实际上,大多数场景根本不需要全局顺序。真正需要的时候,再考虑分桶。不要为了一个不存在的需求把系统复杂度拉满。
坑四:消费者挂了,消息堆积成山
正常消费时队列很短,但某天消费者突然挂了,消息就开始堆积。堆积到内存爆了,RabbitMQ开始swap,性能断崖式下跌,整个系统雪崩。
我见过最离谱的案例:消费者代码有个bug,抛出空指针异常,消息被不断重试——不是重新消费,是重试那条一直失败的消息。结果这个队列堵了16万条消息,新消息进不去,整个上游服务全部超时。
解法:死信队列(DLX)+重试次数限制
@Bean
public Queue orderQueue() {
return QueueBuilder.durable("order.queue")
.withArgument("x-dead-letter-exchange", "order.dlx")
.withArgument("x-dead-letter-routing-key", "order.dead")
.withArgument("x-message-ttl", 30000) // 超时变死信
.build();
}
超过重试次数的消息进死信队列,不阻塞主流程。同时要做好监控:队列深度超过阈值就报警,别等爆炸了才发现。
坑五:并发数没控制好,把下游服务打爆
队列是缓冲,但缓冲不是无限的。消费者说"我性能好",把并发拉到100,结果下游数据库连接池只有50,连接不够用,请求全在等。
正确的做法是:消费并发数 = 下游能承受的最大并发数。不是越多越好,是刚刚好。
// RabbitMQ手动限流
spring.rabbitmq.listener.simple.prefetch=10 // 每次只拿10条,处理完再拿新的
spring.rabbitmq.listener.simple.concurrency=5
spring.rabbitmq.listener.simple.maxConcurrency=20
prefetch这个参数极其重要。它控制的是"一次从broker拿多少条消息"。设得太高,消费者内存压力大;设得太低,吞吐量上不去。一般建议是:并发数 × 2~5。
终极心法:消息队列使用的三条铁律
踩了这么多年坑,我总结出三条铁律,按重要性排列:
- 幂等大于一切。只要消息队列可能重试,你就必须做幂等。没有例外,不接受反驳。
- 消息不轻发。发送前想清楚,这条消息失败了怎么办?有没有补偿机制?不要只管发不管收。
- 监控比代码先上线。队列深度、消费速率、消费者数量、DLQ长度,这些指标要在第一条消息发送之前就配上。
写在最后
消息队列本身是个好工具,但它是那种"入门门槛很低,天花板很高"的东西。写一个能跑的队列消费两小时就学会了,但要做到生产级别稳定可靠,需要无数次的故障和加班来喂。
所以下次有人跟你说"消息队列嘛,很简单,上就完了",你可以问他:你的DLQ配了吗?你的幂等做了吗?你的Confirm确认了吗?如果三个问题都回答不上来,建议让他先在测试环境跑一个月再说话。
祝大家的队列都稳稳当当,永远不丢消息。🦞