分佈式鎖的所有套路

故事凌

什麼是分佈式鎖?分佈式鎖是控制分佈式系統之間同步訪問共享資源的一種方式。在分佈式系統中,常常需要協調他們的動作。


分佈式鎖的所有套路

如果不同的系統或是同一個系統的不同主機之間共享了一個或一組資源,那麼訪問這些資源的時候,往往需要互斥來防止彼此干擾來保證一致性,在這種情況下,便需要使用到分佈式鎖。


為什麼要使用分佈式鎖


為了保證一個方法或屬性在高併發情況下的同一時間只能被同一個線程執行。


在傳統單體應用單機部署的情況下,可以使用 Java 併發處理相關的 API(如 ReentrantLock 或 Synchronized)進行互斥控制;在單機環境中,Java 中提供了很多併發處理相關的 API。


但是,隨著業務發展的需要,原單體單機部署的系統被演化成分佈式集群系統後,由於分佈式系統多線程、多進程並且分佈在不同機器上,這將使原單機部署情況下的併發控制鎖策略失效,單純的 Java API 並不能提供分佈式鎖的能力。


為了解決這個問題就需要一種跨 JVM 的互斥機制來控制共享資源的訪問,這就是分佈式鎖要解決的問題!

舉個例子:機器 A,機器 B 是一個集群。A,B 兩臺機器上的程序都是一樣的,具備高可用性能。


A,B 機器都有一個定時任務,每天晚上凌晨 2 點需要執行一個定時任務,但是這個定時任務只能執行一遍,否則的話就會報錯。


那 A,B 兩臺機器在執行的時候,就需要搶鎖,誰搶到鎖,誰執行,誰搶不到,就不用執行了!


鎖的處理


鎖的處理方式如下:

  • 單個應用中使用鎖:(單進程多線程)Synchronize。
  • 分佈式鎖控制分佈式系統之間同步訪問資源的一種方式。
  • 分佈式鎖是控制分佈式系統之間同步訪問共享資源的一種方式。


分佈式鎖的實現


分佈式鎖的實現方式如下:

  • 基於數據的樂觀鎖實現分佈式鎖
  • 基於 Zookeeper 臨時節點的分佈式鎖
  • 基於 Redis 的分佈式鎖


Redis 的分佈式鎖


獲取鎖


在 set 命令中,有很多選項可以用來修改命令的行為,以下是 set 命令可用選項的基本語法:

<code>redis 127.0.0.1:6379>SET KEY VALUE [EX seconds] [PX milliseconds] [NX|XX]

- EX seconds 設置指定的到期時間(單位為秒)
- PX milliseconds 設置指定的到期時間(單位毫秒)
- NX: 僅在鍵不存在時設置鍵
- XX: 只有在鍵已存在時設置/<code>


方式 1:推介

<code>   private static final String LOCK_SUCCESS = "OK";
   private static final String SET_IF_NOT_EXIST = "NX";
   private static final String SET_WITH_EXPIRE_TIME = "PX";

public static boolean getLock(JedisCluster jedisCluster, String lockKey, String requestId, int expireTime) {
       // NX: 保證互斥性
       String result = jedisCluster.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
       if (LOCK_SUCCESS.equals(result)) {
           return true;
      }
       return false;
  }/<code>


方式 2:

<code>public static boolean getLock(String lockKey,String requestId,int expireTime) {
    Long result = jedis.setnx(lockKey, requestId);
    if(result == 1) {
        jedis.expire(lockKey, expireTime);
        return true;
    }
    return false;
}/<code>


注意:推介方式 1,因為方式 2 中 setnx 和 expire 是兩個操作,並不是一個原子操作,如果 setnx 出現問題,就是出現死鎖的情況,所以推薦方式 1。


釋放鎖


方式 1:del 命令實現

<code>public static void releaseLock(String lockKey,String requestId) {
   if (requestId.equals(jedis.get(lockKey))) {
       jedis.del(lockKey);
  }
}/<code>


方式 2:Redis+Lua 腳本實現(推薦)

<code>public static boolean releaseLock(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 (result.equals(1L)) {
           return true;
}
       return false;
  }/<code>


Zookeeper 的分佈式鎖


Zookeeper 分佈式鎖實現原理


理解了鎖的原理後,就會發現,Zookeeper 天生就是一副分佈式鎖的胚子。


首先,Zookeeper 的每一個節點,都是一個天然的順序發號器。


