阅读 289

基于REDIS实现延时任务

背景介绍

最近业务上有个需求,背景如下:有一个养殖类游戏,通过给养的宠物喂食来升级,一次喂食后,宠物需要花4个小时吃完。现在有个新需求,可以使用道具卡来丰富玩法。道具卡有两种,一种是加速卡,一种是自动喂食卡。加速卡会使吃食的时间缩短两个小时,自动喂食卡可以在宠物吃完当前喂食的狗粮后系统帮助其自动喂食一次。

业务需求里的自动喂食就是一种典型的延时任务。延时任务是指需要在指定的未来的某个时间点自动触发。与之类似的场景还有:

  • 活动结束前2小时给用户推送消息;
  • 优惠券过期前2小时给用户推送消息;
  • 秒杀时,下单后10分钟内未付款就自动取消订单等;

业界解决方案

扫表

对于延时任务,常见的方案就是扫表。扫表就是用一个后台进程,每隔一段时间扫描数据库的整张数据表,判断每个任务是否达到触发的条件。如果达到条件就执行相应的业务。扫描全表对数据库压力较大,所以一般选择扫从库。扫表的最大优势是实现起来比较简单,而且数据本身存在DB里,因此也不用担心任务数据会丢失,失败的任务可以下次扫描时再重入。但是扫表存在以下问题:

  • 扫表一整张表需要一段时间,会造成任务的触发有延时,有的时候一个进程每个还要扫多个表;
  • 扫表不可能太频繁,因为太频繁会对数据库造成太大压力,每隔一段较长的时间才能再扫一遍,这个时间间隔一般至少在一分钟以上。这也会造成任务延时;
  • 扫表扫的是从库,而主从同步存在延时。特别是当大事务出现时,会导致几分钟甚至几小时的延时;
  • 扫表的方法很笨重,每次扫描一整张表而实际需要触发的任务可能没几个,资源利用很低下;

扫表最大的问题就是会有延迟,不能再指定的时间里触发,对于时效性高的场景,这种方案是不能满足需求的。

延时消息队列

目前,有些MQ消息队列可以支持延时消息,如kafka。延时消息就是消息发送后,可以指定在多少时间之后才会发送到消费者那里。这个方案,开发成本也很小,不过需要使用的中间件能支持延时消息。而且该方案也存在一个瓶颈就是如果,延时任务需要重新更新时间就做不到了,因为消息已经发出去了,收不回了。

时间片轮询

用环形队列做成时间片,环形队列的每个格子里维护一个链表。每个时刻有一个当前指针指向环形队列某个格子,定时器每超时一次,就把当前指针指向下环形队列的下一个格子。然后处理这个格子保存的链表里的任务。如果只是这样维护,如果要做到秒级的粒度,时间长度最长一天,那么这个环形队列就会非常大。因此,有人又有人改进了一下,当存在任务进入队列时,就用时间长度除以环形队列的长度,记为圈数。这样每次遍历到该元素时,将圈数减一,如果减一后为0就执行改任务,否者不执行。

kafka的延时消息的内部实现就是采用时间片轮询的方式来实现的。

对于时间跨度非常大的场景,如果使用这种方法会导致链表上的元素非常多,遍历链表的开销也不小,甚至在一个时间片内遍历不完。因此,又有了进一步的改进,将时间片分为不同粒度的。比如,粒度为小时的时间轮,粒度为分钟的时间轮,粒度为秒钟的时间轮。小时里的时间轮达到触发的条件后会放到分钟的时间轮里,分钟的时间轮到达触发的条件后会放到秒的时间轮里。(图片来自网络,侵删)

该方案时间片存放在内存,因此轮询起来效率非常高,也可以根据不同的粒度调整时间片,因此也非常灵活。但是该方案需要自己实现持久化与高可用,以及对储存的管理,如果没有现成的轮子开发耗时会比较长。

Redis的ZSET实现

Redis实现延时任务,是通过其数据结构ZSET来实现的。ZSET会储存一个score和一个value,可以将value按照score进行排序,而SET是无序的。

延时任务的实现分为以下几步来实现:

(1) 将任务的执行时间作为score,要执行的任务数据作为value,存放在zset中; (2) 用一个进程定时查询zset的score分数最小的元素,可以用ZRANGEBYSCORE key -inf +inf limit 0 1 withscores命令来实现; (3) 如果最小的分数小于等于当前时间戳,就将该任务取出来执行,否则休眠一段时间后再查询

redis的ZSET是通过跳跃表来实现的,复杂度为O(logN),N是存放在ZSET中元素的个数。用redis来实现可以依赖于redis自身的持久化来实现持久化,redis的集群来支持高并发和高可用。因此开发成本很小,可以做到很实时。

具体实现

扫表的方法延时太高不能满足实时的需求,团队目前使用的消息队列还不支持延时消息队列,时间轮的方法开发起来很耗时,因此最终选择了Redis来实现。

前面介绍了Redis实现延时任务的原理,为了实现更高的并发还需要在原理的基础上进行设计。接下来将详细阐述具体的实现。架构设计图如下:

说明:

  • 为了避免一个key存储在数据量变多以后,首先会导致查询速度变慢,因为其时间复杂度为O(logN),其次如果在同一个时间点有多个任务时,一个key会分发不过来,造成拥堵。因此,我们将其设计为多个key来存储,通过uuid进行hash路由到对应的key中,如果任务量增长,我们可以快速扩容redis key的数量来抗住增长的数量;
  • 建立与多个key相同的进程或者线程数,每个进程一个编号,分别对应一个key,不断轮询相应的key;
  • 轮询key的进程我们将其称为event进程,event进程只查询出任务,但是不处理业务,将该任务写入到消息队列中。另外有work进行从消息队列取消息,然后执行业务。这样work进行可以分布式部署,event进行只需做分发,这样可以把并发做到非常高,即使同一时间有大量的任务,也能很小的延时内完成任务;
  • 为了避免event进程单机部署,在机器宕机后导致无法取消息,redis储存的数据还会被积压。我们多机部署event进程,并使用zookeeper选主,只有leader主机上的进程才从redis取消息。leader主机宕机后,zookeeper会自动选择新的leader;
  • 在实际的业务中,还依赖DB写入数据。延时任务产生是先修改DB然后再向redis写入数据,那么就存在DB更新成功,然后redis写失败的场景,这个时候首先是通过重试来减少redis写入失败的概率,如果重试任然不能成功,就发送一条消息给daemon进程进行异步补偿;

在延时任务的基础上,本次业务还有一个需求,就是延时任务如果还没有到达执行时间,那么该延时任务的时间是可以被更改的。为了实现这个需求,我们另外给每个用户维护一个ZSET,这个ZSET中存放该用户所有的延时任务。为了便于描述,我们将这个ZSET称为ZSET-USER。如果用户需要修改其延时任务,如果没有办法从整体的延时任务的ZSET中找到这个任务,而是即使能找到,也只能遍历这个ZSET,显然这种方法太慢,太耗资源。我们采取的方法是从ZSET-USER中取出这个用户的延时任务,然后修改score,最后重新ZADD到延时任务ZSET和ZSET-USER中,ZADD会覆盖原来的任务,而score则发生了更新。这样看来,这个需求还只能通过Redis来实现。

本篇文章,借着业务需求的背景首先探讨了延时任务的业界实现方案,然后详细阐述了通过redis来实现延时任务方法,并分析了高并发,高可用的设计思路。

关注下面的标签,发现更多相似文章
评论