老实说,消息队列这个词被聊得有点烂大街。项目一遇到性能问题就有人说“上MQ”,可你追问他:想用 MQ 解决什么问题?打算在哪个环节落队列?消息丢了怎么办?往往支支吾吾答不上来。我做了几年后端,实话讲,MQ 是中间件里最容易“用错”的一个。用好了,异步、解耦、削峰都很漂亮;用错了,系统平白多出一堆分布式一致性问题、排查链路也长一截。这篇文章想从实际使用场景出发,把消息队列的适用边界、五种典型场景、对应设计思路,还有我在生产环境踩过的坑一次讲清楚。不管你是刚接触 MQ 的新人,还是已经用了很久但总被问“这里为什么用 MQ”的人,都可以从里面找到参考。
1. 消息队列的能力边界:先搞清楚这东西到底能干什么
1.1 三大基础能力:异步、解耦、削峰
消息队列能流行起来,靠的是三样东西,分别对应三类典型诉求。
- 异步:主链路做完关键动作后,把耗时不敏感的后续步骤交给消费者慢慢处理。拿点餐举例,同步方式是你站在柜台前,等后厨把菜全部做好再端走;异步方式是你点完单拿个号,先找座位坐着,后厨做好之后叫你。用户体感上,异步明显更快,因为点单这个动作本身只需要几秒。
- 解耦:生产者只负责发消息,不关心谁在消费、消费成功与否。下游系统即使临时更换、下线或者升级,上游都不需要改代码。这就好比报社只管印报,至于读者是早上看、中午看还是晚上看,报纸印出去那一刻就已经解耦了。
- 削峰填谷:瞬时流量大时,先把请求收进队列,让下游按自身能力慢慢消费。水库的原理,雨季蓄水、旱季放水,把峰谷差抹平,保护下游数据库和核心服务不被瞬时洪水冲垮。
这三个能力看起来很诱人,但很多人忽略了一个前提:MQ 的每一个能力,都是用额外成本换来的。
1.2 用 MQ 付出的代价:没有免费的削峰
引入消息队列之后,系统里多了一个强依赖的中间件节点。它带来的代价非常具体:
- 一致性变难了,生产端发送成功不等于消费端处理成功,两个系统之间的数据状态容易出现短暂不一致。
- 重复消费是常态,网络抖动、消费者重启、rebalance 都可能让同一条消息被处理两次。
- 顺序问题容易被放大,多个消费者并发处理同一类消息时,先后顺序就可能错乱。
- 链路变长,一条消息从生产到消费,中间经过存储、网络、重试机制,任何一个环节出问题,排查都要比同步调用更费劲。
所以,判断一个场景要不要上 MQ,我的标准很简单:这个环节是否允许异步?失败了是否可以重试?是否真的需要削峰?如果三个答案里有“否”,就先别急着上 MQ。
尤其是那种“其实同步调用也就慢几百毫秒”的场景,硬引入 MQ 反而把简单问题复杂化。异步改造不是为了炫技,是为了主链路的响应时间、稳定性或者系统边界发生了实质变化才做的。
2. 场景一:异步化改造——把不需要立刻做完的事挪出主链路
2.1 典型业务:注册送积分、下单发短信、支付回调通知
异步化改造是消息队列最常见的入门场景。举个最典型的注册流程。一个用户注册成功后,需要做下面几件事:保存用户记录、发送欢迎邮件、发放新人积分、给运营系统推送注册事件。如果全部串行执行,注册接口的耗时取决于最慢的那一步,比如邮件服务超时三秒,用户就得多等三秒。
改造思路很直接:注册主链路只保留“保存用户”这一步,其余动作封装成事件丢进队列,让消费端慢慢处理。用户端响应时间从几百毫秒降到几十毫秒,注册成功率也会提高,因为发邮件失败不再影响注册主流程。
类似场景还有:
- 下单成功后异步推送订单消息给仓库系统、财务系统。
- 支付成功后异步通知第三方渠道做对账。
- 用户修改密码后异步发送通知短信。
这些动作的共性是:用户的“主诉求”已经完成,后续动作晚几秒甚至几分钟处理,用户没有感知或者感知很弱。
2.2 设计要点:消息体要克制,消费要幂等
异步改造不是简单“把同步调用换成消息发送”,有几个细节必须处理好。
先看一个简化版代码,典型改造前和改造后的对比:
// 改造前:串行完成所有动作,接口耗时 = 保存用户 + 发邮件 + 发积分 public void register(User user) { userDao.save(user); emailService.sendWelcome(user.getEmail()); pointsService.reward(user.getId(), 100); } // 改造后:主链路只保存用户,后续动作异步化 public void register(User user) { userDao.save(user); mqProducer.send("user.registered", new UserRegisteredEvent(user.getId())); }这里有个很多人容易踩的坑:MQ 发送操作不应该和业务操作在一个非本地事务里“先发送后保存”,也不应该“先保存后发送”就完事。如果保存用户成功、发消息失败,用户就收不到积分;如果先发消息、保存用户失败,消息就消费了一个不存在的用户。
规范做法是:保证业务数据和消息的写入在同一个本地事务里。简单实现是引入一张“本地消息表”,业务数据表和消息表在同一个数据库事务里写入,之后由发送任务把消息投递给 MQ。这个方案我在后面分布式事务章节会展开讲。
另一个重点是消息体设计。很多开发习惯把整个订单对象、整个用户对象都扔进消息里,字段几十个,消费方还要临时兼容新版字段。我的经验是消息里只放“事件 ID + 业务主键 + 必要属性”。比如注册事件就放 userId,消费方真正需要用户详情时自己查库。消息体越小,序列化和网络传输开销越低,版本兼容问题也越少。
消费端必须做幂等。比如注册事件消费端要发放积分,必须记录“这个 userId 是否已经发放过积分”。消息队列提供的是“至少一次投递”,而不是“恰好一次”,所以消费端天然要处理重复消息。幂等实现通常有三种:数据库唯一键、Redis 记录处理状态、业务状态机校验。最简单可靠的是数据库唯一键,比如发放积分表里对 userId 建唯一索引,重复插入直接冲突,不会重复加积分。
2.3 操作提醒:失败重试要有边界
异步消息如果消费失败,默认做法是重试。但重试不能无限重试,也不能毫无间隔地重试。我见过一个系统,消费者抛异常后,消息队列秒级重试几十次,把下游数据库活活打挂了。
建议重试策略分两段:前半段用固定间隔,比如 5 秒、15 秒、30 秒退避重试;超过一定次数后,把消息投递到死信队列,由人工或专门的补偿任务处理。消费逻辑里还要注意捕获一切异常,不要把消息直接丢掉。你宁可让它进死信队列,也别让它凭空消失。
3. 场景二:流量削峰——秒杀、预约、集中抢购里的排队机制
3.1 问题本质:瞬时流量远大于系统水位
削峰是 MQ 最硬核的使用场景,典型就是秒杀、限量预约、优惠券抢购。这类场景有两个特点:瞬时流量极高,但真正能成交的请求极少。比如一场秒杀有 10 万用户参与,库存只有 100 件,数据库如果直接承受 10 万并发查询和下单,大概率直接崩,但其实只有 100 个人需要真正落库。
这里要理解“水位”这个词。假设一个数据库单机能扛的写入水位是每秒 1000 个事务,秒杀瞬间涌入的请求是每秒 5 万个,两者差了 50 倍。同步处理意味着数据库必然被打满,后面的请求全部超时,用户体验是“页面打不开”。
引入 MQ 之后,请求在入口处先经过校验和限流,再把“有限数量”的请求放进队列,消费端再以数据库能承受的速度慢慢下单。外部看是全部请求都收到了,但真正进入交易流程的只有队列里的那些。
3.2 削峰设计:前置校验 + 限流入队 + 异步放量
一套比较完整的秒杀削峰方案可以拆成四步:
- 前置校验:用户点击秒杀后,先做本地校验,比如是否登录、是否已经秒杀过、活动时间是否开始。这一步在应用内存里完成,不查数据库。
- 限流入队:通过令牌桶或计数器限流,超过阈值的请求直接返回“已抢完”,未超阈值的请求把 userId+商品Id 组装成消息发进队列,同时返回用户一个“排队中”的标识。
- 异步放量消费:消费者从队列里拉取请求,逐个进行真正的库存扣减和订单创建。数据库流量被限制在一个可控速率内。
- 结果通知:用户如何知道自己是否秒杀成功?两种方式,一种是前端轮询查询结果状态,另一种是处理完成后通过站内信、短信或 WebSocket 通知用户。
需要注意的是,队列本身也可能被流量打爆。所以限流一定要放在进队之前,不能等到 MQ 扛不住才想起保护。限流阈值怎么定?根据消费端实际能力来,比如数据库安全水位是每秒 1000 写,消费者集群处理能力是每秒 1100,那入口的放行速率最多压在 1000 左右。这里宁可少放一点,也别让消息快速积压到几十万。
3.3 参数怎么算:消费者数量和队列容量
很多团队在削峰场景里不知道队列容量该设置多大、消费者开多少个。这里给一个可参考的计算方式。
假设秒杀活动持续 30 秒,入口每秒放行 1 万条请求,消费端单消费者每秒处理能力是 500 条,如果只开 1 个消费者,每秒积压 9500 条,30 秒下来积压量是 28.5 万。如果你想在 5 分钟内把这些积压全部处理完,需要的消费者数量大约是:
积压总量 = 9500 × 30 = 285000 期望清空时间 = 300 秒 单消费者每秒处理 = 500 所需消费者数 ≈ 285000 / (500×300) ≈ 1.9,也就是至少 2 个消费者
这还不算处理失败重试、网络抖动等额外耗时。所以我一般会在算出的理论值上再多留 20% 的容量,防止某个消费者恰好卡住。另外,积压量一定要有监控面板盯着,而不是等线上响应变慢才去查。
3.4 避坑心得:别让消费端把数据库打穿
削峰做得不好,最常见的结果就是“峰没削成,把下游打穿了”。根源往往是消费端没有限速,甚至为了提高吞吐量把批处理 size 调得巨大。消息队列消费端也能批量拉取,比如一次拉 500 条,然后循环插入数据库,这本质上是把瞬时压力集中爆发,数据库该挂还是挂。
我的做法是消费端并发度保守一点,但保证持续稳定,比如固定 10 个消费者线程,每个线程内控制事务大小,宁愿让积压时间多持续几分钟,也不要让数据库出现明显的 CPU 抖动。秒杀场景的体验核心是“快速反馈是否抢到”,不是“几分钟内处理完所有请求”。可以把积压清空时间放宽到 10 分钟到 30 分钟,用户侧看到的结果几乎没有差别。
4. 场景三:事件驱动与跨系统数据分发——一个事件让多个下游各干各的
4.1 痛点:订单创建之后要通知一堆系统
在电商或交易类系统里,订单创建是一个高频率事件。它会牵动很多下游:库存服务要预占库存,财务要记录流水,推荐算法要更新用户偏好,数仓要同步数据做分析,客服系统要生成工单。如果每个都靠上游在业务代码里同步调用,那订单服务要维护一堆远程接口,还要处理各种超时和异常,改一个下游代码,上游就要重新发版。
使用消息队列的 publish-subscribe 模型之后,订单服务只负责发一条“order.created”事件,谁要消费谁订阅。下游系统跟订单服务之间的代码依赖全部消失,新增一个订阅方不需要动订单服务一行代码。
这种场景我在实际项目里见过一个直观的对比。某跨平台系统早期所有通知都写在订单服务里,每接入一个新下游,订单模块就要加一个 HTTP 调用,不仅代码膨胀,还出现过一次下游接口超时导致整个下单主链路回滚的情况。改造为事件驱动后,这些问题基本消失。
4.2 事件结构设计:不要替下游把活干完
事件消息的结构设计有很多讲究,我常用的字段格式是这样的:
{ "eventId": "879a6f2d-4a2e-4b3c-8f1a-9f7dab0c21e3", "eventType": "order.created", "occurredAt": "2025-01-12T10:24:36+08:00", "bizId": "order_20250112102436_8821" }eventId 用于消费端去重和幂等,bizId 是业务主键,消费方拿它去查业务系统详情。我见过一些团队把整个订单的几十个字段全塞进 eventType 的 payload 里,导致事件结构频繁变化,上游每次改字段,所有下游都要重新测试。我的原则是:消息只描述“发生了什么”,不承载“下游需要什么”。下游如果需要订单详情,自行调用订单查询接口或查只读库。
另外要给 eventType 做版本化,比如 order.created.v1。版本化帮助你优雅升级:下游没来得及消费旧版消息时,不会因为字段变化导致解析失败。刚开始系统简单时这个设计看起来多余,一旦链路多起来,它就是救命稻草。
4.3 选型取舍:不同场景该用哪类 MQ
没指名道姓不代表可以乱选。不同中间件擅长的事不一样,我通常按场景分成几类来选:
- 轻量企业内部系统、低延迟通知:适合选择部署简单、基于推模式、支持延迟消息和死信队列的轻量级队列。这类队列功能完整,运维成本低。
- 大数据日志管道、超高吞吐流处理:适合选择分区分段存储、支持批量拉取、吞吐能力强的日志型队列。这种队列在大量数据堆积时的表现更好,但功能相对简单。
- 交易核心、分布式事务消息:适合选择原生支持事务消息、定时消息、消息轨迹、先进先出队列的专业级分布式消息中间件。这类组件更重量级,但对业务可靠性的支撑也更强。
选型要在场景真实需求之上,而不是跟随技术热度。小项目用重队列,会给运维带来不必要负担;大数据链路用轻队列扛吞吐,早晚会因为延迟和丢消息出事。
5. 场景四:分布式事务与最终一致性——用 MQ 把“多系统同时成功”变成“都能补偿”
5.1 难题:跨系统扣库存、加积分、更新订单状态
在分布式环境里,一个操作往往要更新多个系统的数据。最经典的例子是下单流程:订单系统要创建订单记录,库存系统要扣库存,积分系统要给用户加积分。如果这三个操作分布在三个独立部署的服务里,就没办法用本地数据库事务保证“要么全成功要么全失败”。
最早大家会想用分布式事务中间件做两阶段提交,但二阶段提交的性能开销太大,而且长时间持有数据库锁,在互联网高并发场景下根本不现实。实际业务中,大多数场景并不需要强一致,只需要最终一致:短时间允许数据不一致,但最终所有系统都会达到一致状态。消息队列就是实现最终一致性的常用载体。
5.2 本地消息表方案:一个本地事务解决发送丢失
本地消息表是最容易理解和实现的方案,核心思路是“业务操作和消息发送放在同一个数据库事务里”。
具体流程这样:
- 在业务库里建一张消息表,字段包括消息 ID、业务类型、业务主键、消息内容、状态、重试次数。
- 业务操作发生时,业务表和消息表在同一个本地事务里一起插入数据。
- 事务提交成功后,有一个发送任务去查“待发送”状态的消息,把消息内容投递到 MQ。
- MQ 发送成功后,把消息表状态改成“已发送”。注意这里存在极短时间窗口内发送成功但状态更新失败的情况,没关系,因为发送任务可以扫描超时未确认的消息再发一次,消费者端做幂等即可。
- 如果消息发送失败或超时,定时任务会不断重试,直到超过重试上限,进入人工处理列表。
这个方案能解决什么问题?它解决了“业务成功但消息没发出去”的经典难题。因为消息和业务数据不再分属两个事务,而是要么一起提交、要么一起回滚。
消费端依然要做重试和补偿。比如库存系统扣减成功,返回消息确认;扣减失败则触发重试,最终由人工或自动化补偿任务处理。消费成功后的状态要记录,保证重复消息不会二次扣减。
5.3 事务消息:专业队列的“半消息”机制
本地消息表虽然简单可靠,但它有个缺点:业务库和消息表耦合了,消息量大的时候会影响业务库。
一些专业消息中间件提供“事务消息”能力,把“本地消息表”的职责下沉到 MQ 内部。它的执行过程是:
- 生产者发送一条“半消息”给 MQ,这个消息处于不可消费状态。
- 生产者执行本地事务,比如创建订单、扣库存。
- 本地事务执行成功后,生产者向 MQ 发送确认提交,半消息变为可被消费者消费的消息。
- 本地事务执行失败,生产者发送回滚,MQ 删除半消息。
- 如果生产者在本地事务执行后宕机,没来得及提交或回滚,MQ 会反向回查生产者,询问这条本地事务最终状态,根据返回值决定提交还是删除消息。
这种机制的好处是业务方只需要关注本地事务,不需要自己维护消息表。但陷阱在于:如果本地事务状态不明确(比如事务提交后网络断开),回查时业务方需要能准确给出事务状态。我的建议是:业务方在本地事务里写一个状态标记,事务提交成功后置为“成功”,回查时直接查这个状态即可。
实际业务中,我用事务消息处理过支付回调、订单状态流转、跨系统积分同步,整体效果稳定。但如果你所在团队对所用 MQ 的事务消息机制不熟悉,我更推荐先从本地消息表开始,因为它的每个环节都比较透明,出了问题你只要盯着数据库表和定时任务日志,就基本能定位。
5.4 最终一致性:别追求每条消息都“秒级一致”
设计最终一致性方案时,首先要分清业务对延迟的容忍度。像“用户支付后查询订单状态”这种场景,容忍度可能是几秒;而“跨天对账”容忍度可能是几小时。
我的经验是给每个消息场景定义 SLA:积压多少条算异常、处理延迟超过多长时间需要告警、超过多长时间需要人工介入。没有 SLA 的最终一致性方案,等于没有标准。曾有一次我排查一个积分延迟问题,消费者线程池里一个慢调用阻塞了所有消费线程,积压涨到几十万,但监控只报了队列长度,没有任何人关心。后来我专门把“消费延迟”和“重试超限数”两个指标加进告警规则,类似问题才在刚冒头时就被发现。
最终一致性不代表可以无限期不一致。系统里要配套定时任务做“对账”。比如每天固定的任务检查:数据库里的订单状态和 MQ 消费记录是否一致,不一致的自动触发补偿。这类补偿任务才是最终一致性的最后一道保险,不能被省略。
6. 场景五:数据管道、日志采集与延迟消息——MQ 的进阶玩法
6.1 日志采集与监控数据管道:吞吐优先
日志采集是 MQ 被大量使用的场景之一。应用日志、访问日志、接口调用日志、业务埋点日志,每天产生几个 TB 很正常。这些日志要是每个都走同步 HTTP 推送,不仅消耗应用资源,还会在高峰期拖垮日志服务。
标准做法是应用进程把日志异步发送到高吞吐的日志型队列,再由独立的消费者批量拉取日志,经过格式化、清洗后写入对象存储、点击流仓库或全文检索集群。这个管道里,单条日志不要求强实时,也不要求复杂路由,核心指标是吞吐和堆积能力。所以选型时一定优先考虑分区多、顺序写磁盘的队列,而不是功能花哨但吞吐上不去的轻量队列。
我踩过一个坑:一开始图省事用轻量队列收集日志,单机吞吐确实不差,但日志量陡增时,它的持久化机制导致消息堆积后消费变慢,最终反过来阻塞应用日志发送。后来切到分区日志型队列,批量发送加上自动提交 offset,问题就消失了。记住,日志管道最重要的是“量大不堵”。
6.2 数据库变更同步(CDC):一张表变更驱动多个缓存更新
另一个进阶场景是数据库变更数据捕获(CDC)。商品价格被运营修改后,要同步更新很多地方:搜索引擎里的价格字段、缓存里的商品详情、数仓里的销售事实表。传统做法是在每一个入口业务代码里都加一份触发逻辑,但这很容易漏。
利用 CDC 工具捕获数据库 binlog 的变化,把变更事件转成消息发送到 MQ。下游消费方各取所需:一个消费者更新缓存,一个同步搜索引擎,一个推给数据仓库。这样做的好处是业务代码只需关注自己的业务逻辑,不需要知道还有多少个下游系统依赖这张表。
我参与过的某电商项目中,商品信息变更就是这样走 CDC 管道。最早是运维同学写了一个定时任务每五分钟扫数据库对比一次,问题和延迟都很严重。后来改造为“商品变更事件入队”,搜索索引更新延迟从分钟级降到秒级,代码也干净了。
6.3 延迟消息:订单超时关闭、支付提醒
延迟消息是很常见的业务需求:用户下单后超过 30 分钟未支付,自动关闭订单;账户注册后超过 24 小时未激活,发送提醒短信。同步定时任务扫表可以做,但表数据量一大,扫表本身就变成巨大的数据库压力。MQ 的延迟消息能力为这类场景提供了更优雅的方案。
延迟消息的三种实现方式,我按推荐程度排个序:
- 使用消息中间件自带的延迟级别或定时消息功能,比如发送时指定“延迟 30 分钟消费”,这是最省事、最精准的方案,前提是所选 MQ 支持。
- 用死信队列模拟延迟。生产者发送一条设置了消息存活时间(TTL)的消息,这条消息到期后没有被消费,会被投递到死信队列,专门写一个死信消费者来执行真正的业务。这个方案绕开了对延迟消息功能的依赖,但需要清楚理解死信机制,而且消息不会被“按秒精确”触发,只能做到近似延迟。
- 用 Redis 有序集合配合定时任务,把到期时间作为 score,每秒扫描最早到期的任务处理。这个方案适合消息量不大、延迟精度要求不高的场景,但多了一个 Redis 依赖。
延迟消息场景里最需要注意的坑是“任务重复执行”。比如订单超时关闭,一旦延迟消息到了,执行关闭动作时一定要判断订单当前状态,如果订单已经支付,直接退出。延迟消息的“延迟”只是延后触发,并不保证状态一定如你所料,消费端做状态校验是必须的。
6.4 扇出模型:一个数据喂给多个团队
MQ 的另一个好用场景是扇出。数仓团队、算法团队、数据分析师,都想要同一份用户行为数据,但他们各自处理逻辑不同。你不可能为每个团队单独接一套数据源。把原始行为事件发到 MQ 的同一个 topic,多个消费者组各自独立消费,每个团队定义自己的过滤、清洗逻辑。这种模型既避免了重复采集,也让下游团队互不干扰。
这里有一个经验:不同团队消费进度不一样,有的实时消费,有的离线批量拉取,最好让 MQ 支持每个消费者组独立记录消费位置,互不影响。否则一个慢消费者会把所有下游都拖慢。
7. 常见问题排查与实操避坑经验表
7.1 消息丢失:三个环节分别加保险
消息丢失是使用 MQ 最怕的问题,也是最常被误解的问题。消息从生产到消费,会经过生产端、MQ 服务端、消费端三个环节。任何一个环节丢了都不会有人告诉你,所以只能自己加重保证。
生产端的保险是开启发送确认机制。发送后必须等到确认结果,失败就重试。有些队列支持同步确认、异步确认、批量确认,实际项目中异步确认用得最多,因为性能更好且不会漏。
服务端的保险是持久化和副本。消息写入磁盘后,即使服务端重启也不会丢。副本数不足会导致磁盘损坏场景下消息丢失,所以生产环境至少保持多副本。
消费端的保险是关闭自动确认,改为手动确认。消费者处理完消息业务逻辑后,再向 MQ 发送确认。否则消费者刚收到消息还没处理,进程就挂了,MQ 会认为这条消息已经消费完。
我见过一个典型的丢消息案例:消费端代码里先调用框架自动确认,然后才执行业务逻辑,结果业务逻辑抛异常,消息也已经被确认。用户数据出现大量缺失,排查半天才发现是确认顺序错了。这个顺序问题极其常见,写代码时务必遵守“先业务后确认”。
7.2 重复消费:幂等是唯一出路
消息不丢的另一面就是重复。生产端超时重发、消费端处理超时后重新投递、消费者组发生重平衡,都可能导致一条消息被多次消费。你无法彻底杜绝重复,只能让业务处理对重复“免疫”。
幂等方案我按适用场景列一下:
| 场景 | 推荐方案 | 说明 |
|---|---|---|
| 插入类业务 | 数据库唯一键 | 重复插入直接报错,不会产生脏数据 |
| 更新类业务 | 版本号/状态机 | 比如订单状态从“待支付”到“已支付”,旧状态重复提交会被拒绝 |
| 通用场景 | 记录业务消息 ID | 用一个集中存储记录已处理过的消息 ID,重复消息跳过 |
| 高并发场景 | Redis SETNX | 利用 Redis 原子操作实现轻量去重,但注意和业务操作的原子性 |
这里想特别强调状态机方案。订单状态有明确流转路径:待支付、已支付、已关闭。如果消费端重复收到“支付成功”消息,第二次处理时检查订单状态已经是“已支付”,直接跳过即可。状态机本身就是天然幂等,比单纯建唯一键更贴近业务语义。
7.3 消息积压:先止血,再排查
积压是生产环境最容易出现的 MQ 异常。队列里消息越堆越多,消费者来不及处理,紧接着就是业务延迟、超时、下游雪崩。
排查思路按优先级排序:
- 看积压指标:确认是哪个 topic 积压,积压量有多大,消费延迟多少。
- 看消费者状态:消费者是否全部在线,有没有频繁重平衡,消费线程有没有卡住。
- 看消费耗时:单条消息平均处理耗时是多少,有没有慢日志。往往是下游数据库一条慢 SQL 把整个消费速度拖垮了。
- 看消息内容:是不是某类异常消息反复重试,占据大量消费资源。
止血方案通常是先扩容消费者,快速把积压量降下来。如果消费者扩容也没用,可能是下游依赖已经成为瓶颈,就得临时简化消费逻辑,把非核心处理先跳过,保住核心链路。要知道,积压 10 万条不可怕,可怕的是你一边排查一边还让新消息继续堆积。
7.4 顺序消息:为什么总是会乱
需要顺序控制的场景很常见:订单状态流转、支付流水记录、商品库存变更。同步调用天然有序,换到 MQ 之后顺序却经常乱。原因是多个消费者并发消费同一队列,或者同一个业务主键的消息被分发到不同队列。
保证顺序的核心手段只有一个:让同一个业务主键的所有消息,永远进入同一个队列,并且这个队列只被一个消费者串行处理。比如订单号做哈希取模,把 belongs 到同一个分区。如果消息中间件支持 key 路由,用订单号作为消息 key,就能实现分区级顺序。
要注意的是,顺序消息的代价是并发度下降。同一个订单的消息必须串行,但不同订单的消息可以并行处理。如果你为了顺序把整个队列并发度降成 1,吞吐就会很惨。实际项目里,我会按业务主键粒度做分区,而不是全局串行。
7.5 死信队列:别让坏消息反复折磨消费者
消息消费失败后,如果一直重试还是失败,进入死信队列是正确归宿。死信队列里有消息进入,往往说明业务上有大问题,比如下游数据库结构变更、上游消息体格式错误、业务规则调整后老消息校验不过。
我的建议是:对死信队列做独立监控,死信数量从 0 变成 1 就要告警。很多团队不关心死信,结果死信堆积几万条才发现业务已经受影响很久了。死信不应该是因为业务还在继续就没人管的角落。
7.6 经验速查表
| 问题 | 常见原因 | 优先排查方向 | 解决思路 |
|---|---|---|---|
| 消息丢失 | 发送无确认、未持久化、自动确认 | 生产端日志、服务端持久化配置、消费者确认方式 | 异步确认、持久化、手动确认、先业务后确认 |
| 重复消费 | 超时重发、重平衡、消费重试 | 消费日志、消息 ID、业务幂等记录 | 唯一键、状态机、消息 ID 去重 |
| 消息积压 | 消费者过少、下游慢、消息体过大 | 积压指标、消费耗时日志、消费者线程状态 | 扩容消费者、简化消费逻辑、限速入队 |
| 顺序错乱 | 多消费者并发、key 路由不准 | 消息分区、消费者并发数 | 按业务主键路由到分区,单分区单消费者 |
| 消费失败不断重试 | 异常消息格式不合法、业务逻辑固执 | 异常堆栈、重试次数 | 捕获异常进死信、确认重试退避 |
| 延迟过高 | 消费者阻塞、线程池排队、网络抖动 | 消费耗时、线程池饱和度 | 优化下游慢依赖、扩容消费者线程 |
最后再分享一个小技巧
我做过不少和 MQ 相关的改造,最后发现,消息队列用得好的项目,通常不是因为它选型多先进,而是团队对“哪些步骤可以延迟、哪些任务必须立即完成”分得很清楚。我一般会先问自己:如果这条消息发出去之后消费者挂了,最后会有什么后果?如果后果可以接受,才值得引入 MQ。
还有个很实用的小建议:刚开始使用 MQ 时,在生产和消费两侧都把关键日志和消息 ID 打出来。很多诡异问题,比如消息重复、消息丢失、顺序错乱,只要日志里带上了消息 ID,看一眼生产时间和消费时间就能定位到具体环节。很多人排查 MQ 问题耗时,不是问题本身多难,而是日志里找不到消息的完整轨迹。
MQ 说到底是个工具,用它的核心不是 API 怎么调,而是脑子里有没有一张清晰的全链路图:消息从哪来、经过什么存储、被哪些消费者处理、失败了怎么办。这张图清晰了,M 甚至是 Q,都不会成为系统里最复杂的部分。