1. 程式人生 > >快取讀取術之防止快取雪崩

快取讀取術之防止快取雪崩

概述

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

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過期時間均勻分佈
  • 排斥鎖
  • 快取過期標記+非同步重新整理

前兩個方案各自有適用的場景。第三個方案具備一定的通用性。也適用於方案一二的場景。是我推薦的方案。