目录

概要

一、实现原理

适用场景

二、准备工作

三、代码实现

四、zset的优缺点

优点

缺点


概要

        本文章主要记录的是使用Redis中的zset实现延时任务,在工作中,像这样的的延时任务是不可避免的,举个栗子:买一张火车票,必须在30分钟之内付款,否则该订单被自动取消。订单30分钟不付款自动取消,这个任务就是一个延时任务。

一、实现原理

首先来介绍一下实现原理,我们需要使用redis zset来实现延时任务的需求,所以我们需要知道zset的应用特性。zset作为redis的有序集合数据结构存在,排序的依据就是score

                

redis 延时消息 redis实现延时任务_redis

 所以我们可以利用zset score这个排序的这个特性,来实现延时任务

适用场景

  • 在用户下单的时候,同时生成延时任务放入redis,key是可以自定义的,比如:delaytask:order
  • value的值分成两个部分:
  • 一个部分是score用于排序
  • 一个部分是member:member的值我们设置为订单对象(如:订单编号),因为后续延时任务时效达成的时候,我们需要有一些必要的订单信息(如:订单编号),才能完成订单自动取消关闭的动作。
  • 延时任务实现的重点:score设置为:订单生成时间+延时时长。这样redis会对zset按照score延时时间进行排序。
  • 开启redis扫描任务,获取“当前时间 > score”的延时任务并执行。即:当前时间 > 订单生成时间 + 延时时长 的时候,执行延时任务。

二、准备工作

使用redis zset这个方案来完成延时任务的需求,首先肯定是需要redis,这一点毫无疑问,redis 的环境搭建在这里就不赘述了。其次,使用SpringBoot框架来完成。

Spingboot集成redis:

<!-- 工具类jar包 -->
<dependency>
    <groupId>commons-collections</groupId>
    <artifactId>commons-collections</artifactId>
    <version>${commons.collections.version}</version>
</dependency>

<!--redis-->
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>${jedis.version}</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Springboot中application.yml配置文件中配置redis相关信息:

spring:
  redis:
    database: 0 # Redis 数据库索引(默认为 0)
    host: 192.168.161.3 # Redis 服务器地址
    port: 6379 # Redis 服务器连接端口
    password: 123456 # Redis 服务器连接密码(默认为空)
    timeout:  5000  # 连接超时,单位ms
    lettuce:
      pool:
        max-active: 8 # 连接池最大连接数(使用负值表示没有限制) 默认 8
        max-wait: -1 # 连接池最大阻塞等待时间(使用负值表示没有限制) 默认 -1
        max-idle: 8 # 连接池中的最大空闲连接 默认 8
        min-idle: 0 # 连接池中的最小空闲连接 默认 0

三、代码实现

下面的这个类就是延时任务的核心实现了,一共包含三个核心方法:

  • produce方法,用于生成订单——order为订单信息,可以是订单流水号,也可以是订单对象,具体看业务需要,用于延时任务达到时效后关闭订单
  • afterPropertiesSet方法是InitialzingBean接口的方法,之所以实现这个接口,是因为我们需要在应用启动的时候开启redis扫描任务。(也可以使用定时任务)即:当OrderDelayServiceImpl bean初始化的时候,开启redis扫描任务循环获取延时任务数据。
  • consuming函数,用于从redis获取延时任务数据,消费延时任务,执行超时订单关闭等操作。为了避免阻塞for循环,影响后面延时任务的执行,所以这个consuming函数一定要做成异步的(参考Spring Boot异步任务及Async注解的使用方法)。
package com.techstone.techstone.modules.service.impl;

import org.apache.commons.collections.CollectionUtils;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.Set;

/**
 * ZSet延时任务
 */
@Service
public class OrderDelayServiceImpl implements InitializingBean {

    /**
     * redis zset key
     */
    public static final String ORDER_DELAY_TASK_KEY = "delaytask:order";

