所谓延时任务我举个例子:你买了一张火车票,必须在30分钟内付款,否则订单会自动取消。30分钟内未付款,订单将自动取消。此任务是延迟任务。之前写过2篇关于延迟任务的文章:《完整实现-通过DelayQueue实现延时任务》《延时任务(二)-基于netty时间轮算法实战》这两种方式都有一个缺点:都是基于单个应用的内存运行延迟任务。一旦出现单点故障,可能会出现延迟任务数据丢失的情况。所以本文介绍第三种实现延迟任务的方式,结合rediszset实现延迟任务,可以解决单点故障问题。给出实现原理,完整的实现代码,以及这种实现的优缺点。一、实现原理首先介绍一下实现原理。我们需要使用rediszset来满足延迟任务的需求,所以我们需要了解zset的应用特点。Zset作为redis的有序集合数据结构存在,排序依据是score。所以我们可以利用zsetscore的排序特性来实现延迟任务。当用户下单时,会产生一个延时任务,放入redis。密钥可以自定义。比如delaytask:ordervalue的值分为两部分,一部分是score用于排序,一部分是member,我们将member的值设置为order对象(如:订单号),因为后续延迟的时候任务时限到达,我们需要有一些必要的订单信息(如:订单号),才能完成自动取消和关闭订单的动作。执行延迟任务的关键就在这里。我们将分数设置为:订单生成时间+延迟时间。这样redis会根据分数延迟时间对zset进行排序。启动redis扫描任务,获取“当前时间>分数”的延时任务并执行。即:当当前时间>订单生成时间+延迟时间时,执行延迟任务。2、准备工作要使用rediszset方案来满足延时任务的需求,首先必须要有redis,这是毋庸置疑的。网上关于redis搭建的文章很多,这里就不赘述了。其次,笔者长期的java应用系统开发都是使用SpringBoot完成的,所以也习惯使用SpringBoot的redis集成方案。首先引入spring-boot-starter-data-redisorg.springframework.bootspring-boot-starter-data-redisorg.apache.commonscommons-pool2接下来需要在SpringBoot的application.yml配置文件中配置redis数据库的链接信息。我这里配置的是redis单实例。如果你的生产环境是哨兵模式或者集群模式的redis,这里的配置方式需要微调。其实这部分内容在我的个人博客中已经系统地介绍过了。感兴趣的朋友可以关注我的个人博客。spring:redis:database:0#Redis数据库索引(默认为0)host:192.168.161.3#Redis服务器地址端口:6379#Redis服务器连接端口密码:123456#Redis服务器连接密码(默认为空)timeout:5000#连接超时,单位mslettuce:pool:max-active:8#连接池最大连接数(使用负值表示不限制)default8max-wait:-1#连接池最大阻塞等待时间(使用负值表示不限制)Default-1max-idle:8#连接池中最大空闲连接数默认为8min-idle:0#连接池中最小空闲连接数默认为03.代码实现下面这个类是延迟任务的核心实现,包括三个核心方法,下面一一说明:延时任务到达时限后的顺序afterPropertiesSet方法是InitializingBean接口方法,之所以实现这个接口是因为我们需要在应用启动的时候启动redis扫描任务。即:OrderDelayServicebean初始化时,启动redis扫描任务循环,获取延时任务数据。消费函数用于从redis获取延迟任务数据,消费延迟任务,进行超时订单关闭等操作。为了避免阻塞for循环,影响后面延迟任务的执行,必须把这个消费函数做成异步的。参考SpringBoot异步任务和Async注解的使用。之前写过一个SpringBoot的可观察易配置的异步任务线程池的开源项目。源码地址为:https://gitee.com/hanxt/zimug...。我的zimug-monitor-threadpool开源项目可以监控线程池的使用情况。我通常使用它,效果很好。我把它推荐给了每一个人!@ComponentpublicclassOrderDelayServiceimplementsInitializingBean{//rediszsetkeypublicstaticfinalStringORDER_DELAY_TASK_KEY="delaytask:order";@ResourceprivateStringRedisTemplatestringRedisTemplate;//生成订单-order是订单信息,可以是订单的序号,使用用于延迟任务到达时限后关闭订单publicvoidproduce(StringorderSerialNo){stringRedisTemplate.opsForZSet().add(ORDER_DELAY_TASK_KEY,//rediskeyorderSerialNo,//zsetmember//延迟30分钟System.currentTimeMillis()+(30*60*1000)//zsetscore);}//延时任务也是异步任务。延迟任务到达时间限制后,将关闭订单并将延迟任务从redis中删除opsForZSet().rangeByScoreWithScores(ORDER_DELAY_TASK_KEY,0,//延迟任务得分最小值System.currentTimeMillis()//延迟任务得分最大值(当前时间));if(!CollectionUtils.isEmpty(orderSerialNos)){for(ZSetOperations.TypedTupleorderSerialNo:orderSerialNos){//这里根据orderSerialNo检查用户是否完成了订单支付//如果用户还没有支付订单,执行订单关闭操作System.out.println("order"+orderSerialNo.getValue()+"超时自动关闭");//订单关闭后,从队列中删除订单延迟任务stringRedisTemplate.opsForZSet().remove(ORDER_DELAY_TASK_KEY,orderSerialNo.getValue());}}}//实例化该类的对象Bean后,启动while扫描任务@OverridepublicvoidafterPropertiesSet()throwsException{newThread(()->{//开启新线程,否则无法启动SpringBoot应用初始化while(true){try{Thread.sleep(5*1000);//每5秒扫描一次redis库获取延迟数据,不要太频繁也没有必要}catch(InterruptedExceptione){e.printStackTrace();//本文只是一个示例,请在生产环境中做相关的异常处理}consuming();}}).start();}}更多内容请参考代码中的注释。需要注意的地方是:上面这篇文章中的rangeByScoreWithScores方法是用来从redis中获取延迟任务的。所有大于0小于当前时间的延迟任务都会从redis中取出,每5秒执行一次,所以延迟任务的误差不会超过5秒。上面的订单信息,我只保留订单的唯一序列号,用于关闭订单。如果您的业务需要传递更多的订单信息,请使用RedisTemplate操作订单类对象,不要使用StringRedisTemplate操作订单流水号字符串。下单时,使用如下方法将订单流水号放入rediszset中,实现延时任务orderDelayService.produce("在此填写订单号");4.优缺点使用rediszset实现延时任务的优点是:相对于本文开头介绍的两种方式,我们的延时任务存储在redis中,redis有数据持久化机制,可以有效避免丢失延迟任务数据。此外,redis还可以通过哨兵模式和集群模式有效避免单点故障导致的服务中断。至于缺点,我觉得没什么缺点。如果非要硬说一个缺点,那就是需要额外维护redis服务,增加了对硬件资源的需求和运维??成本。但是现在随着微服务的兴起,redis几乎已经成为了应用系统的标配,而且redis是可以复用的,所以我觉得这不是缺点!码文不易,如果觉得有帮助请点击观看或分享,没有你们的支持我可能无法坚持下去!欢迎关注我的公告号:字母哥杂谈,回复003送作者专栏《docker修炼之道》30余篇优质docker文章PDF版。Antetokounmpo博客:zimug.com