首頁>技術>

提到鎖大家肯定有了解,像 Synchronized、ReentrantLock,在單程序情況下,多個執行緒訪問同一資源,可以用它們來保證執行緒的安全性。

圖片來自 Pexels

不過目前網際網路專案越來越多的專案採用叢集部署,也就是分散式情況,這兩種鎖就有些不夠用了。

來兩張圖舉例說明下,本地鎖的情況下:

分散式鎖情況下:

就其思想來說,就是一種“我全都要”的思想,所有服務都到一個統一的地方來取鎖,只有取到鎖的才能繼續執行下去。

說完思想,下面來說一下具體的實現。

Redis 實現

為實現分散式鎖,在 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 分鐘了,就直接把門給撬開了,一看裡面卻是張四,這就很尷尬啊。

多說無益,煩人,直接上程式碼:

//基於jedis和lua指令碼來實現 privatestaticfinal String LOCK_SUCCESS = "OK"; privatestaticfinal Long RELEASE_SUCCESS = 1L; privatestaticfinal String SET_IF_NOT_EXIST = "NX"; privatestaticfinal String SET_WITH_EXPIRE_TIME = "PX";  @Override public String acquire() {     try {         // 獲取鎖的超時時間,超過這個時間則放棄獲取鎖         long end = System.currentTimeMillis() + acquireTimeout;         // 隨機生成一個 value         String requireToken = UUID.randomUUID().toString();         while (System.currentTimeMillis() < end) {             String result = jedis                 .set(lockKey, requireToken, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);             if (LOCK_SUCCESS.equals(result)) {                 return requireToken;             }             try {                 Thread.sleep(100);             } catch (InterruptedException e) {                 Thread.currentThread().interrupt();             }         }     } catch (Exception e) {         log.error("acquire lock due to error", e);     }      returnnull; }  @Override public boolean release(String identify) {     if (identify == null) {         returnfalse;     }     //透過lua指令碼進行比對刪除操作,保證原子性     String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";     Object result = new Object();     try {         result = jedis.eval(script, Collections.singletonList(lockKey),             Collections.singletonList(identify));         if (RELEASE_SUCCESS.equals(result)) {             log.info("release lock success, requestToken:{}", identify);             returntrue;         }     } catch (Exception e) {         log.error("release lock due to error", e);     } finally {         if (jedis != null) {             jedis.close();         }     }      log.info("release lock failed, requestToken:{}, result:{}", identify, result);     returnfalse; } 

思考:加鎖和釋放鎖的原子性可以用 lua 指令碼來保證,那鎖的自動續期改如何實現呢?

Redisson 實現

Redisson 顧名思義,Redis 的兒子,本質上還是 Redis 加鎖,不過是對 Redis 做了很多封裝,它不僅提供了一系列的分散式的 Java 常用物件,還提供了許多分散式服務。

在引入 Redisson 的依賴後,就可以直接進行呼叫:

<dependency>     <groupId>org.redisson</groupId>     <artifactId>redisson</artifactId>     <version>3.13.4</version> </dependency> 

先來一段 Redisson 的加鎖程式碼:

