背景介绍 & 实际操作

一个客户的部署环境在周五晚上进行了一次大版本升级。过程中某个表 f 的改表操作出了一点故障导致 1/4 的分片改表失败,DBA 的检测工具有 bug 导致没有把缺字段的问题检测出来,直到第二天上午 DBA 通过错误监控发现了这个问题随后完成改表,此后恢复正常更新但问题期间的一些更新并没有成功落库。

f 表按 user_id 分片,这个改表失败导致从周五 22:30 左右到周六 10:40 左右 f 表的这些分片上的写入都失败了。 好在这些写入操作绝大多数来都自于 RocketMQ 的异步更新,且更新失败会利用 RocketMQ 的重试机制重试。

后续业务侧确认了出错的错误日志可以和 DBA 的改表时间能对应上;RocketMQ 运维同学确认了死信队列中有几万条消息,时间也可以匹配;业务侧确认了代码逻辑,重新消费死信队列中的消息可以把漏更新的补上且不会把周六 10:40 之后更新过的回写成旧数据。

随后 RocketMQ 运维同学用小工具把死信队列中的消息读出来重新 produce 到原 topic 中,这样业务代码不需要改动就可以直接消费重新 produce 过来的消息,由于 DB 问题已经修复,所以这些重新 produce 回来的消息可以正常消费。 最后观察了一下 MQ 的堆积增长、下落,业务侧成功消费到了故障期间的消息,DBA 查询了几条 SQL 确认更新符合预期,问题解决 👏。

一些细节 & 相关原理

重试和死信队列

首先复习一下 RocketMQ 的重试和死信队列逻辑。

消息队列RocketMQ版消息收发过程中,若Consumer消费某条消息失败或消费超时,则消息队列RocketMQ版会在重试间隔时间后,将消息重新投递给Consumer消费,若达到最大重试次数后消息还没有成功被消费,则消息将被投递至死信队列。 重试间隔:消息消费失败后再次被消息队列RocketMQ版投递给Consumer消费的间隔时间。 最大重试次数:消息消费失败后,可被消息队列RocketMQ版重复投递的最大次数。 ——阿里云 RocketMQ 消息重试 文档

其中顺序消费场景直接原地重试,直到业务侧重试一定次数后放弃重试认为这个消息成功继续消费下一个,或者一直阻塞重试直到最大值(默认最大值非常大)。 无序消费场景的间隔时间是随着重试次数阶梯式增长的,1s ~ 2h,默认最大重试次数 16 次。

由于这个阶梯重试,一条消息最终重试 16 次全部失败被放入死信队列至少需要 4h+50min+10s 的时间。所以在这次问题中观察日志发现死信队列中没有周六 05:00 开始的消息,因为从 04:50 左右开始的错误最终能通过重试机制成功消费掉。

对死信队列的操作

参考阿里云 RocketMQ 死信队列 文档,管理后台中可以查看死信队列中的消息,并可以选择消息并重新投递。

这次实际 case 中由于这个环境的管理工具比较旧,似乎没有重新投递的工具,所以最后使用小工具自己读取自己投递的方式。

死信队列本身是一个 topic,似乎不能用 push 模式的 Consumer 消费,用 pull 模式 Consumer 消费成功了,且 pull 模式消费过程中 offset 也是被记录的,所以如果要重新从头消费的话需要配置一下参数从头开始或者新开一个 consumer group。

顺便发现的问题

最新的逻辑因为一些方面的考虑已经改为有序消费了,会在内存中做几次重试,全部失败后就标记成功继续后续的处理。幸好这个环境中还在使用旧的逻辑,否则处理起来会比较麻烦。

这时候就在想,有序消费的场景是不是有必要在最终消费失败的场景下把消息往一个专门的队列里面投递一次,好应对这种下游组件出错多个小时超出重试时长的场景。


讨论链接:实际 case:多亏了 RocketMQ 的死信队列 · Discussion #57 · binderclip/clip