一个超时功能的设计
超时功能的设计
产品需求
有一个产品需求,需要执行某个动作之后,需要生成一个超时的任务,在超时时间到了之后执行后续的动作,后续动作的执行大约耗时1秒钟。
任务允许在未到超时间删除,超时时间不超过30天。要求在现有的产品架构上实现此功能。
产品架构
- 分布式服务实例数量:3
- 缓存:redis 3.2.3
- 数据库:mysql 5.7
- 消息队列:rabbitMq 3.7.6
- 分布式定时任务:elastic-job 2.1.5
功能设计
-
方案一
首先看到这个需求,第一时间想到的就是在内存中维护一个集合,然后通过定时任务每分钟扫描集合,将到过期时间的任务取出并执行后续的操作。
删除任务则直接将集合中的任务删除。- 优点:
- 代码逻辑实现简单。
- 充分利用了所有实例,每个实例指需要负责自己内存中的任务即可。
- 存在问题:
- 每次全量扫描集合过于浪费cpu性能,比如许多任务远还未到过期时间的任务。优化方案:采用有序集合,每次只需要扫描第一个元素,如果
第一个元素还未到过期时间直接等待下次循环。 - 任务未持久化,假如服务器重启,宕机或者服务器不可恢复会丢失任务。由于任务时间存在一个月,因此这点算是比较致命的问题。
- 每次全量扫描集合过于浪费cpu性能,比如许多任务远还未到过期时间的任务。优化方案:采用有序集合,每次只需要扫描第一个元素,如果
- 优点:
-
方案二
既然方案一存在持久化的问题,那么只要解决这个问题即可,比如存储在一个公共的存储上面,如mysql和开启持久化功能的redis。那么到底应该选择什么
来存储呢。mysql和redis都是天然支持有序的记录。只是两者使用的数据结构不同,mysql采用b+树,而redis采用的是跳表。对于查询而言,两者的时间
复杂度都为n(logn)(redis直接在内存中相比mysql而言还少了io)。但是由于我们过期时间插入是非顺序的,对于mysql索引插入和删除同时还会带来页的分裂,而跳表插入和删除
更加简单。综合考虑这里采用redis存储。- 优点:
- 解决了持久化问题,按照不同服务实例id存储不同的zset。
- redis不仅拥有更优秀的查询效率,还有更优秀的插入和删除效率。
- 存在问题:
- 虽然解决了持久化问题,但是由于不同实例的zset是不同的,如果某个实例挂了无法启动,那么其过期任务在超过了过期时间之后,依然不会执行。
那么是否可以将所有任务都丢到同一个key中而不区分实例呢。确实是可以的,这么做的话对于服务和存储来说都是无状态的,服务只要消费同一个zset
中的数据即可。
- 虽然解决了持久化问题,但是由于不同实例的zset是不同的,如果某个实例挂了无法启动,那么其过期任务在超过了过期时间之后,依然不会执行。
- 优点:
-
方案三
所有添加的超时任务都存储到同一个zset中,所有实例的超时线程去消费这个zset。但是这就带来了一个问题,由于之前不同实例是不同zset因此不需要去分配
具体消费什么任务,自己的zset中有什么任务到期就消费什么任务。现在所有的任务都在一个zset中应该如何去分配呢,获取到期任务的指令是zrange,只能获取sorce范围内的所有
数据,如果每个实例都是这么获取任务,那么必然都是在重复消费。在redis5.0之后的版本中存在zpopmax/zpopmin命令可以满足这个需求,每次实例线程取任务时弹出第一个任务。但是在这之前的版本并不支持。- 优点
- 解决了服务实例挂了无法启动,这个服务实例分配的任务无法执行问题。
- 存在问题
- 多实例间任务无法分配。如果只使用一个实例消费过于浪费性能,同时无法扩容。(将数据存储在数据库中也会带来同样问题,同时还会带来并发消费问题)
- 优点
-
方案四
由于方案三存在任务分配问题,那么是否可以单独做一个线程来分配所有已经到期的任务呢。因此就单独使用一个线程来获取所有已经到期的任务,然后在将这些任务均匀的
分配到所有实例线程中去。最好的方式就是采用mq了。将该线程从redis中获取的所有已经到期的任务都丢到队列中,所有实例线程都消费队列即可,不但解决了分配问题,
同时也不存在并发问题,任务丢失等问题。- 具体实现:
分配任务线程可采用elastic-job,只使用一个分片,不但避免了获取任务并发问题,同时elastic-job可以保证高可用,当分配任务线程实例挂了之后,自动
寻找新的实例启动分配线程。分配线程每分钟去redis中获取一次已经到期的任务,同时将任务发送至mq中,同时每个实例中都存在监听这个队列的消费者,来完成后续
的操作。
- 优点
- 解决了所有问题。
- 缺点
- 实现复杂,引入中间件和组件过多。
- 具体实现:
-
方案五
是否有更简单的方法来实现这个功能呢,答案是有的。既然使用了mq,可以在任务生成的时候直接发送到mq中,利用mq延迟队列的属性来直接完成这个功能。当队列中的消息
到了过期时间之后才会被消费者消费。如rabbitmq可以采用ttl和死信队列来完成这个功能。- 优点
- 解决了所有问题,同时实现简单。
- 缺点
- 已经添加的任务无法删除,必须要能够在执行之前可以判断此任务是否被删除。
- 优点
总结
其实这个是在研发时候经常会遇到的需求,在不同的需求下有不同的解决方案。以上是自己在遇到这个需求时候的考虑,最终选择的是方案五。但是可能存在更好的解决方式。
再此也是记录一下这个看似简单的需求,背后自己的思考。如果有更好的解决方案,也希望大家不吝赐教。
本文地址:https://blog.csdn.net/jy00733505/article/details/107372978
上一篇: 图解Java设计模式之UML类图