Redis專題(3):鎖的基本概念到Redis分散式鎖實現

宜信技術學院發表於2019-09-25

擴充閱讀: Redis閒談(1):構建知識圖譜

Redis專題(2):Redis資料結構底層探秘

近來,分散式的問題被廣泛提及,比如分散式事務、分散式框架、ZooKeeper、SpringCloud等等。本文先回顧鎖的概念,再介紹分散式鎖,以及如何用Redis來實現分散式鎖。

一、鎖的基本瞭解

首先,回顧一下我們工作學習中的鎖的概念。

為什麼要先講鎖再講分散式鎖呢?

我們都清楚,鎖的作用是要解決多執行緒對共享資源的訪問而產生的執行緒安全問題,而在平時生活中用到鎖的情況其實並不多,可能有些朋友對鎖的概念和一些基本的使用不是很清楚,所以我們先看鎖,再深入介紹分散式鎖。

透過一個賣票的小案例來看,比如大家去搶dota2 ti9門票,如果不加鎖的話會出現什麼問題?此時程式碼如下:

package Thread;
import java.util.concurrent.TimeUnit;
public class Ticket {
    /**
     * 初始庫存量
     * */
    Integer ticketNum = 8;
    public void reduce(int num){
        //判斷庫存是否夠用
        if((ticketNum - num) >= 0){
            try {
                TimeUnit.MILLISECONDS.sleep(200);
            }catch (InterruptedException e){
                e.printStackTrace();
            }
            ticketNum -= num;
            System.out.println(Thread.currentThread().getName() + "成功賣出"
            + num + "張,剩餘" + ticketNum + "張票");
        }else {
            System.err.println(Thread.currentThread().getName() + "沒有賣出"
                    + num + "張,剩餘" + ticketNum + "張票");
        }
    }
    public static void main(String[] args) throws InterruptedException{
        Ticket ticket = new Ticket();
        //開啟10個執行緒進行搶票,按理說應該有兩個人搶不到票
        for(int i=0;i<10;i++){
            new Thread(() -> ticket.reduce(1),"使用者" + (i + 1)).start();
        }
        Thread.sleep(1000L);
    }
}

程式碼分析:這裡有8張ti9門票,設定了10個執行緒(也就是模擬10個人)去併發搶票,如果搶成功了顯示成功,搶失敗的話顯示失敗。按理說應該有8個人搶成功了,2個人搶失敗,下面來看執行結果:

我們發現執行結果和預期的情況不一致,居然10個人都買到了票,也就是說出現了執行緒安全的問題,那麼是什麼原因導致的呢?

原因就是多個執行緒之間產生了 時間差

如圖所示,只剩一張票了,但是兩個執行緒都讀到的票餘量是1,也就是說執行緒B還沒有等到執行緒A改庫存就已經搶票成功了。

怎麼解決呢?想必大家都知道,加個 synchronized關鍵字就可以了,在一個執行緒進行reduce方法的時候,其他執行緒則阻塞在等待佇列中,這樣就不會發生多個執行緒對共享變數的競爭問題。

舉個例子

比如我們去健身房健身,如果好多人同時用一臺機器,同時在一臺跑步機上跑步,就會發生很大的問題,大家會打得不可開交。如果我們加一把鎖在健身房門口,只有拿到鎖的鑰匙的人才可以進去鍛鍊,其他人在門外等候,這樣就可以避免大家對健身器材的競爭。程式碼如下:

public  synchronized void reduce(int num){
        //判斷庫存是否夠用
        if((ticketNum - num) >= 0){
            try {
                TimeUnit.MILLISECONDS.sleep(200);
            }catch (InterruptedException e){
                e.printStackTrace();
            }
            ticketNum -= num;
            System.out.println(Thread.currentThread().getName() + "成功賣出"
            + num + "張,剩餘" + ticketNum + "張票");
        }else {
            System.err.println(Thread.currentThread().getName() + "沒有賣出"
                    + num + "張,剩餘" + ticketNum + "張票");
        }
    }

執行結果:

果不其然,結果有兩個人沒有成功搶到票,看來我們的目地達成了。

二、鎖的效能最佳化

2.1 縮短鎖的持有時間

事實上,按照我們對日常生活的理解,不可能整個健身房只有一個人在運動。所以我們只需要對某一臺機器加鎖就可以了,比如一個人在跑步,另一個人可以去做其他的運動。

對於票務系統來說,我們只需要對庫存的修改操作的程式碼加鎖就可以了,別的程式碼還是可以並行進行,這樣會大大減少鎖的持有時間,程式碼修改如下:

public void reduceByLock(int num){
        boolean flag = false;
        synchronized (ticketNum){
            if((ticketNum - num) >= 0){
                ticketNum -= num;
                flag = true;
            }
        }
        if(flag){
            System.out.println(Thread.currentThread().getName() + "成功賣出"
                        + num + "張,剩餘" + ticketNum + "張票");
        }
        else {
            System.err.println(Thread.currentThread().getName() + "沒有賣出"
                        + num + "張,剩餘" + ticketNum + "張票");
        }
        if(ticketNum == 0){
            System.out.println("耗時" + (System.currentTimeMillis() - startTime) + "毫秒");
        }
    }

這樣做的目的是 充分利用cpu的資源,提高程式碼的執行效率

這裡我們對兩種方式的時間做個列印:

public synchronized void reduce(int num){
        //判斷庫存是否夠用
        if((ticketNum - num) >= 0){
            try {
                TimeUnit.MILLISECONDS.sleep(200);
            }catch (InterruptedException e){
                e.printStackTrace();
            }
            ticketNum -= num;
            if(ticketNum == 0){
                System.out.println("耗時" + (System.currentTimeMillis() - startTime) + "毫秒");
            }
            System.out.println(Thread.currentThread().getName() + "成功賣出"
            + num + "張,剩餘" + ticketNum + "張票");
        }else {
            System.err.println(Thread.currentThread().getName() + "沒有賣出"
                    + num + "張,剩餘" + ticketNum + "張票");
        }
    }

果然,只對部分程式碼加鎖會大大提供程式碼的執行效率。

所以,在解決了執行緒安全的問題後,我們還要 考慮到加鎖之後的程式碼執行效率問題

2.2 減少鎖的粒度

舉個例子,有兩場電影,分別是最近剛上映的魔童哪吒和蜘蛛俠,我們模擬一個支付購買的過程,讓方法等待,加了一個CountDownLatch的await方法,執行結果如下:

package Thread;
import java.util.concurrent.CountDownLatch;
public class Movie {
    private final CountDownLatch latch =  new CountDownLatch(1);
    //魔童哪吒
    private Integer babyTickets = 20;
    //蜘蛛俠
    private Integer spiderTickets = 100;
    public synchronized void showBabyTickets() throws InterruptedException{
        System.out.println("魔童哪吒的剩餘票數為:" + babyTickets);
        //購買
        latch.await();
    }
    public synchronized void showSpiderTickets() throws InterruptedException{
        System.out.println("蜘蛛俠的剩餘票數為:" + spiderTickets);
        //購買
    }
    public static void main(String[] args) {
        Movie movie = new Movie();
        new Thread(() -> {
            try {
                movie.showBabyTickets();
            }catch (InterruptedException e){
                e.printStackTrace();
            }
        },"使用者A").start();
        new Thread(() -> {
            try {
                movie.showSpiderTickets();
            }catch (InterruptedException e){
                e.printStackTrace();
            }
        },"使用者B").start();
    }
}

執行結果:

魔童哪吒的剩餘票數為:20

我們發現買哪吒票的時候阻塞會影響蜘蛛俠票的購買,而實際上,這兩場電影之間是相互獨立的,所以我們需要 減少鎖的粒度,將movie整個物件的鎖變為兩個全域性變數的鎖,修改程式碼如下:

public void showBabyTickets() throws InterruptedException{
        synchronized (babyTickets) {
            System.out.println("魔童哪吒的剩餘票數為:" + babyTickets);
            //購買
            latch.await();
        }
    }
    public void showSpiderTickets() throws InterruptedException{
        synchronized (spiderTickets) {
            System.out.println("蜘蛛俠的剩餘票數為:" + spiderTickets);
            //購買
        }
    }

執行結果:

魔童哪吒的剩餘票數為:20
蜘蛛俠的剩餘票數為:100

現在兩場電影的購票不會互相影響了,這就是第二個最佳化鎖的方式: 減少鎖的粒度。順便提一句,Java併發包裡的ConcurrentHashMap就是把一把大鎖變成了16把小鎖,透過分段鎖的方式達到高效的併發安全。

2.3 鎖分離

鎖分離就是常說的讀寫分離,我們把鎖分成讀鎖和寫鎖,讀的鎖不需要阻塞,而寫的鎖要考慮併發問題。

三、鎖的種類

  • 公平鎖: ReentrantLock
  • 非公平鎖: Synchronized、ReentrantLock、cas
  • 悲觀鎖: Synchronized
  • 樂觀鎖:cas
  • 獨享鎖:Synchronized、ReentrantLock
  • 共享鎖:Semaphore