    /**
     * @ --> @Resource和@Autowired区别
     * 1.来源不同:
     *      来自不同的父类,@Autowired是Spring定义的注解,@Resource是java定义的注解
     * 2.依赖查找的顺序不同:+
     *      有两种方式:按名称(byName)查询、按照类型(byType)查找。
     *      。@Autowired是先根据类型查找,如果存在多个Bean再根据名称查找;
     *      。@Resource是先根据名称查找,如果查不到,再根据类型进行查找。
     * 3.支持的参数不同:
     *      。@Autowired只支持设置一个required的参数
     *      。@Resource支持7个参数,
     * 4.依赖注入的用法不同:
     *      。@Autowired支持属性注入、构造方法注入、Setter注入
     *      。@Resource只支持属性注入和Setter注入
     * 5.编译器提示不同:
     *      在IDEA中注入的是Mapper对象,使用@Autowired编译器回提示报错信息;
     *      使用@Resource就不会了
     */
    @Resource
    private StringRedisTemplate stringRedisTemplate;


    /**
     * 生成订单-order为订单信息,可以是订单流水号,用于延时任务达到时效后关闭订单
     * @param orderSerialNo
     */
    public void produce(String orderSerialNo){
        stringRedisTemplate.opsForZSet().add(
          ORDER_DELAY_TASK_KEY,  // redis key
          orderSerialNo,    // zset memeber
          System.currentTimeMillis() + (60 * 1000) // zset score
        );
    }

    /**
     * 延时任务,也是异步任务,延时任务达到时效之后关闭订单,并将延时任务从redis zset删除
     */
    @Async("test")
    public void consuming(){
        // rangeByScoreWithScores方法用于从redis中获取延时任务,score大于0小于当前时间的所有延时任务
        Set<ZSetOperations.TypedTuple<String>> orderSerialNos = stringRedisTemplate.opsForZSet().rangeByScoreWithScores(
                ORDER_DELAY_TASK_KEY,
                0, // 延时任务score最小值
                System.currentTimeMillis() // 延时任务score最大值(当前时间)
        );
        if(!CollectionUtils.isEmpty(orderSerialNos)){
            for (ZSetOperations.TypedTuple<String> orderSerialNo : orderSerialNos) {
                // 这里根据orderSerialNo去检查用户是否完成了订单支付
                // 如果用户没有支付订单,去执行订单关闭的操作
                System.out.println("订单"+ orderSerialNo.getValue() + "超时被自动关闭");
                // 订单关闭之后,将订单延时任务从队列中删除
                stringRedisTemplate.opsForZSet().remove(ORDER_DELAY_TASK_KEY, orderSerialNo.getValue());
            }
        }
    }

    /**
     * 该类对象Bean实例化之后,就开启while扫描任务
     * @throws Exception
     */
    @Override
    public void afterPropertiesSet() throws Exception {
        // 开启新的线程,否则SpringBoot应用初始化无法启动
        new Thread(()->{
            while (true){
                try {
                    // 每5秒扫描一次redis库获取延时数据,不用太频繁没必要
                    Thread.sleep(5 * 1000);
                }catch (InterruptedException e) {
                    e.printStackTrace();
                }
                consuming();
            }
        }).start();
    }
}

需要关注的点:

  • 上文中的rangeByScoreWithScores方法用于从redis中获取延时任务,score大于0小于当前时间的所有延时任务,都将被从redis里面取出来。每5秒执行一次,所以延时任务的误差不会超过5秒。
  • 上文中的订单信息,只保留了订单唯一流水号,用于关闭订单。如果业务需要传递更多的订单信息,请使用RedisTemplate操作订单类对象,而不是StringRedisTemplate操作订单流水号字符串。

模拟订单下单,使用如下操作,将订单序列号放入redis zset中即可实现延时任务,笔者是采用接口的形式测试的。

@GetMapping("/testZSetScore")
@ApiOperation(value = "测试缓存ZSet延时任务")
public Result<String> testZSetScore(@RequestParam("id") String id){
    // 主要调用这个方法
    orderDelayService.produce(id);
    return new Result<String>().ok("已成功将key设置到Redis缓存中");
}

四、zset的优缺点

优点

  • 延时任务保存再redis里,redis具有数据持久化的机制,可以有效的避免延时任务数据的丢失。还可以通过哨兵模式、集群模式有效的避免单点故障造成的服务中断。

缺点

  • 需要额外维护redis服务,增加了硬件资源的需求和运维成本。

作者:筱白爱学习!!