Redis底層原理 (Redis實現分佈式鎖)(八)

業務場景

  • 防止用戶重複下單
  • MQ消息去重
  • 訂單操作變更
  • 庫存超賣

分析:

業務場景共性:
共享資源:用戶id、訂單id、商品id。。。
解決方案

  • 共享資源互斥
  • 共享資源串行化

問題轉化
     鎖的問題 (將需求抽象後得到問題的本質)

鎖應用

      單應用中使用鎖:(單進程多線程)synchronized、ReentrantLock
      分佈式應用中使用鎖:(多進程多線程)分佈式鎖是控制分佈式系統之間同步訪問共享資源的一種方式。
解決分佈式下併發訪問:

try{
    if(getLock()){
        扣減庫存
    }
}finally{
    unlock();
}

分佈式鎖特性:

  •  互斥性:我持有則別人不能持有
  • 同一性:我加鎖,別人不能解鎖
  • 可重入性:我加鎖,鎖超時,則鎖失效,別人可以加鎖;我加鎖,還可再次加鎖(多次持有)

Redis實現分佈式鎖

原理:利用Redis的單線程特性對共享資源進行串行化處理
獲取鎖:

方式一

/**
* 使用redis的set命令實現獲取分佈式鎖 推薦使用
* @param lockKey 可以就是鎖
* @param requestId 請求ID,保證同一性 uuid+threadID
* @param expireTime 過期時間,避免死鎖
* @return
*/
public boolean getLock(String lockKey,String requestId,int expireTime) {
    //NX:保證互斥性
    // hset 原子性操作
    String result = jedis.set(lockKey, requestId, "NX", "EX", expireTime);
    if("OK".equals(result)) {
        return true;
    } 
    return false;
}

方式二 (使用setnx命令實現) -- 併發會產生問題

public boolean getLock(String lockKey,String requestId,int expireTime) {
    Long result = jedis.setnx(lockKey, requestId);
    if(result == 1) {
    //成功設置 失效時間
    jedis.expire(lockKey, expireTime);
        return true;
    } 
    return false;
}

釋放鎖

方式1(del命令實現) -- 併發

/
**
* 釋放分佈式鎖
* @param lockKey
* @param requestId
*/
public static void releaseLock(String lockKey,String requestId) {
    if (requestId.equals(jedis.get(lockKey))) {
        jedis.del(lockKey);
    }
}

方式2(redis+lua腳本實現)--推薦

public static boolean releaseLock(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";

    Object result = jedis.eval(script, Collections.singletonList(lockKey),
    Collections.singletonList(requestId));
    if (result.equals(1L)) {
        return true;
    } 
    return false;
}

存在問題

  • 單機:無法保證高可用
  • 主--從:無法保證數據的強一致性,在主機宕機時會造成鎖的重複獲得
  • 無法續租:超過expireTime後,不能繼續使用

本質分析

CAP模型分析
       在分佈式環境下不可能滿足三者共存,只能滿足其中的兩者共存,在分佈式下P不能捨棄(捨棄P就是單機了)。所以只能是CP(強一致性模型)和AP(高可用模型)。
       分佈式鎖是CP模型,Redis集羣是AP模型。 (base)
       Redis集羣不能保證數據的隨時一致性,只能保證數據的最終一致性。
爲什麼還可以用Redis實現分佈式鎖?
       與業務有關
       當業務不需要數據強一致性時,比如:社交場景,就可以使用Redis實現分佈式鎖
       當業務必須要數據的強一致性,即不允許重複獲得鎖,比如金融場景(重複下單,重複轉賬)就不要使用
       可以使用CP模型實現,比如:zookeeper和etcd。
分佈式鎖的實現方式

  • 基於Redis的set實現分佈式鎖
  • 基於 zookeeper 臨時節點的分佈式鎖
  • 基於etcd實現

三者的對比,如下表

生產環境中的分佈式鎖

落地生產環境用分佈式鎖,一般採用開源框架,比如Redisson。
POM

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>2.7.0</version>
</dependency

配置Redisson
 

public class RedissonManager {
private static Config config = new Config();
//聲明redisso對象
private static Redisson redisson = null;
//實例化redisson
static{
    config.useClusterServers()
    // 集羣狀態掃描間隔時間,單位是毫秒
    .setScanInterval(2000)
    //cluster方式至少6個節點(3主3從,3主做sharding,3從用來保證主宕機後可以高可用)
    .addNodeAddress("redis://127.0.0.1:6379" )
    .addNodeAddress("redis://127.0.0.1:6380")
    .addNodeAddress("redis://127.0.0.1:6381")
    .addNodeAddress("redis://127.0.0.1:6382")
    .addNodeAddress("redis://127.0.0.1:6383")
    .addNodeAddress("redis://127.0.0.1:6384");
    //得到redisson對象
    redisson = (Redisson) Redisson.create(config);
}
//獲取redisson對象的方法
public static Redisson getRedisson(){
    return redisson;
}
}

鎖的獲取和釋放
 

public class DistributedRedisLock {
//從配置類中獲取redisson對象
private static Redisson redisson = RedissonManager.getRedisson();
private static final String LOCK_TITLE = "redisLock_";

//加鎖
public static boolean acquire(String lockName){
    //聲明key對象
    String key = LOCK_TITLE + lockName;
    //獲取鎖對象
    RLock mylock = redisson.getLock(key);
    //加鎖,並且設置鎖過期時間3秒,防止死鎖的產生 uuid+threadId
    mylock.lock(2,3,TimeUtil.SECOND);
    //加鎖成功
    return true;
}

//鎖的釋放
public static void release(String lockName){
    //必須是和加鎖時的同一個key
    String key = LOCK_TITLE + lockName;
    //獲取所對象
    RLock mylock = redisson.getLock(key);
    //釋放鎖(解鎖)
    mylock.unlock();
}
}

業務邏輯中使用分佈式鎖
 

public String discount(){
    String key = "test123";
    //加鎖
    DistributedRedisLock.acquire(key);
    //執行具體業務邏輯
    dosoming
    //釋放鎖
    DistributedRedisLock.release(key);
    //返回結果
    return soming;
}

Redisson分佈式鎖的實現原理

加鎖機制
如果該客戶端面對的是一個redis cluster集羣,他首先會根據hash節點選擇一臺機器。發送lua腳本到redis服務器上,腳本如下:
 

"if (redis.call('exists',KEYS[1])==0) then "+
"redis.call('hset',KEYS[1],ARGV[2],1) ; "+
"redis.call('pexpire',KEYS[1],ARGV[1]) ; "+
"return nil; end ;" +
"if (redis.call('hexists',KEYS[1],ARGV[2]) ==1 ) then "+
"redis.call('hincrby',KEYS[1],ARGV[2],1) ; "+
"redis.call('pexpire',KEYS[1],ARGV[1]) ; "+
"return nil; end ;" +
"return redis.call('pttl',KEYS[1]) ;"

lua的作用:保證這段複雜業務邏輯執行的原子性。
lua的解釋:
KEYS[1]) : 加鎖的key
ARGV[1] : key的生存時間,默認爲30秒
ARGV[2] : 加鎖的客戶端ID (UUID.randomUUID()) + “:” + threadId)
第一段if判斷語句,就是用“exists myLock”命令判斷一下,如果你要加鎖的那個鎖key不存在的話,你就進行加鎖。如何加鎖呢?很簡單,用下面的命令:
hset myLock 8743c9c0-0795-4907-87fd-6c719a6b4586:1 1
通過這個命令設置一個hash數據結構,這行命令執行後,會出現一個類似下面的數據結構:
myLock :{"8743c9c0-0795-4907-87fd-6c719a6b4586:1":1 }
上述就代表“8743c9c0-0795-4907-87fd-6c719a6b4586:1”這個客戶端對“myLock”這個鎖key完成了加鎖。
鎖互斥機制
那麼在這個時候,如果客戶端2來嘗試加鎖,執行了同樣的一段lua腳本,會咋樣呢?
很簡單,第一個if判斷會執行“exists myLock”,發現myLock這個鎖key已經存在了。
接着第二個if判斷,判斷一下,myLock鎖key的hash數據結構中,是否包含客戶端2的ID,但是明顯不是的,因爲那裏包含的是客戶端1的ID。所以,客戶端2會獲取到pttl myLock返回的一個數字,這個數字代表了myLock這個鎖key的剩餘生存時間。比如還剩15000毫秒的生存時間。此時客戶端2會進入一個while循環,不停的嘗試加鎖。
自動延時機制
只要客戶端1一旦加鎖成功,就會啓動一個watch dog看門狗,他是一個後臺線程,會每隔10秒檢查一下,如果客戶端1還持有鎖key,那麼就會不斷的延長鎖key的生存時間。
可重入鎖機制
第一個if判斷肯定不成立,“exists myLock”會顯示鎖key已經存在了。
第二個if判斷會成立,因爲myLock的hash數據結構中包含的那個ID,就是客戶端1的那個ID,也就是“8743c9c0-0795-4907-87fd-6c719a6b4586:1”此時就會執行可重入加鎖的邏輯,他會用:incrby myLock 8743c9c0-0795-4907-87fd-6c71a6b4586:1 1 通過這個命令,對客戶端1的加鎖次數,累加1。數據結構會變成:myLock :{"8743c9c0-0795-4907-87fd-6c719a6b4586:1":2 }
釋放鎖機制
執行lua腳本如下:
 

#如果key已經不存在,說明已經被解鎖,直接發佈(publish)redis消息
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end;" +
# key和field不匹配,說明當前客戶端線程沒有持有鎖,不能主動解鎖。
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
# 將value減1
"local counter = redis.call('hincrby', KEYS[1], ARGV[3],-1); " +
# 如果counter>0說明鎖在重入,不能刪除key
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
# 刪除key並且publish 解鎖消息
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",

– KEYS[1] :需要加鎖的key,這裏需要是字符串類型。
– KEYS[2] :redis消息的ChannelName,一個分佈式鎖對應唯一的一個channelName:“redisson_lockchannel{” + getName() + “}”
– ARGV[1] :reids消息體,這裏只需要一個字節的標記就可以,主要標記redis的key已經解鎖,再結合redis的Subscribe,能喚醒其他訂閱解鎖消息的客戶端線程申請鎖。
– ARGV[2] :鎖的超時時間,防止死鎖
– ARGV[3] :鎖的唯一標識,也就是剛纔介紹的 id(UUID.randomUUID()) + “:” + threadId
如果執行lock.unlock(),就可以釋放分佈式鎖,此時的業務邏輯也是非常簡單的。
每次都對myLock數據結構中的那個加鎖次數減1。如果發現加鎖次數是0了,說明這個客戶端已經不再持有鎖了,此時就會用:
“del myLock”命令,從redis裏刪除這個key。然後呢,另外的客戶端2就可以嘗試完成加鎖了

 


常見緩存問題

數據讀

  • 緩存穿透:對不存在的key進行高併發訪問,導致數據庫壓力瞬間增大,這就叫做【緩存穿透】
  • 緩存雪崩:突然間大量的key失效或redis重啓,大量訪問數據庫,導致數據庫壓力瞬間增加,叫做【緩存雪崩】
  • 緩存擊穿:當某一key恰好在失效時,發生了高併發訪問,導致數據庫壓力瞬間增大,叫做【緩存擊穿】

緩存穿透
       一般的緩存系統,都是按照key去緩存查詢,如果不存在對應的value,就應該去後端系統查找(比如DB)。如果key對應的value是一定不存在的,並且對該key併發請求量很大,就會對後端系統造成很大的壓力。也就是說,對不存在的key進行高併發訪問,導致數據庫壓力瞬間增大,這就叫做【緩存穿透】。
        解決方案:
        對查詢結果爲空的情況也進行緩存,緩存時間設置短一點,或者該key對應的數據insert了之後清理緩存。
緩存雪崩
當緩存服務器重啓或者大量緩存集中在某一個時間段失效,這樣在失效的時候,也會給後端系統(比如DB)帶來很大壓力。突然間大量的key失效了或redis重啓,大量訪問數據庫
       解決方案:
       1、 key的失效期分散開 不同的key設置不同的有效期
       2、設置二級緩存
       3、高可用

緩存擊穿
       對於一些設置了過期時間的key,如果這些key可能會在某些時間點被超高併發地訪問,是一種非常“熱點”的數據。這個時候,需要考慮一個問題:緩存被“擊穿”的問題,這個和緩存雪崩的區別在於這裏針對某一key緩存,前者則是很多key。
       緩存在某個時間點過期的時候,恰好在這個時間點對這個Key有大量的併發請求過來,這些請求發現緩存過期一般都會從後端DB加載數據並回設到緩存,這個時候大併發的請求可能會瞬間把後端DB壓垮。
       解決方案:
      1、 用分佈式鎖控制訪問的線程使用redis的setnx互斥鎖先進行判斷,這樣其他線程就處於等待狀態,保證不會有大併發操作去操作數據庫。if(redis.sexnx()==1){ //先查詢緩存 //查詢數據庫 //加入緩存 }
      2、不設超時時間,但是會有寫一致問題


數據寫

數據不一致的根源 : 數據源不一樣
如何解決強一致性很難,追求最終一致性
互聯網業務數據處理的特點:

  • 高吞吐量
  • 低延遲
  • 數據敏感性低於金融業

時序控制是否可行?
先更新數據庫,再更新緩存 或者 先更新緩存,再更新數據庫;本質上不是一個原子操作,所以時序控制不可行
保證數據的最終一致性(延時雙刪)
1、先更新數據庫同時刪除緩存項(key),等讀的時候再填充緩存
2、2秒後再刪除一次緩存項(key)
3、設置緩存過期時間 Expired Time 比如 10秒 或1小時
4、將緩存刪除失敗記錄到日誌中,利用腳本提取失敗記錄再次刪除(緩存失效期過長 7*24)

升級方案
通過數據庫的binlog來異步淘汰key,利用工具(canal)將binlog日誌採集發送到MQ中,然後通過ACK機制確認處理刪除緩存。
 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章