談?wù)劮植际芥i的設(shè)計(jì)與實(shí)現(xiàn)

定義

正常的單機(jī)狀態(tài)的,共享資源都是在通過一個(gè)數(shù)據(jù)庫下的,可以在單機(jī)中進(jìn)行加鎖,保證共享數(shù)據(jù)的線程安全,分布式環(huán)境下,因?yàn)椴皇窃谕惶摂M機(jī)進(jìn)程的,全局的某些唯一資源需要進(jìn)行鎖定,這時(shí)候就需要分布式鎖。

現(xiàn)如今都是分布式系統(tǒng),需要部署多臺服務(wù)器,進(jìn)行負(fù)載均衡。如圖:


image

上圖可以看到,變量A存在JVM1、JVM2、JVM3三個(gè)JVM內(nèi)存中(這個(gè)變量A主要體現(xiàn)是在一個(gè)類中的一個(gè)成員變量,是一個(gè)有狀態(tài)的對象,例如:UserController控制器中的一個(gè)整形類型的成員變量),如果不加任何控制的話,變量A同時(shí)都會在JVM分配一塊內(nèi)存,三個(gè)請求發(fā)過來同時(shí)對這個(gè)變量操作,顯然結(jié)果是不對的!即使不是同時(shí)發(fā)過來,三個(gè)請求分別操作三個(gè)不同JVM內(nèi)存區(qū)域的數(shù)據(jù),變量A之間不存在共享,也不具有可見性,處理的結(jié)果也是不對的!

如果我們業(yè)務(wù)中確實(shí)存在這個(gè)場景的話,我們就需要一種方法解決這個(gè)問題!
為了保證一個(gè)方法或?qū)傩栽诟卟l(fā)情況下的同一時(shí)間只能被同一個(gè)線程執(zhí)行,在傳統(tǒng)單體應(yīng)用單機(jī)部署的情況下,可以使用Java并發(fā)處理相關(guān)的API(如ReentrantLock或Synchronized)進(jìn)行互斥控制。在單機(jī)環(huán)境中,Java中提供了很多并發(fā)處理相關(guān)的API。但是,隨著業(yè)務(wù)發(fā)展的需要,原單體單機(jī)部署的系統(tǒng)被演化成分布式集群系統(tǒng)后,由于分布式系統(tǒng)多線程、多進(jìn)程并且分布在不同機(jī)器上,這將使原單機(jī)部署情況下的并發(fā)控制鎖策略失效,單純的Java API并不能提供分布式鎖的能力。

為了解決這個(gè)問題就需要一種跨JVM的互斥機(jī)制來控制共享資源的訪問,這就是分布式鎖要解決的問題!

滿足條件

1、在分布式系統(tǒng)環(huán)境下,一個(gè)方法在同一時(shí)間只能被一個(gè)機(jī)器的一個(gè)線程執(zhí)行;
2、高可用的獲取鎖與釋放鎖;
3、高性能的獲取鎖與釋放鎖;
4、具備可重入特性;
5、具備鎖失效機(jī)制,防止死鎖;
6、具備非阻塞鎖特性,即沒有獲取到鎖將直接返回獲取鎖失敗。

具體例子

  1. 交易訂單鎖定

    需要處理防止重復(fù)下單。

    解決業(yè)務(wù)層面的冪等問題

  2. MQ消息消費(fèi)的冪等性

    發(fā)送的消息重復(fù)。

    消息消費(fèi)端去重。

    比如手機(jī)提現(xiàn),不能重復(fù)提現(xiàn)。

  3. 在用戶對商品下單后,訂單狀態(tài)為待支付,在某一時(shí)刻用戶正在對該訂單做支付操作,商家正在進(jìn)行改價(jià)操作??? 這時(shí)候,該狀態(tài)需要做串行處理,避免出現(xiàn)數(shù)據(jù)錯(cuò)亂。

解決方式

1.基于數(shù)據(jù)庫實(shí)現(xiàn)分布式鎖;

創(chuàng)建一個(gè)表:

DROP TABLE IF EXISTS `method_lock`;
CREATE TABLE `method_lock` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵',
  `method_name` varchar(64) NOT NULL COMMENT '鎖定的方法名',
  `desc` varchar(255) NOT NULL COMMENT '備注信息',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='鎖定中的方法';

image

(2)想要執(zhí)行某個(gè)方法,就使用這個(gè)方法名向表中插入數(shù)據(jù)

    INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '測試的methodName');

因?yàn)槲覀儗ethod_name做了唯一性約束,這里如果有多個(gè)請求同時(shí)提交到數(shù)據(jù)庫的話,數(shù)據(jù)庫會保證只有一個(gè)操作可以成功,那么我們就可以認(rèn)為操作成功的那個(gè)線程獲得了該方法的鎖,可以執(zhí)行方法體內(nèi)容。

(3)成功插入則獲取鎖,執(zhí)行完成后刪除對應(yīng)的行數(shù)據(jù)釋放鎖:

    delete from method_lock where method_name ='methodName';

注意:這只是使用基于數(shù)據(jù)庫的一種方法,使用數(shù)據(jù)庫實(shí)現(xiàn)分布式鎖還有很多其他的玩法!

使用基于數(shù)據(jù)庫的這種實(shí)現(xiàn)方式很簡單,但是對于分布式鎖應(yīng)該具備的條件來說,它有一些問題需要解決及優(yōu)化:

1、因?yàn)槭腔跀?shù)據(jù)庫實(shí)現(xiàn)的,數(shù)據(jù)庫的可用性和性能將直接影響分布式鎖的可用性及性能,所以,數(shù)據(jù)庫需要雙機(jī)部署、數(shù)據(jù)同步、主備切換;

2、不具備可重入的特性,因?yàn)橥粋€(gè)線程在釋放鎖之前,行數(shù)據(jù)一直存在,無法再次成功插入數(shù)據(jù),所以,需要在表中新增一列,用于記錄當(dāng)前獲取到鎖的機(jī)器和線程信息,在再次獲取鎖的時(shí)候,先查詢表中機(jī)器和線程信息是否和當(dāng)前機(jī)器和線程相同,若相同則直接獲取鎖;

3、沒有鎖失效機(jī)制,因?yàn)橛锌赡艹霈F(xiàn)成功插入數(shù)據(jù)后,服務(wù)器宕機(jī)了,對應(yīng)的數(shù)據(jù)沒有被刪除,當(dāng)服務(wù)恢復(fù)后一直獲取不到鎖,所以,需要在表中新增一列,用于記錄失效時(shí)間,并且需要有定時(shí)任務(wù)清除這些失效的數(shù)據(jù);

4、不具備阻塞鎖特性,獲取不到鎖直接返回失敗,所以需要優(yōu)化獲取邏輯,循環(huán)多次去獲取。

2.基于redis做分布式鎖

為什么?

redis本身是單線程,唯一線程串行處理。

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

Redis Setnx命令,在指定的key不存在時(shí),為key設(shè)置指定的值.多個(gè)線程并發(fā)的請求去設(shè)置時(shí),只有一個(gè)可以設(shè)置成功。其他的會返回失敗。一般設(shè)置五秒鐘。

//設(shè)置成功,返回1,設(shè)置失敗,返回 0
Setnx KEY_NAME VALUE Expire Time 

分析存在問題:

  1. 單點(diǎn)問題

    單機(jī)模式,設(shè)置T1 T2兩個(gè)線程.如果T1剛設(shè)置成功,單機(jī)掛了,重啟,請求丟了,T2去請求再去拿鎖,會獲取不到,這時(shí)候會獲取不到key。(因?yàn)榉植际芥i一般不考慮做持久化,所以這里不考慮持久化。)

    主從模式,主從數(shù)據(jù)異步,會存在鎖失效的問題,主服務(wù)器還未同步到從服務(wù)器,這時(shí)候主掛了,從服務(wù)器獲取不到鎖。

  2. 鎖時(shí)間不可以控制,無法續(xù)租期

Redis本身建議:使用RedLock算法來保證,但是問題是需要至少三個(gè)Redis主從實(shí)例來完成,維護(hù)成本很高。這個(gè)等同于自己簡單實(shí)現(xiàn)的一致性協(xié)議,細(xì)節(jié)繁瑣,且容易出錯(cuò)。

是否能使用

業(yè)務(wù)場景來規(guī)定,在設(shè)計(jì)交易時(shí),只能發(fā)一次交易請求,這時(shí)候不適合。如果是MQ消息消費(fèi)場景,依次獲取不到,可以在發(fā)送一次消息保證能被消費(fèi)。

CAP問題

分布式鎖,主要選擇滿足C P模型,而redis實(shí)現(xiàn)的主要滿足AP模型。不太ok。

代碼實(shí)現(xiàn)

使用命令介紹:

(1)SETNX

    SETNX key val:當(dāng)且僅當(dāng)key不存在時(shí),set一個(gè)key為val的字符串,返回1;若key存在,則什么都不做,返回0。

(2)expire

    expire key timeout:為key設(shè)置一個(gè)超時(shí)時(shí)間,單位為second,超過這個(gè)時(shí)間鎖會自動釋放,避免死鎖。

1
(3)delete

    delete key:刪除key

實(shí)現(xiàn)思想:

(1)獲取鎖的時(shí)候,使用setnx加鎖,并使用expire命令為鎖添加一個(gè)超時(shí)時(shí)間,超過該時(shí)間則自動釋放鎖,鎖的value值為一個(gè)隨機(jī)生成的UUID,通過此在釋放鎖的時(shí)候進(jìn)行判斷。

(2)獲取鎖的時(shí)候還設(shè)置一個(gè)獲取的超時(shí)時(shí)間,若超過這個(gè)時(shí)間則放棄獲取鎖。

(3)釋放鎖的時(shí)候,通過UUID判斷是不是該鎖,若是該鎖,則執(zhí)行delete進(jìn)行鎖釋放。

/**
 * 分布式鎖的簡單實(shí)現(xiàn)代碼
 * Created by liuyang on 2017/4/20.
 */
public class DistributedLock {

    private final JedisPool jedisPool;

    public DistributedLock(JedisPool jedisPool) {
        this.jedisPool = jedisPool;
    }

