分散式-鎖-初見

ML李嘉圖發表於2021-08-25

介紹幾種常見的分散式鎖寫法

多執行緒中為了防止多個執行緒同時執行同一段程式碼,我們可以用 synchronized 關鍵字或 JUC 裡面的 ReentrantLock 類來控制,

但是目前幾乎任何一個系統都是部署多臺機器的,單機部署的應用很少,synchronized 和 ReentrantLock 發揮不出任何作用,

此時就需要一把全域性的鎖,來代替 JAVA 中的 synchronized 和 ReentrantLock。

分散式鎖的實現方式流行的主要有三種

  1. 分別是基於快取 Redis 的實現方式
  2. 基於 zk 臨時順序節點的實現
  3. 基於資料庫行鎖的實現。

官網

目錄 · redisson/redisson Wiki · GitHub

Jedis

使用 Redis 做分散式鎖的思路是:

在 redis 中設定一個值表示加了鎖,然後釋放鎖的時候就把這個 key 刪除。

    /**
     * 嘗試獲取分散式鎖
     *
     * @param jedis      Redis客戶端
     * @param lockKey    鎖
     * @param requestId  請求標識
     * @param expireTime 超期時間
     * @return 是否獲取成功
     */
    public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
        // set支援多個引數 NX(not exist) XX(exist) EX(seconds) PX(million seconds)
        String result = jedis.set(lockKey, requestId, "NX", "EX", expireTime);
        if (LOCK_SUCCESS.equals(result)) {
            return true;
        }
        return false;
    }


    /**
     * 釋放分散式鎖
     *
     * @param jedis     Redis客戶端
     * @param lockKey   鎖
     * @param requestId 請求標識,當前工作執行緒執行緒的名稱
     * @return 是否釋放成功
     */
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
        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));
        if (RELEASE_SUCCESS.equals(result)) {
            return true;
        }
        return false;
    }

問題

  • SET key value ,而沒有使用 SETNX+EXPIRE 的命令,原因是 SETNX+EXPIRE 是兩條命令無法保證原子性,而 SET 是原子操作。

  • 那這裡為什麼要設定超時時間呢?

    原因是當一個客戶端獲得了鎖在執行任務的過程中掛掉了,來不及顯式地釋放鎖,這塊資源將會永遠被鎖住,

    這將會導致死鎖,所以必須設定一個超時時間

  • A 加的鎖 B 不能去 del 掉,誰加的鎖就誰去解,我們一般把 value 設為當前執行緒的 Id,

    Thread.currentThread().getId(),然後在刪的時候判斷下是不是當前執行緒。

  • 驗證和釋放鎖是兩個獨立操作,不是原子性,

    使用 Lua 指令碼,即 if redis.call('get', KEYS[1]) == ARGV[1] then returnredis.call('del', KEYS[1]) else return 0 end,它能給我們保證原子性。

當 redis.call() 在執行命令的過程中發生錯誤時,指令碼會停止執行,並返回一個指令碼錯誤,

錯誤的輸出資訊會說明錯誤造成的原因:

Redisson

Redisson 是 Java 的 Redis 客戶端之一,提供了一些 API 方便操作 Redis。

Redisson 跟 Jedis 定位不同,它不是一個單純的 Redis 客戶端,

而是基於 Redis 實現的分散式的服務,

鎖只是它的冰山一角,並且它對主從,哨兵,叢集等模式都支援。

public class LockTest{
	private static Redis sonClient redissonClient;
    
	static {
		Config config = new Config();
		config. useSingleServer().setAddress("redis://127.0.0.1:6379");
		redissonClient = Redisson.create ( config);
    }

