首页 / 资讯中心 / 文章详情

MQ消息队列故障排查全链路指南:生产消费设计与实践

MQ消息队列故障排查全链路指南:生产消费设计与实践 ★ FEATURED ARTICLE
消息队列MQ相关的问题我这些年碰到的实在不少。每次帮团队排查线上故障最后总会发现很多问题表象在消费端根子却埋在生产端有些问题看起来是随机偶发实际上是客户端状态机在特定时序下被打破了。这篇文章不打算讲某个具体产品的配置手册而是把我实际踩过、也帮别人排查过的MQ常见问题做一个梳理覆盖从投递、消费、积压、顺序到事务和死信的全链路尽量说清楚每个问题的产生原因、排查思路和修复方案。如果你是刚接触MQ的后端开发或者正在被线上偶发消费异常折磨这篇应该能帮你省掉不少弯路。1. 先定责任层排查MQ问题的第一步不是翻代码遇到MQ相关故障我最常看到的一种操作是一上来就打开消费者代码反复核对反序列化逻辑和业务处理逻辑。不是说这不对而是说顺序反了。MQ是一条完整链路消息从生产端产生经过服务端存储和转发最后到达消费端。任何一个环节出问题表象都可能一模一样消息没消费、消费延迟、消费报错。如果你不先把问题定位到具体某一层很容易在错误的方向上反复折腾。我自己的排查习惯是先画一条时间线把消息什么时候生产的什么时候进入服务端的什么时候投递给消费者的消费者什么时候报错的这几个时间点对齐。只要时间线对上了责任层基本就清楚了。举个例子有一次线上反馈说某些订单始终收不到通知消息生产端日志显示发送成功消费者日志里却完全没有收到过这条消息的记录。从时间线看消息在生产端发送成功之后、消费者收到之前就消失了那问题大概率在生产端到服务端这一段或者是消息在服务端就被丢弃了。后来定位发现是消息体里的一个时间字段用了不兼容的格式序列化之后超过了某款MQ单条消息大小限制服务端直接拒收了。生产端日志却显示发送成功因为那款MQ的客户端在消息超限时会静默丢弃不抛异常。所以排查的第一步一定是先确认消息到底有没有离开生产端有没有到达服务端有没有投递给消费者。这三件事分别对应生产者确认机制、服务端存储日志、消费者拉取记录。每一层的日志时间点能对上问题才不会跑到无关代码里去找。另外一个容易忽略的点是排查问题时不要只盯着一台机器看。生产端可能是多个实例轮询发送消费端也可能是多个实例负载均衡。我之前帮某团队排查过一次消息重复消费他们看了单台消费者日志觉得没有重复啊但其实另外一台消费实例也处理了同一条消息只是日志打印得不够显性被漏掉了。所以排查阶段的第一要务是把全链路日志串起来而不是单点分析。这里也建议大家在一开始接入MQ的时候就把链路追踪字段规划好。无论是用现成的traceId还是自己拼一个requestId消息从生产到消费必须能串联起来。没有这个字段之后排查任何问题都会像盲人摸象每条日志孤立存在根本拼不出完整画面。这是我见过的最多的基础设施层面欠账。2. 生产端发送成功不等于消息真的到达很多新手对发送成功这个反馈有误解。以常见的几种MQ产品为例生产端通常有三种发送模式同步发送、异步发送、单向发送。同步发送是指在收到服务端明确ack之后才返回成功异步发送是指发送后立刻返回通过回调通知结果单向发送则只发不管结果。如果你用的是单向发送或者异步发送但没好好处理回调日志里打印的发送成功其实只代表客户端把消息交给了网络不代表服务端真的收到了。有位同学跟我分享过一次他自己的事故他用异步发送发送完之后只是简单记录了send success回调里针对失败的情况只是打印了warn日志没有做重试也没有告警。结果某天服务端因为磁盘问题短暂拒绝了一部分写入回调里对应的失败日志被大量其他日志淹没了直到业务方反馈大量消息缺失才定位到。这其实不是MQ的问题是发送端的可靠性设计问题。可靠的生产端至少要满足几件事发送结果要有明确反馈同步结果或回调发送失败要有补偿策略通常我们会做本地重试重试仍失败就落地到一张本地消息表由定时任务扫描再补发关键业务消息要打印包含业务唯一键的发送日志。还有一个容易被忽视的点自定义消息ID。很多MQ产品支持由生产者指定消息ID如果不指定服务端会生成一个。服务端生成的ID对排查问题来说不够友好因为你拿到消费端报错里的消息ID很难反查到是哪个业务、哪个订单。自定义消息ID建议直接用业务唯一键比如订单号或业务流水号这样全链路都能用同一个键串起来。关于重试还有一个必须想清楚的问题重试带来的重复消息。你第一次发送时服务端其实已经写盘了但ack在网络上丢了客户端判断超时于是重发了一次服务端就存了两条一模一样的消息。这几乎是MQ天然会发生的场景不是某款产品的问题也不完全是网络问题。所以幂等消费不是可选项而是接入MQ的第一前提。我会在下一节专门展开讲幂等这里先给结论生产端重试机制必须设计但设计的同时就要接受重复消息一定会发生这个事实。从原理上讲MQ服务端为了保证可用性通常采用多副本机制。如果一个副本写入成功就返回ack那在极端情况下主副本宕机但数据还没同步到从副本可能会有消息丢失如果要求所有副本都写入成功才返回ack那延迟和可用性都会受影响。所以不同产品、不同配置下成功的含义其实是有细微差别的。这块虽然不能完全由应用侧控制但你可以通过合理配置去平衡可靠性和性能比如针对核心业务使用更高的确认级别牺牲一点吞吐换确定性。选型时如果有条件最好用压测去验证一下这类极端场景不要看文档写得天花乱坠就结束。3. 消费者重复收到消息幂等设计里的常见误区重复消费这块几乎每个团队都会踩只是表现形式不同。有的表现为数据重复入库有的表现为通知消息发了两次有的表现为对账金额对不上。很多人第一反应是MQ是不是有问题是不是需要只消费一次。技术上要泼一盆冷水MQ在标准投递语义下通常是at least once也就是至少一次不保证只投递一次。要保证恰好一次不是靠MQ本身而是靠消费端自己实现幂等。幂等设计最常见的误区有三个。第一个误区是自定义一个processId放进Redis SETNX以是否成功写入来判断是否处理过。这个方案本身没问题但坑在于很多人在业务处理完成之后才删除这个幂等标记如果业务处理时间过长事务还没提交另一个消费者实例或者重试消息又来了发现SETNX失败就直接跳过导致这条消息实际上没有被处理。正确的做法是幂等判断要在业务处理之前幂等标记的过期时间和业务处理时长要预留足够余量而且不要轻易删除标记让Redis自然过期即可。第二个误区是拿业务库里的某个字段做幂等判断但字段选得不够唯一。比如用用户ID判断这笔订单只能处理一次但用户ID是共享的同一个用户发来两条不同业务的消息后一条就被误判为重复。幂等键必须精确对应到一次业务操作的唯一事件。最理想的方式是消息本身带上业务唯一键比如订单号加事件类型这样既能防重复也能在排查问题的时候快速关联到业务单据。第三个误区是只做了应用层幂等没有考虑状态机幂等。举个例子一条消息表示退款成功如果重复消费第一次把订单状态改成已退款第二次再消费时如果代码里只定义了从待退款变成已退款这个流转没有对已退款再收到退款成功消息做兼容就会抛异常。所以幂等不只是存不存在的判断还包括对状态字段的业务幂等校验。通常的做法是引入状态机每个状态允许哪些事件进入定义了之后再也不用靠if else打补丁。关于就算做了幂等还是会出现重复的场景我后来发现大多跟消费者超时有关。消费端处理消息超过某个时间MQ会认为消费失败而重新投递但老的消费线程其实还在处理最终两边都处理完了重复又发生了。所以幂等判断尽量不要只依赖处理之前查一次这样的一次性校验最好在写入数据的关键字段上加上数据库唯一索引兜底双保险。这一点是真的能救命的应用层幂等可能因为时序问题失效唯一索引不会。4. 消息积压止血、定位、追量三步走消息积压算是MQ问题里最直观、也最让人紧张的一类。服务端水位还在不断涨业务方不断催消费者机器加了几台好像也不太管用。这时候最忌讳的就是一边加机器一边改业务逻辑做了很多动作却没有一个明确的判断依据。我习惯把积压处置拆成三步止血、定位、追量。这三步的顺序不能乱。第一步止血原则是优先保证业务链路转起来而不是立刻解决所有积压。首先要确认积压的后果是什么有些消息过期会直接丢失影响数据一致性有些消息晚一点没关系只要最终处理就行。如果是后者可以考虑临时关闭一些非核心的消费逻辑比如原本消费时要同步调用外部接口、要做复杂计算的可以先降级成只做基础落库让消费速度快速提上来。如果是前者那就需要立刻扩容消费者实例想办法在过期之前抢时间。扩容消费者的时候要特别小心消费者数量超过分区/队列数量之后再增加实例是没有效果的因为每个分区同一时刻只有实例会分到消息。某团队曾经把消费者从5台扩到20台结果消息积压纹丝不动因为队列只有10个分区另外10台机器完全空闲。看到积压先查分区数再决定扩容规模是基本操作。第二步定位要弄清楚是单条消息太慢还是某条消息卡死。单条消息太慢通常表现在消费耗时监控上比如某条消息消费耗时几秒钟那就需要去优化核心逻辑。某条消息卡死则比较隐蔽比如消费端在处理某条消息时抛了异常而异常没被捕获导致该处理线程直接退出或者进入死循环消费者不再拉取后续消息积压自然就上来了。定位这种问题需要看消费者线程状态和最近消费记录的最后一条消息把那一条消息拿出来单独重放一般都能复现问题。还有一种常见情况是消费逻辑里调用了远程接口但远程接口没有设置超时时间线程被挂起等其他线程也在同一类调用上挂起整个消费线程池就干了这件事我当然见过这种把消费并发从20降到0的情况。第三步追量积压的原因修复之后新的消息正常消费了但旧积压的消息还在。这时候不要一次性全放进去担心打爆下游。建议按积压量分批追先评估一个安全消费速率然后通过控制消费者的并发窗口比如用信号量限制同时在处理中的消息数让积压消息慢慢消化掉。如果积压的消息里有一部分已经过期无用可以在追量之前做一次筛选跳过避免让无用的旧消息占用宝贵带宽。从根上说消息积压的预防比处理重要。监控KPI至少要有三个队列/分区当前积压量、消费延迟时间、积压变化趋势。延迟时间比积压量更能反映消费者落后了多少因为积压量的绝对值和队列深度有关不能完全说明紧迫程度。只要延迟时间开始持续上升就要考虑扩容消费能力或者优化消费逻辑了。5. 顺序消息搞清楚有序的真实成本再决定要不要顺序问题在MQ场景里经常被误解。业务方说我需要消息有序很多人下意识就想着让MQ像消息队列一样按照先进先出严格处理。但分布式环境下所谓的有序通常不是全局有序而是分区有序。也就是说同一类业务的消息被哈希到同一个分区/队列在这个分区内严格按照发送顺序被消费不同分区之间的顺序不做保证。为什么不能做全局有序因为要保证全局有序整个MQ集群同一时刻只能有一个消费者在处理消息等于把所有并行能力都砍掉了。对于绝大多数业务场景来说我们真正关心的只是同一业务实例的相关事件有序。比如一个订单的创建、支付、退款消息如果乱序消费很可能出现退款消息先到而创建订单还没处理完业务状态就错乱了。但如果不同订单之间的消息乱序了其实没什么影响。所以接入顺序消息的正确姿势是确定分区键。分区键的选择决定了哪些消息会被放进同一个分区。常见分区键就是订单ID、用户ID。同一个订单的消息永远进同一个分区就能保证这个订单的事件处理顺序。但这一步有个容易被忽略的坑如果你在消费逻辑里把消息重新放进了线程池异步处理分区有序的语义就会被打破。分区有序只是保证分发到消费者时有序消费者拿到消息后再经过内部线程池一并发处理顺序就又乱了。所以要么消费逻辑直接同步串行处理要么把同一分区的消息哈希到同一个处理线程上并发处理也不能打乱分区内顺序。顺序消息还有一个体验问题当一个分区里有一条消息处理失败、不断重试时会阻塞这个分区后续所有消息。消息队列不会跳过它去处理后面的消息因为一旦跳过顺序就乱了。所以顺序消息消费者的容错设计通常要单独考虑要么对失败消息做有限次数重试后放到专门的重试队列等这个分区的其他消息处理完再单独回放要么接受这个分区的短暂阻塞等人工介入处理。我记得某团队做券码核销系统时用了顺序消息结果有一次某条消息反复消费失败导致后面几百条同用户消息全部排队超时体验非常糟糕。后来他们把失败消息直接转入死信队列并且把重试逻辑改成记录失败异步补偿不再阻塞后续消息。如果你想确认消费顺序对不对建议在消息体里带一个自增序号消费端可以对序号做检查配合日志告警。这个序号不仅能暴露乱序问题还能帮你在排查问题时快速定位是哪一批消息出了问题。6. 事务消息和本地消息表解决业务落库和发消息不同步很多业务场景有这样一个需求本地数据库事务提交之后才允许发消息如果数据库事务回滚了消息也不能发出去。这里有一个经典难题先改库再发消息如果发消息失败数据已经改了消息却没发出去下游感知不到先发消息再改库如果改库失败消息却已经发出去了下游就收到了一个不存在的事件。这两种不一致都让人头疼。常规的做法有三种。第一种是本地消息表方案。在业务数据库里建一张消息表业务操作和插入一条待发送消息放在同一个数据库事务里完成事务提交后一个后台任务扫描这张表把消息投递到MQ投递成功后再把消息表里的记录标记为已发送。这个方案的关键是投递动作做成幂等的允许后台任务重复扫描和重复投递重复投递产生重复消息再由消费端幂等兜底。它的优点是通用、不依赖某个具体MQ的特性缺点是需要多维护一张表还要处理消息表本身的数据积累和清理。第二种是使用事务消息。目前几款主流MQ产品里有的把事务消息作为一种标准能力提供。基本流程是生产端先发一条半消息对消费者不可见然后执行本地事务根据本地事务的结果提交或回滚这条半消息。如果本地事务执行期间网络异常、进程宕机导致生产端没有给出最终提交结果MQ会反向询问生产端回查生产端需要实现一个回查接口告诉它这条消息对应的本地事务最终是成功还是失败。这里有一个容易踩的坑回查接口必须能用业务唯一键查到本地事务状态所以你的业务表里一定要留一个能关联到事务消息的字段。有人在接入事务消息时没有预留这个字段结果回查时只能拿一条消息ID去数据库大海捞针效率极低。回查调用频率和超时时间也值得关注如果业务事务执行时间可能超过MQ回查时间点会提前触发回查报错需要合理设置回查参数。第三种做法是变更数据捕获CDCChange Data Capture思路。通过订阅数据库的binlog或类似的变更日志把业务表的数据变更作为触发源再生成对应的MQ消息。这样做的好处是生产和业务解耦得比较干净不需要在业务代码里显式调发送接口。但它对数据库有额外依赖也需要处理变更日志的时效性和格式兼容问题适合对实时性要求不是极高、又希望避免侵入业务代码的团队。国内不少团队采用这种模式处理跨系统数据同步。那么三种方案怎么选如果技能栈比较通用不想绑死特定MQ本地消息表是最稳妥的。如果已经用了支持事务消息的产品并且团队对回查机制理解到位事务消息代码最简洁运维成本也最低。如果业务系统本来就有一整套数据库同步基础设施CDC方案会更优雅但不要为了这个消息发送而去新建一套CDC平台成本不低。无论选哪种消费端的最终一致性保障都别忘了消息的消费处理绝大多数场景做不到第一步成功则万事大吉。你需要一个对账任务定期把业务库中已存在的数据和消息表/事件表里的已确认记录做比对发现不一致就及时补发。只有对账任务能真正兜住那些看起来都成功了但实际漏掉一条的边缘情况。7. 死信队列和毒消息兜底机制要怎么兜消费端反复重试仍然失败的消息如果一直在普通队列里重投会占用消费带宽也可能阻塞后续消息。所以成熟的MQ产品都会提供死信队列把处理失败且达到重试上限的消息转入死信队列。设计死信队列的核心问题不是怎么配置而是死信消息来了之后你打算怎么办。我见过不少团队设置了死信队列却没有设置告警和后续处理流程。死信消息静静躺在队列里直到业务方发现某个数据缺失才想起来去翻。这就失去了死信队列的意义。死信队列本质上是一个问题仓库它的存在是要触发后续动作的及时告警、人工介入分析、或者自动补偿。处理死信消息我建议至少配置三件事。第一死信消息要保留原始消息的所有元信息包括业务唯一键、生产端时间、重试次数。第二要有专门的消费任务去扫描死信队列失败消息先做一次自动复查如果重试逻辑已经从代码层面修复可以对死信消息做有限次数的回放如果确认是无用消息直接标记丢弃。第三死信队列的告警阈值要设置得当比如死信队列积压超过100条告警而不是等到业务反馈了再来看。在定位死信消息根因时最常用的方法是把死信消息的内容和当时的消费日志做关联看它到底在哪个环节抛的异常。有时候死信消息不是真的有问题而是消费逻辑的Bug恰好被某条特殊数据触发了。比如某条消息里的金额字段是负数消费端没有做校验直接放入余额扣减逻辑抛了个业务异常重试N次还是失败最终进入死信。修复方式通常是业务逻辑加上边界校验再对死信消息做回放。所以死信队列不只用于止损也是很好的测试集能持续验证你的消费逻辑是否够健壮。还要提醒一点不是所有消息都适合设置死信队列。有些对实时性极其敏感、重试没有意义的场景比如实时价格更新消息过期后本身就是无效的不如直接丢弃再加告警。死信机制是为了兜住可以救回来的消息不是为了收留所有处理失败的消息。这个边界在设计之初就要和业务方对齐清楚。8. 那些看似偶发的怪问题连接、线程和客户端状态最后一类MQ问题最磨人它不持续出现而是每隔一段时间冒一次重试一下就又好过几天又来一次。这类问题往往不是业务逻辑错了而是客户端状态、连接管理、线程池水位出了问题。我挑几个典型场景说说。第一个场景是消费者线程池耗尽。消费者拉取到一批消息交给业务线程池处理如果某个下游接口没有设置超时时间接口一直不返回线程池里的线程被越占越多最终新的消息没有线程处理。表面上看是消息积压实际上是在消费端内部就堵住了。排查这类问题重点看线程池活跃线程数和队列大小以及有没有调用方长时间不返回的日志。预防的手段很明确所有外部调用必须设置超时时间线程池要配置拒绝策略和告警不要无脑调大线程数要找到真正占住线程的资源。第二个场景是连接数被打满。如果某款MQ客户端每创建一个消费者实例就建立一批连接而业务代码在某个循环里不停创建消费者把消费者实例放到了请求处理方法里连接数会以肉眼可见的速度涨上去最后把服务端连接数限制打满新连接全部失败旧连接也可能因为心跳超时被踢掉。解决办法是把消费者做成常驻实例而不是每次请求都新建。这也是生产环境代码审查时要重点盯的地方。第三个场景是客户端元数据缓存导致的延迟感知问题。有些MQ客户端是定时拉取服务端元数据比如队列分区信息、broker列表的如果你在运行期动态调整了队列数量、主题分区数客户端不会立刻感知到可能会持续往旧的分区发消息导致消息路由不符合预期。遇到这种情况一般需要触发客户端的元数据刷新机制比如调用官方提供的刷新方法或者重启消费实例。这块虽然在正常操作下很少发生但一旦遇到动态扩容场景就很容易中招提前了解你的客户端刷新策略能少踩一个坑。第四个场景是消息消费失败的重试风暴。当某条消息消费失败且重试间隔较短时如果下游服务整体处于异常状态多条消息会同时进入重试节奏形成对下游的周期性重试风暴严重的时候下游恢复之后又被这批重试打挂了。稳妥的做法是给消费者的重试逻辑加上退避策略并增加最大重试次数限制同时重试队列单独限流避免无脑重试放大故障。总结下来MQ这类问题是典型的日常不显山露水出了事火烧眉毛。我自己的体会是与其等出故障再排查不如把前面这些设计原则在接入MQ的第一天就定好自定义消息ID、全链路trace、幂等键、死信队列、对账任务、积压监控。这些基础设施看起来多花了几天时间但每一条后来都在线上故障里救过我。如果你现在正被某个MQ问题折磨建议先冷静下来按责任层一点点切分不要跳步骤更不要凭感觉改配置。多数问题都不神秘只是链路太长一时没找到观察它的正确角度而已。
阅读完成 · 觉得有帮助?
咨询建站