    /**
     * 加鎖
     * @param lockName       鎖的key
     * @param acquireTimeout 獲取超時(shí)時(shí)間
     * @param timeout        鎖的超時(shí)時(shí)間
     * @return 鎖標(biāo)識
     */
    public String lockWithTimeout(String lockName, long acquireTimeout, long timeout) {
        Jedis conn = null;
        String retIdentifier = null;
        try {
            // 獲取連接
            conn = jedisPool.getResource();
            // 隨機(jī)生成一個(gè)value
            String identifier = UUID.randomUUID().toString();
            // 鎖名,即key值
            String lockKey = "lock:" + lockName;
            // 超時(shí)時(shí)間,上鎖后超過此時(shí)間則自動釋放鎖
            int lockExpire = (int) (timeout / 1000);

            // 獲取鎖的超時(shí)時(shí)間,超過這個(gè)時(shí)間則放棄獲取鎖
            long end = System.currentTimeMillis() + acquireTimeout;
            while (System.currentTimeMillis() < end) {
                if (conn.setnx(lockKey, identifier) == 1) {
                    conn.expire(lockKey, lockExpire);
                    // 返回value值,用于釋放鎖時(shí)間確認(rèn)
                    retIdentifier = identifier;
                    return retIdentifier;
                }
                // 返回-1代表key沒有設(shè)置超時(shí)時(shí)間,為key設(shè)置一個(gè)超時(shí)時(shí)間
                if (conn.ttl(lockKey) == -1) {
                    conn.expire(lockKey, lockExpire);
                }

                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        } catch (JedisException e) {
            e.printStackTrace();
        } finally {
            if (conn != null) {
                conn.close();
            }
        }
        return retIdentifier;
    }

    /**
     * 釋放鎖
     * @param lockName   鎖的key
     * @param identifier 釋放鎖的標(biāo)識
     * @return
     */
    public boolean releaseLock(String lockName, String identifier) {
        Jedis conn = null;
        String lockKey = "lock:" + lockName;
        boolean retFlag = false;
        try {
            conn = jedisPool.getResource();
            while (true) {
                // 監(jiān)視lock,準(zhǔn)備開始事務(wù)
                conn.watch(lockKey);
                // 通過前面返回的value值判斷是不是該鎖,若是該鎖,則刪除,釋放鎖
                if (identifier.equals(conn.get(lockKey))) {
                    Transaction transaction = conn.multi();
                    transaction.del(lockKey);
                    List<Object> results = transaction.exec();
                    if (results == null) {
                        continue;
                    }
                    retFlag = true;
                }
                conn.unwatch();
                break;
            }
        } catch (JedisException e) {
            e.printStackTrace();
        } finally {
            if (conn != null) {
                conn.close();
            }
        }
        return retFlag;
    }
}

測試剛才實(shí)現(xiàn)的分布式鎖

例子中使用50個(gè)線程模擬秒殺一個(gè)商品,使用–運(yùn)算符來實(shí)現(xiàn)商品減少,從結(jié)果有序性就可以看出是否為加鎖狀態(tài)。

模擬秒殺服務(wù),在其中配置了jedis線程池,在初始化的時(shí)候傳給分布式鎖,供其使用.

/**
 * Created by liuyang on 2017/4/20.
 */
public class Service {

    private static JedisPool pool = null;

    private DistributedLock lock = new DistributedLock(pool);

    int n = 500;

    static {
        JedisPoolConfig config = new JedisPoolConfig();
        // 設(shè)置最大連接數(shù)
        config.setMaxTotal(200);
        // 設(shè)置最大空閑數(shù)
        config.setMaxIdle(8);
        // 設(shè)置最大等待時(shí)間
        config.setMaxWaitMillis(1000 * 100);
        // 在borrow一個(gè)jedis實(shí)例時(shí),是否需要驗(yàn)證,若為true,則所有jedis實(shí)例均是可用的
        config.setTestOnBorrow(true);
        pool = new JedisPool(config, "127.0.0.1", 6379, 3000);
    }

    public void seckill() {
        // 返回鎖的value值,供釋放鎖時(shí)候進(jìn)行判斷
        String identifier = lock.lockWithTimeout("resource", 5000, 1000);
        System.out.println(Thread.currentThread().getName() + "獲得了鎖");
        System.out.println(--n);
        lock.releaseLock("resource", identifier);
    }
}

模擬線程進(jìn)行秒殺服務(wù)

public class ThreadA extends Thread {
    private Service service;

    public ThreadA(Service service) {
        this.service = service;
    }

