框架篇:分佈式鎖

前言

java有synchronize和Lock,mysql 修改類的sql也帶有鎖。鎖定數據狀態,讓數據狀態在並發場景,按我們預想邏輯進行狀態轉移,然而在分佈式,集群的情況下,怎麼去鎖定數據狀態呢

  • 數據庫的分佈式鎖方案
  • 基於redis實現分佈式鎖
  • 基於zookeeper實現分佈式鎖

關注公眾號,一起交流,微信搜一搜: 潛行前行

數據庫的分佈式鎖方案

數據庫分佈鎖的難點

  • 單點故障? 數據庫可以多搞個數據庫備份
  • 沒有失效時間? 每次加鎖時,插入一個期待的有效時間;A:定時任務,隔一段時間清理時間失效鎖。B:下次加鎖時則先判斷當前時間是否大於鎖的有效時間,以此判斷鎖是否失效
  • 不可重入? 在數據加鎖時加入一個冪等唯一值字段,下次獲取時,先判斷這個字段是否一致,一致則說明是當前操作重入操作

基於redis實現分佈式鎖

  • redis 是一個快速訪問的高性能服務,相比數據庫,在redis實現鎖比直接在數據庫的數據加鎖,性能好。同時也為數據庫減壓,減少事務執行因為鎖的問題阻塞
  • 引入jedis
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
</dependency>

setnx + expire

  • setnx + expire 存在死鎖的問題。setnx()方法作用就是SET IF NOT EXIST,expire()方法就是給鎖加一個過期時間。由於這是兩條Redis命令,不具有原子性
Long result = jedis.setnx(lockKey, requestId);
if (result == 1) {
    // 這裡程序突然崩潰,則無法設置過期時間,將發生死鎖
    jedis.expire(lockKey, expireTime);
}

lua腳本(正確方式)

  • lua腳本在Redis的執行過程是原子性,要麼成功,要麼失敗。
// setnx + expire 放在lua腳本執行
String script = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then" +
            " redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";  
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if(result.equals(1)){
    .... //加鎖成功的操作
}

set {key} {value} nx ex {second} (正確方式)

  • 這是Redis的SET指令擴展參數,具有原子性
String lockKey = "鎖的KEY值";//固定的
String requestId = "當次加鎖操作的唯一標識";
int  expireTime = 1000;//失效時間
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);

刪除redis分佈鎖

//-------- 錯誤方式 ------------
// 判斷加鎖與解鎖是不是同一個客戶端
if (requestId.equals(jedis.get(lockKey))) {
    // 若在此時,這把鎖突然不是這個客戶端的,則會誤解鎖
    jedis.del(lockKey);
}
//-------- 正確的方式 使用 lua ------------
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));

基於Redlock算法實現分佈式鎖

  • 以上redis分佈鎖的缺點就是它加鎖時只作用在一個Redis節點上,即使redis通過sentinel保證高可用,如果這個master節點由於某些原因發生了主從切換,那麼就會出現鎖丟失的情況。redis主從同步不能保證一致性,master會優先返回結果,在同步數據到slave
  • 例如:在redis的master節點上拿到了鎖 -> 這個加鎖的key還沒有同步到slave節點 -> master故障,發生故障轉移,slave節點升級為master節點 -> 導致鎖丟失
  • RedLock算法的實現步驟
    image.png

1: 獲取當前時間,以毫秒為單位

2: 按順序向5個master節點請求加鎖。客戶端設置網絡連接和響應超時時間,並且超時時間要小於鎖的失效時間。(假設鎖自動失效時間為10秒,則超時時間一般在5-50毫秒之間,我們就假設超時時間是50ms吧)。如果超時,跳過該master節點,儘快去嘗試下一個master節點

3: 加鎖後客戶端使用當前時間減去開始獲取鎖時間(即步驟1記錄的時間),得到獲取鎖使用的時間。當且僅當超過一半(N/2+1,這裡是5/2+1=3個節點)的Redis master節點都獲得鎖,並且獲取鎖使用的時間小於鎖失效時間時,鎖才算獲取成功。(如上圖:10s> 30ms+40ms+50ms+20ms+50ms)

4: 如果成功取到鎖,key的真正有效時間等於 鎖失效時間 減去 獲取鎖所使用的時間。

