redis 過期時間技巧之緩存雪崩

概述

如今許多互聯網應用系統都重度依賴緩存來提高讀操作的性能,對於這些系統來說如何正確地使用緩存至關重要。本文從緩存讀取這個視角來討論緩存架構設計上的一些思路。重點關注如何防止緩存雪崩。

1. 緩存讀操作   

 

引入緩存後,讀數據的流程如下:

  • (1)先讀緩存,如果緩存中有數據(hit),則返回緩存中的結果;
  • (2)如果緩存中沒有數據(miss),則回源到database獲取,然後把結果寫入緩存再返回。

2. 緩存雪崩

在正常情況下,一旦miss就去查DB是沒有問題的。但是如果大量緩存集中在某一時間段失效,將導致所有請求都去訪問後端的DB,DB壓力會很大,甚至被壓垮,造成雪崩。

  • 場景一

電商系統的某個大促活動的首頁,首頁有很多新上架的商品。活動開始前,技術團隊對緩存做了預熱,由於是腳本化預熱,這些商品的Cache數據幾乎都是同時創建好,並且過期時間都設置爲5分鐘。這就會導致這大量的商品數據在5分鐘後集中失效。

  • 場景二

cache系統剛上線(或者剛從崩潰中恢復過來),沒有對cache進行預熱。cache中什麼也沒有,這時瞬時大流量過來也會產生雪崩。

3. 解決思路

3.1 cache過期時間均勻分佈

針對上面的場景一,可以對cache的過期時間做一個均勻分佈的處理。比如1-5分鐘內,隨機分佈。

3.2 排斥鎖

針對場景二,可以考慮使用排斥鎖(mutex)。即第一個線程過來讀取cache,發現沒有,就去訪問DB。後續線程再過來就需要等待第一個線程讀取DB成功,cache裏的value變得可用,後續線程返回新的value。僞代碼如下:

public Object getCacheValue(String key, int expiredTime) {
    Object cacheValue = cache.get(key);
    if (cacheValue != null) {
        return cacheValue;
    } else {
        try {
            if (DistributeLock.lock(key)) {
                cacheValue = cache.get(key);
                if (cacheValue != null) { // double check
                    return cacheValue;
                } else {
                    cacheValue = GetValueFromDB(); // 讀數據庫
                    cache.set(key, cacheValue, expiredTime);
                }
            }
        } finally {
            DistributeLock.unlock(key);
        }
        return cacheValue;
    }

}

 

方案細節:

 

  • 使用了分佈式鎖,這當然是考慮到在分佈式環境下,讀請求會落到集羣中的不同應用服務機器上。分佈式鎖可以選用zookeeper或基於redis的setnx這類原子性操作來實現。

  • 加鎖時需要用到經典的double-check lock。

  • 本方案雖然能夠減輕DB壓力,防止雪崩。但由於用到了加鎖排隊,吞吐率是不高的。僅適用於併發量不大的場景。

3.3 緩存過期標記+異步刷新

排斥鎖方案對緩存過期是零容忍的:cache一旦過期,後續所有讀操作就必須返回新的value。如果我們稍微放寬點限制:在cache過期時間T到達後,允許短時間內部分讀請求返回舊值,我們就能提出兼顧吞吐率的方案。實際上既然用了cache,系統就默許了容忍cache和DB的數據短時間的不一致。

限制放寬後,下面我們提出一個優化思路。時間T到達後,cache中的key和value不會被清掉,而只是被標記爲過期(邏輯上過期,物理上不過期),然後程序異步去刷新cache。而後續部分讀線程在前面的線程刷新cache成功之前,暫時獲取cache中舊的value返回。一旦cache刷新成功,後續所有線程就能直接獲取cache中新的value。可以看到,這個思路很大程度上減少了排斥鎖的使用(雖然並沒有完全消除排斥鎖)。 
下面先看下僞代碼:

public Object getCacheValue(String key, int expiredTime) {
    final String signKey = "sign:" + key;
    Object cacheValue = cache.get(key);
    if (!isExpired(signKey, false)) { // 緩存標記未過期
        return cacheValue;
    } else {
        // 緩存標記signKey已過期,異步更新緩存key
        THREAD_POOL.execute(() -> {
            try {
                if (DistributeLock.lock(key)) {
                    if (isExpired(signKey, true)) { // double-check
                        Object cacheValue = GetValueFromDB(); // 讀數據庫
                        if (cacheValue != null) {
                            cache.set(key, cacheValue); // 設置緩存
                            setSign(signKey, expiredTime); // 設置緩存標記
                        }
                    }
                }
            } catch (Exception ex) {
                logger.error(ex.getMessage(), ex);
            } finally {
                DistributeLock.unlock(key);
            }

        });
        return cacheValue;
    }

}

// 判斷緩存標記是否過期
private boolean isExpired(String signKey, boolean prolongTime) {

    Object time = cache.get(signKey);
        if (null == time || Long.valueOf(time) < System.currentTimeMillis()) {
            if (prolongTime) {
                // 將過期時間後延一分鐘,防止同一時間過期多次而出現多次重載
                this.setSign(signKey, 1 * 60);
            }
            return true;
        }
        return false;
}

// 設置signKey的過期時間
private void setSign(String key, int expiredSeconds) {
    DateTime dateTime = new DateTime();
    dateTime = dateTime.plusSeconds(expiredSeconds);// 當前時間延後expiredSeconds秒
    cache.set(key, String.valueOf(dateTime.getMillis()));
}

 

方案細節

 

  • signKey:既然存放數據的cache不會被清掉,那麼就通過別的key也就是代碼中的signKey來標記過期。signKey的過期時間一到,就代表實際key邏輯過期。
  • 異步刷新cache時也用到了排斥鎖,這是因爲同一時間多個讀線程進來都發現signKey已過期,就都要去異步刷新cache,所以這裏有必要加上排斥鎖。但注意到isExpired方法中(35-41行),signKey一旦過期,馬上把過期時間延後1分鐘,這是爲了讓後續進來的線程先返回舊的value。這樣只有極少一部分讀線程去刷新cache。因此需要加排斥鎖的線程也並不多。

4.小結

本文討論了防止緩存雪崩的三個方案:

  • cache過期時間均勻分佈
  • 排斥鎖
  • 緩存過期標記+異步刷新
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章