溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點(diǎn)擊 登錄注冊 即表示同意《億速云用戶服務(wù)條款》

Redis專題(3):鎖的基本概念到Redis分布式鎖實(shí)現(xiàn)

發(fā)布時(shí)間:2020-08-01 21:02:39 來源:網(wǎng)絡(luò) 閱讀:1268 作者:宜信技術(shù) 欄目:數(shù)據(jù)庫

拓展閱讀:Redis閑談(1):構(gòu)建知識(shí)圖譜

Redis專題(2):Redis數(shù)據(jù)結(jié)構(gòu)底層探秘

近來,分布式的問題被廣泛提及,比如分布式事務(wù)、分布式框架、ZooKeeper、SpringCloud等等。本文先回顧鎖的概念,再介紹分布式鎖,以及如何用Redis來實(shí)現(xiàn)分布式鎖。

一、鎖的基本了解

首先,回顧一下我們工作學(xué)習(xí)中的鎖的概念。

為什么要先講鎖再講分布式鎖呢?

我們都清楚,鎖的作用是要解決多線程對(duì)共享資源的訪問而產(chǎn)生的線程安全問題,而在平時(shí)生活中用到鎖的情況其實(shí)并不多,可能有些朋友對(duì)鎖的概念和一些基本的使用不是很清楚,所以我們先看鎖,再深入介紹分布式鎖。

Redis專題(3):鎖的基本概念到Redis分布式鎖實(shí)現(xiàn)

通過一個(gè)賣票的小案例來看,比如大家去搶dota2 ti9門票,如果不加鎖的話會(huì)出現(xiàn)什么問題?此時(shí)代碼如下:

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個(gè)線程進(jìn)行搶票,按理說應(yīng)該有兩個(gè)人搶不到票
        for(int i=0;i<10;i++){
            new Thread(() -> ticket.reduce(1),"用戶" + (i + 1)).start();
        }
        Thread.sleep(1000L);
    }

}

代碼分析:這里有8張ti9門票,設(shè)置了10個(gè)線程(也就是模擬10個(gè)人)去并發(fā)搶票,如果搶成功了顯示成功,搶失敗的話顯示失敗。按理說應(yīng)該有8個(gè)人搶成功了,2個(gè)人搶失敗,下面來看運(yùn)行結(jié)果:

Redis專題(3):鎖的基本概念到Redis分布式鎖實(shí)現(xiàn)

我們發(fā)現(xiàn)運(yùn)行結(jié)果和預(yù)期的情況不一致,居然10個(gè)人都買到了票,也就是說出現(xiàn)了線程安全的問題,那么是什么原因?qū)е碌哪兀?/p>

原因就是多個(gè)線程之間產(chǎn)生了時(shí)間差。

如圖所示,只剩一張票了,但是兩個(gè)線程都讀到的票余量是1,也就是說線程B還沒有等到線程A改庫存就已經(jīng)搶票成功了。

Redis專題(3):鎖的基本概念到Redis分布式鎖實(shí)現(xiàn)

怎么解決呢?想必大家都知道,加個(gè)synchronized關(guān)鍵字就可以了,在一個(gè)線程進(jìn)行reduce方法的時(shí)候,其他線程則阻塞在等待隊(duì)列中,這樣就不會(huì)發(fā)生多個(gè)線程對(duì)共享變量的競爭問題。

舉個(gè)例子

比如我們?nèi)ソ∩矸拷∩?,如果好多人同時(shí)用一臺(tái)機(jī)器,同時(shí)在一臺(tái)跑步機(jī)上跑步,就會(huì)發(fā)生很大的問題,大家會(huì)打得不可開交。如果我們加一把鎖在健身房門口,只有拿到鎖的鑰匙的人才可以進(jìn)去鍛煉,其他人在門外等候,這樣就可以避免大家對(duì)健身器材的競爭。代碼如下:

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 + "張票");
        }
    }

運(yùn)行結(jié)果:

Redis專題(3):鎖的基本概念到Redis分布式鎖實(shí)現(xiàn)

果不其然,結(jié)果有兩個(gè)人沒有成功搶到票,看來我們的目地達(dá)成了。

二、鎖的性能優(yōu)化

