關於快取穿透、快取擊穿、快取雪崩的模擬與解決(Redis)

一零之間Go發表於2020-12-07

前言

在我們日常的開發中,無不都是使用資料庫來進行資料的儲存,但當遇到大量資料併發請求的需求,如秒殺、熱點資料請求等,若所有請求都直接打到資料庫上會佔用大量的硬碟資源,系統在極短的時間內完成成千上萬次的讀/寫操作,極其容易造成資料庫系統癱瘓。

此時我們會引入快取層來阻擋大部分的請求,減輕資料庫壓力。但引入快取層往往帶來快取穿透,快取擊穿,快取雪崩等問題。

本文以Redis為例模擬且解決以上三個問題。

快取擊穿

快取擊穿是指快取中沒有但資料庫中有的資料(一般是快取時間到期),這時由於併發使用者特別多,同時讀快取沒讀到資料,又同時去資料庫去取資料,此時如果你的程式碼沒有實現同步機制,會造成小部分的請求直接打到資料庫上,給資料庫帶來一定的壓力。

模擬需求

模擬需求:某秒殺活動即將開始,模擬1w個請求同時發生,要獲取某商品的商品詳情資訊

期望:只能有1個請求打到資料庫,其他請求均打到Redis或其他快取中

錯誤示例

我們先看錯誤示例,以下示例程式碼沒有做任何同步,模擬情況一。

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * @author HeyS1
 * @date 2020/3/12
 * @description
 */
public class ConcurrentTest {
    //請求次數
    private int reqestQty = 10000;
    //倒數計時器,當傳送reqestQty次請求後繼續執行主執行緒
    private CountDownLatch latch = new CountDownLatch(reqestQty);

    //記錄請求落在資料庫上的次數
    private AtomicInteger dbSelectCount = new AtomicInteger();
    //記錄請求落在快取中的次數
    private AtomicInteger cacheSelectCount = new AtomicInteger();
    //用HashMap模擬快取儲存
    private Map<String, String> cache = new HashMap<>();

    public static void main(String[] args) {
        new ConcurrentTest().go();
    }


