快取在高併發場景下的常見問題

快取一致性問題

當資料時效性要求很高的時候,需要保證快取中的資料與資料庫中的保持一致,而且需要保證快取節點和副本中的資料也要保持一致,不能出現差異現象。這樣就比較依賴快取的過期和更新策略。一般會在資料庫發生更改的時候,主動更新快取中的資料或者移除對應的快取。

  1. 更新資料庫成功—>更新快取失敗—資料不一致
  2. 更新快取成功—>更新資料庫失敗—資料不一致
  3. 更新資料庫成功—>淘汰快取失敗—資料不一致
  4. 淘汰快取成功—>更新資料庫失敗—查詢快取丟失

快取併發問題

快取過期後將嘗試從資料庫獲取資料,在單執行緒情況下是合理而又穩固的流程,但是在高併發情況下,有可能多個請求併發的從資料庫中獲取資料,對後端資料庫造成極大的壓力,甚至導致資料庫崩潰。另外,當某個快取的key被更新時,同時也有可能在被大量的請求獲取,也同樣導致資料一致性的問題。如何解決?一般我們會想到類似“鎖”的機制,在快取更新或者過期的情況下,先獲取鎖,在進行更新或者從資料庫中獲取資料後,再釋放鎖,需要一定的時間等待,就可以從快取中繼續獲取資料

  1. 使用互斥鎖(mutex key)
    只讓一個執行緒構建快取,其他執行緒構建快取的執行緒執行完,重新從快取獲取資料就ojbk了
    單機直接用synchronized或者lock,分散式就用分散式鎖(可以用memcache的add,redis的setnx,zookeeper的節點新增監聽等等等…..)

    memcache虛擬碼如下

if (memcache.get(key) == null) {  
    // 3 min timeout to avoid mutex holder crash  
    if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {  
        value = db.get(key);  
        memcache.set(key, value);  
        memcache.delete(key_mutex);  
    } else {  
        sleep(50);  
        retry();  
    }  
}  

redis虛擬碼

String get(String key){
    String value = redis.get(key);
    if(value == null){
        if(redis.setnx(key_Mutex),"1"){
            redis.expire(key_mutex,3*60);//防止死鎖
            value = db.get(key);
            redis.set(key,value);
            resdis.delete(key_Mutex);
        }else{
            Thread.sleep(50);
            get(key);
        }
    }
}
  1. 提前使用互斥鎖
    在value內部設定1個超時值(timeout1), timeout1比實際的memcache timeout(timeout2)小。當從cache讀取到timeout1發現它已經過期時候,馬上延長timeout1並重新設定到cache。然後再從資料庫載入資料並設定到cache中。虛擬碼如下
v = memcache.get(key);
if (v == null) {
    if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {
        value = db.get(key);
        memcache.set(key, value);
        memcache.delete(key_mutex);
    } else {
        sleep(50);
        retry();
    }
} else {
    if (v.timeout <= now()) {
        if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {
            // extend the timeout for other threads
            v.timeout += 3 * 60 * 1000;
            memcache.set(key, v, KEY_TIMEOUT * 2);

            // load the latest value from db
            v = db.get(key);
            v.timeout = KEY_TIMEOUT;
            memcache.set(key, value, KEY_TIMEOUT * 2);
            memcache.delete(key_mutex);
        } else {
            sleep(50);
            retry();
        }
    }
}

上面兩種方案
優點:避免cache失效時刻大量請求獲取不到mutex並進行sleep
缺點:程式碼複雜性增大,會出現死鎖和執行緒池阻塞等問題,因此一般場合用方案一也已經足夠

  1. 永遠不過期
    這裡的“永遠不過期”包含兩層意思:
    (1) 從redis上看,沒有設定過期時間,就不會出現熱點key過期問題,也就是“物理”不過期。
    (2) 從功能上看,如果不過期,那不就成靜態的了嗎?所以我們把過期時間存在key對應的value裡,如果發現要過期了,通過一個後臺的非同步執行緒進行快取的構建,也就是“邏輯”過期,有一個問題是在非同步構建快取完成之前其他執行緒訪問的是舊的資料
String get(final String key) {  
        V v = redis.get(key);  
        String value = v.getValue();  
        long timeout = v.getTimeout();  
        if (v.timeout <= System.currentTimeMillis()) {  
            // 非同步更新後臺異常執行  
            threadPool.execute(new Runnable() {  
                public void run() {  
                    String keyMutex = "mutex:" + key;  
                    if (redis.setnx(keyMutex, "1")) {  
                        // 3 min timeout to avoid mutex holder crash  
                        redis.expire(keyMutex, 3 * 60);  
                        String dbValue = db.get(key);  
                        redis.set(key, dbValue);  
                        redis.delete(keyMutex);  
                    }  
                }  
            });  
        }  
        return value;  
    }  
  1. 資源保護(尚未了解)

快取穿透問題

場景:在高併發場景下,如果一個key被高併發訪問,沒有被命中,處於對容錯性的考慮,會嘗試去從後端資料庫中獲取,從而導致了大量請求到達資料庫,而當該key對應的資料本身就是空的情況下,就導致資料庫中併發地去執行很多不必要的查詢操作,從而導致巨大沖擊和壓力
可以通過下面的幾種常用方式來避免快取問題

  1. 快取空物件
    對查詢結果為空的物件也進行快取,如果是集合,可以快取一個空的集合(非null),如果是快取單個物件,可以通過欄位標識來區分。這樣避免請求穿透到後端資料庫,同時,也需要保證快取資料的時效性。適合命中不高,但可能被頻繁更新的資料
    這裡寫圖片描述
  2. 單獨過濾處理
    對所有可能對應資料為空的key進行統一的存放,並在請求前做攔截,這樣避免請求穿透到後端資料庫。這種方式實現起來相對複雜,比較適合命中不高,但是更新不頻繁的資料

總結:作為一個併發量較大的網際網路應用,我們的目標有3個:

  1. 加快使用者訪問速度,提高使用者體驗。

  2. 降低後端負載,保證系統平穩。

  3. 保證資料“儘可能”及時更新(要不要完全一致,取決於業務,而不是技術。)

---接下來一篇將對 快取雪崩 做個簡單的總結