【Redis場景3】快取穿透、擊穿問題

2023-01-31 06:02:32

場景問題及原因

快取穿透:

原因:使用者端請求的資料在快取和資料庫中不存在,這樣快取永遠不會生效,請求全部打入資料庫,造成資料庫連線異常。

解決思路:

  1. 快取空物件

    1. 對於不存在的資料也在Redis建立快取,值為空,並設定一個較短的TTL時間
    2. 問題:實現簡單,維護方便,但短期的資料不一致問題

快取雪崩:

原因:在同一時段大量的快取key同時失效或者Redis服務宕機,導致大量請求到達資料庫,帶來巨大壓力。

解決思路:給不同的Key的TTL新增隨機值(簡單),給快取業務新增降級限流策略(複雜),給業務新增多級快取(複雜)

快取擊穿(熱點Key):

前提條件:熱點Key&在某一時段被高並行存取&快取重建耗時較長

原因:熱點key突然過期,因為重建耗時長,在這段時間內大量請求落到資料庫,帶來巨大沖擊

解決思路:

  1. 互斥鎖

    1. 給快取重建過程加鎖確保重建過程只有一個執行緒執行,其它執行緒等待
    2. 問題:執行緒阻塞,導致效能下降且有死鎖風險
  2. 邏輯過期

    1. 熱點key快取永不過期,而是設定一個邏輯過期時間,查詢到資料時通過對邏輯過期時間判斷,來決定是否需要重建快取;重建快取也通過互斥鎖保證單執行緒執行,但是重建快取利用獨立執行緒非同步執行,其它執行緒無需等待,直接查詢到的舊資料即可
    2. 問題:不保證一致性,有額外記憶體消耗且實現複雜

場景問題實踐解決

完整程式碼地址:https://github.com/xbhog/hm-dianping

分支:20221221-xbhog-cacheBrenkdown

分支:20230110-xbhog-Cache_Penetration_Avalance

快取穿透:

程式碼實現:

public Shop queryWithPassThrough(Long id){
    //從redis查詢商鋪資訊
    String shopInfo = stringRedisTemplate.opsForValue().get(SHOP_CACHE_KEY + id);
    //命中快取,返回店鋪資訊
    if(StrUtil.isNotBlank(shopInfo)){
        return JSONUtil.toBean(shopInfo, Shop.class);
    }
    //redis既沒有key的快取,但查出來資訊不為null,則為空字串
    if(shopInfo != null){
        return null;
    }
    //未命中快取
    Shop shop = getById(id);
    if(Objects.isNull(shop)){
        //將null新增至快取,過期時間減少
        stringRedisTemplate.opsForValue().set(SHOP_CACHE_KEY+id,"",5L, TimeUnit.MINUTES);
        return null;
    }
    //物件轉字串
    stringRedisTemplate.opsForValue().set(SHOP_CACHE_KEY+id,JSONUtil.toJsonStr(shop),30L, TimeUnit.MINUTES);
    return shop;
}

上述流程圖和程式碼非常清晰,由於快取雪崩簡單實現(複雜實踐不會)增加隨機TTL值,快取穿透和快取雪崩不過多解釋。

快取擊穿:

快取擊穿邏輯分析:

首先執行緒1在查詢快取時未命中,然後進行查詢資料庫並重建快取。注意上述快取擊穿發生的條件,被高並行存取&快取重建耗時較長;

由於快取重建耗時較長,在這時間穿插執行緒2,3,4進入;那麼這些執行緒都不能從快取中查詢到資料,同一時間去存取資料庫,同時的去執行資料庫操作程式碼,對資料庫存取壓力過大。

互斥鎖:

解決方式:加鎖;****可以採用**tryLock方法 + double check**來解決這樣的問題

執行緒2執行的時候,由於執行緒1加鎖在重建快取,所以執行緒2被阻塞,休眠等待執行緒1執行完成後查詢快取。由此造成在重建快取的時候阻塞程序,效率下降且有死鎖的風險。

private Shop queryWithMutex(Long id) {
    //從redis查詢商鋪資訊
    String shopInfo = stringRedisTemplate.opsForValue().get(SHOP_CACHE_KEY + id);
    //命中快取,返回店鋪資訊
    if(StrUtil.isNotBlank(shopInfo)){
        return JSONUtil.toBean(shopInfo, Shop.class);
    }
    //redis既沒有key的快取,但查出來資訊不為null,則為空字串
    if(shopInfo != null){
        return null;
    }
    //實現快取重建
    String lockKey = "lock:shop:"+id;
    Shop shop = null;
    try {
        Boolean aBoolean = tryLock(lockKey);
        if(!aBoolean){
            //加鎖失敗,休眠
            Thread.sleep(50);
            //遞迴等待
            return queryWithMutex(id);
        }
        //獲取鎖成功應該再次檢測redis快取是否還存在,做doubleCheck,如果存在則無需重建快取。
        synchronized (this){
            //從redis查詢商鋪資訊
            String shopInfoTwo = stringRedisTemplate.opsForValue().get(SHOP_CACHE_KEY + id);
            //命中快取,返回店鋪資訊
            if(StrUtil.isNotBlank(shopInfoTwo)){
                return JSONUtil.toBean(shopInfoTwo, Shop.class);
            }
            //redis既沒有key的快取,但查出來資訊不為null,則為「」
            if(shopInfoTwo != null){
                return null;
            }
            //未命中快取
            shop = getById(id);
            // 5.不存在,返回錯誤
            if(Objects.isNull(shop)){
                //將null新增至快取,過期時間減少
                stringRedisTemplate.opsForValue().set(SHOP_CACHE_KEY+id,"",5L, TimeUnit.MINUTES);
                return null;
            }
            //模擬重建的延時
            Thread.sleep(200);
            //物件轉字串
            stringRedisTemplate.opsForValue().set(SHOP_CACHE_KEY+id,JSONUtil.toJsonStr(shop),30L, TimeUnit.MINUTES);
        }

    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    } finally {
        unLock(lockKey);
    }
    return shop;
}