這裡就不一一講述每一種鎖的概念了,大家可以自己學習,鎖還可以按照偏向鎖、輕量級鎖、重量級鎖來分類。

四、Redis分散式鎖

瞭解了鎖的基本概念和鎖的最佳化後,重點介紹分散式鎖的概念。

上圖所示是我們搭建的分散式環境,有三個購票專案,對應一個庫存,每一個系統會有多個執行緒,和上文一樣,對庫存的修改操作加上鎖,能不能保證這6個執行緒的執行緒安全呢?

當然是不能的,因為每一個購票系統都有各自的JVM程式,互相獨立,所以加synchronized只能保證一個系統的執行緒安全, 並不能保證分散式的執行緒安全。

所以需要對於三個系統都是 公共的一箇中介軟體來解決這個問題。

這裡我們選擇Redis來作為分散式鎖,多個系統在Redis中set同一個key,只有key不存在的時候,才能設定成功,並且該key會對應其中一個系統的唯一標識,當該系統訪問資源結束後,將key刪除,則達到了釋放鎖的目的。

4.1 分散式鎖需要注意哪些點

1)互斥性

在任意時刻只有一個客戶端可以獲取鎖。

這個很容易理解,所有的系統中只能有一個系統持有鎖。

2)防死鎖

假如一個客戶端在持有鎖的時候崩潰了,沒有釋放鎖,那麼別的客戶端無法獲得鎖,則會造成死鎖,所以要保證客戶端一定會釋放鎖。

Redis中我們可以設定鎖的過期時間來保證不會發生死鎖。

3)持鎖人解鎖

解鈴還須繫鈴人,加鎖和解鎖必須是同一個客戶端,客戶端A的執行緒加的鎖必須是客戶端A的執行緒來解鎖,客戶端不能解開別的客戶端的鎖。

4)可重入

當一個客戶端獲取物件鎖之後,這個客戶端可以再次獲取這個物件上的鎖。

4.2 Redis分散式鎖流程

Redis分散式鎖的具體流程:

1)首先利用Redis快取的性質在Redis中設定一個key-value形式的鍵值對,key就是鎖的名稱,然後客戶端的多個執行緒去競爭鎖,競爭成功的話將value設為客戶端的唯一標識。

2)競爭到鎖的客戶端要做兩件事:

  • 設定鎖的有效時間 目的是防死鎖  (非常關鍵)

需要根據業務需要,不斷的壓力測試來決定有效期的長短。

  • 分配客戶端的唯一標識, 目的是保證持鎖人解鎖(非常重要)

所以這裡的value就設定成唯一標識(比如uuid)。

3)訪問共享資源

4)釋放鎖,釋放鎖有兩種方式,第一種是 有效期結束後自動釋放鎖,第二種是先 根據唯一標識判斷自己是否有釋放鎖的許可權,如果標識正確則釋放鎖

4.3 加鎖和解鎖

4.3.1 加鎖

1)setnx命令加鎖

set if not exists 我們會用到Redis的命令setnx,setnx的含義就是隻有鎖不存在的情況下才會設定成功。

2)設定鎖的有效時間,防止死鎖 expire

加鎖需要兩步操作,思考一下會有什麼問題嗎?

假如我們加鎖完之後客戶端突然掛了呢?那麼這個鎖就會成為一個沒有有效期的鎖,接著就可能發生死鎖。雖然這種情況發生的機率很小,但是一旦出現問題會很嚴重,所以我們也要把這兩步合為一步。

幸運的是,Redis3.0已經把這兩個指令合在一起成為一個新的指令。

來看jedis的官方文件中的原始碼:

    public String set(String key, String value, String nxxx, String expx, long time) {
        this.checkIsInMultiOrPipeline();
        this.client.set(key, value, nxxx, expx, time);
        return this.client.getStatusCodeReply();
    }

這就是我們想要的!

4.3.2 解鎖

  • 檢查是否自己持有鎖(判斷唯一標識);
  • 刪除鎖。

解鎖也是兩步,同樣也要保證解鎖的原子性,把兩步合為一步。

這就無法藉助於Redis了,只能依靠 Lua指令碼來實現。

if Redis.call("get",key==argv[1])then
    return Redis.call("del",key)
else return 0 end

這就是一段判斷是否自己持有鎖並釋放鎖的Lua指令碼。

為什麼Lua指令碼是原子性呢?因為Lua指令碼是jedis用eval()函式執行的,如果執行則會全部執行完成。