    public static void main(String[] args) throws InterruptedException {
		RLock rLock = redissonClient . getLock(”zwt" );
		//最多等待100秒、 上鎖10s以後自動解鎖
		if (rLock.tryLock(100, 10, TimeUnit. SECONDS)) {
			System . out . println("獲取鎖成功" );
		}
		//Thread. sleep(20000);
		rLock. unlock( );
		redissonClient . shutdown();
	}
}


這裡獲取鎖有很多種的方式,有公平鎖有讀寫鎖,我們使用的是 redissonClient.getLock, 這是一個可重入鎖。

在加鎖的時候,寫入了一個 HASH 型別的值,key 是鎖名稱 zwt,field 是執行緒的名稱,而 value 是 1(即表示鎖的重入次數)。

點進 tryLock() 方法的 tryAcquire() 方法,再到->tryAcquireAsync() 再到->tryLockInnerAsync(),

終於見到廬山真面目了,原來它最終也是通過 Lua 指令碼來實現的。

<T> RFuture<T> tryLockInnerAsync (long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
	internalLockLeaseTime = unit. toMillis( leaseTime);

    return commandExecutor . evalWriteAsync getName(), LongCodec . INSTANCE, command,
				"if (redis.call( 'exists', KEYS[1]) == 0) then" +
					"redis.call('hset', KEYS[1], ARGV[2], 1);" + 
					"redis. call('pexpire', KEYS[1], ARGV[1]);" +
					"return nil;" +
				"end; " + 
				"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
					"redis. call( 'hincrby', KEYS[1], ARGV[2], 1);" +
					"redis. call( 'pexpire', KEYS[1], ARGV[1]);" +
					"return nil;" +
				"end;" +
				"return redis.call('pttl', KEYS[1]);",
				Collections.<~>singLetonL ist(getName()), internalLockLeaseTime, getLockName (threadId));
}

Lua指令碼拉出來分析一下:

// KEYS[1] 鎖名稱 updateAccount
// ARGV[1] key 過期時間 10000ms
// ARGV[2] 執行緒名稱
// 鎖名稱不存在
if (redis.call('exists', KEYS[1]) == 0) then
// 建立一個 hash,key=鎖名稱,field=執行緒名,value=1
redis.call('hset', KEYS[1], ARGV[2], 1);
// 設定 hash 的過期時間
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
// 鎖名稱存在,判斷是否當前執行緒持有的鎖
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
// 如果是,value+1,代表重入次數+1
redis.call('hincrby', KEYS[1], ARGV[2], 1);
// 重新獲得鎖,需要重新設定 Key 的過期時間
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
// 鎖存在,但是不是當前執行緒持有,返回過期時間(毫秒)
return redis.call('pttl', KEYS[1]);

unlock() 中的 unlockInnerAsync() 釋放鎖,同樣也是通過 Lua 指令碼實現。

// KEYS[1] 鎖的名稱 updateAccount
// KEYS[2] 頻道名稱 redisson_lock__channel:{updateAccount}
// ARGV[1] 釋放鎖的訊息 0
// ARGV[2] 鎖釋放時間 10000
// ARGV[3] 執行緒名稱
// 鎖不存在(過期或者已經釋放了)
if (redis.call('exists', KEYS[1]) == 0) then
// 釋出鎖已經釋放的訊息
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
// 鎖存在,但是不是當前執行緒加的鎖
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end;

// 鎖存在,是當前執行緒加的鎖
// 重入次數-1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
// -1 後大於 0,說明這個執行緒持有這把鎖還有其他的任務需要執行
if (counter > 0) then
// 重新設定鎖的過期時間
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
// -1 之後等於 0,現在可以刪除鎖了
redis.call('del', KEYS[1]);
// 刪除之後釋出釋放鎖的訊息
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;

// 其他情況返回 nil
return nil;

問題

  • 業務沒執行完,鎖到期了怎麼辦,這個由watchdog來保障。
  • 叢集模式下,如果對多個master加鎖,導致重複加鎖怎麼辦,Redission會自動選擇同一個 master。
  • 業務沒執行完,Redis master掛掉了怎麼辦,沒關係,Redis slave還有這個資料。

RedLock

名字由來

RedLock 的中文是直譯過來的,就叫紅鎖。

紅鎖並非是一個工具,而是 Redis 官方提出的一種分散式鎖的演算法

我們知道如果採用單機部署模式,會存在單點問題,只要 redis 故障了,加鎖就不行了。

如果採用 master-slave 模式,加鎖的時候只對一個節點加鎖,

即便通過 sentinel 做了高可用,但是如果 master 節點故障了,發生主從切換,

此時就會有可能出現鎖丟失的問題。

基於以上的考慮,其實 redis 的作者 Antirez 也考慮到這個問題,他提出了一個 RedLock 的演算法。

演算法實現

通過以下步驟獲取一把鎖:

1.獲取當前時間戳,單位是毫秒

2.輪流嘗試在每個 master 節點上建立鎖,過期時間設定較短,一般就幾十毫秒

3.嘗試在大多數節點上建立一個鎖,比如5個節點就要求是3個節點(n / 2 +1)