private void test() {     //分散式鎖名  鎖的粒度越細,效能越好     RLock lock = redissonClient.getLock("test_lock");     lock.lock();     try {         //具體業務......     } finally {         lock.unlock();     } } 

就是這麼簡單,使用方法 jdk 的 ReentrantLock 差不多,並且也支援 ReadWriteLock(讀寫鎖)、Reentrant Lock(可重入鎖)、Fair Lock(公平鎖)、RedLock(紅鎖)等各種鎖,詳細可以參照redisson官方文件來檢視。

那麼 Redisson 到底有哪些優勢呢?鎖的自動續期(預設都是 30 秒),如果業務超長,執行期間會自動給鎖續上新的 30s,不用擔心業務執行時間超長而鎖被自動刪掉。

前面也提到了鎖的自動續期,我們來看看 Redisson 是如何來實現的。

先說明一下,這裡主要講的是 Redisson 中的 RLock,也就是可重入鎖,有兩種實現方法:

// 最常見的使用方法 lock.lock();  // 加鎖以後10秒鐘自動解鎖 // 無需呼叫unlock方法手動解鎖 lock.lock(10, TimeUnit.SECONDS); 

而只有無參的方法是提供鎖的自動續期操作的,內部使用的是“看門狗”機制,我們來看一看原始碼。

不管是空參還是帶參方法,它們都呼叫的是同一個 lock 方法,未傳參的話時間傳了一個 -1,而帶參的方法傳過去的就是實際傳入的時間。

繼續點進 scheduleExpirationRenewal 方法:

點進 renewExpiration 方法:

總結一下,就是當我們指定鎖過期時間,那麼鎖到時間就會自動釋放。如果沒有指定鎖過期時間,就使用看門狗的預設時間 30s,只要佔鎖成功,就會啟動一個定時任務,每隔 10s 給鎖設定新的過期時間,時間為看門狗的預設時間,直到鎖釋放。

小結:雖然 lock() 有自動續鎖機制,但是開發中還是推薦使用 lock(time,timeUnit),因為它省掉了整個續期帶來的效能損,可以設定過期時間長一點,搭配 unlock()。

若業務執行完成,會手動釋放鎖,若是業務執行超時,那一般我們服務也都會設定業務超時時間,就直接報錯了,報錯後就會透過設定的過期時間來釋放鎖。

public void test() {     RLock lock = redissonClient.getLock("test_lock");     lock.lock(30, TimeUnit.SECONDS);     try {         //.......具體業務     } finally {         //手動釋放鎖         lock.unlock();     } } 

基於 Zookeeper 來實現分散式鎖

很多小夥伴都知道在分散式系統中,可以用 ZK 來做註冊中心,但其實在除了做祖冊中心以外,用 ZK 來做分散式鎖也是很常見的一種方案。

先來看一下 ZK 中是如何建立一個節點的?ZK 中存在 create [-s] [-e] path [data] 命令,-s 為建立有序節點,-e 建立臨時節點。

這樣就建立了一個父節點併為父節點建立了一個子節點,組合命令意為建立一個臨時的有序節點。

而 ZK 中分散式鎖主要就是靠建立臨時的順序節點來實現的。至於為什麼要用順序節點和為什麼用臨時節點不用持久節點?先考慮一下,下文將作出說明。

同時還有 ZK 中如何檢視節點?ZK 中 ls [-w] path 為檢視節點命令,-w 為新增一個 watch(監視器),/ 為檢視根節點所有節點,可以看到我們剛才所建立的節點,同時如果是跟著指定節點名字的話為檢視指定節點下的子節點。

後面的 00000000 為 ZK 為順序節點增加的順序。註冊監聽器也是 ZK 實現分散式鎖中比較重要的一個東西。

下面來看一下 ZK 實現分散式鎖的主要流程:

當第一個執行緒進來時會去父節點上建立一個臨時的順序節點。

第二個執行緒進來發現鎖已經被持有了,就會為當前持有鎖的節點註冊一個 watcher 監聽器。

第三個執行緒進來發現鎖已經被持有了,因為是順序節點的緣故,就會為上一個節點去建立一個 watcher 監聽器。

看到這裡,聰明的小夥伴們都已經看出來順序節點的好處了。非順序節點的話,每進來一個執行緒進來都會去持有鎖的節點上註冊一個監聽器,容易引發“羊群效應”。

這麼大一群羊一起向你飛奔而來,不管你頂不頂得住,反正 ZK 伺服器是會增大宕機的風險。

而順序節點的話就不會,順序節點當發現已經有執行緒持有鎖後,會向它的上一個節點註冊一個監聽器,這樣當持有鎖的節點釋放後,也只有持有鎖的下一個節點可以搶到鎖,相當於是排好隊來執行的,降低伺服器宕機風險。

至於為什麼使用臨時節點,和 Redis 的過期時間一個道理,就算 ZK 伺服器宕機,臨時節點會隨著伺服器的宕機而消失,避免了死鎖的情況。

下面來上一段程式碼的實現:

public class ZooKeeperDistributedLock implements Watcher {      private ZooKeeper zk;     private String locksRoot = "/locks";     private String productId;     private String waitNode;     private String lockNode;     private CountDownLatch latch;     private CountDownLatch connectedLatch = new CountDownLatch(1);     private int sessionTimeout = 30000;      public ZooKeeperDistributedLock(String productId) {         this.productId = productId;         try {             String address = "192.168.189.131:2181,192.168.189.132:2181";             zk = new ZooKeeper(address, sessionTimeout, this);             connectedLatch.await();         } catch (IOException e) {             throw new LockException(e);         } catch (KeeperException e) {             throw new LockException(e);         } catch (InterruptedException e) {             throw new LockException(e);         }     }      public void process(WatchedEvent event) {         if (event.getState() == KeeperState.SyncConnected) {             connectedLatch.countDown();             return;         }          if (this.latch != null) {             this.latch.countDown();         }     }      public void acquireDistributedLock() {         try {             if (this.tryLock()) {                 return;             } else {                 waitForLock(waitNode, sessionTimeout);             }         } catch (KeeperException e) {             throw new LockException(e);         } catch (InterruptedException e) {             throw new LockException(e);         }     }     //獲取鎖     public boolean tryLock() {         try {         // 傳入進去的locksRoot + “/” + productId         // 假設productId代表了一個商品id,比如說1         // locksRoot = locks         // /locks/10000000000,/locks/10000000001,/locks/10000000002         lockNode = zk.create(locksRoot + "/" + productId, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);          // 看看剛建立的節點是不是最小的節點         // locks:10000000000,10000000001,10000000002         List<String> locks = zk.getChildren(locksRoot, false);         Collections.sort(locks);          if(lockNode.equals(locksRoot+"/"+ locks.get(0))){             //如果是最小的節點,則表示取得鎖             return true;         }          //如果不是最小的節點,找到比自己小1的節點       int previousLockIndex = -1;             for(int i = 0; i < locks.size(); i++) {         if(lockNode.equals(locksRoot + “/” + locks.get(i))) {                     previousLockIndex = i - 1;             break;         }        }         this.waitNode = locks.get(previousLockIndex);         } catch (KeeperException e) {             throw new LockException(e);         } catch (InterruptedException e) {             throw new LockException(e);         }         return false;     }      private boolean waitForLock(String waitNode, long waitTime) throws InterruptedException, KeeperException {         Stat stat = zk.exists(locksRoot + "/" + waitNode, true);         if (stat != null) {             this.latch = new CountDownLatch(1);             this.latch.await(waitTime, TimeUnit.MILLISECONDS);             this.latch = null;         }         return true;     }      //釋放鎖     public void unlock() {         try {             System.out.println("unlock " + lockNode);             zk.delete(lockNode, -1);             lockNode = null;             zk.close();         } catch (InterruptedException e) {             e.printStackTrace();         } catch (KeeperException e) {             e.printStackTrace();         }     }     //異常     public class LockException extends RuntimeException {         private static final long serialVersionUID = 1L;          public LockException(String e) {             super(e);         }          public LockException(Exception e) {             super(e);         }     } } 

總結

既然明白了 Redis 和 ZK 分別對分散式鎖的實現,那麼總該有所不同的吧。沒錯,我都幫大家整理好了:

實現方式的不同,Redis 實現為去插入一條佔位資料,而 ZK 實現為去註冊一個臨時節點。

Redis 在沒搶佔到鎖的情況下一般會去自旋獲取鎖,比較浪費效能,而 ZK 是透過註冊監聽器的方式獲取鎖,效能而言優於 Redis。

不過具體要採用哪種實現方式,還是需要具體情況具體分析,結合專案引用的技術棧來落地實現。

出處:juejin.im/post/6891571079702118407

16
最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • 做資料分析必懂的資料倉庫知識