如何利用Redis實現延時處理

我一定會有貓的發表於2019-03-09

背景

最近需要做一個延時處理的功能,主要是從kafka中消費訊息後根據訊息中的某個延時欄位來進行延時處理,在實際的實現過程中有一些需要注意的地方,記錄如下。

實現過程

說到java中的定時功能,首先想到的Timer和ScheduledThreadPoolExecutor,但是相比之下Timer可以排除,主要原因有以下幾點:

  • Timer使用的是絕對時間,系統時間的改變會對Timer產生一定的影響;而ScheduledThreadPoolExecutor使用的是相對時間,所以不會有這個問題。
  • Timer使用單執行緒來處理任務,長時間執行的任務會導致其他任務的延時處理,而ScheduledThreadPoolExecutor可以自定義執行緒數量。
  • Timer沒有對執行時異常進行處理,一旦某個任務觸發執行時異常,會導致整個Timer崩潰,而ScheduledThreadPoolExecutor對執行時異常做了捕獲(可以在 afterExecute() 回撥方法中進行處理),所以更加安全。
  1. ScheduledThreadPoolExecutor 決定了用ScheduledThreadPoolExecutor來進行實現,接下來就是程式碼編寫啦(大體流程程式碼)。 主要的延時實現如下:
ScheduledExecutorService executorService = new ScheduledThreadPoolExecutor(10, new NamedThreadFactory("scheduleThreadPool"), new 
ThreadPoolExecutor.AbortPolicy());
//從訊息中取出延遲時間及相關資訊的程式碼略
int delayTime = 0;
executorService.scheduleWithFixedDelay(new Runnable() {
        @Override
        public void run() {
            //具體操作邏輯
        }},0,delayTime, TimeUnit.SECONDS);
複製程式碼

其中NamedThreadFactory是我自定義的一個執行緒工廠,主要給執行緒池定義名稱及相關日誌列印便於後續的問題分析,這裡就不多做介紹了。拒絕策略也是採用預設的拒絕策略。 然後測試了一下,滿足目標需求的功能,可以做到延遲指定時間後執行,至此似乎功能就被完成了。 大家可能疑問,這也太簡單了有什麼好說的,但是這種方式實現簡單是簡單但是存在一個潛在的問題,問題在哪呢,讓我們看一下ScheduledThreadPoolExecutor的原始碼:

public ScheduledThreadPoolExecutor(int corePoolSize,ThreadFactory threadFactory) {
    super(corePoolSize, Integer.MAX_VALUE, 0, 
    TimeUnit.NANOSECONDS,new DelayedWorkQueue(), threadFactory);}
複製程式碼

ScheduledThreadPoolExecutor 由於它自身的延時和週期的特性,預設使用了DelayWorkQueue,而並不像我們平時使用的SingleThreadExecutor等構造是可以使用自己定義的LinkedBlockingQueue並且設定佇列大小,問題就出在這裡。DelayWrokQueue是一個無界佇列,而我們的目標資料來源是kafka,也就是一個高併發高吞吐的訊息佇列,很大可能在某一時間段有大量的訊息過來從而導致OOM,在使用多執行緒時我們是肯定要考慮到OOM的可能性的,因為OOM帶來的後果往往比較嚴重,系統OOM臨時的解決辦法一般只能是重啟,可能會導致使用者資料丟失等不可能挽回的問題,所以從編碼設計階段要採用儘可能穩妥的手段來避免這些問題。

  1. 採用redis和執行緒結合,這一次換了思路,採用redis來幫助我們做緩衝,從而避免訊息過多OOM的問題。 相關redis zset api:
//新增元素
ZADD key score member [[score member] [score member] …]
//根據分值及限制數量查詢
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]
//從zset中刪除指定成員
ZREM key member [member …]
複製程式碼

我們採用redis基礎資料結構的zset結構,採用score來儲存我們目標傳送時間的數值,整體處理流程如下:

  • 第一步資料儲存:9:10分從kafka接收了一條a的訂單訊息,要求30分鐘後進行發貨通知,那我們就將當前時間加上30分鐘然後轉為時間戳作為a的score,key為a的訂單號存入redis中。程式碼如下:
