我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

消息队列四大痛点:可靠性、顺序性、幂等性与积压的实战应对

消息队列四大痛点:可靠性、顺序性、幂等性与积压的实战应对 消息队列的坑做后端的基本都踩过。平时开发环境一切正常一上生产就出幺蛾子订单数据丢了、消息重复入账、消费进度卡住不动。而且这些问题往往不是单一原因是可靠性、顺序性、幂等性、消息积压这四件事搅在一起搞出来的。很多人花大把时间调MQ本质上就是在跟这四个老对手较劲。这篇文章我就把自己在实际项目中处理这四类问题的思路、参数选型和踩坑记录一次性整理出来希望对正在被MQ折磨的同行有点用。1. 可靠性消息不丢这件事得从整条链路看1.1 生产端确认消息发出去只是开始确认收到才算数我早期犯过一个低级错误以为把消息丢给MQ就完事了从来不看发送结果。直到有一次业务方反馈说有大量用户订单没生成排查发现是某台机器在发消息时网络闪断消息压根没到Broker但业务代码还以为发成功了直接返回了“下单成功”。从那以后我接手的项目里生产端发送必须带确认机制。主流的MQ都提供了发送确认机制。以Kafka为例Producer可以设置acks参数可选项是0、1和all。acks0表示发送后不等待Broker任何响应吞吐最高但丢消息概率也最高网络抖动一下消息就没了acks1表示Leader分区写入成功就返回但极端情况下Leader还没来得及把数据复制给Follower就宕机了消息照样会丢acksall表示ISR里所有副本都写入成功才算发送完成这是最稳的选择代价是延迟升高因为要等所有副本确认。实际生产中如果业务对数据一致性要求很高比如交易、支付、库存这类场景acksall基本是必选项。我见过一些团队为了压吞吐把acks调到1结果遇到Broker节点抖动就直接数据对不上账最后还是要靠对账系统去补数据成本和麻烦远大于那点延迟损失。这里也顺带说一下retries参数它控制发送失败后的重试次数建议设置成较大的值比如3或5并且要打开enable.idempotence开启幂等发送避免重试时Broker重复落盘数据。注意开启幂等发送要求acksall否则会报参数不合法。RocketMQ的发送确认就直白一些同步发送会返回SendResult里面有sendStatus只有SEND_OK才代表Broker真正接收了消息。我建议所有核心链路都使用同步发送不要图省事用sendOneway那是超高频埋点才用得上的模式放在订单、支付这类场景里就是拿数据开玩笑。异步发送也可以但回调里一定要判断成功状态并做失败补偿。1.2 Broker端保障刷盘策略和副本数量决定了消息能不能留住生产端把消息交到Broker手上不等于就进了保险箱。Broker自身得先把消息落到磁盘还要保证多副本否则节点宕机照样丢数据。Kafka的Broker端log.flush.interval.messages和log.flush.interval.ms控制刷盘频率默认值其实已经比较保守了一般不用动。真正影响可靠性的是min.insync.replicas这个参数指定了至少要有多少个副本确认写入才算成功。假设Topic设置了3个副本min.insync.replicas2那么即使Leader挂了只要有一个ISR里的Follower还在数据就不会丢因为写入时已经至少同步到了2个副本。这里有一个常见误操作把min.insync.replicas设置成副本数一样大比如3副本就设置成3这样只要有1个副本挂掉生产端写入就会报NotEnoughReplicasException整个链路直接不可用。可靠性和可用性要平衡3副本配min.insync.replicas2是多数核心业务的合理配置。RocketMQ的刷盘机制分两种SYNC_FLUSH和ASYNC_FLUSH。SYNC_FLUSH是消息写入PageCache后立即调用flush落盘性能损耗较大但每个消息都实打实落在磁盘上ASYNC_FLUSH是攒一批再刷性能好但宕机时可能丢最近一小批数据。RocketMQ官方默认是ASYNC_FLUSH配合主从复制也能做到较高可靠性但真正要求强一致的场景比如金融级别的对账、交易流水建议开启SYNC_FLUSH。RocketMQ的主从复制也分同步和异步同步复制是Master和Slave都写成功才返回给Producer异步复制则只要求Master写成功。同步复制性能损失大但不会因为Master宕机丢消息。注意可靠性的核心矛盾永远是“数据不丢”和“性能不差”之间的博弈。没有绝对不丢消息的系统只有你能接受的丢失窗口有多小。我个人的经验是核心链路按“同步刷盘多副本发送确认”全套上非核心链路可以用异步刷盘和异步复制换吞吐。1.3 消费端可靠性手动ACK是你的最后一道防线消息生产端稳稳当当到了Broker如果消费端处理不当一样丢消息。最典型的场景就是消费者拉取到消息先处理业务逻辑然后再提交offset。如果业务逻辑处理完了还没来得及提交offset消费者宕机或Rebalance那么这条消息会被重新分配给其他消费者再次消费看起来像是“重复消费”了反过来如果先提交offset再处理业务处理过程中挂了消息就彻底丢了不会再有人消费到它。所以核心原则是先业务处理成功再提交offset。在Kafka的Java客户端里就是要关掉enable.auto.commit改成手动提交。手动提交分两种同步提交commitSync()和异步提交commitAsync()。同步提交会阻塞等待Broker确认严格但慢异步提交不阻塞但可能提交失败。我见过很多团队用异步提交回调里补一次同步提交兜底这样既保证速度也保证了最终会提交成功。RocketMQ则是通过MessageListener的消费回执来控制返回ConsumeConcurrentlyStatus.CONSUME_SUCCESS代表消费成功返回RECONSUME_LATER代表稍后重试。这里要注意业务逻辑必须是幂等的才能放心返回成功否则业务处理到一半抛出异常时返回成功就等于丢了那条消息。1.4 可靠性方案的参数配置参考我这里整理一份生产环境常用的配置组合已经在我参与过的多个项目中验证过环节组件推荐配置说明生产端Kafka Produceracksallretries5enable.idempotencetrue核心链路标配生产端RocketMQ Producer同步发送检查SEND_OK失败要进补偿队列Broker端Kafkamin.insync.replicas2Topic副本数3兼顾可靠性和可用性Broker端RocketMQflushDiskTypeSYNC_FLUSH同步复制核心Topic高一致性场景使用消费端Kafka Consumerenable.auto.commitfalse手动提交业务成功后提交消费端RocketMQ Consumer返回CONSUME_SUCCESS前确认业务完成依赖业务幂等性2. 顺序性消息有序这件事没那么难也没那么简单2.1 先分清全局顺序和局部顺序很多业务场景要求消息按顺序处理比如“创建订单—支付订单—取消订单”这三条消息如果顺序乱了最终状态就是错的。所谓的全局顺序就是Topic里所有消息都严格按生产顺序消费这在分布式系统里几乎是不可接受的性能陷阱因为所有分区都会退化成单分区。而局部顺序才是实际业务中真正需要的同一个订单、同一个用户、同一个设备产生的消息按顺序处理不同订单之间互不干扰。Kafka天生只保证同一分区内的消息有序所以实现局部顺序的关键业务就一句话把需要有序的消息通过Key路由到同一个分区。比如订单号作为Key相同订单号的消息一定会进入同一分区消费者在分区内按offset顺序消费就保证了这一组消息有序。RocketMQ的做法类似它提供了MessageQueueSelector你可以在发送时自己选队列把同一业务ID的消息发到同一个队列消费端就会按顺序处理。2.2 分区键设计是顺序性的灵魂分区键选得不好顺序性和扩展性全都会出问题。我见过有人把所有消息的Key都设成同一个常量比如业务ID字段为null导致所有消息都跑到同一个分区其他分区白白空转。这就是典型的分区键设计失误。正确做法是按业务维度拆分Key。比如订单场景用订单号用户场景用用户ID设备上报场景用设备ID。选择业务ID作为分区键还有个好处天然就满足局部有序的诉求。但要注意Key的基数如果业务ID只有几个枚举值比如SUCCESS、FAILED、TIMEOUT那分区数量再多实际活跃分区也就那么几个分布会严重不均衡。遇到这种情况可以在Key后面拼接一个随机后缀做打散但前提是你对这些消息没有顺序要求或者可以用更细的业务子ID来区分。顺序性还有一个隐蔽的坑重试。如果一个分区内的消息A处理失败消费者按顺序阻塞在原地反复重试后面排队的消息会被一直卡住。比如A消息消费失败一直重试B消息即使是可以正常处理的也只能干等着。这在订单场景里经常出问题支付结果消息因为下游接口暂时不可用一直失败导致后面的退款消息也无法处理整个链路被单条坏消息堵死。处理办法通常是设置合理的最大重试次数超过后把消息转投到死信队列或专门的延迟队列等人工或定时任务介入而不是无限阻塞顺序消费线程。2.3 消费端并发模型影响顺序保障服务端保证了同一分区有序写入如果消费端开了多线程并发消费同一个分区的消息顺序还是会被打乱。Kafka的单分区数据只会被同一个消费者实例的某个线程拉取但如果消费者客户端内部用了多线程处理那就需要自己维护分区内消息的顺序。比如用KafkaConsumer默认max.poll.records500一次拉取的500条消息如果都丢给线程池并发处理顺序就乱了。要让它们有序处理要么单线程消费该分区消息要么在业务代码里按照业务主键自己再做一次分组排序。RocketMQ的顺序消息消费比如MessageListenerOrderly是锁队列的粒度来保证串行处理但用锁就意味着同一队列的消息是严格串行消费的吞吐量被限制在单条消息的处理速度上。2.4 顺序性需求下吞吐和延迟的取舍保证顺序是要付出代价的最大代价就是同一分区的消费吞吐被串行化。假设一条消息处理耗时50毫秒单个分区每秒最多处理20条。如果你需要处理每秒10万条的消息且保持严格有序那这个业务模型本身就有问题要么这批消息实际上不需要全局有序要么业务处理逻辑需要优化减少耗时。我个人经验是大部分“必须顺序”的业务实际都是局部顺序完全可以靠合理分区键解决。真正需要考虑全局顺序的场景极少比如某些增量日志同步。如果确实遇到全局顺序需求与其硬扛不如重新审视一下业务模型看看能不能通过最终一致性加状态机替代。顺序不是目的数据最终正确才是目的。3. 幂等性重复消费是常态不是意外3.1 为什么重复消费防不胜防前面提到先业务后提交offset这个方案保障了不丢消息代价就是重复消费。网络抖动导致消费端提交offset失败消费者重启导致正在处理的消息被重新分配还有重试机制带来的重复投递都在制造重复消息。所以做MQ消费端第一条设计原则就是默认消费逻辑必须幂等即同一消息无论被消费多少次对业务数据的影响都跟消费一次一样。很多人遇到重复消费问题时一脸懵“我这条消息明明消费成功了为什么又执行了一次” 实际上在分布式系统里副本、重试、超时误判都是常态重复不是bug是物理定律。接受这个前提之后所有消费逻辑都往幂等方向设计能少掉很多生产事故。3.2 幂等判定的核心是唯一业务键幂等方案的第一步是给每条消息定义一个全局唯一的业务键。这个键不是消息本身的Message ID因为消息在重试时Message ID可能变化而是业务层面的唯一标识。比如订单支付回调消息业务键就是“支付流水号”用户状态变更消息业务键就是“用户ID 变更版本号”库存扣减消息业务键就是“订单号 SKU ID”。有了唯一业务键后最常见的幂等方案是去重表。在业务数据库里建一张去重表主键就是业务键。消费时先对业务键做插入操作如果插入成功说明这条消息以前没处理过可以继续执行正常业务逻辑如果插入报主键冲突说明重复消息直接丢弃或标记。这个方案简单可靠因为主键的唯一约束是数据库层面硬性保证的没有并发问题。RocketMQ的官方文档里也提到了类似思路不过更推荐使用业务数据库表本身具备唯一约束来兜底。比如订单状态表里有一个唯一索引SQL里用INSERT ... ON DUPLICATE KEY UPDATE重复插入时不会报错而是更新或忽略天然幂等。3.3 状态机幂等最适合订单类业务的方案去重表是全局拦截适合“这个业务操作之前是否执行过”的判定。但有些场景还要应对“状态乱序”问题。比如订单状态从“待支付”到“已支付”到“已退款”如果旧状态的重复消息在退款已经完成后才再次到达去重表能挡住这条消息本身但挡不住业务上“已支付完成再来一条待支付消息”的情况。这种情况下建议用状态机加版本号来做幂等。业务表里维护一个当前状态字段和一个期望状态字段消费时用SQL条件更新UPDATE order_info SET status PAID, version version 1 WHERE order_id 202401011234 AND status PENDING AND version 3;UPDATE影响行数为0说明当前状态已经不是期望的待处理状态可能是重复消息或者过期的乱序消息直接忽略。我在支付回调场景里一直用这种方式效果很好。需要注意这种方式要求消息里带着目标状态和来源状态不能只是丢一个无状态的RPC调用过来。3.4 并发陷阱先查后插都有坑幂等方案里最怕的就是“先查后处理”。假设去重表里还没有业务键A线程查到“不存在”开始处理业务此时B线程也查到“不存在”也进来处理结果两个线程同时更新了业务数据重复执行两边。所以幂等判定的核心操作必须是“写驱动”而不是“读驱动”。利用数据库唯一索引或者Redis的SETNXSET if Not eXists这类原子操作一次原子调用完成占位和判定。Redis做幂等判断的注意点有两个一是要设置合理的过期时间不能太长占用内存也不能太短导致业务还没处理完键就过期了一般设置成消息处理的超时时间加上余量二是Redis本身也有极低概率的宕机风险用了Redis做幂等一旦Redis不可用消费端所有消息都会判定失败这需要在上游做好降级。我建议核心链路用数据库去重表非核心链路才用Redis权重不同可靠性要求也不同。还有一个隐蔽问题RocketMQ和Kafka的消费者都支持“消费失败重试”如果消费逻辑没有幂等性重试就是反复执行带副作用的操作。处理方式是消费失败后先记录失败原因和消息内容到一张异常表然后手动确认消费成功由后台任务去修复数据。这相当于把自己从“反正会重试”的懒惰思维里解放出来控制系统行为而不是让系统随缘。4. 消息积压容量危机的应急和根治4.1 积压不一定是消费慢先看怎么度量消息积压的典型现象就是消费延迟越来越大业务方开始反馈数据不一致、订单没生成、报表迟迟不出。但积压问题不能只看表象拿到积压报告第一件事是确认积压量到底多大、消费速率和生产速率分别是多少。Kafka的消费组Lag可以在Kafka Manager或命令行工具里查看RocketMQ则可以在控制台直接看到Consumer的堆积数量。Lag的计算本质就是“生产端写入的总offset减去消费端已提交的offset”。熟悉这两个指标之后你就能快速判断积压是生产端突然暴涨导致的还是消费端处理能力下降导致的。4.2 常见积压根因从代码到依赖再到配置我自己排查积压问题时基本按这个顺序过一遍首先看消费者日志里有没有大量报错比如下游数据库连接超时、外部API调用一直无响应这些都是导致消费线程卡死或反复重试的元凶接着看消费者线程数和批量拉取参数Kafka默认拉取500条如果单条处理耗时高500条会撑爆线程池再看消息处理逻辑里有没有同步等待或锁竞争。这里有一个特别常见的隐蔽根因消费者里调用了慢SQL。数据库某个查询因为缺索引要跑2秒每条消息都触发一次这样的查询消费线程就集体排队。我遇到过一次比较严重的积压排查到最后是一个字段没加索引导致的加完索引后Lag肉眼可见地往下掉。所以查看积压之前先复盘最近有没有发过代码变更有没有改过SQL有没有升过依赖版本很多时候积压是“变更”引发的不是系统凭空变慢。4.3 应急扩容增加消费者和加分区要分清顺序积压爆发后第一件事是止损核心手段就是扩容消费能力。Kafka场景下如果Topic的分区数是20消费组最多只能有20个消费者实例并行消费没有分区了再加更多消费者实例也没用。所以扩容前先确认分区数是否已经打满。如果分区数没打满可以临时增加消费者实例如果已经打满可以考虑临时给Topic增加分区但这是一个风险操作增加分区后同一Key的消息可能被路由到不同分区破坏顺序性而且旧分区的数据不会自动重新分布新增分区只能提高新消息的消费速度对存量积压的加速作用有限。RocketMQ的场景比较类似一个消费者实例默认消费多个队列增加消费者实例确实能分担队列的消费压力但如果所有队列都被占满新增实例也不会被分配到队列。这里要注意的是RocketMQ的集群模式下同一个消费组内的消费者会负载均衡每个队列加消费者之前先确认当前队列数量和消费者数量别盲目加机器。如果扩容消费者都来不及还有一个应急手段临时写一个快速消费脚本直接跳过业务处理只把消息体原样落盘或者转存到另一个临时Topic先让消费者Lag恢复到正常水平之后再拉取临时Topic里的数据慢慢补处理。这个方案在业务允许延后处理的场景下非常好用我处理过一次大促后的积压就是这么干的先保证消费管道通畅再补业务数据。4.4 根治积压限流、削峰和消费逻辑重构应急手段只是治标积压的根治往往要回到消息模型本身。第一类是生产速率瞬间暴涨造成的积压比如大促秒杀、活动开始时的大量请求这种情况要配合业务做削峰填谷消息队列本身就是用来缓冲流量的生产者限流或者消费端按业务优先级分流都是常见做法。第二类是消费端本身处理能力不足这时候要优化消费逻辑比如把耗时的外部调用改成异步、把多次Redis操作合并成Pipeline、把单条消息处理中不必要的事务范围缩小。另外消费端加缓存也能显著降低积压风险。比如下游数据库压力大可以在消费者里加一层本地缓存把高频查询拦截在本地减少对DB的压力。但要注意缓存的一致性和时效性别为了解决积压引入数据不一致的新问题。还有思路是用并行消费把一条消息拆成多条子任务分发给多个下游并行执行不过这只适用于无严格顺序要求的场景。4.5 积压期间的数据一致性补偿方案当积压已经发生并且持续了一段时间消费端恢复后要注意数据的一致性问题。比如订单状态流转消息延迟了半小时用户看到的状态可能已经过期甚至因为积压期间用户主动发起了其他操作导致恢复后的消息到达时已经不能直接执行原有的状态更新逻辑。我的习惯做法是积压超过阈值时先冻结消费组不让消息继续流出同时检查积压消息的类型和影响范围。恢复消费后先跑一遍对账程序把积压期间产生的业务变化和消息里的状态做比较能自动修正的自动修正不能自动修正的走异常工单人工确认。这一步很多人会忽略结果积压解决了但数据错乱在业务上爆发了更大的问题。5. 实操心得先保命再优化5.1 一劳永逸只存在于PPT里监控才是日常MQ这四个核心问题的每次出现几乎都伴随监控预警不及时的尴尬。我在实践中体会最深的一点就是监控指标不能只看Lag数字本身还要围绕“生产速率”“消费速率”“消费失败数”“重试队列深度”“死信队列深度”建一套完整的看板。最好给Lag设置分级告警超过阈值就发告警持续增长就触发更高级别的通知自动提交和手动提交的消费组要分开看指标。另外消息轨迹追踪非常有用。Kafka和RocketMQ生态里都有消息轨迹插件开启后可以完整看到一条消息从生产到消费的完整链路。排查问题时顺着消息ID查看它经过了哪个Broker、哪个分区、被哪个消费者实例消费能省掉大量盲猜的时间。5.2 死信队列不是一个选项是一个必需品没有死信队列的MQ系统就像没有垃圾桶的厨房。消费失败的消息不能无限重试到达最大重试次数后必须有个去处。Kafka没有原生死信队列概念但可以约定一个_DLT后缀的Topic专门接收处理失败的消息RocketMQ原生支持死信队列。我的经验是死信队列里只保存原始消息和失败原因并且要配套一个管理后台或者定时任务能把死信重新回放。处理死信的频率不要太低至少每小时拉取一次关键业务甚至做到分钟级。死信积压同样是一种积压不及时处理时间长了就变成没人敢动的一堆“历史问题”。5.3 给后来者的一句话如果你刚接手一个MQ相关项目别急着放大改先把上面四个维度的现状盘清楚消息会不会丢、顺序有没有保障、重复消费有没有兜底、积压有没有预案。这四件事单独看都不难难的是它们随时会叠加发作。比如既要保证幂等又要保证顺序去重键和分区键怎么配合既要处理积压又不破坏顺序扩容方案就要小心设计。真正考验功力的地方恰恰是在这些矛盾的约束之间找到那个可靠的平衡点这也是我写这篇文章最想传递出来的东西。
返回列表