在每一個節點下面創建子節點時,只要選擇的創建類型是有序(EPHEMERAL_SEQUENTIAL 臨時有序或者 PERSISTENT_SEQUENTIAL 永久有序)類型,那麼,新的子節點後面,會加上一個次序編號。


這個次序編號,是上一個生成的次序編號加 1,比如,創建一個用於發號的節點“/test/lock”,然後以他為父親節點,在這個父節點下面創建相同前綴的子節點。


假定相同的前綴為“/test/lock/seq-”,在創建子節點時,同時指明是有序類型。


如果是第一個創建的子節點,那麼生成的子節點為 /test/lock/seq-0000000000,下一個節點則為 /test/lock/seq-0000000001,依次類推,等等。

分佈式鎖的所有套路

其次,Zookeeper 節點的遞增性,可以規定節點編號最小的那個獲得鎖。


一個 Zookeeper 分佈式鎖,首先需要創建一個父節點,儘量是持久節點(PERSISTENT 類型),然後每個要獲得鎖的線程都會在這個節點下創建個臨時順序節點,由於序號的遞增性,可以規定排號最小的那個獲得鎖。


所以,每個線程在嘗試佔用鎖之前,首先判斷自己是排號是不是當前最小,如果是,則獲取鎖。


第三,Zookeeper 的節點監聽機制,可以保障佔有鎖的方式有序而且高效。


每個線程搶佔鎖之前,先搶號創建自己的 ZNode。同樣,釋放鎖的時候,就需要刪除搶號的 Znode。


搶號成功後,如果不是排號最小的節點,就處於等待通知的狀態。等誰的通知呢?不需要其他人,只需要等前一個 Znode 的通知就可以了。


當前一個 Znode 刪除的時候,就是輪到了自己佔有鎖的時候。第一個通知第二個、第二個通知第三個,擊鼓傳花似的依次向後。


Zookeeper 的節點監聽機制,可以說能夠非常完美的,實現這種擊鼓傳花似的信息傳遞。


具體的方法是,每一個等通知的 Znode 節點,只需要監聽 linsten 或者 watch 監視排號在自己前面那個,而且緊挨在自己前面的那個節點。


只要上一個節點被刪除了,就進行再一次判斷,看看自己是不是序號最小的那個節點,如果是,則獲得鎖。


為什麼說 Zookeeper 的節點監聽機制,可以說是非常完美呢?


一條龍式的首尾相接,後面監視前面,就不怕中間截斷嗎?比如,在分佈式環境下,由於網絡的原因,或者服務器掛了或者其他的原因,如果前面的那個節點沒能被程序刪除成功,後面的節點不就永遠等待麼?


其實,Zookeeper 的內部機制,能保證後面的節點能夠正常的監聽到刪除和獲得鎖。


在創建取號節點的時候,儘量創建臨時 Znode 節點而不是永久 Znode 節點。


一旦這個 Znode 的客戶端與 Zookeeper 集群服務器失去聯繫,這個臨時 Znode 也將自動刪除。排在它後面的那個節點,也能收到刪除事件,從而獲得鎖。


說 Zookeeper 的節點監聽機制,是非常完美的。還有一個原因。Zookeeper 這種首尾相接,後面監聽前面的方式,可以避免羊群效應。


所謂羊群效應就是每個節點掛掉,所有節點都去監聽,然後做出反映,這樣會給服務器帶來巨大壓力,所以有了臨時順序節點,當一個節點掛掉,只有它後面的那一個節點才做出反映。


Zookeeper 分佈式鎖實現示例


Zookeeper 是通過臨時節點來實現分佈式鎖:

<code>import org.apache.curator.RetryPolicy;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.junit.Before;
import org.junit.Test;

/**
* @ClassName ZookeeperLock
* @Description TODO
* @Author lingxiangxiang
* @Date 2:57 PM
* @Version 1.0
**/
public class ZookeeperLock {
   // 定義共享資源
   private static int NUMBER = 10;

   private static void printNumber() {
       // 業務邏輯: 秒殺

       System.out.println("*********業務方法開始************\\n");
       System.out.println("當前的值: " + NUMBER);
       NUMBER--;
       try {
           Thread.sleep(2000);
      } catch (InterruptedException e) {
           e.printStackTrace();
      }
       System.out.println("*********業務方法結束************\\n");

  }

