Redis(六)應用問題解決

第一章 緩存穿透

1.1 問題描述

key對應的數據在數據源並不存在,每次針對此key的請求從緩存獲取不到,請求都會壓到數據源,從而可能壓垮數據源。比如用一個不存在的用戶id獲取用戶信息,不論緩存還是數據庫都沒有,若黑客利用此漏洞進行攻擊可能壓垮數據庫。

img

1.2 解決方案

一個一定不存在緩存及查詢不到的數據,由於緩存是不命中時被動寫的,並且出於容錯考慮,如果從存儲層查不到數據則不寫入緩存,這將導致這個不存在的數據每次請求都要到存儲層去查詢,失去了緩存的意義。

解決方案:

(1) 對空值緩存:如果一個查詢返回的數據爲空(不管是數據是否不存在),我們仍然把這個空結果(null)進行緩存,設置空結果的過期時間會很短,最長不超過五分鐘

(2) 設置可訪問的名單(白名單):

使用bitmaps類型定義一個可以訪問的名單,名單id作爲bitmaps的偏移量,每次訪問和bitmap裏面的id進行比較,如果訪問id不在bitmaps裏面,進行攔截,不允許訪問。

(3) 採用布隆過濾器:(布隆過濾器(Bloom Filter)是1970年由布隆提出的。它實際上是一個很長的二進制向量(位圖)和一系列隨機映射函數(哈希函數)。

布隆過濾器可以用於檢索一個元素是否在一個集合中。它的優點是空間效率和查詢時間都遠遠超過一般的算法,缺點是有一定的誤識別率和刪除困難。)

將所有可能存在的數據哈希到一個足夠大的bitmaps中,一個一定不存在的數據會被 這個bitmaps攔截掉,從而避免了對底層存儲系統的查詢壓力。

(4) 進行實時監控:當發現Redis的命中率開始急速降低,需要排查訪問對象和訪問的數據,和運維人員配合,可以設置黑名單限制服務

第二章 緩存擊穿

2.1 問題描述

key對應的數據存在,但在redis中過期,此時若有大量併發請求過來,這些請求發現緩存過期一般都會從後端DB加載數據並回設到緩存,這個時候大併發的請求可能會瞬間把後端DB壓垮。

img

2.2 解決方案

key可能會在某些時間點被超高併發地訪問,是一種非常“熱點”的數據。這個時候,需要考慮一個問題:緩存被“擊穿”的問題。

解決問題:

(1)預先設置熱門數據:在redis高峯訪問之前,把一些熱門數據提前存入到redis裏面,加大這些熱門數據key的時長

(2)實時調整:現場監控哪些數據熱門,實時調整key的過期時長

(3)使用鎖:

(1) 就是在緩存失效的時候(判斷拿出來的值爲空),不是立即去load db。

(2) 先使用緩存工具的某些帶成功操作返回值的操作(比如Redis的SETNX)去set一個mutex key

(3) 當操作返回成功時,再進行load db的操作,並回設緩存,最後刪除mutex key;

(4) 當操作返回失敗,證明有線程在load db,當前線程睡眠一段時間再重試整個get緩存的方法。

img

第三章 緩存雪崩

3.1 問題描述

key對應的數據存在,但在redis中過期,此時若有大量併發請求過來,這些請求發現緩存過期一般都會從後端DB加載數據並回設到緩存,這個時候大併發的請求可能會瞬間把後端DB壓垮。

緩存雪崩與緩存擊穿的區別在於這裏針對很多key緩存,後者則是某一個key

正常訪問

img

緩存失效瞬間

img

3.2 解決方案

緩存失效時的雪崩效應對底層系統的衝擊非常可怕!

解決方案:

(1) 構建多級緩存架構:nginx緩存 + redis緩存 +其他緩存(ehcache等)

(2) 使用鎖或隊列:

用加鎖或者隊列的方式保證來保證不會有大量的線程對數據庫一次性進行讀寫,從而避免失效時大量的併發請求落到底層存儲系統上。不適用高併發情況

(3) 設置過期標誌更新緩存:

記錄緩存數據是否過期(設置提前量),如果過期會觸發通知另外的線程在後臺去更新實際key的緩存。

