日常开发中,我们经常遇到这种业务场景,如:外卖订单超 30 分钟未支付,则自动取订单;用户注册成功 15 分钟后,发短信息通知用户等等。这就延时任务处理场景。
在电商,支付等系统中,一设都是先创建订单(支付单),再给用户一定的时间进行支付,如果没有按时支付的话,就需要把之前的订单(支付单)取消掉。这种类以的场景有很多,还有比如到期自动收货,超时自动退款,下单后自动发送短信等等都是类似的业务问题。
定时任务
@Scheduled(cron = "0/10 * * * * ? ")
public void run() {
log.info("查询超时订单并关闭");
}
- 优点:实现容易,成本低,基本不依赖其他组件。
- 缺点:
- 时间可能不够精确。由于定时任务扫描的间隔是固定的,所以可能造成一些订单已经过期了一段时间才被扫描到,订单关闭的时间比正常时间晚一些。
- 增加了数据库的压力。随着订单的数量越来越多,扫描的成本也会越来越大,执行时间也会被拉长,可能导致某些应该被关闭的订单迟迟没有被关闭。
- 总结:采用定时任务的方案比较适合对时间要求不是很敏感,并且数据量不太多的业务
场景。
JDK 延迟队列 DelayQueue
private DelayQueue<TaskEntity> delayQueue = new DelayQueue<>();
@PostConstruct
public void closeOrder() {
new Thread(() -> {
while(true) {
try {
TaskEntity task = delayQueue.take();
if (task != null) {
log.info("订单关闭");
}
} catch (InterruptedException e) {
log.error("订单关闭失败");
}
}
}).start();
}
@Data
@AllArgsConstructor
public class TaskEntity implements Delayed {
private Integer taskId;
private String taskName;
private Long runTime;
private Long delaySeconds;
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(this.runTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
@Override
public int compareTo(Delayed o) {
TaskEntity object = (TaskEntity) o;
return this.delaySeconds.compareTo(object.getDelaySeconds());
}
}
DelayQueue 是 JDK 提供的一个无界队列,我们可以看到,DelayQueue 队列中的元素需要实现 Delayed,它只提供了一个方法,就是获取过期时间。
- 优点:不依赖任何第三方组件,连数据库也不需要了,实现起来也方便。
- 缺点:
- 因为 DelayQueue 是一个无界队列,如果放入的订单过多,会造成 JVM OOM。
- DelayQueue 基于 JVM 内存,如果 JVM 重启了,那所有数据就丢失了。
- 总结:DelayQueue 适用于数据量较小,且丢失也不影响主业务的场景,比如内部系统的一些非重要通知,就算丢失,也不会有太大影响。
Redisson 分布式延迟队列
源码解析
Redisson源码(二)延迟队列RDelayedQueue的使用及原理分析
Redission实现延迟队列消息用到了四个数据结构:
-
timeoutSetName
:redisson_delay_queue_timeout:{queue_name}
定期队列,ZSET结构(value为消息,score为过期时间),这样就可以知道当前过期的消息。存放未到期的消息&到期时间,提供消息延时排序功能 -
queueName
:redisson_delay_queue:{queue_name}
顺序队列,LIST结构,按照消息添加顺序存储,移除消息时可以按照添加顺序删除。存放未到期消息 -
channelName
:redisson_delay_queue_channel:{queue_name}
发布订阅channel主题,用于通知客户端定时器从定期队列转移到期的消息到目标队列。 -
getName()
目标队列,LIST结构,存储实际到期可以被消费的消息供消费者拉取消费。
消息生产源码
- 通过redissonClient.getDelayedQueue获取RDelayedQueue对象
- 然后delayedQueue调用offer方法去保存消息
- 最后真正的保存逻辑是由RedissonDelayedQueue执行offerAsync方法调用的lua脚本
public class RedissonDelayedQueue<V> extends RedissonExpirable implements RDelayedQueue<V> {
@Override
public RFuture<Void> offerAsync(V e, long delay, TimeUnit timeUnit) {
if (delay < 0) {
throw new IllegalArgumentException("Delay can't be negative");
}
long delayInMs = timeUnit.toMillis(delay);
// 消息过期时间 = 当前时间 + 延迟时间
long timeout = System.currentTimeMillis() + delayInMs;
// 生成随机id,应该是为了允许插入到zset重复的消息
long randomId = ThreadLocalRandom.current().nextLong();
// 执行脚本
return commandExecutor.evalWriteAsync(getName(), codec, RedisCommands.EVAL_VOID,
// 将消息打包成二进制的, 打包的消息 = 随机数 + 消息,有了随机数意味着消息就可以重复
"local value = struct.pack('dLc0', tonumber(ARGV[2]), string.len(ARGV[3]), ARGV[3]);"
// 将打包的消息和过期时间 插入redisson_delay_queue_timeout队列
+ "redis.call('zadd', KEYS[2], ARGV[1], value);"
// 顺序插入redisson_delay_queue队列
+ "redis.call('rpush', KEYS[3], value);"
// 如果刚插入的消息就是timeout队列的最前面,即刚插入的消息最近要到期
+ "local v = redis.call('zrange', KEYS[2], 0, 0); "
+ "if v[1] == value then "
// 发布消息通知客户端消息到期时间,让它定期执行转移操作
+ "redis.call('publish', KEYS[4], ARGV[1]); "
+ "end;",
Arrays.<Object>asList(getName(), timeoutSetName, queueName, channelName),
// 三个参数:1-过期时间 2-随机数 3-消息
timeout, randomId, encode(e));
}
}
定时器转移消息源码分析
在调用redissonClient.getDelayedQueue获取RDelayedQueue对象时创建的:
-
通过redissonClient.getDelayedQueue获取RDelayedQueue对象
-
然后会执行RedissonDelayedQueue的构造函数方法
-
在这个构造方法里就会新建QueueTransferTask这个对象去执行转移操作
public class Redisson implements RedissonClient {
@Override
public <V> RDelayedQueue<V> getDelayedQueue(RQueue<V> destinationQueue) {
if (destinationQueue == null) {
throw new NullPointerException();
}
// 执行RedissonDelayedQueue构造方法
return new RedissonDelayedQueue<V>(queueTransferService, destinationQueue.getCodec(), connectionManager.getCommandExecutor(), destinationQueue.getName());
}
}
public class RedissonDelayedQueue<V> extends RedissonExpirable implements RDelayedQueue<V> {
protected RedissonDelayedQueue(QueueTransferService queueTransferService, Codec codec, final CommandAsyncExecutor commandExecutor, String name) {
...
QueueTransferTask task = new QueueTransferTask(commandExecutor.getConnectionManager()) {
@Override
protected RFuture<Long> pushTaskAsync() {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_LONG,
// 从redisson_delay_queue_timeout队列获取100个到期的消息
"local expiredValues = redis.call('zrangebyscore', KEYS[2], 0, ARGV[1], 'limit', 0, ARGV[2]); "
+ "if #expiredValues > 0 then "
+ "for i, v in ipairs(expiredValues) do "
// 将包装的消息执行解包操作,随机数 + 原消息
+ "local randomId, value = struct.unpack('dLc0', v);"
// 将原消息插入到{queue_name}队列,就可以被消费了
+ "redis.call('rpush', KEYS[1], value);"
+ "redis.call('lrem', KEYS[3], 1, v);"
+ "end; "
// 转移后redisson_delay_queue_timeout队列也移除这些消息
+ "redis.call('zrem', KEYS[2], unpack(expiredValues));"
+ "end; "
// 从定时队列获取最近到期时间然后供定时器到时间再执行
+ "local v = redis.call('zrange', KEYS[2], 0, 0, 'WITHSCORES'); "
+ "if v[1] ~= nil then "
+ "return v[2]; "
+ "end "
+ "return nil;",
Arrays.<Object>asList(getName(), timeoutSetName, queueName),
System.currentTimeMillis(), 100);
}
// 主题redisson_delay_queue_channel:{queue_name}注册发布/订命令执行阅监听器
@Override
protected RTopic getTopic() {
return new RedissonTopic(LongCodec.INSTANCE, commandExecutor, channelName);
}
};
// 将定时器命令执行逻辑注册到发布/订阅主题,这样就可以在收到订阅时执行转移操作了
queueTransferService.schedule(queueName, task);
...
}
}
消息订阅后
订阅到topic消息后,会先判断其是否临期(delay<10ms),如果是则调用pushTask
方法(1中有说明),不是则启动一个定时任务(使用的netty时间轮),延时delay后执行pushTask
方法。
// 订阅topic onMessage 时调用
private void scheduleTask(final Long startTime) {
TimeoutTask oldTimeout = lastTimeout.get();
if (startTime == null) {
return;
}
if (oldTimeout != null) {
oldTimeout.getTask().cancel();
}
long delay = startTime - System.currentTimeMillis();
if (delay > 10) {
// 使用 netty 时间轮 启动一个定时任务
Timeout timeout = connectionManager.newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
pushTask();
TimeoutTask currentTimeout = lastTimeout.get();
if (currentTimeout.getTask() == timeout) {
lastTimeout.compareAndSet(currentTimeout, null);
}
}
}, delay, TimeUnit.MILLISECONDS);
if (!lastTimeout.compareAndSet(oldTimeout, new TimeoutTask(startTime, timeout))) {
timeout.cancel();
}
} else {
pushTask();
}
}
// pushTaskAsync 就是前面1中重写的方法
private void pushTask() {
RFuture<Long> startTimeFuture = pushTaskAsync();
startTimeFuture.onComplete((res, e) -> {
if (e != null) {
if (e instanceof RedissonShutdownException) {
return;
}
log.error(e.getMessage(), e);
scheduleTask(System.currentTimeMillis() + 5 * 1000L);
return;
}
if (res != null) {
scheduleTask(res);
}
});
}
消息消费源码分析
Redis Blpop 命令移出并获取列表的第一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。
public class RedissonBlockingQueue<V> extends RedissonQueue<V> implements RBlockingQueue<V> {
@Override
public RFuture<V> takeAsync() {
// 执行redis中List的BLPOP命令,从{queue_name}队列阻塞取出元素
return commandExecutor.writeAsync(getName(), codec, RedisCommands.BLPOP_VALUE, getName(), 0);
}
}
简单总结大致流程
offer()
入队流程
- 将 打包的消息和过期时间 插入定期队列
timeoutSetName
- 顺序插入
queueName
队列 - 如果刚插入的消息就是即将要过期的,就发送消息通知客户端消息到期时间,定期将消息执行转移操作。
publish channelName 2000
消息转移流程
- 在调用
redissonClient.getDelayedQueue
获取RDelayedQueue
对象时,会创建QueueTransferTask
,后面调用queueTransferService.schedule(queueName, task);
订阅主题 - 入队发布消息后,订阅消息的客户端收到信息,开始转移消息
- 从定期队列
timeoutSetName
获取100个到期的消息,解包 将消息插入到getName()
目标队列 - 从顺序队列
queueName
和定期队列timeoutSetName
移除到期消息 - 从定期队列
timeoutSetName
在获取第一个要到期的消息的时间戳提供给定时器 - 定时器继续执行
take()
出队流程
- 执行blpop命令执行任务,移出并获取列表的第一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。
demo
pom依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.17.6</version>
</dependency>
redis配置信息
spring:
redis:
host: 127.0.0.1
port: 6379
任务枚举类
package com.example.demo.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor
@Getter
public enum RedisDelayQueueEnum {
ORDER_PAYMENT_TIMEOUT("ORDER_PAYMENT_TIMEOUT","超时订单自动关闭队列");
/**
* 延迟队列 Redis Key
*/
private String code;
/**
* 中文描述
*/
private String name;
}
队列出入队工具类
package com.example.demo.util;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RBlockingQueue;
import org.redisson.api.RDelayedQueue;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
@Slf4j
public class RedisDelayQueueUtil {
@Autowired
private RedissonClient redissonClient;
public <T> void addDelayQueue(String queueName, T data, long delay, TimeUnit timeUnit) {
try {
RBlockingQueue<Object> blockingDeque = redissonClient.getBlockingDeque(queueName);
RDelayedQueue<Object> delayedQueue = redissonClient.getDelayedQueue(blockingDeque);
delayedQueue.offer(data, delay, timeUnit);
log.info("(添加延时队列成功) 队列键:{},队列值:{},延迟时间:{},单位:{}", queueName, data, delay,timeUnit);
} catch (Exception e) {
log.error("(添加延时队列失败) {}", e.getMessage());
throw new RuntimeException("(添加延时队列失败)");
}
}
public <T> T getDelayQueue(String queueName) {
try {
RBlockingQueue<Object> blockingDeque = redissonClient.getBlockingDeque(queueName);
T data = (T) blockingDeque.take();
log.info("(队列出队成功) 队列键:{},队列值:{},延迟时间:{},单位:{}", queueName, data);
return data;
} catch (Exception e) {
log.error("(队列出队失败) {}", e.getMessage());
throw new RuntimeException("(队列出队失败)");
}
}
}
延迟队列执行器
package com.example.demo.handler;
import com.example.demo.enums.RedisDelayQueueEnum;
import org.springframework.beans.factory.InitializingBean;
public interface RedisDelayQueueHandler<T> extends InitializingBean {
void execute(T t);
@Override
default void afterPropertiesSet() throws Exception {
RegisterHandlerUtil.registerScheduler(supportedHandler(), this);
}
RedisDelayQueueEnum supportedHandler();
}
package com.example.demo.handler;
import com.example.demo.enums.RedisDelayQueueEnum;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Map;
@Component
@Slf4j
public class OrderTimeOutHandler implements RedisDelayQueueHandler<Map> {
@Override
public void execute(Map map) {
log.info("(收到超时订单延迟消息) {}", map);
// TODO 订单相关,处理业务逻辑...
// 1.调用第三方(微信,支付宝)的支付接口,查询订单是否已经支付,如果确认没支付则,调用关闭订单支付的api,并修改订单的状态为关闭,同时回滚库存数量。
// 2.如果支付状态为已支付则需要做补偿操作,修改订单的状态为已支付,订单历史记录
log.info("调用第三方(微信,支付宝)的支付接口确认未支付,超时订单关闭");
}
@Override
public RedisDelayQueueEnum supportedHandler() {
return RedisDelayQueueEnum.ORDER_PAYMENT_TIMEOUT;
}
}
注册不同延迟队列的执行bean
package com.example.demo.handler;
import com.example.demo.enums.RedisDelayQueueEnum;
import org.springframework.stereotype.Component;
import java.util.EnumMap;
@Component
public class RegisterHandlerUtil {
private static EnumMap<RedisDelayQueueEnum, RedisDelayQueueHandler<?>> handlerrMap = new EnumMap<>(RedisDelayQueueEnum.class);
public static void registerScheduler(RedisDelayQueueEnum type, RedisDelayQueueHandler<?> handler) {
handlerrMap.putIfAbsent(type, handler);
}
public static RedisDelayQueueHandler getHandler(RedisDelayQueueEnum type) {
return handlerrMap.get(type);
}
}
redis延迟队列主线程
package com.example.demo.handler;
import com.digitforce.framework.spring.SpringContextHolder;
import com.example.demo.enums.RedisDelayQueueEnum;
import com.example.demo.util.RedisDelayQueueUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
@Component
@Slf4j
public class RedisDelayQueueRunner {
@Autowired
private RedisDelayQueueUtil redisDelayQueueUtil;
private ThreadPoolExecutor threadPool;
@PostConstruct
public void run() {
log.info("redis 延迟队列启动");
threadPool = new ThreadPoolExecutor(10, 50, 30, TimeUnit.SECONDS,
new LinkedBlockingQueue<Runnable>(1000), Executors.defaultThreadFactory());
threadPool.execute(() -> {
while (true) {
RedisDelayQueueEnum[] queueEnums = RedisDelayQueueEnum.values();
log.info("queueEnums" + queueEnums.toString());
for (RedisDelayQueueEnum queueEnum : queueEnums) {
Object value = redisDelayQueueUtil.getDelayQueue(queueEnum.getCode());
if (value != null) {
RedisDelayQueueHandler handler = RegisterHandlerUtil.getHandler(queueEnum);
handler.execute(value);
}
}
}
});
log.info("线程池启动成功");
}
}
Redis故障可能带来的挑战
如果Redis发生故障(例如崩溃或者网络问题),会对未支付订单的处理产生以下影响:
- 任务丢失:所有存储在Redis延迟队列中的任务可能会因为Redis的故障而丢失。
- 任务重复处理:在Redis崩溃时,如果处理逻辑未得到执行,可能会出现重复操作的情况,导致资源的浪费或状态的不一致。
为了应对Redis挂掉的情况,我们可以采取以下措施:
- 使用持久化存储
-
持久化订单信息:
在将未支付订单放入Redisson的延迟队列之前,将这些订单的信息存储在关系型数据库(如MySQL)中。数据库可以保证数据的持久性,即使Redis出现故障。 -
定期同步:
定期检查和同步Redis延迟队列与数据库中的订单记录,确保在状态更新或系统崩溃后能够恢复未支付的订单状态。
-
- 任务状态管理
- 订单状态字段:
在数据库中维护订单的状态字段(如待支付、已关闭等)。当放入延迟队列后,更新订单的状态为“待关闭”。如Redis崩溃,可以根据状态字段判断哪些订单需要被关闭。 - 使用分布式锁:
在处理未支付订单的关闭操作时应用分布式锁,以防止同一订单被多个消费者同时关闭,从而造成数据不一致。
- 订单状态字段:
- 监控与报警机制
- Redis监控:
设置监控(如使用Prometheus和Grafana)监测Redis的健康状态,如果Redis出现故障可以及时收到警报。 - 异常处理机制:
在关闭订单的业务逻辑中加上异常处理逻辑,如果尝试读取或更新Redis失败,应进行重试或回退策略,防止造成无法预知的后果。
- Redis监控: