Redisson實現(xiàn)延遲隊列
1.場景介紹
假設(shè)有這樣一個場景,我們有一個訂單,或者工單等等。需要在超時30分鐘后進行關(guān)閉。這個時候我們最先想到的應該是采用定時任務(wù)去進行輪訓判斷,但是呢,每個訂單的創(chuàng)建時間是不一樣的,這個時間怎么確定才好呢,5分鐘。。1分鐘。。執(zhí)行一次嗎。這樣就會非常影響性能。且時間誤差很大。基于以上業(yè)務(wù)需要我們想到了有以下解決方案。
- JDK延遲隊列,但是數(shù)據(jù)都在內(nèi)存中,重啟后什么都沒了。
- MQ中的延遲隊列,比如RocketMQ。
- 基于Redisson的延遲隊列
2.JDK延遲隊列
我們首先來回顧下JDK的延遲隊列
基于延遲隊列要實現(xiàn)接口Delayed
,并且實現(xiàn)getDelay
方法和compareTo
方法
-
getDelay
主要是計算返回剩余時間,單位時間戳(毫秒)延遲任務(wù)是否到時就是按照這個方法判斷如果返回的是負數(shù)則說明到期否則還沒到期 -
compareTo
主要是自定義實現(xiàn)比較方法返回 1 0 -1三個參數(shù)
@ToString
public class MyDelayed<T> implements Delayed {
/**
* 延遲時間
*/
Long delayTime;
/**
* 過期時間
*/
Long expire;
/**
* 數(shù)據(jù)
*/
T t;
public MyDelayed(long delayTime, T t) {
this.delayTime = delayTime;
// 過期時間 = 當前時間 + 延遲時間
this.expire = System.currentTimeMillis() + delayTime;
this.t = t;
}
/**
* 剩余時間 = 到期時間 - 當前時間
*/
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(this.expire - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
/**
* 優(yōu)先級規(guī)則:兩個任務(wù)比較,時間短的優(yōu)先執(zhí)行
*/
@Override
public int compareTo(Delayed o) {
long f = this.getDelay(TimeUnit.MILLISECONDS) - o.getDelay(TimeUnit.MILLISECONDS);
return (int) f;
}
訂單的實體,為了簡單就定義基礎(chǔ)幾個字段。
@Data
public class OrderInfo implements Serializable {
private static final long serialVersionUID = -2837036864073566484L;
/**
* 訂單id
*/
private Long id;
/**
* 訂單金額
*/
private Double salary;
/**
* 訂單創(chuàng)建時間 對于java8LocalDateTime 以下注解序列化反序列化用到
*/
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime createTime;
}
為了簡單我們暫且定義延遲時間為10s
public static void main(String[] args) throws InterruptedException {
OrderInfo orderInfo = new OrderInfo();
orderInfo.setCreateTime(LocalDateTimeUtil.parse("2022-07-01 15:00:00", "yyyy-MM-dd HH:mm:ss"));
MyDelayed<OrderInfo> myDelayed = new MyDelayed<>(10000L,orderInfo);
DelayQueue<MyDelayed<OrderInfo>> queue = new DelayQueue<>();
queue.add(myDelayed);
System.out.println(queue.take().getT().getCreateTime());
System.out.println("當前時間:" + LocalDateTime.now());
}
輸出結(jié)果
2022-07-01T15:00
當前時間:2022-07-01T15:10:37.375
3.基于Redisson的延遲隊列
當然今天的主角是它了,我們主要圍繞著基于Redisson的延遲隊列來說。
其實Redisson延遲隊列內(nèi)部也是基于redis來實現(xiàn)的,我們先來進行整合使用看看效果。基于springboot
1.依賴:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.16.7</version>
</dependency>
2.創(chuàng)建redisson.yml
# 單節(jié)點配置
singleServerConfig:
# 連接空閑超時,單位:毫秒
idleConnectionTimeout: 10000
# 連接超時,單位:毫秒
connectTimeout: 10000
# 命令等待超時,單位:毫秒
timeout: 3000
# 命令失敗重試次數(shù),如果嘗試達到 retryAttempts(命令失敗重試次數(shù)) 仍然不能將命令發(fā)送至某個指定的節(jié)點時,將拋出錯誤。
# 如果嘗試在此限制之內(nèi)發(fā)送成功,則開始啟用 timeout(命令等待超時) 計時。
retryAttempts: 3
# 命令重試發(fā)送時間間隔,單位:毫秒
retryInterval: 1500
# 密碼
password:
# 單個連接最大訂閱數(shù)量
subscriptionsPerConnection: 5
# 客戶端名稱
clientName: null
# 節(jié)點地址
address: redis://127.0.0.1:6379
# 發(fā)布和訂閱連接的最小空閑連接數(shù)
subscriptionConnectionMinimumIdleSize: 1
# 發(fā)布和訂閱連接池大小
subscriptionConnectionPoolSize: 50
# 最小空閑連接數(shù)
connectionMinimumIdleSize: 32
# 連接池大小
connectionPoolSize: 64
# 數(shù)據(jù)庫編號
database: 0
# DNS監(jiān)測時間間隔,單位:毫秒
dnsMonitoringInterval: 5000
# 線程池數(shù)量,默認值: 當前處理核數(shù)量 * 2
#threads: 0
# Netty線程池數(shù)量,默認值: 當前處理核數(shù)量 * 2
#nettyThreads: 0
# 編碼
codec: !<org.redisson.codec.JsonJacksonCodec> {}
# 傳輸模式
transportMode : "NIO"
3.創(chuàng)建配置類RedissonConfig,這里是為了讀取我們剛剛創(chuàng)建在配置文件中的yml
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() throws IOException {
Config config = Config.fromYAML(RedissonConfig.class.getClassLoader().getResource("redisson.yml"));;
return Redisson.create(config);
}
}
4.測試
// redisson 延遲隊列
// Redisson的延時隊列是對另一個隊列的再包裝,使用時要先將延時消息添加到延時隊列中,
// 當延時隊列中的消息達到設(shè)定的延時時間后,該延時消息才會進行進入到被包裝隊列中,因此,我們只需要對被包裝隊列進行監(jiān)聽即可。
RBlockingQueue<OrderInfo> blockingFairQueue = redissonClient.getBlockingQueue("my-test");
RDelayedQueue<OrderInfo> delayedQueue = redissonClient.getDelayedQueue(blockingFairQueue);
OrderInfo orderInfo = new OrderInfo();
// 訂單生成時間
orderInfo.setCreateTime(LocalDateTime.now());
// 10秒鐘以后將消息發(fā)送到指定隊列
delayedQueue.offer(orderInfo, 10, TimeUnit.SECONDS);
RBlockingQueue<OrderInfo> outQueue = redissonClient.getBlockingQueue("my-test");
OrderInfo orderInfo2 = outQueue.take();
System.out.println("訂單生成時間" + orderInfo2.getCreateTime());
System.out.println("訂單關(guān)閉時間" + LocalDateTime.now());
// 在該對象不再需要的情況下,應該主動銷毀。僅在相關(guān)的Redisson對象也需要關(guān)閉的時候可以不用主動銷毀
delayedQueue.destroy();
控制臺輸出:
訂單生成時間2022-07-01T15:22:10.304
訂單關(guān)閉時間2022-07-01T15:22:20.414
解決項目重新啟動并不會消費之前隊列里的消息的問題,增加如下代碼
redissonClient.getDelayedQueue(deque);
4.深入探究Redisson的延遲隊列實現(xiàn)原理
我們首先來了解兩個API
RBlockingQueue 就是目標隊列
RDelayedQueue 就是中轉(zhuǎn)隊列
那么為什么會涉及到兩個隊列呢,這兩個隊列到底有什么用呢?
首先我們實際操作的是RBlockingQueue阻塞隊列,并不是RDelayedQueue隊列,RDelayedQueue對接主要是提供中間轉(zhuǎn)發(fā)的一個隊列,類似中間商的意思
畫個小圖理解下
這里不難看出我們都是基于RBlockingQueue
目標隊列在進行消費,而RDelayedQueue
就是會把過期的消息放入到我們的目標隊列中
我們只要從RBlockingQueue
隊列中取數(shù)據(jù)即可。
好像還是不夠深入,我們接著看。我們知道Redisson
是基于redis來實現(xiàn)的那么我們看看里面到底做了什么事
打開redis客戶端,執(zhí)行monitor命令,看下在執(zhí)行上面訂單操作時redis到底執(zhí)行了哪些命令
monitor命令可以看到操作redis時執(zhí)行了什么命令
// 這里訂閱了一個固定的隊列 redisson_delay_queue_channel:{my-test},為了開啟進程里面的延時任務(wù)
"SUBSCRIBE" "redisson_delay_queue_channel:{my-test}"
// Redis Zrangebyscore 返回有序集合中指定分數(shù)區(qū)間的成員列表。有序集成員按分數(shù)值遞增(從小到大)次序排列。
// redisson_delay_queue_channel:{my-test} 是一個zset,當有延時數(shù)據(jù)存入Redisson隊列時,就會在此隊列中插入 數(shù)據(jù),排序分數(shù)為延時的時間戳(毫秒 以下同理)。
"zrangebyscore" "redisson_delay_queue_timeout:{my-test}" "0" "1656404479385" "limit" "0" "100"
// 取出第一個數(shù),也就是判斷上面執(zhí)行的操作是否有下一頁。(因為剛剛開始總是0的)除非是之前的操作(zrangebyscore)沒有取完
"zrange" "redisson_delay_queue_timeout:{my-test}" "0" "0" "WITHSCORES"
// 往zset里面設(shè)置 數(shù)據(jù)過期的時間戳(當前執(zhí)行的時間戳+延時的時間毫秒值)內(nèi)容就是訂單數(shù)據(jù)
"zadd" "redisson_delay_queue_timeout:{my-test}" "1656404489400" "b\x99M9\x9b\x0c\xd3\xc3\\\x00\x00\x00{\"@class\":\"com.example.mytest.domain.OrderInfo\",\"createTime\":[2022,6,28,16,21,19,400000000]}"
// 同步一份數(shù)據(jù)到list隊列
"rpush" "redisson_delay_queue:{my-test}" "b\x99M9\x9b\x0c\xd3\xc3\\\x00\x00\x00{\"@class\":\"com.example.mytest.domain.OrderInfo\",\"createTime\":[2022,6,28,16,21,19,400000000]}"
// 取出排序好的第一個數(shù)據(jù),也就是最臨近要觸發(fā)的數(shù)據(jù),然后發(fā)送通知
"zrange" "redisson_delay_queue_timeout:{my-test}" "0" "0"
// 發(fā)送通知 之前第一步 SUBSCRIBE 訂閱了 客戶端收到通知后,就在自己進程里面開啟延時任務(wù)(HashedWheelTimer),到時間后就可以從redis取數(shù)據(jù)發(fā)送
"publish" "redisson_delay_queue_channel:{my-test}" "1656404489400"
// 這里就是取數(shù)據(jù)環(huán)節(jié)了
"BLPOP" "my-test" "0"
// 在范圍 0-過期時間 取出100條數(shù)據(jù)
"zrangebyscore" "redisson_delay_queue_timeout:{my-test}" "0" "1656404489444" "limit" "0" "100"
// 將上面取到的數(shù)據(jù)push到阻塞隊列 很顯然能看到 com.example.mytest.domain.OrderInfo 是我們的訂單數(shù)據(jù)
"rpush" "my-test" "{\"@class\":\"com.example.mytest.domain.OrderInfo\",\"createTime\":[2022,6,28,16,21,19,400000000]}"
// 刪除數(shù)據(jù)
"lrem" "redisson_delay_queue:{my-test}" "1" "b\x99M9\x9b\x0c\xd3\xc3\\\x00\x00\x00{\"@class\":\"com.example.mytest.domain.OrderInfo\",\"createTime\":[2022,6,28,16,21,19,400000000]}"
"zrem" "redisson_delay_queue_timeout:{my-test}" "b\x99M9\x9b\x0c\xd3\xc3\\\x00\x00\x00{\"@class\":\"com.example.mytest.domain.OrderInfo\",\"createTime\":[2022,6,28,16,21,19,400000000]}"
// 取zset第一個數(shù)據(jù),有的話繼續(xù)上面邏輯取數(shù)據(jù)
"zrange" "redisson_delay_queue_timeout:{my-test}" "0" "0" "WITHSCORES"
// 退訂
"UNSUBSCRIBE" "redisson_delay_queue_channel:{my-test}"
這里參考:https://zhuanlan.zhihu.com/p/343811173
我們知道Zset是按照分數(shù)升序的也就是最小的分數(shù)在最前面,基于這個特點,大致明白,利用過期時間的時間戳作為分數(shù)放入到Zset中,那么即將過期的就在最上面。
直接上個圖解