5: 如果獲取鎖失敗(沒有在至少N/2+1個master實例取到鎖,或者獲取鎖時間已經超過了鎖失效時間),客戶端要在所有的master節點上解鎖(即便有些master節點根本就沒有加鎖成功,也需要解鎖,以防止有些漏網之魚)

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
      <groupId>org.redisson</groupId>
      <artifactId>redisson</artifactId>
      <version>3.4.3</version>
</dependency>
  • 代碼示例
Config config = new Config().useSingleServer().setAddress("127.0.0.1:6380").setDatabase(0);
RedissonClient rLock1 = Redisson.create(config);
config = new Config().useSingleServer().setAddress("127.0.0.1:6381").setDatabase(0);
RedissonClient rLock2 = Redisson.create(config);
config = new Config().useSingleServer().setAddress("127.0.0.1:6382").setDatabase(0);
RedissonClient rLock3 = Redisson.create(config);
//初始化
String lockKey = "XXX";
RLock rLock1 = redissonRed1.getLock(lockKey);
RLock rLock2 = redissonRed2.getLock(lockKey);
RLock rLock3 = redissonRed2.getLock(lockKey);
RedissonRedLock rLock = new RedissonRedLock(rLock1,rLock2,rLock3);
//加鎖
rLock.lock();
//釋放
rLock.unlock();

基於 zookeeper 實現分佈式鎖

  • maven引入
<dependency>
   <groupId>org.apache.curator</groupId>
   <artifactId>curator-recipes</artifactId>
   <version>2.4.1</version>
</dependency>
  • Redlock算法往往需要多個redis集群才能實現,東西越多,就越容易出錯。但是如何實現一個高效高可用的分佈式鎖呢 ? zookeeper
  • zookeeper特點
    • 最終一致性:客戶端的操作狀態會在 zookeepr 集群保持一致
    • 可靠性:zookeeper 集群具有簡單、健壯、良好的性能
    • 原子性:操作只能成功或者失敗,沒有中間狀態
    • 時間順序性:如果消息 A 在消息 B 發佈,則 A 則排在 B 前面
  • zookeeper 臨時順序節點:臨時節點的生命周期和客戶端會話綁定。也就是說,如果客戶端會話失效,那麼這個節點就會自動被清除掉(可解決分佈式鎖的自動失效)。另外,在臨時節點下面不能創建子節點,集群zk環境下,同一個路徑的臨時節點只能成功創建一個
  • zookeeper 監視器:zookeeper創建一個節點時,會註冊一個該節點的監視器,當節點狀態發生改變時,watch會被觸發,zooKeeper將會向客戶端發送一條通知
  • zookeeper 分佈式鎖原理

創建臨時有序節點,每個線程均能創建節點成功,但是其序號不同,只有序號最小的可以擁有鎖,其它線程只需要監聽比自己序號小的節點狀態即可

1: 在指定的節點下創建一個鎖目錄lock

2: 線程X進來獲取鎖在lock目錄下,並創建臨時有序節點

3: 線程X獲取lock目錄下所有子節點,並獲取比自己小的兄弟節點,如果不存在比自己小的節點,說明當前線程序號最小,順利獲取鎖

4: 此時線程Y進來創建臨時節點並獲取兄弟節點,判斷自己是否為最小序號節點,發現不是,於是設置監聽(watch)比自己小的節點(這裡是為了發生上面說的羊群效應)

5: 線程X執行完邏輯,刪除自己的節點,線程Y監聽到節點有變化,進一步判斷自己是已經是最小節點,順利獲取鎖

  • 代碼實例
//初始化
CuratorFramework curatorFramework= CuratorFrameworkFactory.newClient("zookeeper1.tq.master.cn:2181",new ExponentialBackoffRetry(1000,3));
curatorFramework.start();
//創建臨時節點鎖
String lockPath = "/distributed/lock/";//根節點
//可重入排它鎖
String lockName = "xxxx";
InterProcessMutex interProcessMutex = new InterProcessMutex(curatorFramework, lockPath + lockName);
//加鎖
interProcessMutex.acquire(2, TimeUnit.SECONDS)
//釋放鎖
if(interProcessMutex.isAcquiredInThisProcess()){
    interProcessMutex.release();
    curatorFramework.delete().inBackground().forPath(lockPath + lockName);
}

歡迎指正文中錯誤

參數文章