2.1 縮短鎖的持有時(shí)間

事實(shí)上,按照我們對(duì)日常生活的理解,不可能整個(gè)健身房只有一個(gè)人在運(yùn)動(dòng)。所以我們只需要對(duì)某一臺(tái)機(jī)器加鎖就可以了,比如一個(gè)人在跑步,另一個(gè)人可以去做其他的運(yùn)動(dòng)。

對(duì)于票務(wù)系統(tǒng)來說,我們只需要對(duì)庫存的修改操作的代碼加鎖就可以了,別的代碼還是可以并行進(jìn)行,這樣會(huì)大大減少鎖的持有時(shí)間,代碼修改如下:

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("耗時(shí)" + (System.currentTimeMillis() - startTime) + "毫秒");
        }
    }

這樣做的目的是充分利用cpu的資源,提高代碼的執(zhí)行效率

這里我們對(duì)兩種方式的時(shí)間做個(gè)打?。?/p>

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("耗時(shí)" + (System.currentTimeMillis() - startTime) + "毫秒");
            }
            System.out.println(Thread.currentThread().getName() + "成功賣出"
            + num + "張,剩余" + ticketNum + "張票");
        }else {
            System.err.println(Thread.currentThread().getName() + "沒有賣出"
                    + num + "張,剩余" + ticketNum + "張票");
        }
    }

Redis專題(3):鎖的基本概念到Redis分布式鎖實(shí)現(xiàn)

Redis專題(3):鎖的基本概念到Redis分布式鎖實(shí)現(xiàn)

果然,只對(duì)部分代碼加鎖會(huì)大大提供代碼的執(zhí)行效率。

所以,在解決了線程安全的問題后,我們還要考慮到加鎖之后的代碼執(zhí)行效率問題

2.2 減少鎖的粒度

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

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("魔童哪吒的剩余票數(shù)為:" + babyTickets);
        //購買
        latch.await();
    }

    public synchronized void showSpiderTickets() throws InterruptedException{
        System.out.println("蜘蛛俠的剩余票數(shù)為:" + 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();
    }

}

執(zhí)行結(jié)果:

魔童哪吒的剩余票數(shù)為:20

我們發(fā)現(xiàn)買哪吒票的時(shí)候阻塞會(huì)影響蜘蛛俠票的購買,而實(shí)際上,這兩場電影之間是相互獨(dú)立的,所以我們需要減少鎖的粒度,將movie整個(gè)對(duì)象的鎖變?yōu)閮蓚€(gè)全局變量的鎖,修改代碼如下:

public void showBabyTickets() throws InterruptedException{
        synchronized (babyTickets) {
            System.out.println("魔童哪吒的剩余票數(shù)為:" + babyTickets);
            //購買
            latch.await();
        }
    }

    public void showSpiderTickets() throws InterruptedException{
        synchronized (spiderTickets) {
            System.out.println("蜘蛛俠的剩余票數(shù)為:" + spiderTickets);
            //購買
        }
    }

執(zhí)行結(jié)果:

魔童哪吒的剩余票數(shù)為:20
蜘蛛俠的剩余票數(shù)為:100

現(xiàn)在兩場電影的購票不會(huì)互相影響了,這就是第二個(gè)優(yōu)化鎖的方式:減少鎖的粒度。順便提一句,Java并發(fā)包里的ConcurrentHashMap就是把一把大鎖變成了16把小鎖,通過分段鎖的方式達(dá)到高效的并發(fā)安全。

2.3 鎖分離

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

三、鎖的種類

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

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

四、Redis分布式鎖

了解了鎖的基本概念和鎖的優(yōu)化后,重點(diǎn)介紹分布式鎖的概念。

Redis專題(3):鎖的基本概念到Redis分布式鎖實(shí)現(xiàn)

上圖所示是我們搭建的分布式環(huán)境,有三個(gè)購票項(xiàng)目,對(duì)應(yīng)一個(gè)庫存,每一個(gè)系統(tǒng)會(huì)有多個(gè)線程,和上文一樣,對(duì)庫存的修改操作加上鎖,能不能保證這6個(gè)線程的線程安全呢?

