阿里面試真題,如何用 Redis 實現延時任務?

1、什麼是延時任務

延時任務,顧名思義,就是延遲一段時間後才執行的任務。延時任務的使用還是很廣泛的。關於延時任務的實現方式,我知道的就不少 3 種,今天就講下如何用 redis 實現延時任務。

2、延時任務的特點

在介紹具體方案之前,我們不妨先想一下要實現一個延時系統,有哪些內容是必須存儲下來的(這裏的存儲不一定是指持久化,也可以是放在內存中,取決於延時任務的重要程度)。

首先要存儲的就是任務的描述。假如你要處理的延時任務是延時發佈資訊,那麼你至少要存儲資訊的id吧。另外,如果你有多種任務類型,比如:延時推送消息、延時清洗數據等等,那麼你還需要存儲任務的類型。以上總總,都歸屬於任務描述。

除此之外,你還必須存儲任務執行的時間點吧,一般來說就是時間戳。此外,我們還需要根據任務的執行時間進行排序,因爲延時任務隊列裏的任務可能會有很多,只有到了時間點的任務才應該被執行,所以必須支持對任務執行時間進行排序。

3、使用 Redis 實現延時任務

以上就是一個延遲任務系統必須具備的要素了。回到 Redis,有什麼數據結構可以既存儲任務描述,又能存儲任務執行時間,還能根據任務執行時間進行排序呢?想來想去,似乎只有 Sorted Set 。我們可以把任務的描述序列化成字符串,放在 Sorted Set 的 value 中,然後把任務的執行時間戳作爲 score,利用 Sorted Set 天然的排序特性,執行時刻越早的會排在越前面。

這樣一來,我們只要開一個或多個定時線程,每隔一段時間去查一下這個 Sorted Set 中 score 小於或等於當前時間戳的元素(這可以通過 zrangebyscore 命令實現),然後再執行元素對應的任務即可。當然,執行完任務後,還要將元素從 Sorted Set 中刪除,避免任務重複執行。

如果是多個線程去輪詢這個 Sorted Set,還有考慮併發問題,假如說一個任務到期了,也被多個線程拿到了,這個時候必須保證只有一個線程能執行這個任務,這可以通過 zrem 命令來實現,只有刪除成功了,才能執行任務,這樣就能保證任務不被多個任務重複執行了。

接下來看代碼。首先看下項目結構:

一共 4 個類:Constants 類定義了 Redis key 相關的常量。DelayTaskConsumer 是延時任務的消費者,這個類負責從 Redis 拉取到期的任務,並封裝了任務消費的邏輯。DelayTaskProducer 則是延時任務的生產者,主要用於將延時任務放到 Redis 中。RedisClient 則是 Redis 客戶端的工具類。

最主要的類就是 DelayTaskConsumer 和 DelayTaskProducer 了。

我們先來看下生產者 DelayTaskProducer:

代碼很簡單,就是將任務描述(爲了方便,這裏只存儲資訊的 id)和任務執行的時間戳放到 Redis 的 Sorted Set 中。

接下來是延時任務的消費者 DelayTaskConsumer:

public class DelayTaskConsumer {
    private ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
    public void start(){
        scheduledExecutorService.scheduleWithFixedDelay(new DelayTaskHandler(),1,1, TimeUnit.SECONDS);
    }
    public static class DelayTaskHandler implements Runnable{
        @Override
        public void run() {
            Jedis client = RedisClient.getClient();
            try {
                Set<String> ids = client.zrangeByScore(Constants.DELAY_TASK_QUEUE, 0, System.currentTimeMillis(),
                        0, 1);
                if(ids==null||ids.isEmpty()){
                    return;
                }
                for(String id:ids){
                    Long count = client.zrem(Constants.DELAY_TASK_QUEUE, id);
                    if(count!=null&&count==1){
                        System.out.println(MessageFormat.format("發佈資訊。id - {0} , timeStamp - {1} , " +
                                "threadName - {2}",id,System.currentTimeMillis(),Thread.currentThread().getName()));
                    }
                }
            }finally {
                client.close();
            }
        }
    }
}

首先看 start 方法。在這個方法裏面我們利用 Java 的ScheduledExecutorService 開了一個調度線程池,這個線程池會每隔 1 秒鐘調度 DelayTaskHandler 中的 run 方法。

DelayTaskHandler 類就是具體的調度邏輯了。主要有 2 個步驟,一個是從 Redis Sorted Set 中拉取到期的延時任務,另一個是執行到期的延時任務。拉取到期的延時任務是通過 zrangeByScore 命令實現的,處理多線程併發問題是通過 zrem 命令實現的。代碼不復雜,這裏就不多做解釋了。

接下來測試一下:

我們首先生產了 4 個延時任務,執行時間分別是程序開始運行後的 5 秒、10 秒、15 秒、20 秒,然後啓動了 10 個消費者去消費延時任務。運行效果如下:

可以看到,任務確實能夠在相應的時間點左右被執行,不過有少許時間誤差,這個是因爲我們拉取到期任務是通過定時任務拉取而不是實時推送的,而且拉取任務時有一部分網絡開銷,再者,我們的任務處理邏輯是同步處理的,需要上一次的任務處理完,才能拉取下一批任務,這些因素都會造成延時任務的執行時間產生偏差。

4、總結

以上就是通過 Redis 實現延時任務的思路了。這裏提供的只是一個最簡單的版本,實際上還有很多地方可以優化。比如,我們可以把任務的處理邏輯再放到單獨的線程池中去執行,這樣的話任務消費者只需要負責任務的調度就可以了,好處就是可以減少任務執行時間偏差。還有就是,這裏爲了方便,任務的描述存儲的只是任務的 id,如果有多種不同類型的任務,像前面說的發送資訊任務和推送消息任務,那麼就要通過額外存儲任務的類型來進行區分,或者使用不同的 Sorted Set 來存放延時任務了。

除此之外,上面的例子每次拉取延時任務時,只拉取一個,如果說某一個時刻要處理的任務數非常多,那麼會有一部分任務延遲比較嚴重,這裏可以優化成每次拉取不止一個到期的任務,比如說 10 個,然後再逐個進行處理,這樣的話可以極大地提升調度效率,因爲如果是使用上面的方法,拉取 10 個任務需要 10 次調度,每次間隔 1 秒,總共需要 10 秒才能把 10 個任務拉取完,如果改成一次拉取 10 個,只需要 1 次就能完成了,效率提升還是挺大的。

最後一個需要考慮的地方是,上面的代碼並沒有對任務執行失敗的情況進行處理,也就是說如果某個任務執行失敗了,那麼連重試的機會都沒有。因此,在生產環境使用時,還需要考慮任務處理失敗的情況。有一個簡單的方法是在任務處理時捕獲異常,當在處理過程中出現異常時,就將該任務再放回 Redis Sorted 中,或者由當前線程再重試處理。

那麼使用 Redis 實現延時任務有什麼優缺點呢?優點就是可以滿足吞吐量。缺點則是存在任務丟失的風險(當 Redis 實例掛了的時候)。因此,如果對性能要求比較高,同時又能容忍少數情況下任務的丟失,那麼可以使用這種方式來實現。

Redis 在國內各大公司都能看到其身影,比如我們熟悉的新浪,阿里,騰訊,百度,美團,小米等。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章