利用Zset实现延时队列

440 阅读2分钟

技术背景

公司需要实现发布信息后如果7天没有发布推广则短信通知用户.

技术方案

1、mq延时队列 碍于项目架构,公司采用的wmb(58消息队列) 该队列最长支持2天超时,不符合需求。
2、定时任务扫表 需求允许推送消息误差时间控制在10分钟左右,当表数据量增大时 频繁扫表容易给数据库造成压力。
3、 定时任务+redis zset 需要处理重复推送问题以及服务器宕机消息丢失问题.

实现思路

定时任务1扫描当天需要推送的消息(6-7天前创建的数据)存入redis有序队列,分值设为 MMddHHmmssSSS 定时任务2每20分钟查询redis 根据当前系统时间取出分值大于当前系统时间的元素 进行短信通知

redis相关实现

放置元素

/**
 * @param queueName    队列名称
 * @param queueElement 队列元素
 * @return boolean
 * @author duanshaojie
 * @date 2021/8/20
 * @Throw
 * @deprecated
 **/
public boolean MsgEnqueue(String queueName, QueueElement queueElement) {
    SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMddHHmmssSSS");
    String timeFlag = simpleDateFormat.format(new Date());
    return redisTemplate.opsForZSet().add(queueName, queueElement, Long.parseLong(timeFlag));
}

取出元素

/**
 * @param queueName 队列名称
 * @param delay     $分钟前
 * @return java.util.Set<com.example.redisspring.entity.QueueElement>
 * @author duanshaojie
 * @date 2021/8/20 11:54
 * @Throw
 * @deprecated 从指定队列读取指定时间前的元素
 * 只是读取 也可能同时被其他系统读取到
 **/
public Set<QueueElement> getMsgForQueue(String queueName, int delay) {
    Calendar beforeTime = Calendar.getInstance();
    beforeTime.add(Calendar.MINUTE, 0 - delay);
    Date beforeD = beforeTime.getTime();
    String before = new SimpleDateFormat("yyyyMMddHHmmssSSS").format(beforeD);
    ZSetOperations<String, QueueElement> zSetOperations = redisTemplate.opsForZSet();
    Set<QueueElement> taskIdSet = zSetOperations.reverseRangeByScore(queueName, 0, Long.parseLong(before));
    return taskIdSet;
}

生产者

/**
 * @param
 * @return
 * @Author duanshaojie
 * @date 18:05 2021/8/19
 * @throw
 * @deprecated 延时队列生产者 使用Zset有序集合将时间设定为分数 存入Zset集合中
 **/
@Test
void provider() {
    for (int i = 0; i < 999; i++) {
        System.out.println("入队" + redisTimeQueueService.MsgEnqueue(TIME_QUEUE, redisTimeQueueService.makeElement("provider", i)));
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

消费者

/**
 * @param
 * @return
 * @Author duanshaojie
 * @date 18:07 2021/8/19
 * @throw
 * @deprecated 延时队列消费者  取出ZSet集合中的复合时间条件的分数进行
 * 进行处理 这是不是阻塞的 所以不存在消费失败后出现阻塞行为
 * 有可能消费失败可以在data中增加一个消费次数值 如果出现异常或者消费失败自增一
 * TODO 这里需要先取出 然后进行操作 防止被重复消费 如果消费失败 重新自增进入队列 等待下次消费
 * 失败次数到达一定次数后 数据取出进行存入私信队列或者记录操作
 **/

@Test
void consumer() {
    while (true) {
        Set<QueueElement> elements = redisTimeQueueService.getMsgForQueue(TIME_QUEUE, 1);
        if (elements == null || elements.isEmpty()) {
            System.out.println("没有任务");
        } else {
            elements.forEach(id -> {
                //先取出消息
                //业务处理
                if (redisTimeQueueService.removeElement(TIME_QUEUE, id)) {
                    try {
                        if (id.getFailNum() > 4) {
                            //进入死信队列 或者记录处理
                        } else {
                            System.out.println("从延时队列中获取到任务,taskId:" + id + " , 当前时间:" + new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date()));
                        }
                    } catch (Exception e) {
                        log.info("队列{}中元素{}处理失败 重新入队", TIME_QUEUE, id);
                        if (redisTimeQueueService.makeFail(TIME_QUEUE, id)) {
                            //重新入队成功 不做处理
                        } else {
                            //重新入队失败
                            //进入死信队列或者 记录手动处理
                        }
                    } finally {
                        //进行消费日志记录
                    }
                } else {
                    log.info("队列{}中元素{}移除失败 可能是已被处理 这里不做处理了", TIME_QUEUE, id);
                }
            });
        }
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}