(4) 將緩存失效時間分散開:

比如我們可以在原有的失效時間基礎上增加一個隨機值,比如1-5分鐘隨機,這樣每一個緩存的過期時間的重複率就會降低,就很難引發集體失效的事件。

第四章 分佈式鎖

4.1 問題描述

隨着業務發展的需要,原單體單機部署的系統被演化成分佈式集羣系統後,由於分佈式系統多線程、多進程並且分佈在不同機器上,這將使原單機部署情況下的併發控制鎖策略失效,單純的Java API並不能提供分佈式鎖的能力。爲了解決這個問題就需要一種跨JVM的互斥機制來控制共享資源的訪問,這就是分佈式鎖要解決的問題!

分佈式鎖主流的實現方案:

  1. 基於數據庫實現分佈式鎖

  2. 基於緩存(Redis等)

  3. 基於Zookeeper

每一種分佈式鎖解決方案都有各自的優缺點:

  1. 性能:redis最高

  2. 可靠性:zookeeper最高

這裏,我們就基於redis實現分佈式鎖。

4.2 解決方案:使用redis實現分佈式鎖

redis:命令

# set sku:1:info “OK” NX PX 10000
  • EX second :設置鍵的過期時間爲 second 秒。 SET key value EX second 效果等同於 SETEX key second value 。
  • PX millisecond :設置鍵的過期時間爲 millisecond 毫秒。 SET key value PX millisecond 效果等同於 PSETEX key millisecond value 。
  • NX :只在鍵不存在時,纔對鍵進行設置操作。 SET key value NX 效果等同於 SETNX key value 。
  • XX :只在鍵已經存在時,纔對鍵進行設置操作。

img

  1. 多個客戶端同時獲取鎖(setnx)

  2. 獲取成功,執行業務邏輯{從db獲取數據,放入緩存},執行完成釋放鎖(del)

  3. 其他客戶端等待重試

4.3 編寫代碼

Redis: set num 0