   // 這裡使用@Test會報錯
   public static void main(String[] args) {
       // 定義重試的側策略 1000 等待的時間(毫秒) 10 重試的次數
       RetryPolicy policy = new ExponentialBackoffRetry(1000, 10);

       // 定義zookeeper的客戶端
       CuratorFramework client = CuratorFrameworkFactory.builder()
              .connectString("10.231.128.95:2181,10.231.128.96:2181,10.231.128.97:2181")
              .retryPolicy(policy)
              .build();
       // 啟動客戶端
       client.start();

       // 在zookeeper中定義一把鎖
       final InterProcessMutex lock = new InterProcessMutex(client, "/mylock");

       //啟動是個線程
       for (int i = 0; i <10; i++) {
           new Thread(new Runnable() {
               @Override
               public void run() {
                   try {
                       // 請求得到的鎖
                       lock.acquire();
                       printNumber();
                  } catch (Exception e) {
                       e.printStackTrace();
                  } finally {
                       // 釋放鎖
                       try {
                           lock.release();

                      } catch (Exception e) {
                           e.printStackTrace();
                      }
                  }
              }
          }).start();
      }

  }
}/<code>


基於數據的分佈式鎖


我們在討論使用分佈式鎖的時候往往首先排除掉基於數據庫的方案,本能的會覺得這個方案不夠“高級”。


從性能的角度考慮,基於數據庫的方案性能確實不夠優異,整體性能對比:緩存>Zookeeper、etcd>數據庫。


也有人提出基於數據庫的方案問題很多,不太可靠。數據庫的方案可能並不適合於頻繁寫入的操作。


下面我們來了解一下基於數據庫(MySQL)的方案,一般分為三類:

  • 基於表記錄
  • 樂觀鎖
  • 悲觀鎖


基於表記錄


要實現分佈式鎖,最簡單的方式可能就是直接創建一張鎖表,然後通過操作該表中的數據來實現了。


當我們想要獲得鎖的時候,就可以在該表中增加一條記錄,想要釋放鎖的時候就刪除這條記錄。


為了更好的演示,我們先創建一張數據庫表,參考如下:

