概念及场景
延迟队列的概念
- 队列:先进先出,有序
- 延时队列:队列中的元素带有时间属性,希望在指定时间被处理
延时队列的应用场景
xxx事件之后/之前,执行xxx:
- 订单未支付则,自动取消。
- 新创建的店铺,如果在十天内都没有上传过商品,则自动发送消息提醒。
- 账单在一周内未支付,则自动结算。
- 用户注册成功后,如果三天内没有登陆则进行短信提醒。
- 用户发起退款,如果三天内没有得到处理则通知相关运营人员。
- 预定会议后,需要在预定的时间点前十分钟通知各个与会人员参加会议
为什么要用延时队列
定时轮询
上面说的应用场景除了延时队列,其实也可以用定时轮询的方式处理,定时轮询扫描数据库根据数据有效时间判断是否过期,如果过期则更新状态(删除订单等等)
但仅当定时任务数据量少时,可以用轮询(比如每秒差一次元素的时间属性)
轮询的缺点
轮询在大数据量时,轮询元素的时间可能大于轮询周期(一秒内不能轮询完)
如果元素周期比较长,比如一周后执行,每秒轮询效率比较低
实现延时队列1:JDK DelayQueue
创建订单类
放入DelayQueue
的对象需要实现Delayed
接口
public interface Delayed extends Comparable<Delayed> {
long getDelay(TimeUnit unit);
}
可以看到,Delayed
包含一个getDelay
抽象方法,同时继承了Comparable<Delayed>
接口,因此要实现Delayed
接口需要实现getDelay
和Comparable<Delayed>
两个抽象方法,最后完成订单类CancelOrder
,实现Delayed
接口:
public class CancelOrder implements Delayed {
// 订单号
private String orderNo;
// 过期时间 nano seconds
private long timeout;
public CancelOrder(String orderNo, long timeout) {
this.orderNo = orderNo;
this.timeout = timeout + System.nanoTime();
}
@Override
public String toString() {
return "CancelOrder{" +
"orderNo='" + orderNo + '\'' +
", timeout=" + timeout +
'}';
}
@Override
public long getDelay(TimeUnit unit) {
// 以JVM高分辨率时间源的值为参考,获取过期时刻
return unit.convert(timeout - System.nanoTime(), TimeUnit.NANOSECONDS);
}
@Override
public int compareTo(Delayed o) {
if (o == this){
return 0;
}
CancelOrder t = (CancelOrder) o;
long d = (getDelay(TimeUnit.NANOSECONDS) - t.getDelay(TimeUnit.NANOSECONDS));
return (d == 0) ? 0 : ((d < 0)? -1 : 1);
}
}
这里有几个地方需要啰嗦下:
- 订单类
CancelOrder
包含两个成员属性:orderNo
:订单编号timeout
:过期时间,单位为纳秒(1ns = 10^-9s),所以要用long
getDelay()
方法:用于获取订单过期的时刻,订单过期时刻是以JVM的时间作为起点计算的System.nanoTime()
: 返回正在运行的Java虚拟机的高分辨率时间源的当前值,以纳秒计- 订单过期的时刻 =JVM时间源的当前值+过期时间
timeout
compareTo
方法:就是实现了优先队列的比较方法,根据各个订单的过期时刻排序,这里其实就是一个小顶堆,队头为过期时刻最小的订单。
模拟延时队列
创建一个DelayQueue
其实就跟创建PriorityQueue
差不多,只不过这里不需要重写个Comparator
,因为订单对象已经重写了CompareTo
了,是一个队头为最早过期(过期时刻最小的)元素的小顶堆。
下面主要用到DelayQueue
的两个方法,分别用于加入/取出订单:
put()
: 非常亲切的入队方法,跟普通队列一样,将对象加入队尾;take()
: 取出队头【过期】对象(不是跟poll()
一样直接取出了)
弄个测试类测试一下
public class DelayQueueTest {
public static void main(String[] args) throws InterruptedException {
DelayQueue<CancelOrder> queue = new DelayQueue<>();
// 每秒生成1个订单,共生成5个订单
for (int i = 0; i < 5; i++){
// 10s过期
CancelOrder cancelOrder = new CancelOrder("orderNo"+i, TimeUnit.NANOSECONDS.convert(10, TimeUnit.SECONDS));
// 获取当前时间
String time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
System.out.println(time + ": 生成订单, 10s有效,order: " + cancelOrder);
// 将订单放入延时队列
queue.put(cancelOrder);
// 控制每秒生成一个订单
Thread.sleep(1000);
}
// 延时队列取出超时订单
try {
while (!queue.isEmpty()){
// 轮询获取队头过期元素
CancelOrder order = queue.take();
// 获取当前时间
String timeout = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
System.out.println(timeout + ": 订单超时,order:"+order);
}
} catch (InterruptedException e){
throw new RuntimeException(e);
}
}
}
输出:
2024-03-30 18:54:37: 生成订单, 10s有效,order: CancelOrder{orderNo='orderNo0', timeout=636498762218800}
2024-03-30 18:54:38: 生成订单, 10s有效,order: CancelOrder{orderNo='orderNo1', timeout=636499784320900}
2024-03-30 18:54:39: 生成订单, 10s有效,order: CancelOrder{orderNo='orderNo2', timeout=636500788490700}
2024-03-30 18:54:40: 生成订单, 10s有效,order: CancelOrder{orderNo='orderNo3', timeout=636501792751100}
2024-03-30 18:54:41: 生成订单, 10s有效,order: CancelOrder{orderNo='orderNo4', timeout=636502796614500}
2024-03-30 18:54:47: 订单超时,order:CancelOrder{orderNo='orderNo0', timeout=636498762218800}
2024-03-30 18:54:48: 订单超时,order:CancelOrder{orderNo='orderNo1', timeout=636499784320900}
2024-03-30 18:54:49: 订单超时,order:CancelOrder{orderNo='orderNo2', timeout=636500788490700}
2024-03-30 18:54:50: 订单超时,order:CancelOrder{orderNo='orderNo3', timeout=636501792751100}
2024-03-30 18:54:51: 订单超时,order:CancelOrder{orderNo='orderNo4', timeout=636502796614500}
优缺点
优点:
- 简单,不需要借助其他第三方组件,成本低,适合单体应用
缺点:
- 不适合数据量较大的场景:所有可能超时的数据都要进入
DelayQueue
中,全部保存在JVM内存中,内存开销大,可能引发内存溢出 - 无法持久化:因为存在JVM内存中,不像Redis可以通过AOF或者RDB持久化,宕机数据就丢失了
- 无法较好地适配分布式集群:真要分布式就只能在集群中选一台leader专门处理,效率低
实现延时队列2:RabbitMQ
RabbitMQ实现消息队列主要有两种方式:
- TTL+死信队列
- RabbitMQ 延迟队列插件
TTL+死信队列
什么是TTL
RabbitMQ中消息或队列具有一个属性,TTL(Time To Live),表明一条消息或者队列中的所有消息的最大存活时间
// 设置队列的TTL
Map<String, Object> args = new HashMap<String, Object>();
args.put("x-message-ttl", 6000);
channel.queueDeclare(queueName, durable, exclusive, autoDelete, args);`
// 设置每条消息的TTL
AMQP.BasicProperties.Builder builder = new AMQP.BasicProperties.Builder();
builder.expiration("6000");
AMQP.BasicProperties properties = builder.build();
channel.basicPublish(exchangeName, routingKey, mandatory, properties, "msg body".getBytes());`
有一些注意事项:
- 如果同时配置了队列的TTL和消息的TTL,就用较小值
- 如果设置队列的TTL:消息过期就会被队列遗弃
- 如果设置消息的TTL:消息过期会在即将送给消费者时确定,不会马上丢弃
- 不设置TTL:消息永不过期
- 如果将TTL设置为0:如果不能马上给到消费者就会被丢弃
TTL设置方式1:TTL设置在队列属性
- 生产者生产一条延时消息,根据需要延时时间的不同,利用不同的routingkey将消息路由到不同的延时队列,每个队列都设置了不同的TTL属性,并绑定在同一个死信交换机中
- 消息过期后,根据routingkey的不同,又会被路由到不同的死信队列中,消费者只需要监听对应的死信队列进行处理即可。
缺点:每增加一个新的实践需求就要增加一个新队列
TTL设置方式2:TTL设置在消息
延时队列可以接受任意延时时长到消息,增加一个对应的死信队列和routinkey
RabbitMQ延迟队列插件
springboot有集成,感兴趣朋友可以按这个博文自己试试
优缺点:
优点:
- RabbitMQ消息服务可靠性高,消息处理速度快,支持大数据量
- 支持分布式横向扩展方便
缺点
- 引入中间件系统复杂度增高,运维成本增加
- 使用起来配置较复杂
类似的MQ中间件
比如RocketMQ
优点:
- 使用简单,和普通消息一样
- 支持分布式
- 精度高,支持任意时刻
缺点:
- 时长有限制:定时时长最大24h
- 存储成本高:每新增一个定时消息,不会马上消费
- 同一时刻大量消息会导致延迟(比如双十一活动)
实现延时队列3:Redis实现延时队列
过期监听
Redis通过set key and expire
+ RedisExpirationListener
过期监听实现延时队列,主要还是基于Redis的发布/订阅模式实现。
准备工作
找到redis安装目录的redis.windows.conf与redis.windows-server.conf中的“notify-keyspace-events”,取消注释
notify-keyspace-events Ex
然后重启下Redis,windows中会直接使用redis-server.exe,不会加载redis.windows.conf这个配置文件,需要用进入安装目录,命令行启动:
redis-server.exe redis.windows.conf
如果这时命令行报错:
nvalid argument during startup: unknown conf file parameter :
那么要注意了,Redis配置里要顶格!要顶格!要顶格!
稍微复习下Jedis与JedisPool
首先明确一点,Jedis实例不是线程安全的,所以不可以多个线程共用一个Jedis实例
那是不是要开很多个Jedis实例呢?当然也不行,因为更多的Jedis实例意味着要建立更多的socket连接
所以就需要JedisPool
,就是一个线程安全的网络连接池,类似ThreadPool
可以复用Thread
线程实例,JedisPool
可以创建一些可靠的Jedis实例,并且在后续复用,能够提高性能,并且不需要更多的Socket连接
// 开一个连接池
JedisPool jedisPool = new JedisPool("127.0.0.1", 6379); // HOST, PORT
// 获取一个Jedis连接
Jedis jedis = jedisPool.getResource();
模拟延时队列
这里开两个线程:
- 一个用于订阅监听事件:因为
subscrib()
会阻塞等待,需要异步初始化: Jedis.subscribe(JedisPubSub实现类, channel)
订阅频道需要两个参数:channel
参数:"keyevent@0:expired"是发布删除过期key信息的channelJedisPubSub实现类
:简单来说就是实现``JedisPubSub`接口的一些方法,当有key过期删除前/后/时执行一些逻辑
- 一个用于生产数据:
set(key, seconds, value)
key
:这个订单集合的名字seconds
:过期时间(s)value
:当前订单的名字(编号)
public class RedisKeyExpireTest {
private static JedisPool jedisPool = new JedisPool("127.0.0.1", 6379);
public static void main(String[] args) throws InterruptedException {
// subscribe方法会阻塞等待,用异步去初始化订阅监听事件
new Thread(() -> {
jedisPool.getResource().subscribe(new RedisSub(), "__keyevent@0__:expired");
}).start();
// 添加几个带过期时间的key
new Thread(() -> {
try {
for (int i = 0; i < 5; i++) {
String time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
jedisPool.getResource().setex("orderNo100" + i, i + 1, "orderNo100" + i);
System.out.println(time + ":生成订单,订单号:orderNo100" + i + ",有效期:" + (i + 1) + "秒");
Thread.sleep(1000);
}
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
class RedisSub extends JedisPubSub {
@Override
public void onMessage(String channel, String message) {
String time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
System.out.println(time + ":订单号:" + message + "已到期");
}
}
输出:
2024-03-30 22:04:21:生成订单,订单号:orderNo1000,有效期:1秒
2024-03-30 22:04:22:生成订单,订单号:orderNo1001,有效期:2秒
2024-03-30 22:04:22:订单号:orderNo1000已到期
2024-03-30 22:04:23:生成订单,订单号:orderNo1002,有效期:3秒
2024-03-30 22:04:24:生成订单,订单号:orderNo1003,有效期:4秒
2024-03-30 22:04:24:订单号:orderNo1001已到期
2024-03-30 22:04:25:生成订单,订单号:orderNo1004,有效期:5秒
2024-03-30 22:04:26:订单号:orderNo1002已到期
2024-03-30 22:04:28:订单号:orderNo1003已到期
2024-03-30 22:04:30:订单号:orderNo1004已到期
优缺点
优点:
- 实现简单,redis内存操作,速度快,性能高,集群扩展方便
- 可以通过AOF和RDB实现消息队列的持久化,适合对延迟精度要求不高的业务场景
缺点:
-
redis的key过期有惰性清除和定时清除两种策略,可能会存在延迟时间不精确的问题
惰性清除:不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key
- 优点:对 CPU 时间最友好。因为每次访问时,才会检查 key 是否过期,所以此策略只会使用很少的系统资源。
- 缺点:对内存不友好。如果一个 key 已经过期,而这个 key 又仍然保留在数据库中,那么只要这个过期 key 一直没有被访问,它所占用的内存就不会释放,造成了一定的内存空间浪费。
定期删除:每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key,如果当前抽取的key中过期的超过25%,就再抽一次,循环至过期比例在25%以内。
- 优点:可以减少内存压力
- 缺点:难以确定删除操作执行的时长和频率。太频繁对CPU不友好;频率太低,过期key得不到及时释放,对内存不友好。
而且,很明显,定期查询是一个循环,为了保证不会循环过度导致线程卡死,存在一个定期删除循环流程的时间上限,默认不超过25ms
-
极端情况下不可靠:如果客户端故障或重启期间有key过期则过期通知事件的数据就丢失了(订单无法过期)
- 可以用定时任务去做轮询补偿
ZSet
实现延时队列
Redis 可以使用有序集合(ZSet)的方式来实现延迟消息队列
Set 有一个 Score 属性可以用来存储延迟执行的时间
- 使用
zadd score1 value1
生产消息 - 利用
zrangebyscore
查询符合条件的任务,通过循环执行队列任务
引入依赖
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.2.0</version>
</dependency>
模拟延时队列
下面模拟一下这个过程,需要开两个线程:
- 一个生产者,往redis数据库里生产数据,主要方法:
zadd(key, value, score)
key
:这个订单集合的名字value
:当前订单的名字(编号)score
:当前订单的过期时刻
- 一个消费者,不停轮询这个
key
的Sorted Set,主要方法:zrangeWithScores(key, start, end)
:返回key
中在start
和end
间的value和score的Tuple集合,比如zrangeWithScores(key, 0, 0)
:获取score最小的一个tuplezrangeWithScores(key, 0, 1)
:获取score最小的两个tuple
- 也可以用
zrangeWithScores(key, start, end)
,这是start
和end
需要是准确时间。
public class CancelOrderRedisTest {
private static JedisPool jedisPool = new JedisPool("127.0.0.1", 6379);
public static void main(String[] args) {
// 开一个线程,放几个订单元素到zset中
new Thread(() -> {
try {
for (int i = 0; i < 5; i++) {
String time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
redisClient().zadd("cancel:order:list", System.currentTimeMillis() + (i + 1) * 1000, "orderNo100" + i);
System.out.println(time + ":生成订单,订单号:orderNo100" + i + ",有效期:" + (i + 1) + "秒");
Thread.sleep(1000);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
// 再开一个线程轮询这个有序集合
new Thread(() -> {
Jedis jedis = redisClient();
while (true){
// 取这个有序集合的第一个,也就是score最小的元素
Set<Tuple> items = jedis.zrangeWithScores("cancel:order:list", 0, 1);
if (items == null || items.isEmpty()){
// 避免空指针异常,因为是两个线程,一个线程生产数据(往redis里放订单),一个(现在这个)线程消费数据(清理过期数据)
// 可能目前redis里的数据都消费完了,所以要sleep一会,等待另一个线程生产
try {
Thread.sleep(100);
}catch (InterruptedException e){
e.printStackTrace();
}
} else {
// 轮询成功拿到redis的数据,下面就判断一下是否到期
Tuple tuple = (Tuple) items.toArray()[0];
long score = (long) tuple.getScore();
// 如果现在的时间大于当前数据的score(过期时间),进行清理
if (System.currentTimeMillis() >= score){
Long num = jedis.zrem("cancel:order:list", tuple.getElement());
if (num != null && num > 0){
String time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
System.out.println(time + ":订单号:" + tuple.getElement() + "已到期");
}
}
}
}
}).start();
}
/**
* 获取Redis连接
* @return
*/
private static Jedis redisClient() {
return jedisPool.getResource();
}
}
输出:
2024-03-30 20:32:58:生成订单,订单号:orderNo1000,有效期:1秒
2024-03-30 20:32:59:订单号:orderNo1000已到期
2024-03-30 20:32:59:生成订单,订单号:orderNo1001,有效期:2秒
2024-03-30 20:33:00:生成订单,订单号:orderNo1002,有效期:3秒
2024-03-30 20:33:01:订单号:orderNo1001已到期
2024-03-30 20:33:01:生成订单,订单号:orderNo1003,有效期:4秒
2024-03-30 20:33:02:生成订单,订单号:orderNo1004,有效期:5秒
2024-03-30 20:33:03:订单号:orderNo1002已到期
2024-03-30 20:33:05:订单号:orderNo1003已到期
2024-03-30 20:33:07:订单号:orderNo1004已到期
优缺点
优点(跟过期监听一样):
- 实现简单,redis内存操作,速度快,性能高,集群扩展方便
- 可以通过AOF和RDB实现消息队列的持久化,适合对延迟精度要求不高的业务场景
缺点:
- 第一个就是轮询带来的问题:
- 轮询线程如果不休眠或休眠时间过短,可能导致过多的空轮询,CPU飙高
- 如果带休眠时间过长,因为现在过期的数据得等到下一轮轮询才能处理,延时队列的延时也就不准确了
- 另外,【没有队列的纯粹轮询】还有个问题,就是数据量太大时,可能一个轮询周期检查不完,这里只需要轮询队头的一个或几个数据,所以不太会有这个问题
- 然后是大部分中间件做延时队列都会有的问题:
- 极端条件下,会丢失数据,不可靠,比如:
- Redis过期通知时,应用正好重启,可能丢失事件(导致订单一直无法关闭)。
- 先删数据在处理订单还是先处理订单再删除数据,处理异常时可能会导致数据丢失。
- 可以用定时任务去做轮询补偿
- 存储维护成本高:需要监听的数据较大时会占用中间件大量的存储空间,增加维护成本
- 极端条件下,会丢失数据,不可靠,比如:
总结
稍微总结一下
-
延迟队列主要用于xxx事件之后/之前,执行xxx的场景。
-
此类场景下,当数据量过大时,如果用长轮询,全部数据轮询一遍需要的时间可能会大于轮询周期,短板就出现了。虽然使用延时队列有时候也需要轮询,但只要轮询队头数据,不会出现这种问题
-
实现延时队列的方法有很多:
- JDK自带的
DelayQueue
,底层就是一个优先队列,传入对象需要实现Delayed
接口,主要重写两个方法:getDelay
和Comparable<Delayed>
,让这个优先队列成为按过期时间排序的小顶堆,每次轮询堆顶元素即可; - 消息队列的中间件,比如RabbitMQ。RabbitMQ实现延时队列可以自己写TTL+死信队列(TTL可以加在数据上也可以加载队列上),或者通过插件配置,过期消息会被放入死信队列,消费者直接去死信队列消费数据;
- Redis可以通过
发布订阅模式
实现过期监听,或者用Zset
实现(zadd
将score设为延时时间,通过zrangwithscores
轮询获取队头元素)。
- JDK自带的
-
各个方法各有优缺点。比如
JDK DelayQueue
实现简单不需要依赖第三方插件,但是不支持分布式集群,并且依赖于JVM内存,不好持久化,数据大量还容易内存溢出;MQ、Redis等中间件均支持分布式存储,而且基本都有持久化机制,但是普遍存在一些问题:- 极端情况下的不可靠:如果Redis过期通知时,应用正好重启,可能丢失事件(导致订单一直无法关闭)。
- 可以通过定时任务做补偿机制
- 存储维护成本高:需要监听的数据较大时会占用中间件大量的存储空间,增加维护成本
但峰值数据量不大也不碍事,毕竟好用
- 极端情况下的不可靠:如果Redis过期通知时,应用正好重启,可能丢失事件(导致订单一直无法关闭)。
Reference
阿里三面:生成订单未支付,则自动取消,该怎么实现 ?搞懵了。。。
还有个简书讲RabbitMQ的帖子找不到了