@GetMapping("testLock")
public void testLock(){
    //1獲取鎖,setne
    Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "111");
    //2獲取鎖成功、查詢num的值
    if(lock){
        Object value = redisTemplate.opsForValue().get("num");
        //2.1判斷num爲空return
        if(StringUtils.isEmpty(value)){
            return;
        }
        //2.2有值就轉成成int
        int num = Integer.parseInt(value+"");
        //2.3把redis的num加1
        redisTemplate.opsForValue().set("num", ++num);
        //2.4釋放鎖,del
        redisTemplate.delete("lock");

    }else{
        //3獲取鎖失敗、每隔0.1秒再獲取
        try {
            Thread.sleep(100);
            testLock();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
} 

重啓,服務集羣,通過網關壓力測試:

ab -n 1000 -c 100 http://192.168.140.1:8080/test/testLock

img

查看redis中num的值:

img

基本實現。

問題:setnx剛好獲取到鎖,業務邏輯出現異常,導致鎖無法釋放

解決:設置過期時間,自動釋放鎖。

4.4 優化之設置鎖的過期時間

設置過期時間有兩種方式:

  1. 首先想到通過expire設置過期時間(缺乏原子性:如果在setnx和expire之間出現異常,鎖也無法釋放)

  2. 在set時指定過期時間(推薦)

img

設置過期時間:

img

壓力測試肯定也沒有問題。自行測試

問題:可能會釋放其他服務器的鎖。

場景:如果業務邏輯的執行時間是7s。執行流程如下

  1. index1業務邏輯沒執行完,3秒後鎖被自動釋放。

  2. index2獲取到鎖,執行業務邏輯,3秒後鎖被自動釋放。

  3. index3獲取到鎖,執行業務邏輯

  4. index1業務邏輯執行完成,開始調用del釋放鎖,這時釋放的是index3的鎖,導致index3的業務只執行1s就被別人釋放。

最終等於沒鎖的情況。

解決:setnx獲取鎖時,設置一個指定的唯一值(例如:uuid);釋放前獲取這個值,判斷是否自己的鎖

4.5 優化之UUID防誤刪

img

img

問題:刪除操作缺乏原子性。

場景:

  1. index1執行刪除時,查詢到的lock值確實和uuid相等

uuid=v1

set(lock,uuid);

img

  1. index1執行刪除前,lock剛好過期時間已到,被redis自動釋放

在redis中沒有了lock,沒有了鎖。

img

  1. index2獲取了lock

index2線程獲取到了cpu的資源,開始執行方法

uuid=v2

set(lock,uuid);

  1. index1執行刪除,此時會把index2的lock刪除

index1 因爲已經在方法中了,所以不需要重新上鎖。index1有執行的權限。index1已經比較完成了,這個時候,開始執行

img

刪除的index2的鎖!

4.6 優化之LUA腳本保證刪除的原子性

@GetMapping("testLockLua")
public void testLockLua() {
    //1 聲明一個uuid ,將做爲一個value 放入我們的key所對應的值中
    String uuid = UUID.randomUUID().toString();
    //2 定義一個鎖:lua 腳本可以使用同一把鎖,來實現刪除!
    String skuId = "25"; // 訪問skuId 爲25號的商品 100008348542
    String locKey = "lock:" + skuId; // 鎖住的是每個商品的數據

    // 3 獲取鎖
    Boolean lock = redisTemplate.opsForValue().setIfAbsent(locKey, uuid, 3, TimeUnit.SECONDS);

    // 第一種: lock 與過期時間中間不寫任何的代碼。
    // redisTemplate.expire("lock",10, TimeUnit.SECONDS);//設置過期時間
    // 如果true
    if (lock) {
        // 執行的業務邏輯開始
        // 獲取緩存中的num 數據
        Object value = redisTemplate.opsForValue().get("num");
        // 如果是空直接返回
        if (StringUtils.isEmpty(value)) {
            return;
        }
        // 不是空 如果說在這出現了異常! 那麼delete 就刪除失敗! 也就是說鎖永遠存在!
        int num = Integer.parseInt(value + "");
        // 使num 每次+1 放入緩存
        redisTemplate.opsForValue().set("num", String.valueOf(++num));
        /*使用lua腳本來鎖*/
        // 定義lua 腳本
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        // 使用redis執行lua執行
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptText(script);
        // 設置一下返回值類型 爲Long
        // 因爲刪除判斷的時候,返回的0,給其封裝爲數據類型。如果不封裝那麼默認返回String 類型,
        // 那麼返回字符串與0 會有發生錯誤。
        redisScript.setResultType(Long.class);
        // 第一個要是script 腳本 ,第二個需要判斷的key,第三個就是key所對應的值。
        redisTemplate.execute(redisScript, Arrays.asList(locKey), uuid);
    } else {
        // 其他線程等待
        try {
            // 睡眠
            Thread.sleep(1000);
            // 睡醒了之後,調用方法。
            testLockLua();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

Lua 腳本詳解:

img

項目中正確使用:

1. 定義key,key應該是爲每個sku定義的,也就是每個sku有一把鎖。String locKey ="lock:"+skuId; // 鎖住的是每個商品的數據Boolean lock = redisTemplate.opsForValue().setIfAbsent(locKey, uuid,3,TimeUnit.*SECONDS*);
img

4.7 總結

加鎖

// 1. 從redis中獲取鎖,set k1 v1 px 20000 nx
String uuid = UUID.randomUUID().toString();
Boolean lock = this.redisTemplate.opsForValue()
      .setIfAbsent("lock", uuid, 2, TimeUnit.SECONDS);

使用lua釋放鎖

// 2. 釋放鎖 del
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
// 設置lua腳本返回的數據類型
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
// 設置lua腳本返回類型爲Long
redisScript.setResultType(Long.class);
redisScript.setScriptText(script);
redisTemplate.execute(redisScript, Arrays.asList("lock"),uuid);

重試

Thread.sleep(500);
testLock();

爲了確保分佈式鎖可用,我們至少要確保鎖的實現同時滿足以下四個條件

  • 互斥性。在任意時刻,只有一個客戶端能持有鎖。
  • 不會發生死鎖。即使有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證後續其他客戶端能加鎖。
  • 解鈴還須繫鈴人。加鎖和解鎖必須是同一個客戶端,客戶端自己不能把別人加的鎖給解了。
  • 加鎖和解鎖必須具有原子性。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章