五、Redis分散式鎖程式碼實現

public class RedisDistributedLock implements Lock {
    //上下文,儲存當前鎖的持有人id
    private ThreadLocal<String> lockContext = new ThreadLocal<String>();
    //預設鎖的超時時間
    private long time = 100;
    //可重入性
    private Thread ownerThread;
    public RedisDistributedLock() {
    }
    public void lock() {
        while (!tryLock()){
            try {
                Thread.sleep(100);
            }catch (InterruptedException e){
                e.printStackTrace();
            }
        }
    }
    public boolean tryLock() {
        return tryLock(time,TimeUnit.MILLISECONDS);
    }
    public boolean tryLock(long time, TimeUnit unit){
        String id = UUID.randomUUID().toString(); //每一個鎖的持有人都分配一個唯一的id
        Thread t = Thread.currentThread();
        Jedis jedis = new Jedis("127.0.0.1",6379);
        //只有鎖不存在的時候加鎖並設定鎖的有效時間
        if("OK".equals(jedis.set("lock",id, "NX", "PX", unit.toMillis(time)))){
            //持有鎖的人的id  
            lockContext.set(id); ①
            //記錄當前的執行緒
            setOwnerThread(t); ②
            return true;
        }else if(ownerThread == t){
            //因為鎖是可重入的,所以需要判斷當前執行緒已經持有鎖的情況
            return true;
        }else {
            return false;
        }
    }
    private void setOwnerThread(Thread t){
        this.ownerThread = t;
    }
    public void unlock() {
        String script = null;
        try{
            Jedis jedis = new Jedis("127.0.0.1",6379);
            script = inputStream2String(getClass().getResourceAsStream("/Redis.Lua"));
            if(lockContext.get()==null){
                //沒有人持有鎖
                return;
            }
            //刪除鎖  ③
            jedis.eval(script, Arrays.asList("lock"), Arrays.asList(lockContext.get()));
            lockContext.remove();
        }catch (Exception e){
            e.printStackTrace();
        }
    }
    /**
     * 將InputStream轉化成String
     * @param is
     * @return
     * @throws IOException
     */
    public String inputStream2String(InputStream is) throws IOException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        int i = -1;
        while ((i = is.read()) != -1) {
            baos.write(i);
        }
        return baos.toString();
    }
    public void lockInterruptibly() throws InterruptedException {
    }
    public Condition newCondition() {
        return null;
    }
}
  • 用一個上下文全域性變數來記錄持有鎖的人的uuid,解鎖的時候需要將該uuid作為引數傳入Lua指令碼中,來判斷是否可以解鎖。
  • 要記錄當前執行緒,來實現分散式鎖的重入性,如果是當前執行緒持有鎖的話,也屬於加鎖成功。
  • 用eval函式來執行Lua指令碼,保證解鎖時的原子性。

六、分散式鎖的對比

6.1 基於資料庫的分散式鎖

1)實現方式

獲取鎖的時候插入一條資料,解鎖時刪除資料。

2)缺點

  • 資料庫如果掛掉會導致業務系統不可用。
  • 無法設定過期時間,會造成死鎖。

6.2 基於zookeeper的分散式鎖

1)實現方式

加鎖時在指定節點的目錄下建立一個新節點,釋放鎖的時候刪除這個臨時節點。 因為有心跳檢測的存在,所以不會發生死鎖,更加安全

2)缺點

效能一般,沒有Redis高效。

所以:

  • 從效能角度:   Redis > zookeeper > 資料庫
  • 從可靠性(安全)性角度:   zookeeper > Redis > 資料庫

七、總結

本文從鎖的基本概念出發,提出多執行緒訪問共享資源會出現的執行緒安全問題,然後透過加鎖的方式去解決執行緒安全的問題,這個方法會效能會下降,需要透過:縮短鎖的持有時間、減小鎖的粒度、鎖分離三種方式去最佳化鎖。

之後介紹了分散式鎖的4個特點:

  • 互斥性
  • 防死鎖
  • 加鎖人解鎖
  • 可重入性

然後用Redis實現了分散式鎖,加鎖的時候用到了Redis的命令去加鎖,解鎖的時候則藉助了Lua指令碼來保證原子性。

最後對比了三種分散式鎖的優缺點和使用場景。

希望大家對分散式鎖有新的理解,也希望大家在考慮解決問題的同時要多想想效能的問題。

作者:楊亨

來源:宜信技術學院

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69918724/viewspace-2658161/,如需轉載,請註明出處,否則將追究法律責任。

相關文章