    @Override
    public void run() {
        service.seckill();
    }
}
//這里推薦使用 countDownLatch
public class Test {
    public static void main(String[] args) {
        Service service = new Service();
        for (int i = 0; i < 50; i++) {
            ThreadA threadA = new ThreadA(service);
            threadA.start();
        }
    }
}

3.基于zookeeper

ZooKeeper是一個(gè)為分布式應(yīng)用提供一致性服務(wù)的開源組件,它內(nèi)部是一個(gè)分層的文件系統(tǒng)目錄樹結(jié)構(gòu),規(guī)定同一個(gè)目錄下只能有一個(gè)唯一文件名?;赯ooKeeper實(shí)現(xiàn)分布式鎖的步驟如下:

(1)創(chuàng)建一個(gè)目錄mylock;
(2)線程A想獲取鎖就在mylock目錄下創(chuàng)建臨時(shí)順序節(jié)點(diǎn);
(3)獲取mylock目錄下所有的子節(jié)點(diǎn),然后獲取比自己小的兄弟節(jié)點(diǎn),如果不存在,則說明當(dāng)前線程順序號最小,獲得鎖;
(4)線程B獲取所有節(jié)點(diǎn),判斷自己不是最小節(jié)點(diǎn),設(shè)置監(jiān)聽比自己次小的節(jié)點(diǎn);
(5)線程A處理完,刪除自己的節(jié)點(diǎn),線程B監(jiān)聽到變更事件,判斷自己是不是最小的節(jié)點(diǎn),如果是則獲得鎖。

這里推薦一個(gè)Apache的開源庫Curator,它是一個(gè)ZooKeeper客戶端,Curator提供的InterProcessMutex是分布式鎖的實(shí)現(xiàn),acquire方法用于獲取鎖,release方法用于釋放鎖。

優(yōu)點(diǎn):具備高可用、可重入、阻塞鎖特性,可解決失效死鎖問題。

缺點(diǎn):因?yàn)樾枰l繁的創(chuàng)建和刪除節(jié)點(diǎn),性能上不如Redis方式。

具體代碼可以看這篇文章:
https://blog.csdn.net/qiangcuo6087/article/details/79067136

自己設(shè)計(jì)一個(gè)分布式鎖

設(shè)計(jì)的目標(biāo)

  • 強(qiáng)一致性
  • 服務(wù)高可用、系統(tǒng)穩(wěn)健
  • 鎖自動續(xù)約及其自動釋放
  • 代碼高度抽象業(yè)務(wù)接入極簡
  • 可視化管理憑他、監(jiān)控及管理

對存儲模型進(jìn)行選型


image

N+1 代表部署奇數(shù)個(gè)

由于redis實(shí)現(xiàn)無法保證一致性,zookeeper對鎖實(shí)現(xiàn)使用創(chuàng)建臨時(shí)節(jié)點(diǎn)和watch機(jī)制,執(zhí)行效率,擴(kuò)展能力、社區(qū)活躍度等方面低于etcd,所以我們會選擇基于etcd實(shí)現(xiàn)。

etcd優(yōu)勢

  • 簡單KV(key Value)
  • 強(qiáng)一致性
  • 高可用
    • 無單點(diǎn)
  • 數(shù)據(jù)可靠性
    • 持久化

整體方案

分布式Client + etcd

Client TTL模式

Server TTL模式

image

拿鎖的時(shí)候,選擇key,ttl是超時(shí)時(shí)間,value可以忽略,uuid為該鎖的唯一憑證,后面對鎖的操作都是對uuid做操作。需要uuid才能做操作。etcd會保證只有一個(gè)線程能拿到鎖。

使用場景1.申請鎖

image
image

使用場景2.申請鎖,鎖已經(jīng)被占用

image

使用場景3.鎖的清理

image

業(yè)務(wù)接入

JDK7以上,建議9.
獲取鎖實(shí)例:

image

釋放鎖示例

image

兼容性考慮

image

ETCD恢復(fù)/版本

image

分布式鎖的特殊場景

特殊場景一:
分布式鎖只是在同一自然時(shí)間的互斥鎖,本省不解決冪等性問題。

接入業(yè)務(wù)需要完善從獲得鎖到釋放鎖中間的數(shù)據(jù)冪等邏輯。

特殊場景二: 鎖沒有按照日期續(xù)約

心跳續(xù)約沒有成功

馬上啟動GC,GCs時(shí)間太長

特殊場景三: etcd內(nèi)部協(xié)調(diào)發(fā)生問題

Leader節(jié)點(diǎn)掛了,選主中,

raft日志數(shù)據(jù)同步發(fā)生錯(cuò)誤或者不一致問題。

待續(xù)。。。

部分摘自:

https://blog.csdn.net/xlgen157387/article/details/79036337

https://blog.csdn.net/qiangcuo6087/article/details/79067136

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容