在獲取鎖失敗時,證明已有執行緒在重建快取,使當前執行緒休眠並重試(遞迴實現)

程式碼中需要注意的是synchronized關鍵字的使用,在獲取到鎖的時候,在判斷下快取是否存在(失效)double-check,該關鍵字鎖的是當前物件。在其關鍵字{}中是同步處理。

推薦部落格https://blog.csdn.net/u013142781/article/details/51697672

然後進行測試程式碼,進行壓力測試(jmeter),首先去除快取中的值,模擬快取失效。

設定1000個執行緒,多執行緒執行間隔5s

所有的請求都是成功的,其qps大約在200,其吞吐量還是比較可觀的。然後看下快取是否成功(只查詢一次資料庫);

邏輯過期:

思路分析:

當用戶開始查詢redis時,判斷是否命中,如果沒有命中則直接返回空資料,不查詢資料庫,而一旦命中後,將value取出,判斷value中的過期時間是否滿足,如果沒有過期,則直接返回redis中的資料,如果過期,則在開啟獨立執行緒後直接返回之前的資料,獨立執行緒去重構資料,重構完成後釋放互斥鎖。

封裝資料:這裡我們採用新建實體類來實現

/**
 * @author xbhog
 * @describe:
 * @date 2023/1/15
 */
@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

使得過期時間和資料有關聯關係,這裡的資料型別是Object,方便後續不同型別的封裝。

public Shop queryWithLogicalExpire( Long id ) {
    String key = CACHE_SHOP_KEY + id;
    // 1.從redis查詢商鋪快取
    String json = stringRedisTemplate.opsForValue().get(key);
    // 2.判斷是否存在
    if (StrUtil.isBlank(json)) {
        // 3.存在,直接返回
        return null;
    }
    // 4.命中,需要先把json反序列化為物件
    RedisData redisData = JSONUtil.toBean(json, RedisData.class);
    Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
    LocalDateTime expireTime = redisData.getExpireTime();
    // 5.判斷是否過期
    if(expireTime.isAfter(LocalDateTime.now())) {
        // 5.1.未過期,直接返回店鋪資訊
        return shop;
    }
    // 5.2.已過期,需要快取重建
    // 6.快取重建
    // 6.1.獲取互斥鎖
    String lockKey = LOCK_SHOP_KEY + id;
    boolean isLock = tryLock(lockKey);
    // 6.2.判斷是否獲取鎖成功
    if (isLock){
        exectorPool().execute(() -> {
            try {
                //重建快取
                this.saveShop2Redis(id, 20L);
            } catch (Exception e) {
                throw new RuntimeException(e);
            } finally {
                unLock(lockKey);
            }
        });
    }
    // 6.4.返回過期的商鋪資訊
    return shop;
}

當前的執行流程跟互斥鎖基本相同,需要注意的是,在獲取鎖成功後,我們將快取重建放到執行緒池中執行,來非同步實現。

執行緒池程式碼:

/**
 * 執行緒池的建立
 * @return
 */
private static ThreadPoolExecutor exectorPool() {
    ThreadPoolExecutor executor = new ThreadPoolExecutor(
            5,
            //根據自己的處理器數量+1
            Runtime.getRuntime().availableProcessors()+1,
            2L,
            TimeUnit.SECONDS,
            new LinkedBlockingDeque<>(3),
            Executors.defaultThreadFactory(),
            new ThreadPoolExecutor.AbortPolicy());
    return executor;
}

快取重建程式碼:

/**
 * 重建快取
 * @param id 重建ID
 * @param l 過期時間
 */
public void saveShop2Redis(Long id, long l) {
    //查詢店鋪資訊
    Shop shop = getById(id);
    //封裝邏輯過期時間
    RedisData redisData = new RedisData();
    redisData.setData(shop);
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(l));
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
}

測試條件100執行緒,1s執行緒間隔時間,快取失效時間10s

測試環境:快取中存在對應的資料,並且在快取快失效之前修改資料庫中的資料,造成快取與資料庫不一致,通過執行壓測,來檢視相關執行緒返回的資料情況。

從上述兩張圖中可以看到,在前幾個執行緒執行過程中店鋪name為102,當執行時間從19-20的時候店鋪name發生變化為105,滿足邏輯過期非同步執行快取重建的需求.