4.客戶端計算建立好鎖的時間,如果建立鎖的時間小於超時時間,就算建立成功了

5.要是鎖建立失敗了,那麼就依次刪除這個鎖

6.只要別人建立了一把分散式鎖,你就得不斷輪詢去嘗試獲取鎖

RLock lock1 = redissonInstance1. getLock("lock1");
RLock lock2 = redissonInstance2. getLock("lock2");
RLock 1ock3 = redissonInstance3. getLock("lock3");
RedissonRedLock lock = new RedissonRedlock(lock1, lock2, lock3);
//同時加鎖。lock1 lock2 lock3
//紅鎖在大部分節點上加鎖成功就算成功。
lock.lock();

//…………

lock.unlock();

Zookeeper寫法(Curator)

獲取鎖

Client1 得到了鎖,Client2 監聽了 Lock1,Client3 監聽了 Lock2。這恰恰形成了一個等待佇列

釋放鎖

1.任務完成,客戶端顯示釋放

當任務完成時,Client1 會顯示呼叫刪除節點 Lock1 的指令。

2.任務執行過程中,客戶端崩潰

獲得鎖的 Client1 在任務執行過程中,如果 Duang 的一聲崩潰,則會斷開與 Zookeeper 服務端的連結。

根據臨時節點的特性,相關聯的節點 Lock1 會隨之自動刪除。

Client2 一直監聽著 Lock1 的存在狀態,當 Lock1 節點被刪除,Client2 會立刻收到通知。

這時候 Client2 會再次查詢 ParentLock 下面的所有節點,確認自己建立的節點 Lock2 是不是目前最小的節點。

如果是最小,則 Client2 順理成章獲得了鎖。

Curator

在 Apache 的開源框架 Apache Curator 中,包含了對 Zookeeper 分散式鎖的實現。

<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-recipes</artifactId>
    <version>4.3.0</version>
</dependency>

Curator的幾種鎖的實現

  • InterProcessMutex:分散式可重入排它鎖
  • InterProcessSemaphoreMutex:分散式排它鎖
  • InterProcessMultiLock:將多個鎖作為單個實體管理的容器
public class ZkDistributedLock implements DistributedLock {

    private final CuratorF ramework client;

    public ZkDistributedLock ( CuratorFramework client) { this.client = client; }

    @override
    public void acquire(String key) throws Exception {
		InterProcessMutex lock = new InterProcessMutex(client, key);
	lock.acquire();
	}

    @Override
    public boolean acquire(String key, long maxwait, TimeUnit waitunit) throws Exception{
		InterProcessMutex lock = new InterProcessMutex(client, key);
		return lock.acquire (maxWait, waitUnit);
	}

    @Override
	public void release(String key) throws Exception {
		InterProcessMutex lock = new InterProcessMutex(client, key);
		lock. release();
	}
}

總結

zookeeper 天生設計定位就是分散式協調,強一致性,鎖很健壯。

如果獲取不到鎖,只需要新增一個監聽器就可以了,不用一直輪詢,效能消耗較小。

缺點: 在高請求高併發下,系統瘋狂的加鎖釋放鎖,最後 zk 承受不住這麼大的壓力可能會存在當機的風險。

zk 鎖效能比 redis 低的原因:zk 中的角色分為 leader,flower,

每次寫請求只能請求 leader,leader 會把寫請求廣播到所有 flower,

如果 flower 都成功才會提交給 leader,在加鎖的時候是一個寫請求,

當寫請求很多時,zk 會有很大的壓力,最後導致伺服器響應很慢。

redis 鎖實現簡單,理解邏輯簡單,效能好,可以支撐高併發的獲取、釋放鎖操作。

缺點: Redis 容易單點故障,叢集部署,並不是強一致性的,鎖的不夠健壯;

​ key 的過期時間設定多少不明確,只能根據實際情況調整;

​ 需要自己不斷去嘗試獲取鎖,比較消耗效能。

最後不管 redis 還是 zookeeper,它們都應滿足分散式鎖的特性:

  • 具備可重入特性(已經獲得鎖的執行緒在執行的過程中不需要再次獲得鎖)
  • 異常或者超時自動刪除,避免死鎖
  • 互斥性,只有一個客戶端能夠持有鎖
  • 分散式環境下高效能、高可用、容錯機制

各有千秋,具體業務場景具體使用。

相關文章