最近公司公司的旧系统中发现了一个bug。业务部门反馈,尽管用户只支付了一年的服务费用,系统却将有效期增加了两年。
原因分析:
到底是什么原因呢?
经过日志分析,发现消息队列(MQ)向第三方服务发送了两次消息。由于第二方服务的接口没有实现幂等性控制,导致了这一重大的bug。
问题反思:
想一想,其实这种问题很简单,怎么会出这种问题呢?
一般来说系统开发中不免会出现不少类似的问题,类似问题的出现并不罕见。一般系统都是从无到有,业务从少到多、早期可能也就几个或者一个研发人员开发出来的,后面升级或重构甚至推倒重来。。。
问题严重性:
这类bug对于系统和业务的影响极大,尤其是涉及金钱的业务,可能会导致严重的财务损失和信誉问题。
在系统开发过程中,正常的服务会确保服务的幂等性,尤其是在涉及金钱交易的业务中。
下面我们就来复盘一下重复消息的产生原因及相应的解决方案:
一、消息重复原因
在消息队列(MQ)系统中,消息重复的情况主要有以下几种原因:
1、生产者重复或重试
生产者代码没有阻止重复请求或处理发送消息后的响应情况,在连接超时等异常情况下,重复发送了相同的消息。当然还有一种情况就是生产者本身设置了重试机制,但重试机制不完善造成重复发送消息。
2、消费者代码问题
消费者在处理消息过程中出现异常,没有正确地手动确认消息,那么该消息会重新投递,导致重复。
3、网络问题
网络延迟或临时断开连接可能导致MQ消费者没有收到确认消息,从而重新消费同一条消息。
4、消息队列集群问题
MQ集群节点之间的状态同步问题,可能导致消息被多个节点重复投递。
当然还有别的一些原因.....
二、消息重复解决方案
针对消息重复的问题,可以从以下几个方面采取解决措施:
1、生产者端解决方案
消息的生产者端的消息溯源还是用户的请求。
第一道防线
首先,前端可以通过禁用按钮、显示加载状态等方式防止用户重复点击提交按钮。禁用按钮是指在用户点击提交按钮后,立即将该按钮禁用,防止再次点击。显示加载状态则是在提交请求后显示加载动画或状态提示,告知用户请求正在处理中。高级一点的做法是使用JavaScript脚本控制按钮的状态,确保用户无法重复提交。不过,前端防重只是第一道防线,因为前端措施容易被绕过,例如通过浏览器开发者工具修改页面元素或捕获和重发请求报文。
第二道防线
如果前端防重措施被绕过,用户可以直接通过程序生成大量请求,此时服务后台需要采取进一步的防重措施。后台可以通过请求频率限制、幂等键、时间戳、哈希值等方式来防止重复请求。请求频率限制是指限制每个用户在一定时间内的请求次数,防止短时间内大量重复请求。幂等键则为每个请求生成唯一的标识符,并在后台存储和检查这些标识符,确保每个请求只处理一次。时间戳和哈希值也是有效的防重手段,通过附加时间戳验证请求的时效性,或对请求内容生成哈希值并检查其唯一性,确保相同内容的请求只处理一次。
第三道防线
在消息的生产者端,也需要采取类似的防重措施以确保消息不被重复发送。例如,在发送消息前生成唯一的消息ID(例如UUID),并将其包含在消息体中。发送消息后,同步等待服务器的回执确认,确保消息只发送一次。此外,可以将消息ID存储在数据库或缓存中,并在发送前检查该ID是否已存在,防止重复发送。当然生产者端一般不会利用这个ID或者叫幂等键来去重,一般会结合消息者端一起来实现。
通过这些多层次的防重措施,能够有效减少消息重复的发生,保障系统的稳定性和可靠性。
2、消费者端解决方案
消息到达消费者端后
第一道防线
消费者端可以使用幂等键来判断请求是否已经处理过。通常情况下,缓存不宜存放过多数据,而重复请求大多数集中在一定时间范围内,因此将幂等键存储在缓存中是一种有效的解决方案。
具体来说,幂等键可以与请求的唯一标识关联,并存储在缓存系统(如Redis或Memcached)中。为每个幂等键设置一个合理的过期时间,可以有效地过滤掉在这个时间范围内的重复请求。通过这种方法,大部分重复请求可以在缓存层被拦截,从而减少对后端服务的压力。
第二道防线
尽管缓存机制可以一定程度上确保幂等性,但是当缓存过期后,可能会再次收到相同的请求。为了更加彻底地解决这个问题,我们需要在数据库层面进一步加强幂等性保证。
当收到请求到达数据库层时,首先检查该请求的幂等键是否已经存在于数据库中。如果存在,则说明该请求已经被处理过,直接返回之前的结果即可;如果不存在,则继续执行请求的业务逻辑。处理完成后,将请求的幂等键及结果持久化到数据库中。当然大多数人还是会选择更简单点的直接把幂等键设置为唯一索引,当报键值重复异常时就忽略些请求直接返回。
3、消息队列层解决方案
- 利用消息队列中间件的去重功能,如设置成手动ACK、去重插件等。
- 设置消息的有效时间(TTL),防止过期消息重复投放。
- 配置死信队列(DLX),存储处理失败的消息。