Redis实现延迟队列

311 阅读4分钟

一、前言

说明: 我们有一个任务的情况下,我们会期望这个任务在某个时间点去执行,那么就要使用延迟队列。一般延迟队列可以使用RabbitMQ或者RocketMQ来进行实现,另外一种常用的方式就是使用Redis来进行实现了!

实现方案: 基于redis来进行实现,我们主要使用的是zset这个数据类型天生的具有score的特性。zset可以根据score放入,而且可以通过range进行排序获取,以及删除指定的值。从业务上,我们可以再新增任务的时候放入,再通过定时任务进行拉取,要注意的一点就是拉取的时候要有 分布式锁 ,不要进行重复拉取,或者交由 分布式任务调度来处理拉取,都是可以的。

使用场景: 我们更加偏向于 定时群发,定时取消 等。就举一个发博客的例子吧,博客我们可以选择定时发布,那么就可以应用redis的延迟队列来进行实现。要注意的一个点就是小心大key的产生,要做好延迟队列的key的隔离。

说明: 整体的源代码和测试类都放在业务类中,因为每个任务的延迟队列的实现都是不同的,所以没法抽成一个工具类,而其借助的Redis分布式锁和Redis数据写入读取的工具类已经被我们封装在了 ape-common-redis 包中!

image.png

二、代码实现

2.1 延迟任务的实体类

package com.ssm.user.delayQueue;

import lombok.Data;

import java.util.Date;

@Data
public class MassMailTask {

    // 相关任务ID
    private Long taskId;

    // 延迟任务的开始时间
    private Date startTime;

}

2.2 任务对延迟队列的推送方法和拉取的方法

实现思路:

入队: 入队消息体一定要有时间的概念,把时间转换为毫秒,来作为我们zset的score;底层就是zset的add方法,由key,value以及score来组成。

出队: 出队要基于rangeByScore来进行实现,指定我们的score的区间,也就是我们要拉取哪些的任务,拉取成功之后,我们先去执行业务逻辑,执行成功之后,我们再将其从消息队列进行删除。

package com.ssm.user.delayQueue;
import com.alibaba.druid.support.json.JSONUtils;
import com.ssm.redis.util.RedisUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;

import javax.annotation.Resource;
import java.util.Collections;
import java.util.Date;
import java.util.Set;
import java.util.stream.Collectors;

@Service
@Slf4j
public class MassMailTaskService {
    @Resource
    private RedisUtil redisUtil;

    // 该MassMailTaskService类位于Redis中ZSet结构的key
    private static final String MASS_MAIL_TASK_KEY = "massMailTask";

    public void pushMassMailTaskQueue(MassMailTask massMailTask) { //入队,推送延迟任务
        Date startTime = massMailTask.getStartTime();
        if(startTime == null) {
            return;
        }

        if(startTime.compareTo(new Date()) <= 0) { //开始时间小于等于当前时间,也不处理(compareTo比大小,小于为-1 等于为0)
            return;
        }

        log.info("定时任务加入队列, massTask:{}", massMailTask);

        //有序集合 Zadd key score1 member1 [score2 member2]
        redisUtil.zAdd(MASS_MAIL_TASK_KEY,  //key
                massMailTask.getTaskId().toString(), //value为id
                startTime.getTime() //score为当前任务开始时间的毫秒数
        );
    }

    public Set<Long> poolMassTaskQueue() { //出队,拉取延迟任务
        //获取有序集合中指定分数范围内的元素(zset默认有序)
        Set<String> taskIdSet = redisUtil.rangeByScore(MASS_MAIL_TASK_KEY, 0, System.currentTimeMillis());

        if(CollectionUtils.isEmpty(taskIdSet)) {
            return Collections.emptySet();
        }

        //此时已得到所有要执行的定时任务了,把zset中的任务remove
        redisUtil.removeZsetList(MASS_MAIL_TASK_KEY, taskIdSet);

        //转为Long类型set
        return taskIdSet.stream().map(n -> Long.parseLong(n)).collect(Collectors.toSet());
    }
}

2.3 编写测试类

注意: 由于可能是分布式服务,所以可能是定时循环拉取,在拉取的时候不能所有服务的拉取都去拉,而是只允许一个任务去拉取,要么使用xxljob来实现,要么就选择分布式锁,只允许一个服务能够拉取并执行!

实际需求中通常配合xxl-job等定时任务来不断拉取延迟队列

package com.ssm.user;

import com.alibaba.druid.support.json.JSONUtils;
import com.ssm.redis.util.RedisShareRedLock;
import com.ssm.user.delayQueue.MassMailTask;
import com.ssm.user.delayQueue.MassMailTaskService;
import lombok.extern.slf4j.Slf4j;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import javax.annotation.Resource;
import java.text.SimpleDateFormat;
import java.util.Set;
import java.util.UUID;

@RunWith(SpringRunner.class)
@SpringBootTest(classes = UserApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Slf4j
public class MassMailTaskTest {

    @Resource
    private MassMailTaskService massMailTaskService;

    @Resource
    private RedisShareRedLock redisShareRedLock;

    @Test
    public void pushTask() throws Exception {
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        MassMailTask massMailTask = new MassMailTask();
        massMailTask.setTaskId(1L);
        massMailTask.setStartTime(simpleDateFormat.parse("2024-09-12 09:55:00")); //利用自定义的时间格式设置延迟任务开始时间

        massMailTaskService.pushMassMailTaskQueue(massMailTask); //入队,推送任务
    }

    @Test
    public void deal() throws Exception {
       String lockKey = "test.delay.task";
       String requestId = UUID.randomUUID().toString(); //设置setnx的id,防止误删

        try {
            boolean lock = redisShareRedLock.lock(lockKey, requestId, 5L);
            if(!lock) { //获取分布式锁失败,其他服务在执行
                return;
            }
            Set<Long> taskIds = massMailTaskService.poolMassTaskQueue();
            log.info("定时任务拉取:{}", JSONUtils.toJSONString(taskIds));
            //执行其他业务逻辑
        } catch (Exception e) {
            log.error("拉取异常:{}", e.getMessage(), e);
        } finally {
            //释放锁
            redisShareRedLock.unLock(lockKey, requestId);
        }
    }
}

2.4 测试结果

1) 第一步: 任务推送延迟队列

1a2a5877e2aea5048bc9457c55dcdfab.png

f3d8579038f79002fbf7131cbf8ef977.png

2) 第二步: 定时任务未到时间,直接进行拉取

image.png

3) 第三步: 定时任务到时间了,然后进行拉取

说明: 可以看到,到了时间之后,就可以正常拉取到定时任务!

df9d41e1b9aaaf3b229018dd59c29524.png

348664fcf50ef9b5fe39c4821f033889.png