面試官:你是如何設計更優的分佈式鎖?

在 JVM 中,在多線程併發的情況下,我們可以使用同步鎖或 Lock 鎖,保證在同一時間內,只能有一個線程修改共享變量或執行代碼塊。但現在我們的服務基本都是基於分佈式集羣來實現部署的,對於一些共享資源,例如我們之前討論過的庫存,在分佈式環境下使用Java 鎖的方式就失去作用了。

這時,我們就需要實現分佈式鎖來保證共享資源的原子性。除此之外,分佈式鎖也經常用來避免分佈式中的不同節點執行重複性的工作,例如一個定時發短信的任務,在分佈式集羣中,我們只需要保證一個服務節點發送短信即可,一定要避免多個節點重複發送短信給同一個用戶。

因爲數據庫實現一個分佈式鎖比較簡單易懂,直接基於數據庫實現就行了,不需要再引入第三方中間件,所以這是很多分佈式業務實現分佈式鎖的首選。但是數據庫實現的分佈式鎖在一定程度上,存在性能瓶頸。

接下來我們一起了解下如何使用數據庫實現分佈式鎖,其性能瓶頸到底在哪,有沒有其它實現方式可以優化分佈式鎖。

數據庫實現分佈式鎖

首先,我們應該創建一個鎖表,通過創建和查詢數據來保證一個數據的原子性:

CREATE TABLE `order` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`order_no` int(11) DEFAULT NULL,
`pay_money` decimal(10, 2) DEFAULT NULL,
`status` int(4) DEFAULT NULL,
`create_date` datetime(0) DEFAULT NULL,
`delete_flag` int(4) DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_status`(`status`) USING BTREE,
INDEX `idx_order`(`order_no`) USING BTREE
) ENGINE = InnoDB

其次,如果是校驗訂單的冪等性,就要先查詢該記錄是否存在數據庫中,查詢的時候要防止幻讀,如果不存在,就插入到數據庫,否則,放棄操作。

select id from order where order_no= ‘xxxx’ for update

最後注意下,除了查詢時防止幻讀,我們還需要保證查詢和插入是在同一個事務中,因此我們需要申明事務,具體的實現代碼如下:

@Transactional
public int addOrderRecord(Order order) {
if(orderDao.selectOrderRecord(order)==null){
int result = orderDao.addOrderRecord(order);
if(result>0){
return 1;
}
}
return 0;
}

到這,我們訂單冪等性校驗的分佈式鎖就實現了。我想你應該能發現爲什麼這種方式會存在性能瓶頸了。在 RR 事務級別,select 的 for update 操作是基於間隙鎖 gap lock 實現的,這是一種悲觀鎖的實現方式,所以存在阻塞問題。

因此在高併發情況下,當有大量的請求進來時,大部分的請求都會進行排隊等待。爲了保證數據庫的穩定性,事務的超時時間往往又設置得很小,所以就會出現大量事務被中斷的情況。除了阻塞等待之外,因爲訂單沒有刪除操作,所以這張鎖表的數據將會逐漸累積,我們需要設置另外一個線程,隔一段時間就去刪除該表中的過期訂單,這就增加了業務的複雜度。

除了這種冪等性校驗的分佈式鎖,有一些單純基於數據庫實現的分佈式鎖代碼塊或對象,是需要在鎖釋放時,刪除或修改數據的。如果在獲取鎖之後,鎖一直沒有獲得釋放,即數據沒有被刪除或修改,這將會引發死鎖問題。

Zookeeper 實現分佈式鎖

除了數據庫實現分佈式鎖的方式以外,我們還可以基於 Zookeeper 實現。Zookeeper 是一種提供“分佈式服務協調“的中心化服務,正是 Zookeeper 的以下兩個特性,分佈式應用程序纔可以基於它實現分佈式鎖功能。

**順序臨時節點:**Zookeeper 提供一個多層級的節點命名空間(節點稱爲 Znode),每個節點都用一個以斜槓(/)分隔的路徑來表示,而且每個節點都有父節點(根節點除外),非常類似於文件系統。

節點類型可以分爲持久節點(PERSISTENT )、臨時節點(EPHEMERAL),每個節點還能被標記爲有序性(SEQUENTIAL),一旦節點被標記爲有序性,那麼整個節點就具有順序自增的特點。一般我們可以組合這幾類節點來創建我們所需要的節點,例如,創建一個持久節點作爲父節點,在父節點下面創建臨時節點,並標記該臨時節點爲有序性。

**Watch 機制:**Zookeeper 還提供了另外一個重要的特性,Watcher(事件監聽器)。ZooKeeper 允許用戶在指定節點上註冊一些 Watcher,並且在一些特定事件觸發的時候,ZooKeeper 服務端會將事件通知給用戶。我們熟悉了 Zookeeper 的這兩個特性之後,就可以看看 Zookeeper 是如何實現分佈式鎖的了。

首先,我們需要建立一個父節點,節點類型爲持久節點(PERSISTENT) ,每當需要訪問共享資源時,就會在父節點下建立相應的順序子節點,節點類型爲臨時節點(EPHEMERAL),且標記爲有序性(SEQUENTIAL),並且以臨時節點名稱 + 父節點名稱 + 順序號組成特定的名字。

在建立子節點後,對父節點下面的所有以臨時節點名稱 name 開頭的子節點進行排序,判斷剛剛建立的子節點順序號是否是最小的節點,如果是最小節點,則獲得鎖。

如果不是最小節點,則阻塞等待鎖,並且獲得該節點的上一順序節點,爲其註冊監聽事件,等待節點對應的操作獲得鎖。

當調用完共享資源後,刪除該節點,關閉 zk,進而可以觸發監聽事件,釋放該鎖。image

以上實現的分佈式鎖是嚴格按照順序訪問的併發鎖。一般我們還可以直接引用 Curator 框架來實現 Zookeeper 分佈式鎖,代碼如下:

InterProcessMutex lock = new InterProcessMutex(client, lockPath);
if ( lock.acquire(maxWait, waitUnit) )
{
  try
  {
      // do some work inside of the critical section here
  }
  finally
  {
      lock.release();
  }
}

Zookeeper 實現的分佈式鎖,例如相對數據庫實現,有很多優點。Zookeeper 是集羣實現,可以避免單點問題,且能保證每次操作都可以有效地釋放鎖,這是因爲一旦應用服務掛掉了,臨時節點會因爲 session 連接斷開而自動刪除掉。

由於頻繁地創建和刪除結點,加上大量的 Watch 事件,對 Zookeeper 集羣來說,壓力非常大。且從性能上來說,其與接下來我要講的 Redis 實現的分佈式鎖相比,還是存在一定的差距。

Redis 實現分佈式鎖

相對於前兩種實現方式,基於 Redis 實現的分佈式鎖是最爲複雜的,但性能是最佳的。

大部分開發人員利用 Redis 實現分佈式鎖的方式,都是使用 SETNX+EXPIRE 組合來實現,在 Redis 2.6.12 版本之前,具體實現代碼如下:

public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {

  Long result = jedis.setnx(lockKey, requestId);// 設置鎖
  if (result == 1) {// 獲取鎖成功
      // 若在這裏程序突然崩潰,則無法設置過期時間,將發生死鎖
      jedis.expire(lockKey, expireTime);// 通過過期時間刪除鎖
      return true;
  }
  return false;
}

我們也可以通過 Lua 腳本來實現鎖的設置和過期時間的原子性,再通過 jedis.eval() 方法運行該腳本:

   private static final String SCRIPT_LOCK = "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then redis.call('pexpire', KEYS[1], ARGV[2]) return 1 else return 0 end";
   // 解鎖腳本
   private static final String SCRIPT_UNLOCK = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; 

雖然 SETNX 方法保證了設置鎖和過期時間的原子性,但如果我們設置的過期時間比較短,而執行業務時間比較長,就會存在鎖代碼塊失效的問題。我們需要將過期時間設置得足夠長,來保證以上問題不會出現。

這個方案是目前最優的分佈式鎖方案,但如果是在 Redis 集羣環境下,依然存在問題。由於 Redis 集羣數據同步到各個節點時是異步的,如果在 Master 節點獲取到鎖後,在沒有同步到其它節點時,Master 節點崩潰了,此時新的 Master 節點依然可以獲取鎖,所以多個應用服務可以同時獲取到鎖。

Redlock 算法

Redisson 由 Redis 官方推出,它是一個在 Redis 的基礎上實現的 Java 駐內存數據網格(In-Memory Data Grid)。它不僅提供了一系列的分佈式的 Java 常用對象,還提供了許多分佈式服務。Redisson 是基於 netty 通信框架實現的,所以支持非阻塞通信,性能相對於我們熟悉的 Jedis 會好一些。

Redisson 中實現了 Redis 分佈式鎖,且支持單點模式和集羣模式。在集羣模式下,Redisson 使用了 Redlock 算法,避免在 Master 節點崩潰切換到另外一個 Master 時,多個應用同時獲得鎖。我們可以通過一個應用服務獲取分佈式鎖的流程,瞭解下 Redlock 算法的實現:

在不同的節點上使用單個實例獲取鎖的方式去獲得鎖,且每次獲取鎖都有超時時間,如果請求超時,則認爲該節點不可用。當應用服務成功獲取鎖的 Redis 節點超過半數(N/2+1,N 爲節點數) 時,並且獲取鎖消耗的實際時間不超過鎖的過期時間,則獲取鎖成功。

一旦獲取鎖成功,就會重新計算釋放鎖的時間,該時間是由原來釋放鎖的時間減去獲取鎖所消耗的時間;而如果獲取鎖失敗,客戶端依然會釋放獲取鎖成功的節點。

具體的代碼實現如下:

1.首先引入 jar 包:

<dependency>
     <groupId>org.redisson</groupId>
     <artifactId>redisson</artifactId>
     <version>3.8.2</version>
</dependency></pre>

2.實現 Redisson 的配置文件:

@Bean
public RedissonClient redissonClient() {
   Config config = new Config();
   config.useClusterServers()
          .setScanInterval(2000) // 集羣狀態掃描間隔時間,單位是毫秒
          .addNodeAddress("redis://127.0.0.1:7000).setPassword("1")
          .addNodeAddress("redis://127.0.0.1:7001").setPassword("1")
          .addNodeAddress("redis://127.0.0.1:7002")
          .setPassword("1");
   return Redisson.create(config);
}

3.獲取鎖操作:

long waitTimeout = 10;
long leaseTime = 1;
RLock lock1 = redissonClient1.getLock("lock1");
RLock lock2 = redissonClient2.getLock("lock2");
RLock lock3 = redissonClient3.getLock("lock3");

RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
// 同時加鎖:lock1 lock2 lock3
// 紅鎖在大部分節點上加鎖成功就算成功,且設置總超時時間以及單個節點超時時間
redLock.trylock(waitTimeout,leaseTime,TimeUnit.SECONDS);

redLock.unlock();</pre>

總結

實現分佈式鎖的方式有很多,有最簡單的數據庫實現,還有 Zookeeper 多節點實現和緩存實現。我們可以分別對這三種實現方式進行性能壓測,可以發現在同樣的服務器配置下,Redis 的性能是最好的,Zookeeper 次之,數據庫最差。

從實現方式和可靠性來說,Zookeeper 的實現方式簡單,且基於分佈式集羣,可以避免單點問題,具有比較高的可靠性。因此,在對業務性能要求不是特別高的場景中,我建議使用 Zookeeper 實現的分佈式鎖。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章