<code>CREATE TABLE `database_lock` ( 

`id` BIGINT NOT NULL AUTO_INCREMENT,
`resource` int NOT NULL COMMENT '鎖定的資源',
`description` varchar(1024) NOT NULL DEFAULT "" COMMENT '描述',
PRIMARY KEY (`id`),
UNIQUE KEY `uiq_idx_resource` (`resource`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='數據庫分佈式鎖表';/<code>


①獲得鎖


我們可以插入一條數據:

<code>INSERT INTO database_lock(resource, description) VALUES (1, 'lock');/<code>


因為表 database_lock 中 resource 是唯一索引,所以其他請求提交到數據庫,就會報錯,並不會插入成功,只有一個可以插入。插入成功,我們就獲取到鎖。


②刪除鎖

<code>INSERT INTO database_lock(resource, description) VALUES (1, 'lock');/<code>

這種實現方式非常的簡單,但是需要注意以下幾點:


①這種鎖沒有失效時間,一旦釋放鎖的操作失敗就會導致鎖記錄一直在數據庫中,其他線程無法獲得鎖。這個缺陷也很好解決,比如可以做一個定時任務去定時清理。


②這種鎖的可靠性依賴於數據庫。建議設置備庫,避免單點,進一步提高可靠性。


③這種鎖是非阻塞的,因為插入數據失敗之後會直接報錯,想要獲得鎖就需要再次操作。


如果需要阻塞式的,可以弄個 for 循環、while 循環之類的,直至 INSERT 成功再返回。


④這種鎖也是非可重入的,因為同一個線程在沒有釋放鎖之前無法再次獲得鎖,因為數據庫中已經存在同一份記錄了。


想要實現可重入鎖,可以在數據庫中添加一些字段,比如獲得鎖的主機信息、線程信息等。


那麼在再次獲得鎖的時候可以先查詢數據,如果當前的主機信息和線程信息等能被查到的話,可以直接把鎖分配給它。


樂觀鎖


顧名思義,系統認為數據的更新在大多數情況下是不會產生衝突的,只在數據庫更新操作提交的時候才對數據作衝突檢測。如果檢測的結果出現了與預期數據不一致的情況,則返回失敗信息。

分佈式鎖的所有套路

樂觀鎖大多數是基於數據版本(version)的記錄機制實現的。何謂數據版本號?


即為數據增加一個版本標識,在基於數據庫表的版本解決方案中,一般是通過為數據庫表添加一個 “version”字段來實現讀取出數據時,將此版本號一同讀出,之後更新時,對此版本號加 1。


在更新過程中,會對版本號進行比較,如果是一致的,沒有發生改變,則會成功執行本次操作;如果版本號不一致,則會更新失敗。


為了更好的理解數據庫樂觀鎖在實際項目中的使用,這裡也就舉了業界老生常談的庫存例子。


一個電商平臺都會存在商品的庫存,當用戶進行購買的時候就會對庫存進行操作(庫存減 1 代表已經賣出了一件)。


如果只是一個用戶進行操作數據庫本身就能保證用戶操作的正確性,而在併發的情況下就會產生一些意想不到的問題。


比如兩個用戶同時購買一件商品,在數據庫層面實際操作應該是庫存進行減 2 操作。


但是由於高併發的情況,第一個用戶購買完成進行數據讀取當前庫存並進行減 1 操作,由於這個操作沒有完全執行完成。


第二個用戶就進入購買相同商品,此時查詢出的庫存可能是未減 1 操作的庫存導致了髒數據的出現【線程不安全操作】。


數據庫樂觀鎖也能保證線程安全,通常代碼層面我們都會這樣做:

<code>select goods_num from goods where goods_name = "小本子";
update goods set goods_num = goods_num -1 where goods_name = "小本子";/<code>


上面的 SQL 是一組的,通常先查詢出當前的 goods_num,然後再 goods_num 上進行減 1 的操作修改庫存。


當併發的情況下,這條語句可能導致原本庫存為 3 的一個商品經過兩個人購買還剩下 2 庫存的情況就會導致商品的多賣。那麼數據庫樂觀鎖是如何實現的呢?


首先定義一個 version 字段用來當作一個版本號,每次的操作就會變成這樣:

<code>select goods_num,version from goods where goods_name = "小本子";
update goods set goods_num = goods_num -1,version =查詢的version值自增 where goods_name ="小本子" and version=查詢出來的version;/<code>


其實,藉助更新時間戳(updated_at)也可以實現樂觀鎖,和採用 version 字段的方式相似。


更新操作執行前線獲取記錄當前的更新時間,在提交更新時,檢測當前更新時間是否與更新開始時獲取的更新時間戳相等。


悲觀鎖


除了可以通過增刪操作數據庫表中的記錄以外,我們還可以藉助數據庫中自帶的鎖來實現分佈式鎖。


在查詢語句後面增加 FOR UPDATE,數據庫會在查詢過程中給數據庫表增加悲觀鎖,也稱排他鎖。當某條記錄被加上悲觀鎖之後,其它線程也就無法再改行上增加悲觀鎖。


悲觀鎖,與樂觀鎖相反,總是假設最壞的情況,它認為數據的更新在大多數情況下是會產生衝突的。


在使用悲觀鎖的同時,我們需要注意一下鎖的級別。MySQL InnoDB 引起在加鎖的時候,只有明確地指定主鍵(或索引)的才會執行行鎖 (只鎖住被選取的數據),否則 MySQL 將會執行表鎖(將整個數據表單給鎖住)。


在使用悲觀鎖時,我們必須關閉 MySQL 數據庫的自動提交屬性(參考下面的示例),因為 MySQL 默認使用 autocommit 模式。


也就是說,當你執行一個更新操作後,MySQL 會立刻將結果進行提交。

<code>mysql> SET AUTOCOMMIT = 0;
Query OK, 0 rows affected (0.00 sec)/<code>


這樣在使用 FOR UPDATE 獲得鎖之後可以執行相應的業務邏輯,執行完之後再使用 COMMIT 來釋放鎖。


我們不妨沿用前面的 database_lock 表來具體表述一下用法。假設有一線程A需要獲得鎖並執行相應的操作。


那麼它的具體步驟如下:

<code>STEP1 - 獲取鎖:SELECT * FROM database_lock WHERE id = 1 FOR UPDATE;。
STEP2 - 執行業務邏輯。
STEP3 - 釋放鎖:COMMIT。/<code>


簡介:生活中的段子手,目前就職於一家地產公司做 DevOPS 相關工作, 曾在大型互聯網公司做高級運維工程師,熟悉 Linux 運維,Python 運維開發,Java 開發,DevOPS 常用開發組件等,個人公眾號:stromling,歡迎來撩我哦!


分享到:


相關文章: