Redis分散式鎖實戰

為何不是夢發表於2020-07-14

大家好,我是walking,感謝你開啟這篇文章,請認真閱讀下去吧。

今天我們聊聊redis的一個實際開發的使用場景,那就是大名鼎鼎的分散式鎖。

啥是分散式鎖?

我們學習 Java 都知道鎖的概念,例如基於 JVM 實現的同步鎖 synchronized,以及 jdk 提供的一套程式碼級別的鎖機制 lock,我們在併發程式設計中會經常用這兩種鎖去保證程式碼在多執行緒環境下執行的正確性。但是這些鎖機制在分散式場景下是不適用的,原因是在分散式業務場景下,我們的程式碼都是跑在不同的JVM甚至是不同的機器上,synchronized 和 lock 只能在同一個 JVM 環境下起作用。所以這時候就需要用到分散式鎖了。

 

例如,現在有個場景就是整點搶消費券(疫情的原因,支付寶最近在8點、12點整點開放搶消費券),消費券有一個固定的量,先到先得,搶完就沒了,線上的服務都是部署多個的,大致架構如下:

 

 

 所以這個時候我們就得用分散式鎖來保證共享資源的訪問的正確性。

為什麼要用分散式鎖嗯?

假設不使用分散式鎖,我們看看 synchronized 能不能保證?其實是不能的,我們來演示一下。

下面我寫了一個簡單的 springboot 專案來模擬這個搶消費券的場景,程式碼很簡單,大致意思是先從 Redis 獲取剩餘消費券數,然後判斷大於0,則減一模擬被某個使用者搶到一個,然後減一後再修改 Redis 的剩餘消費券數量,列印扣減成功,剩餘還有多少,否則扣減失敗,就沒搶到。整塊程式碼被 synchronized 包裹,Redis 設定的庫存數量為50。

//假設庫存編號是00001
private String key = "stock:00001";
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
 * 扣減庫存 synchronized同步鎖
*/
@RequestMapping("/deductStock")
public String deductStock(){
    synchronized (this){
        //獲取當前庫存
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
        if(stock>0){
            int afterStock = stock-1;
            stringRedisTemplate.opsForValue().set(key,afterStock+"");//修改庫存
            System.out.println("扣減庫存成功,剩餘庫存"+afterStock);
        }else {
            System.out.println("扣減庫存失敗");
        }
    }
    return "ok";
}

然後啟動兩個springboot專案,埠分別為8080,8081,然後在nginx裡配置負載均衡

upstream redislock{
    server 127.0.0.1:8080;
    server 127.0.0.1:8081;
}
server {
    listen       80;
    server_name  127.0.0.1;
    location / {
        root   html;
        index  index.html index.htm;
        proxy_pass http://redislock;
    }
}

 然後用jmeter壓測工具進行測試

 

  

 

然後我們看一下控制檯輸出,可以看到我們執行的兩個web例項,很多同樣的消費券被不同的執行緒搶到,證明synchronized在這樣的情況下是不起作用的,所以就需要使用分散式鎖來保證資源的正確性。

 

如何用Redis實現分散式鎖?

在實現分散式鎖之前,我們先考慮如何實現,以及都要實現鎖的哪些功能。

1、分散式特性(部署在多個機器上的例項都能夠訪問這把鎖)

2、排他性(同一時間只能有一個執行緒持有鎖)

3、超時自動釋放的特性(持有鎖的執行緒需要給定一定的持有鎖的最大時間,防止執行緒死掉無法釋放鎖而造成死鎖)

4、...

 

基於以上列出的分散式鎖需要擁有的基本特性,我們思考一下使用Redis該如何實現?

1、第一個分散式的特性Redis已經支援,多個例項連同一個Redis即可

2、第二個排他性,也就是要實現一個獨佔鎖,可以使用Redis的setnx命令實現

3、第三個超時自動釋放特性,Redis可以針對某個key設定過期時間

4、執行完畢釋放分散式鎖

 

科普時間

Redis Setnx 命令

Redis Setnx(SET if Not eXists) 命令在指定的 key 不存在時,為 key 設定指定的值

語法

redis Setnx 命令基本語法如下:

redis 127.0.0.1:6379> SETNX KEY_NAME VALUE

可用版本:>= 1.0.0

返回值:設定成功,返回1, 設定失敗,返回0

 

@RequestMapping("/stock_redis_lock")
public String stock_redis_lock(){
    //底層使用setnx命令
    Boolean aTrue = stringRedisTemplate.opsForValue().setIfAbsent(lock_key, "true");
    stringRedisTemplate.expire(lock_key,10, TimeUnit.SECONDS);//設定過期時間10秒
    if (!aTrue) {//設定失敗則表示沒有拿到分散式鎖
        return "error";//這裡可以給使用者一個友好的提示
    }
    //獲取當前庫存
    int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
    if(stock>0){
        int afterStock = stock-1;
        stringRedisTemplate.opsForValue().set(key,afterStock+"");
        System.out.println("扣減庫存成功,剩餘庫存"+afterStock);
    }else {
        System.out.println("扣減庫存失敗");
    }
    stringRedisTemplate.delete(lock_key);//執行完畢釋放分散式鎖
    return "ok";
}

仍然設定庫存數量為50,我們再用jmeter測試一下,把jmeter的測試地址改為127.0.0.1/stock_redis_lock,同樣的設定再來測一次。

 

測試了5次沒有出現髒資料,把傳送時間改為0,測了5次也沒問題,然後又把執行緒數改為600,時間為0 ,迴圈4次,測了幾次也是正常的。

上面實現分散式鎖的程式碼已經是一個較為成熟的分散式鎖的實現了,對大多數軟體公司來說都已經滿足需求了。但是上面程式碼還是有優化的空間,例如:

1)上面的程式碼我們是沒有考慮異常情況的,實際情況下程式碼沒有這麼簡單,可能還會有別的很多複雜的操作,都有可能會出現異常,所以我們釋放鎖的程式碼需要放在finally塊裡來保證即使是程式碼拋異常了釋放鎖的程式碼他依然會被執行。

2)還有,你有沒有注意到,上面我們的分散式鎖的程式碼的獲取和設定過期時間的程式碼是兩步操作第4行和第5行,即非原子操作,就有可能剛執行了第4行還沒來得及執行第5行這臺機器掛了,那麼這個鎖就沒有設定超時時間,其他執行緒就一直無法獲取,除非人工干預,所以這是一步優化的地方,Redis也提供了原子操作,那就是SET key value EX seconds  NX

科普時間

SET key value [EX seconds] [PX milliseconds] [NX|XX]  將字串值 value 關聯到 key

可選引數

從 Redis 2.6.12 版本開始, SET 命令的行為可以通過一系列引數來修改:

  • 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 :只在鍵已經存在時,才對鍵進行設定操作

SpringBoot的StringRedisTemplate也有對應的方法實現,如下程式碼: 

//假設庫存編號是00001
private String key = "stock:00001";
private String lock_key = "lock_key:00001";
@Autowired
private StringRedisTemplate stringRedisTemplate;
@RequestMapping("/stock_redis_lock")
public String stock_redis_lock() {
    String uuid = UUID.randomUUID().toString();
    try {
        //原子的設定key及超時時間
        Boolean aTrue = stringRedisTemplate.opsForValue().setIfAbsent(lock_key, "true", 30, TimeUnit.SECONDS);
        if (!aTrue) {
            return "error";
        }
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
        if (stock > 0) {
            int afterStock = stock - 1;
            stringRedisTemplate.opsForValue().set(key, afterStock + "");
            System.out.println("扣減庫存成功,剩餘庫存" + afterStock);
        } else {
            System.out.println("扣減庫存失敗");
        }
    } catch (NumberFormatException e) {
        e.printStackTrace();
    } finally {
        //避免死鎖
        if (uuid.equals(stringRedisTemplate.opsForValue().get(lock_key))) {
            stringRedisTemplate.delete(lock_key);
        }
    }
    return "ok";
}

這樣實現是否就完美了呢?嗯,對於併發量要求不高或者非大併發的場景的話這樣實現已經可以了。但是對於搶購 ,秒殺這樣的場景,當流量很大,這時候伺服器網路卡、磁碟IO、CPU負載都可能會達到極限,那麼伺服器對於一個請求的的響應時間勢必變得比正常情況下慢很多,那麼假設就剛才設定的鎖的超時時間為10秒,如果某一個執行緒拿到鎖之後因為某些原因沒能在10秒內執行完畢鎖就失效了,這時候其他執行緒就會搶佔到分散式鎖去執行業務邏輯,然後之前的執行緒執行完了,會去執行 finally 裡的釋放鎖的程式碼就會把正在佔有分散式鎖的執行緒的鎖給釋放掉,實際上剛剛正在佔有鎖的執行緒還沒執行完,那麼其他執行緒就又有機會獲得鎖了...這樣整個分散式鎖就失效了,將會產生意想不到的後果。如下圖模擬了這個場景。

 

所以這個問題總結一下,就是因為鎖的過期時間設定的不合適或因為某些原因導致程式碼執行時間大於鎖過期時間而導致併發問題以及鎖被別的執行緒釋放,以至於分散式鎖混亂。在簡單的說就是兩個問題,1)自己的鎖被別人釋放 2)鎖超時無法續時間。

第一個問題很好解決,在設定分散式鎖時,我們在當前執行緒中生產一個唯一串將value設定為這個唯一值,然後在finally塊裡判斷當前鎖的value和自己設定的一樣時再去執行delete,如下:

String uuid = UUID.randomUUID().toString();
try {
    //原子的設定key及超時時間,鎖唯一值
    Boolean aTrue = stringRedisTemplate.opsForValue().setIfAbsent(lock_key,uuid,30,TimeUnit.SECONDS);
    //...
} finally {
    //是自己設定的鎖再執行delete
    if(uuid.equals(stringRedisTemplate.opsForValue().get(lock_key))){
        stringRedisTemplate.delete(lock_key);//避免死鎖
    }
}

問題一解決了(設想一下上述程式碼還有什麼問題,一會兒講),那鎖的超時時間就很關鍵了,不能太大也不能太小,這就需要評估業務程式碼的執行時間,比如設定個10秒,20秒。即使是你的鎖設定了合適的超時時間,也避免不了可能會發生上述分析的因為某些原因程式碼沒在正常評估的時間內執行完畢,所以這時候的解決方案就是給鎖續超時時間。大致思路就是,業務執行緒單獨起一個分執行緒,定時去監聽業務執行緒設定的分散式鎖是否還存在,存在就說明業務執行緒還沒執行完,那麼就延長鎖的超時時間,若鎖已不存在則業務執行緒執行完畢,然後就結束自己。

“鎖續命”的這套邏輯屬實有點複雜啊,要考慮的問題太多了,稍不注意就會有bug。不要看上面實現分散式鎖的程式碼沒有幾行,就認為實現起來很簡單,如果說自己去實現的時候沒有實際高併發的經驗,肯定也會踩很多坑,例如,

1)鎖的設定和過期時間的設定是非原子操作的,就可能會導致死鎖。

2)還有上面遺留的一個,在finally塊裡判斷鎖是否是自己設定的,是的話再刪除鎖,這兩步操作也不是原子的,假設剛判斷完為true服務就掛了,那麼刪除鎖的程式碼不會執行,就會造成死鎖,即使是設定了過期時間,在沒過期這段時間也會死鎖。所以這裡也是一個注意的點,要保證原子操作的話,Redis提供了執行Lua指令碼的功能來保證操作的原子性,具體怎麼使用不再展開。

所以,“鎖續命”的這套邏輯實現起來還是有點複雜的,好在市面上已經有現成的開源框架幫我們實現了,那就是Redisson。

Redisson分散式鎖的實現原理

實現原理:

1、首先Redisson會嘗試進行加鎖,加鎖的原理也是使用類似Redis的setnx命令原子的加鎖,加鎖成功的話其內部會開啟一個子執行緒

2、子執行緒主要負責監聽,其實就是一個定時器,定時監聽主執行緒是否還持有鎖,持有則將鎖的時間延時,否則結束執行緒

3、如果加鎖失敗則自旋不斷嘗試加鎖

4、執行完程式碼主執行緒主動釋放鎖

 

那我們看一下使用後Redisson後的程式碼是什麼樣的。