當(dāng)然是不能的,因?yàn)槊恳粋€(gè)購票系統(tǒng)都有各自的JVM進(jìn)程,互相獨(dú)立,所以加synchronized只能保證一個(gè)系統(tǒng)的線程安全,并不能保證分布式的線程安全。

所以需要對(duì)于三個(gè)系統(tǒng)都是公共的一個(gè)中間件來解決這個(gè)問題。

這里我們選擇Redis來作為分布式鎖,多個(gè)系統(tǒng)在Redis中set同一個(gè)key,只有key不存在的時(shí)候,才能設(shè)置成功,并且該key會(huì)對(duì)應(yīng)其中一個(gè)系統(tǒng)的唯一標(biāo)識(shí),當(dāng)該系統(tǒng)訪問資源結(jié)束后,將key刪除,則達(dá)到了釋放鎖的目的。

4.1 分布式鎖需要注意哪些點(diǎn)

1)互斥性

在任意時(shí)刻只有一個(gè)客戶端可以獲取鎖。

這個(gè)很容易理解,所有的系統(tǒng)中只能有一個(gè)系統(tǒng)持有鎖。

2)防死鎖

假如一個(gè)客戶端在持有鎖的時(shí)候崩潰了,沒有釋放鎖,那么別的客戶端無法獲得鎖,則會(huì)造成死鎖,所以要保證客戶端一定會(huì)釋放鎖。

Redis中我們可以設(shè)置鎖的過期時(shí)間來保證不會(huì)發(fā)生死鎖。

3)持鎖人解鎖

解鈴還須系鈴人,加鎖和解鎖必須是同一個(gè)客戶端,客戶端A的線程加的鎖必須是客戶端A的線程來解鎖,客戶端不能解開別的客戶端的鎖。

4)可重入

當(dāng)一個(gè)客戶端獲取對(duì)象鎖之后,這個(gè)客戶端可以再次獲取這個(gè)對(duì)象上的鎖。

4.2 Redis分布式鎖流程

Redis專題(3):鎖的基本概念到Redis分布式鎖實(shí)現(xiàn)

Redis分布式鎖的具體流程:

1)首先利用Redis緩存的性質(zhì)在Redis中設(shè)置一個(gè)key-value形式的鍵值對(duì),key就是鎖的名稱,然后客戶端的多個(gè)線程去競爭鎖,競爭成功的話將value設(shè)為客戶端的唯一標(biāo)識(shí)。

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

  • 設(shè)置鎖的有效時(shí)間 目的是防死鎖 (非常關(guān)鍵)

需要根據(jù)業(yè)務(wù)需要,不斷的壓力測試來決定有效期的長短。

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

所以這里的value就設(shè)置成唯一標(biāo)識(shí)(比如uuid)。

3)訪問共享資源

4)釋放鎖,釋放鎖有兩種方式,第一種是有效期結(jié)束后自動(dòng)釋放鎖,第二種是先根據(jù)唯一標(biāo)識(shí)判斷自己是否有釋放鎖的權(quán)限,如果標(biāo)識(shí)正確則釋放鎖

4.3 加鎖和解鎖

4.3.1 加鎖

1)setnx命令加鎖

set if not exists 我們會(huì)用到Redis的命令setnx,setnx的含義就是只有鎖不存在的情況下才會(huì)設(shè)置成功。

2)設(shè)置鎖的有效時(shí)間,防止死鎖 expire

加鎖需要兩步操作,思考一下會(huì)有什么問題嗎?

假如我們加鎖完之后客戶端突然掛了呢?那么這個(gè)鎖就會(huì)成為一個(gè)沒有有效期的鎖,接著就可能發(fā)生死鎖。雖然這種情況發(fā)生的概率很小,但是一旦出現(xiàn)問題會(huì)很嚴(yán)重,所以我們也要把這兩步合為一步。

幸運(yùn)的是,Redis3.0已經(jīng)把這兩個(gè)指令合在一起成為一個(gè)新的指令。

來看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 解鎖
  • 檢查是否自己持有鎖(判斷唯一標(biāo)識(shí));
  • 刪除鎖。

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

這就無法借助于Redis了,只能依靠Lua腳本來實(shí)現(xiàn)。

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

這就是一段判斷是否自己持有鎖并釋放鎖的Lua腳本。

