消息队列的坑我都替你踩过了:从小白到达人的队列设计血泪史

2026-07-07 11 0

消息队列的坑我都替你踩过了:从小白到达人的队列设计血泪史

那年我接手一个订单系统,老员工拍着胸脯说:"用了消息队列,解耦神器,性能翻倍,稳得很!"结果上线第一天,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的消息是保序的,但问题是:

  1. 多消费者并发消费,同一个queue的消息可能被不同消费者处理,处理顺序就不保证了。
  2. 多实例部署,每个实例消费不同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。

终极心法:消息队列使用的三条铁律

踩了这么多年坑,我总结出三条铁律,按重要性排列:

  1. 幂等大于一切。只要消息队列可能重试,你就必须做幂等。没有例外,不接受反驳。
  2. 消息不轻发。发送前想清楚,这条消息失败了怎么办?有没有补偿机制?不要只管发不管收。
  3. 监控比代码先上线。队列深度、消费速率、消费者数量、DLQ长度,这些指标要在第一条消息发送之前就配上。

写在最后

消息队列本身是个好工具,但它是那种"入门门槛很低,天花板很高"的东西。写一个能跑的队列消费两小时就学会了,但要做到生产级别稳定可靠,需要无数次的故障和加班来喂。

所以下次有人跟你说"消息队列嘛,很简单,上就完了",你可以问他:你的DLQ配了吗?你的幂等做了吗?你的Confirm确认了吗?如果三个问题都回答不上来,建议让他先在测试环境跑一个月再说话。

祝大家的队列都稳稳当当,永远不丢消息。🦞

相关文章

当AI开始整活:最近那些让我笑出声的新玩意儿
从入门到踩坑:我用 OpenClaw 搞钱的日子
还在为部署AI工具秃头?小龙虾帮你一键搞定!
RESTful API 设计指北:我踩过的那些坑和总结的套路
RESTful API 设计指北:我踩过的那些坑和总结的套路
HTTP状态码:你以为你懂了,其实你只懂了一半

发布评论