1、首先在pom.xml檔案新增Redisson的maven座標

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

2、我們要拿到Redisson的這個物件,如下配置Bean

@SpringBootApplication
public class RedisLockApplication {
    public static void main(String[] args) {
        SpringApplication.run(RedisLockApplication.class, args);
    }
    @Bean
    public Redisson redisson(){
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379")
                .setDatabase(0);
        return (Redisson) Redisson.create(config);
    }
}

3、然後我們獲取Redisson的例項,使用其API進行加鎖釋放鎖操作

//假設庫存編號是00001
private String key = "stock:00001";
private String lock_key = "lock_key:00001";
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
 * 使用Redisson實現分散式鎖
 * @return
 */
@RequestMapping("/stock_redisson_lock")
public String stock_redisson_lock() {
    RLock redissonLock = redisson.getLock(lock_key);
    try {
        redissonLock.lock();
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
        if (stock > 0) {
            int afterStock = stock - 1;
            stringRedisTemplate.opsForValue().set(key, afterStock + "");
            System.out.println("扣減庫存成功,剩餘庫存" + afterStock);
        } else {
            System.out.println("扣減庫存失敗");
        }
    } catch (NumberFormatException e) {
        e.printStackTrace();
    } finally {
        redissonLock.unlock();
    }
    return "ok";
}

看這個Redisson的分散式鎖提供的API是不是非常的簡單?就像Java併發變成裡AQS那套Lock機制一樣,如下獲取一把RedissonLock

RLock redissonLock = redisson.getLock(lock_key);

預設返回的是RedissonLock的物件,該物件實現了RLock介面,而RLock介面繼承了JDK併發程式設計報包裡的Lock介面

Redis分散式鎖實戰

在使用Redisson加鎖時,它也提供了很多API,如下

現在我們選擇使用的是最簡單的無參lock方法,簡單的點進去跟一下看看他的原始碼,我們找到最終的執行加鎖的程式碼如下:

我們可以看到其底層使用了Lua指令碼來保證原子性,使用Redis的hash結構實現的加鎖,以及可重入鎖。

比我們自己實現分散式鎖看起來還要簡單,但是我們自己寫的鎖功能他都有,我們沒有的他也有。比如,他實現的分散式鎖是支援可重入的,也支援可等待,即嘗試等待一定時間,沒拿到鎖就返回false。上述程式碼中的redissonLock.lock();是一直等待,內部自旋嘗試加鎖。

Distributed Java locks and synchronizers 

Lock 

FairLock 

MultiLock 

RedLock 

ReadWriteLock 

Semaphore 

PermitExpirableSemaphore 

CountDownLatch

redisson.org

Redisson提供了豐富的API,內部運用了大量的Lua指令碼保證原子操作,篇幅原因redisson實現鎖的程式碼暫不分析了。

注意:在上述示例程式碼中,為了方便演示,查詢redis庫存、修改庫存並非原子操作,實際這兩部操作也得保證原子行,可以用redis自帶的Lua指令碼功能去實現

結語

到這裡,Redis分散式鎖實戰基本就講完了,總結一下Redis分散式鎖吧。

1、如果說是自己實現的話,需要特別注意四點:

1) 原子加鎖 2)設定鎖超時時間  3)誰加的鎖誰釋放,且釋放時的原子操作  4)鎖續命問題。

2、如果使用現成的分散式鎖框架Redisson,就需要熟悉一下其常用的API以及實現原理,或者選擇其他開源的分散式鎖框架,充分考察,選擇適合自己業務需求的即可。

 

參考:

http://doc.redisfans.com/string/set.html

https://www.runoob.com/redis/strings-setnx.html

https://github.com/redisson/redisson/wiki/8.-distributed-locks-and-synchronizers#81-lock


附:

如果覺得本文對你有幫助的話,請不要吝嗇你的贊哦。

更多幹貨文章歡迎關注公眾號程式設計大道 

 

公眾號也有好多關於redis的技術文,歡迎關注

 

另外walking本人呢也在整理Redis相關的知識點,做成思維導圖的形式,不過還沒最終整理完,已經整理了好幾天啦,關注公眾號,整理好了會通過公眾號推送給大家~

 

 

 

 

相關文章