public void onMessage(String topic, String message) {
        String orderId;
		int delayTime = 0;
        try {
            Map<String, String> msgMap = gson.fromJson(message, new TypeToken<Map<String, String>>() {
            }.getType());
            if (msgMap.isEmpty()) {
                return;
            }
            LOGGER.info("onMessage kafka content:{}", msgMap.toString());
	    orderId = msgMap.get("orderId");
            if(StringUtils.isNotEmpty(orderId)){
                delayTime = Integer.parseInt(msgMap.get("delayTime"));
                Calendar calendar = Calendar.getInstance();
                //計算出預計傳送時間
                calendar.add(Calendar.MINUTE, delayTime);
                long sendTime = calendar.getTimeInMillis();
                RedisUtils.getInstance().zetAdd(Constant.DELAY, sendTime, orderId);
                LOGGER.info("orderId:{}---放入redis中等待傳送---sendTime:{}", ---orderId:{}, sendTime);
            }
        } catch (Exception e) {
            LOGGER.info("onMessage 延時傳送異常:{}", e);
        }
    }

複製程式碼
  • 第二步資料處理:另起一個執行緒具體排程時間根據業務需求來定,我這裡3分鐘執行一次,內部邏輯:從redis中取出一定量的zset資料,如何取呢,使用zset的zrangeByScore方法,根據資料的score進行排序,當然可以帶上時間段,這裡從0到現在,來進行消費,需要注意的一點是,在取出資料後我們需要用zrem方法將取出的資料從zset中刪除,防止其他執行緒重複消費資料。在此之後進行接下來的發貨通知等相關邏輯。程式碼如下:
public void run(){
        //獲取批量大小
        int orderNum = Integer.parseInt(PropertyUtil.get(Constant.ORDER_NUM,"100"));
        try {
            //批量獲取離傳送時間最近的orderNum條資料
	    Calendar calendar = Calendar.getInstance();
	    long now = calendar.getTimeInMillis();
	    //獲取無限早到現在的事件key(防止上次批量數量小於放入數量,存在歷史資料未消費情況)
	    Set<String> orderIds = RedisUtils.getInstance().zrangeByScore(Constant.DELAY, 0, now, 0, orderNum);
	    LOGGER.info("task.getOrderFromRedis---size:{}---orderIds:{}", orderIds.size(), gson.toJson(orderIds));
            if (CollectionUtils.isNotEmpty(orders)){
                //刪除key 防止重複傳送
                for (String orderId : orderIds) {
                    RedisUtils.getInstance().zrem(Constant.DELAY, orderId);
                }
	        //接下來執行傳送等業務邏輯                 
            }
        } catch (Exception e) {
            LOGGER.warn("task.run exception:{}", e);
        }
    }
複製程式碼

至此完成了依賴redis和執行緒完成了延時傳送的功能。

結語

那麼對上面兩種不同的實現方式進行一下優缺點比較:

  • 第一種方式實現簡單,不依賴外部元件,能夠快速的實現目標功能,但缺點也很明顯,需要在特定的場景下使用,如果是我這種訊息量大的情況下使用很可能是有問題,當然在資料來源訊息不多的情況下不失為好的選擇。

  • 第二種方式實現稍微複雜一點,但是能夠適應訊息量大的場景,採用redis的zset作為了“中介軟體”的效果,並且幫助我們進行延時的功能實現能夠較好的適應高併發場景,缺點在於在編寫的過程中需要考慮實際的因素較多,例如執行緒的執行週期時間,傳送可能會有一定時間的延遲,批量資料大小的設定等等。

綜上是本人這次延時功能的實現過程的兩種實現方式的總結,具體採用哪種方式還需大家根據實際情況選擇,希望能給大家帶來幫助。ps:由於本人的技術能力有限,文章中可能出現技術描述不準確或者錯誤的情況懇請各位大佬指出,我立馬進行改正,避免誤導大家,謝謝!

相關文章