為什么Lua腳本是原子性呢?因?yàn)長ua腳本是jedis用eval()函數(shù)執(zhí)行的,如果執(zhí)行則會(huì)全部執(zhí)行完成。

五、Redis分布式鎖代碼實(shí)現(xiàn)

public class RedisDistributedLock implements Lock {

    //上下文,保存當(dāng)前鎖的持有人id
    private ThreadLocal<String> lockContext = new ThreadLocal<String>();

    //默認(rèn)鎖的超時(shí)時(shí)間
    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(); //每一個(gè)鎖的持有人都分配一個(gè)唯一的id
        Thread t = Thread.currentThread();
        Jedis jedis = new Jedis("127.0.0.1",6379);
        //只有鎖不存在的時(shí)候加鎖并設(shè)置鎖的有效時(shí)間
        if("OK".equals(jedis.set("lock",id, "NX", "PX", unit.toMillis(time)))){
            //持有鎖的人的id  
            lockContext.set(id); ①
            //記錄當(dāng)前的線程
            setOwnerThread(t); ②
            return true;
        }else if(ownerThread == t){
            //因?yàn)殒i是可重入的,所以需要判斷當(dāng)前線程已經(jīng)持有鎖的情況
            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轉(zhuǎn)化成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;
    }
}
  • 用一個(gè)上下文全局變量來記錄持有鎖的人的uuid,解鎖的時(shí)候需要將該uuid作為參數(shù)傳入Lua腳本中,來判斷是否可以解鎖。
  • 要記錄當(dāng)前線程,來實(shí)現(xiàn)分布式鎖的重入性,如果是當(dāng)前線程持有鎖的話,也屬于加鎖成功。
  • 用eval函數(shù)來執(zhí)行Lua腳本,保證解鎖時(shí)的原子性。

六、分布式鎖的對(duì)比

6.1 基于數(shù)據(jù)庫的分布式鎖

1)實(shí)現(xiàn)方式

獲取鎖的時(shí)候插入一條數(shù)據(jù),解鎖時(shí)刪除數(shù)據(jù)。

2)缺點(diǎn)

  • 數(shù)據(jù)庫如果掛掉會(huì)導(dǎo)致業(yè)務(wù)系統(tǒng)不可用。
  • 無法設(shè)置過期時(shí)間,會(huì)造成死鎖。

6.2 基于zookeeper的分布式鎖

1)實(shí)現(xiàn)方式

加鎖時(shí)在指定節(jié)點(diǎn)的目錄下創(chuàng)建一個(gè)新節(jié)點(diǎn),釋放鎖的時(shí)候刪除這個(gè)臨時(shí)節(jié)點(diǎn)。因?yàn)橛行奶鴻z測的存在,所以不會(huì)發(fā)生死鎖,更加安全。

2)缺點(diǎn)

性能一般,沒有Redis高效。

所以:

  • 從性能角度:
    Redis > zookeeper > 數(shù)據(jù)庫
  • 從可靠性(安全)性角度:
    zookeeper > Redis > 數(shù)據(jù)庫

七、總結(jié)

本文從鎖的基本概念出發(fā),提出多線程訪問共享資源會(huì)出現(xiàn)的線程安全問題,然后通過加鎖的方式去解決線程安全的問題,這個(gè)方法會(huì)性能會(huì)下降,需要通過:縮短鎖的持有時(shí)間、減小鎖的粒度、鎖分離三種方式去優(yōu)化鎖。

之后介紹了分布式鎖的4個(gè)特點(diǎn):

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

然后用Redis實(shí)現(xiàn)了分布式鎖,加鎖的時(shí)候用到了Redis的命令去加鎖,解鎖的時(shí)候則借助了Lua腳本來保證原子性。

最后對(duì)比了三種分布式鎖的優(yōu)缺點(diǎn)和使用場景。

希望大家對(duì)分布式鎖有新的理解,也希望大家在考慮解決問題的同時(shí)要多想想性能的問題。

作者:楊亨

向AI問一下細(xì)節(jié)

免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場,如果涉及侵權(quán)請(qǐng)聯(lián)系站長郵箱:is@yisu.com進(jìn)行舉報(bào),并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。

AI