    private void go() {
        //同時建立1w個執行緒獲取
        for (int i = 0; i < reqestQty; i++) {
            new Thread(() -> {
                this.getGoodsDetail("商品id");
                latch.countDown();
            }).start();
        }


        // 計數器大於0 時,await()方法會阻塞程式繼續執行
        try {
            latch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("資料庫查詢次數:" + dbSelectCount.get());
        System.out.println("快取查詢次數:" + cacheSelectCount.get());
    }


    /**
     * 獲取商品資料
     *
     * @param key 商品id
     * @return
     */
    public String getGoodsDetail(String key) {
        //先從快取查詢,存在則直接返回
        String data = this.selectCache(key);
        if (data != null) {
            return data;
        }

        //不存在則從資料庫查詢且將資料放入快取
        data = this.selectDB(key);
        cache.put(key, data);
        return data;
    }


    /**
     * 從快取中獲取資料
     *
     * @param key
     * @return
     */
    public String selectCache(String key) {
        cacheSelectCount.addAndGet(1);//記錄次數

        System.out.println(Thread.currentThread().getId() + " 從cache獲取資料====");
        return cache.get(key);
    }

    /**
     * 從資料庫中獲取資料
     *
     * @param key
     * @return
     */
    public String selectDB(String key) {
        sleep(100);//模擬查詢資料庫花費100ms
        dbSelectCount.addAndGet(1);//記錄次數

        System.out.println(Thread.currentThread().getId() + " 從db獲取資料====");
        return "資料中的資料";
    }

    private static void sleep(long m) {
        try {
            Thread.sleep(m);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
結果:

資料庫查詢次數:202
快取查詢次數:10000

可以看出,如果getGoodsDetail方法沒做任何處理,還是會有少數請求直接打到資料庫,這就是快取穿透

/**
     * 獲取商品資料
     *
     * @param key 商品id
     * @return
     */
    public String getGoodsDetail(String key) {
        //先從快取查詢,存在則直接返回
        String data = this.selectCache(key);
        if (data != null) {
            return data;
        }

        //不存在則從資料庫查詢且將資料放入快取
        data = this.selectDB(key);
        cache.put(key, data);
        return data;
    }

解決方案1:synchronized

使用synchronized 同步程式碼塊可解決該問題,此方案最簡單,但有缺點:在分散式系統/叢集下是無法確保各節點同步,也就是說如果是秒殺等保證庫存不超賣的情景下,不能用此方案。但只是查詢商品詳情這種需求,其實問題也不大,具體看業務。

只需修改一下上面示例中的getGoodsDetail方法即可

 /**
     * 獲取商品資料
     *
     * @param key 商品id
     * @return
     */
    public String getGoodsDetail(String key) {
        //先從快取查詢,存在則直接返回
        String data = this.selectCache(key);
        if (data != null) {
            return data;
        }
        //同步程式碼塊
        synchronized (this) {
            //這裡還需要再次查詢快取,防止其他等待進入同步程式碼塊的執行緒的查詢打到資料庫上
            data = this.selectCache(key);
            if (data != null) {
                return data;
            }

            //不存在則從資料庫查詢且將資料放入快取
            data = this.selectDB(key);
            cache.put(key, data);
            return data;
        }
    }
資料庫查詢次數:1
快取查詢次數:10276

解決方案2:redis分散式鎖

該方案適合叢集或分散式架構,單機使用也可以,但沒意義。

分散式鎖實現方式有一般有3種,本文使用Redis來實現

  1. 資料庫樂觀鎖;
  2. 基於Redis的分散式鎖;
  3. 基於ZooKeeper的分散式鎖

首先,為了確保分散式鎖可用,我們至少要確保鎖的實現同時滿足以下四個條件:
1、互斥性。在任意時刻,只有一個客戶端能持有鎖。
2、不會發生死鎖。即使有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證後續其他客戶端能加鎖。
3、具有容錯性。只要大部分的Redis節點正常執行,客戶端就可以加鎖和解鎖。
4、解鈴還須繫鈴人。加鎖和解鎖必須是同一個客戶端,客戶端自己不能把別人加的鎖給解了。(如果執行緒 C1 獲得鎖,但由於業務處理時間過長,鎖線上程 C1 還未處理完業務之前已經過期了,這時執行緒 C2 獲得鎖,線上程 C2 處理業務期間執行緒 C1 完成業務執行釋放鎖操作,但這時執行緒 C2 仍在處理業務執行緒 C1 釋放了執行緒 C2 的鎖,導致執行緒 C2 業務處理實際上沒有鎖提供保護機制;同理執行緒 C2 可能釋放執行緒 C3 的鎖,從而導致嚴重的問題。)
首先,為了確保分散式鎖可用,我們至少要確保鎖的實現同時滿足以下四個條件:

完整示例:

這裡用到RedisTemplate,請自行使用Spring整合Redis

@RunWith(SpringRunner.class)
@SpringBootTest(classes = App.class)
@Slf4j
public class ConcurrentTest3 {

    @Autowired
    RedisTemplate<String, String> redisTemplate;

    //請求次數
    private int reqestQty = 10000;
    //倒數計時器,當傳送reqestQty次請求後繼續執行主執行緒
    private CountDownLatch latch = new CountDownLatch(reqestQty);

    //記錄請求落在資料庫上的次數
    private AtomicInteger dbSelectCount = new AtomicInteger();
    //記錄請求落在快取中的次數
    private AtomicInteger cacheSelectCount = new AtomicInteger();


    @Test
    public void go() {
        //同時建立1w個執行緒獲取
        for (int i = 0; i < reqestQty; i++) {
            new Thread(() -> {
                this.getGoodsDetail("商品id");
                latch.countDown();
            }).start();
        }

        //計數器大於0 時,await()方法會阻塞程式繼續執行
        try {
            latch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("資料庫查詢次數:" + dbSelectCount.get());
        System.out.println("快取查詢次數:" + cacheSelectCount.get());
    }


    public String getGoodsDetail(String key) {
        //先從快取查詢,存在則直接返回
        String data = this.selectCache(key);
        if (data != null) {
            return data;
        }

       /**
         * requestId主要用來確保解鎖的時候,A客戶端不要把B客戶端獲得的鎖給釋放了
         */
        String lockKey = "鎖的Key";
        String requestId = "請求客戶端ID";

        //加鎖
        if (!this.lock(lockKey, requestId, 10)) {
            //加鎖失敗,證明其他執行緒已獲得了鎖,此時只需等待一會,再次呼叫本方法即可
            sleep(100);
            this.getGoodsDetail(key);
        }

        //加鎖成功
        //這裡還需要再次查詢快取,防止其他等待的執行緒獲得鎖時又打到資料庫上
        data = this.selectCache(key);
        if (data != null) {
            return data;
        }

        //從資料庫查詢且將資料放入快取
        data = this.selectDB(key);
        redisTemplate.opsForValue().set(key, data, 60, TimeUnit.SECONDS);

        //釋放鎖
        this.unLock(lockKey, requestId);
        return data;
    }


    /**
     * 使用redis特性實現互斥鎖(setnx)
     *
     * @param lockKey
     * @param requestId
     * @param expireTime 鎖過期時間,即超過該時間仍然未被解鎖,則自動解鎖,防止死鎖
     * @return
     */
    public boolean lock(String lockKey, String requestId, int expireTime) {
        Boolean res = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, Duration.ofSeconds(expireTime));
        return res != null && res;
    }

    /**
     * 釋放鎖,使用Lua指令碼,確保原子性
     *
     * @param lockKey
     * @param requestId
     * @return
     */
    public boolean unLock(String lockKey, String requestId) {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>(script, Boolean.class);
        Boolean res = redisTemplate.execute(redisScript, Collections.singletonList(lockKey), requestId);
        return res != null && res;
    }


    /**
     * 從快取中獲取資料
     *
     * @param key
     * @return
     */
    public String selectCache(String key) {
        cacheSelectCount.addAndGet(1);//記錄次數

        System.out.println(Thread.currentThread().getId() + " 從cache獲取資料====");
        return redisTemplate.opsForValue().get(key);
    }

    /**
     * 從資料庫中獲取資料
     *
     * @param key
     * @return
     */
    public String selectDB(String key) {
        sleep(100);//模擬查詢資料庫花費100ms
        dbSelectCount.addAndGet(1);//記錄次數

        System.out.println(Thread.currentThread().getId() + " 從db獲取資料====");
        return "資料中的資料";
    }

    private static void sleep(long m) {
        try {
            Thread.sleep(m);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
結果:

資料庫查詢次數:1
快取查詢次數:32095

參考文章:Redis實現分散式鎖的正確使用方式(java版本)

解決方案擴充

只要實現了同步機制,基本就可以從根本上解決擊穿的問題,當然,我們還有一些方法可以去避免發生穿透的發生,比如

1.熱點資料永不過期;
2.快取預熱,比如秒殺活動開始前,先在redis初始化資料。
3.編寫指令碼,去掃描即將過期但此時訪問量巨大的快取,去延遲它的過期時間。

快取穿透

正常情況下,我們去查詢資料都是存在。

那麼請求去查詢一條壓根兒資料庫中根本就不存在的資料,也就是快取和資料庫都查詢不到這條資料,但是請求每次都會打到資料庫上面去。

這種查詢不存在資料的現象我們稱為快取穿透。

一般情況是黑客攻擊,拿著不存在的ID去傳送大量的請求,這樣產生的請求到資料庫去查詢,可能會導致你的資料庫由於壓力過大而宕掉。

解決方案1:快取空值

之所以會發生穿透,就是因為快取中沒有儲存這些空資料的key。從而導致每次查詢都到資料庫去了。

那麼我們就可以為這些key對應的值設定為null或空字串 丟到快取裡面去。後面再出現查詢這個key 的請求的時候,直接返回null 。

這樣,就不用在到資料庫中去走一圈了,但是別忘了設定過期時間且時間不宜過長,如5分鐘。

解決方案2:布隆過濾器

方案1基本能解決業務場景上的問題,但是遇到網站攻擊,不停給redis請求不一樣的Key,會導致redis記憶體爆掉。

此時就需要用到另一種方案:布隆過濾器

布隆過濾器是一個神奇的資料結構,可以用來判斷一個元素是否在一個集合中

具體自行查閱網上文章

快取雪崩

快取雪崩其實是概念性問題,和快取擊穿相似。

快取雪崩是指快取中資料大批量到過期時間,而查詢資料量巨大,引起資料庫壓力過大甚至down機。和快取擊穿不同的是:快取擊穿指併發查同一條資料,快取雪崩是不同資料都過期了,很多資料都查不到從而查資料庫。

只要解決了擊穿和穿透的問題,就不存在雪崩了,即使某個時間點大量資料過期,也不會直接打到資料庫,前提是你的Redis得扛得住。

當然,我們也得儘量避免這個問題,我們可以才用給快取資料設定隨機的過期時間來解決這個問題,避免大量快取在同一時間過期。

其實上面“解決方案擴充”中提到方法也適用於雪崩,要根據業務靈活運用方案。


此文來源:https://my.oschina.net/yejunxi/blog/3193509

相關文章