分佈式鎖用 Redis 好,還是 ZooKeeper 好?
不過目前互聯網項目越來越多的項目採用集羣部署,也就是分佈式情況,這兩種鎖就有些不夠用了。
來兩張圖舉例說明下,本地鎖的情況下:
分佈式鎖情況下:
就其思想來說,就是一種 “我全都要” 的思想,所有服務都到一個統一的地方來取鎖,只有取到鎖的才能繼續執行下去。
說完思想,下面來說一下具體的實現。
爲實現分佈式鎖,在 Redis 中存在 SETNX key value 命令,意爲 set if not exists(如果不存在該 key,纔去 set 值),就比如說是張三去上廁所,看廁所門鎖着,他就不進去了,廁所門開着他纔去。
可以看到,第一次 set 返回了 1,表示成功,但是第二次返回 0,表示 set 失敗,因爲已經存在這個 key 了。
當然只靠 setnx 這個命令可以嗎?當然是不行的,試想一種情況,張三在廁所裏,但他在裏面一直沒有釋放,一直在裏面蹲着,那外面人想去廁所全部都去不了,都想錘死他了。
Redis 同理,假設已經進行了加鎖,但是因爲宕機或者出現異常未釋放鎖,就造成了所謂的 “死鎖”。
聰明的你們肯定早都想到了,爲它設置過期時間不就好了,可以 SETEX key seconds value 命令,爲指定 key 設置過期時間,單位爲秒。
但這樣又有另一個問題,我剛加鎖成功,還沒設置過期時間,Redis 宕機了不就又死鎖了,所以說要保證原子性吖,要麼一起成功,要麼一起失敗。
當然我們能想到的 Redis 肯定早都爲你實現好了,在 Redis 2.8 的版本後,Redis 就爲我們提供了一條組合命令 SET key value ex seconds nx,加鎖的同時設置過期時間。
就好比是公司規定每人最多隻能在廁所呆 2 分鐘,不管釋放沒釋放完都得出來,這樣就解決了 “死鎖” 問題。
但這樣就沒有問題了嗎?怎麼可能。
試想又一種情況,廁所門肯定只能從裏面開啊,張三上完廁所後張四進去鎖上門,但是外面人以爲還是張三在裏面,而且已經過了 3 分鐘了,就直接把門給撬開了,一看裏面卻是張四,這就很尷尬啊。
換成 Redis 就是說比如一個業務執行時間很長,鎖已經自己過期了,別人已經設置了新的鎖,但是當業務執行完之後直接釋放鎖,就有可能是刪除了別人加的鎖,這不是亂套了嗎。
所以在加鎖時候,要設一個隨機值,在刪除鎖時進行比對,如果是自己的鎖,才刪除。
多說無益,煩人,直接上代碼:
1//基於 jedis 和 lua 腳本來實現
2privatestaticfinal String LOCK_SUCCESS = "OK";
3privatestaticfinal Long RELEASE_SUCCESS = 1L;
4privatestaticfinal String SET_IF_NOT_EXIST = "NX";
5privatestaticfinal String SET_WITH_EXPIRE_TIME = "PX";
6
7@Override
8public String acquire() {
9 try {
10 // 獲取鎖的超時時間,超過這個時間則放棄獲取鎖
11 long end = System.currentTimeMillis() + acquireTimeout;
12 // 隨機生成一個 value
13 String requireToken = UUID.randomUUID().toString();
14 while (System.currentTimeMillis() < end) {
15 String result = jedis
16 .set(lockKey, requireToken, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
17 if (LOCK_SUCCESS.equals(result)) {
18 return requireToken;
19 }
20 try {
21 Thread.sleep(100);
22 } catch (InterruptedException e) {
23 Thread.currentThread().interrupt();
24 }
25 }
26 } catch (Exception e) {
27 log.error("acquire lock due to error", e);
28 }
29
30 returnnull;
31}
32
33@Override
34public boolean release(String identify) {
35 if (identify == null) {
36 returnfalse;
37 }
38 //通過 lua 腳本進行比對刪除操作,保證原子性
39 String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
40 Object result = new Object();
41 try {
42 result = jedis.eval(script, Collections.singletonList(lockKey),
43 Collections.singletonList(identify));
44 if (RELEASE_SUCCESS.equals(result)) {
45 log.info("release lock success, requestToken:{}", identify);
46 returntrue;
47 }
48 } catch (Exception e) {
49 log.error("release lock due to error", e);
50 } finally {
51 if (jedis != null) {
52 jedis.close();
53 }
54 }
55
56 log.info("release lock failed, requestToken:{}, result:{}", identify, result);
57 returnfalse;
58}
59
思考:加鎖和釋放鎖的原子性可以用 lua 腳本來保證,那鎖的自動續期改如何實現呢?
Redisson 實現
在引入 Redisson 的依賴後,就可以直接進行調用:
1<dependency>
2 <groupId>org.redisson</groupId>
3 <artifactId>redisson</artifactId>
4 <version>3.13.4</version>
5</dependency>
6
先來一段 Redisson 的加鎖代碼:
1private void test() {
2 //分佈式鎖名 鎖的粒度越細,性能越好
3 RLock lock = redissonClient.getLock("test_lock");
4 lock.lock();
5 try {
6 //具體業務......
7 } finally {
8 lock.unlock();
9 }
10}
11
1// 最常見的使用方法
2lock.lock();
3
4// 加鎖以後10秒鐘自動解鎖
5// 無需調用unlock方法手動解鎖
6lock.lock(10, TimeUnit.SECONDS);
7
而只有無參的方法是提供鎖的自動續期操作的,內部使用的是 “看門狗” 機制,我們來看一看源碼。
不管是空參還是帶參方法,它們都調用的是同一個 lock 方法,未傳參的話時間傳了一個 -1,而帶參的方法傳過去的就是實際傳入的時間。
繼續點進 scheduleExpirationRenewal 方法:
點進 renewExpiration 方法:
總結一下,就是當我們指定鎖過期時間,那麼鎖到時間就會自動釋放。如果沒有指定鎖過期時間,就使用看門狗的默認時間 30s,只要佔鎖成功,就會啓動一個定時任務,每隔 10s 給鎖設置新的過期時間,時間爲看門狗的默認時間,直到鎖釋放。
小結:雖然 lock() 有自動續鎖機制,但是開發中還是推薦使用 lock(time,timeUnit),因爲它省掉了整個續期帶來的性能損,可以設置過期時間長一點,搭配 unlock()。
若業務執行完成,會手動釋放鎖,若是業務執行超時,那一般我們服務也都會設置業務超時時間,就直接報錯了,報錯後就會通過設置的過期時間來釋放鎖。
1public void test() {
2 RLock lock = redissonClient.getLock("test_lock");
3 lock.lock(30, TimeUnit.SECONDS);
4 try {
5 //.......具體業務
6 } finally {
7 //手動釋放鎖
8 lock.unlock();
9 }
10}
11
基於 ZooKeeper 來實現分佈式鎖
很多小夥伴都知道在分佈式系統中,可以用 ZooKeeper 來做註冊中心,但其實在除了做祖冊中心以外,用 ZooKeeper 來做分佈式鎖也是很常見的一種方案。
先來看一下 ZooKeeper 中是如何創建一個節點的?ZooKeeper 中存在 create [-s] [-e] path [data] 命令,-s 爲創建有序節點,-e 創建臨時節點。
這樣就創建了一個父節點併爲父節點創建了一個子節點,組合命令意爲創建一個臨時的有序節點。
而 ZooKeeper 中分佈式鎖主要就是靠創建臨時的順序節點來實現的。至於爲什麼要用順序節點和爲什麼用臨時節點不用持久節點?先考慮一下,下文將作出說明。
同時還有 ZooKeeper 中如何查看節點?ZooKeeper 中 ls [-w] path 爲查看節點命令,-w 爲添加一個 watch(監視器),/ 爲查看根節點所有節點,可以看到我們剛纔所創建的節點,同時如果是跟着指定節點名字的話爲查看指定節點下的子節點。
後面的 00000000 爲 ZooKeeper 爲順序節點增加的順序。註冊監聽器也是 ZooKeeper 實現分佈式鎖中比較重要的一個東西。
下面來看一下 ZooKeeper 實現分佈式鎖的主要流程:
-
當第一個線程進來時會去父節點上創建一個臨時的順序節點。
-
第二個線程進來發現鎖已經被持有了,就會爲當前持有鎖的節點註冊一個 watcher 監聽器。
-
第三個線程進來發現鎖已經被持有了,因爲是順序節點的緣故,就會爲上一個節點去創建一個 watcher 監聽器。
-
當第一個線程釋放鎖後,刪除節點,由它的下一個節點去佔有鎖。
看到這裏,聰明的小夥伴們都已經看出來順序節點的好處了。非順序節點的話,每進來一個線程進來都會去持有鎖的節點上註冊一個監聽器,容易引發 “羊羣效應”。
這麼大一羣羊一起向你飛奔而來,不管你頂不頂得住,反正 ZooKeeper 服務器是會增大宕機的風險。
而順序節點的話就不會,順序節點當發現已經有線程持有鎖後,會向它的上一個節點註冊一個監聽器,這樣當持有鎖的節點釋放後,也只有持有鎖的下一個節點可以搶到鎖,相當於是排好隊來執行的,降低服務器宕機風險。
至於爲什麼使用臨時節點,和 Redis 的過期時間一個道理,就算 ZooKeeper 服務器宕機,臨時節點會隨着服務器的宕機而消失,避免了死鎖的情況。
下面來上一段代碼的實現:
1public class ZooKeeperDistributedLock implements Watcher {
2
3 private ZooKeeper zk;
4 private String locksRoot = "/locks";
5 private String productId;
6 private String waitNode;
7 private String lockNode;
8 private CountDownLatch latch;
9 private CountDownLatch connectedLatch = new CountDownLatch(1);
10 private int sessionTimeout = 30000;
11
12 public ZooKeeperDistributedLock(String productId) {
13 this.productId = productId;
14 try {
15 String address = "192.168.189.131:2181,192.168.189.132:2181";
16 zk = new ZooKeeper(address, sessionTimeout, this);
17 connectedLatch.await();
18 } catch (IOException e) {
19 throw new LockException(e);
20 } catch (KeeperException e) {
21 throw new LockException(e);
22 } catch (InterruptedException e) {
23 throw new LockException(e);
24 }
25 }
26
27 public void process(WatchedEvent event) {
28 if (event.getState() == KeeperState.SyncConnected) {
29 connectedLatch.countDown();
30 return;
31 }
32
33 if (this.latch != null) {
34 this.latch.countDown();
35 }
36 }
37
38 public void acquireDistributedLock() {
39 try {
40 if (this.tryLock()) {
41 return;
42 } else {
43 waitForLock(waitNode, sessionTimeout);
44 }
45 } catch (KeeperException e) {
46 throw new LockException(e);
47 } catch (InterruptedException e) {
48 throw new LockException(e);
49 }
50 }
51 //獲取鎖
52 public boolean tryLock() {
53 try {
54 // 傳入進去的 locksRoot + “/” + productId
55 // 假設 productId 代表了一個商品 id,比如說 1
56 // locksRoot = locks
57 // /locks/10000000000,/locks/10000000001,/locks/10000000002
58 lockNode = zk.create(locksRoot + "/" + productId, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
59
60 // 看看剛創建的節點是不是最小的節點
61 // locks:10000000000,10000000001,10000000002
62 List<String> locks = zk.getChildren(locksRoot, false);
63 Collections.sort(locks);
64
65 if(lockNode.equals(locksRoot+"/"+ locks.get(0))){
66 //如果是最小的節點,則表示取得鎖
67 return true;
68 }
69
70 //如果不是最小的節點,找到比自己小 1 的節點
71 int previousLockIndex = -1;
72 for(int i = 0; i < locks.size(); i++) {
73 if(lockNode.equals(locksRoot + “/” + locks.get(i))) {
74 previousLockIndex = i - 1;
75 break;
76 }
77 }
78
79 this.waitNode = locks.get(previousLockIndex);
80 } catch (KeeperException e) {
81 throw new LockException(e);
82 } catch (InterruptedException e) {
83 throw new LockException(e);
84 }
85 return false;
86 }
87
88 private boolean waitForLock(String waitNode, long waitTime) throws InterruptedException, KeeperException {
89 Stat stat = zk.exists(locksRoot + "/" + waitNode, true);
90 if (stat != null) {
91 this.latch = new CountDownLatch(1);
92 this.latch.await(waitTime, TimeUnit.MILLISECONDS);
93 this.latch = null;
94 }
95 return true;
96 }
97
98 //釋放鎖
99 public void unlock() {
100 try {
101 System.out.println("unlock " + lockNode);
102 zk.delete(lockNode, -1);
103 lockNode = null;
104 zk.close();
105 } catch (InterruptedException e) {
106 e.printStackTrace();
107 } catch (KeeperException e) {
108 e.printStackTrace();
109 }
110 }
111 //異常
112 public class LockException extends RuntimeException {
113 private static final long serialVersionUID = 1L;
114
115 public LockException(String e) {
116 super(e);
117 }
118
119 public LockException(Exception e) {
120 super(e);
121 }
122 }
123}
124
總結
既然明白了 Redis 和 ZooKeeper 分別對分佈式鎖的實現,那麼總該有所不同的吧。沒錯,我都幫大家整理好了:
-
實現方式的不同,Redis 實現爲去插入一條佔位數據,而 ZooKeeper 實現爲去註冊一個臨時節點。
-
遇到宕機情況時,Redis 需要等到過期時間到了後自動釋放鎖,而 ZooKeeper 因爲是臨時節點,在宕機時候已經是刪除了節點去釋放鎖。
-
Redis 在沒搶佔到鎖的情況下一般會去自旋獲取鎖,比較浪費性能,而 ZooKeeper 是通過註冊監聽器的方式獲取鎖,性能而言優於 Redis。
不過具體要採用哪種實現方式,還是需要具體情況具體分析,結合項目引用的技術棧來落地實現。
原文鏈接:https://juejin.cn/post/6891571079702118407
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/1TsBsaItcZ6fOg-bo2tGww