一、应用场景
- 订单15分钟后不支付取消
- 交易发生后5分钟给用户发送短信 这里在我们项目中是来做一个延时的竞赛发布,指定几小时or几天后执行竞赛的发布流程,无需手动执行。
二、实现方式
Redis实现延时队列有两种实现方式:
- key失效监听回调
- zset分数存时间戳
三、方案选择
key失效监听存在两个问题:
- Redis的pubsub不会被持久化,服务器宕机就会被丢弃
- 没有高级特性,没有ack机制,可靠性不高
zset的实现是,轮询队列头部来获取超期的时间戳,实现延时效果,可靠性更高。
Redission的RDelayedQueue是一个封装好的zset实现的延时队列,最终选择了这个方案。
四、demo
这里公司的代码是封装好的,不适合做demo,so在网上找了一个合适的demo放在这里。
public static void main(String[] args) throws InterruptedException, UnsupportedEncodingException {
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379");
RedissonClient redisson = Redisson.create(config);
RBlockingQueue<String> blockingQueue = redisson.getBlockingQueue("test_queue1");
RDelayedQueue<String> delayedQueue = redisson.getDelayedQueue(blockingQueue);
new Thread() {
public void run() {
while(true) {
try {
//阻塞队列有数据就返回,否则wait
System.err.println( blockingQueue.take());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
}.start();
for(int i=1;i<=5;i++) {
// 向阻塞队列放入数据
delayedQueue.offer("test"+i, 13, TimeUnit.SECONDS);
}
}
五、Redission延时队列实现原理
5.1 流程图示
5.2 简单源码分析
5.2.1 offer入队操作
public void offer(V e, long delay, TimeUnit timeUnit) {
get(offerAsync(e, delay, timeUnit));
}
public RFuture<Void> offerAsync(V e, long delay, TimeUnit timeUnit) {
long delayInMs = timeUnit.toMillis(delay);
long timeout = System.currentTimeMillis() + delayInMs;
long randomId = PlatformDependent.threadLocalRandom().nextLong();
return commandExecutor.evalWriteAsync(getName(), codec, RedisCommands.EVAL_VOID,
"local value = struct.pack('dLc0', tonumber(ARGV[2]), string.len(ARGV[3]), ARGV[3]);"
+ "redis.call('zadd', KEYS[2], ARGV[1], value);"
+ "redis.call('rpush', KEYS[3], value);"
// if new object added to queue head when publish its startTime
// to all scheduler workers
+ "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),
timeout, randomId, encode(e));
}
- 首先对名为timeoutSet的zset做zadd操作,score为当前时间+延时时间,单位是时间戳。
- 再对名为queue的list做rpush操作。
- 之后判断timeoutSet第一个值是否是当前结构体,是的话发布timeOut消息。
5.2.2 转移至目标队列
QueueTransferTask task = new QueueTransferTask(commandExecutor.getConnectionManager()) {
@Override
protected RFuture<Long> pushTaskAsync() {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_LONG,
"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);"
+ "redis.call('rpush', KEYS[1], value);"
+ "redis.call('lrem', KEYS[3], 1, v);"
+ "end; "
+ "redis.call('zrem', KEYS[2], unpack(expiredValues));"
+ "end; "
// get startTime from scheduler queue head task
+ "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);
}
@Override
protected RTopic<Long> getTopic() {
return new RedissonTopic<Long>(LongCodec.INSTANCE, commandExecutor, channelName);
}
};
queueTransferService.schedule(queueName, task);
- 这里pushTaskAsync方法主要是将到期元素由元素队列移动到目标队列
- 执行zrangebyscore取得分介于0到当前时间戳的元素(即过期元素),取前100条
- 之后rpush移交到目标队列,在调用lrem删除元素,从timeoutSet中删除该元素
5.2.3 定时轮询
- 这里就不贴源码了,实际上就是定时任务轮询延时队列,将到期的任务转移到延时队列。
5.2.4 总结
用到了3个队列or集合结构
- 延时队列list:数据入队的队列
- 目标队列list:过期数据所在的队列
- timeoutSet过期时间zset:分数值为timeout值,辅助判断元素是否过期