隨着架構系統的演進,由純真的單機架構到容器化編排的分佈式架構,能夠說是一個大型互聯網企業發展的必然走向。在網站初創時,應用數量和用戶較少,能夠把 Tomcat 和Mysql 部署在同一臺機器上。隨着用戶數量增多,訪問量增大,併發升高,Tomcat 和 MySQL 競爭資源,此時,單機已經扛不住了,須要把 Tomcat 和 MySQL 分離在不一樣的機器上,用於提高單臺機器的處理能力。業務歷來沒有減小,產品越作越大。應用也愈來愈複雜,原來的大應用,拆分紅多個小應用,加入各級緩存,作了反向代理負責均衡,最後墜入分庫分表的深淵。html
微服務漸漸代替了龐大冗雜的服務,每一個小服務,各司其職。這時候是否是就不存在資源競爭的問題了呢?答案毋庸置疑,在架構的演進過程當中,無時無刻都存在着資源競爭的問題。java
提及資源競爭的問題,是否是想起了在計算機科學中的一個經典問題——哲學家就餐,也就是在並行計算中多線程同步( Synchronization )時產生的問題?哲學家就餐問題用來解釋死鎖和資源耗盡的問題,咱們不作詳細的討論,感興趣的同窗能夠搜索資料瞭解。既然存在資源競爭的問題,解決的方案必然是對資源加鎖,對於鎖你們確定不陌生,在 Java 中synchronized 關鍵字和 ReentrantLock 可重入鎖在咱們的代碼或者一些開源代碼中隨處可見的,通常用於在本地多線程環境中控制對資源的併發訪問。可是隨着微服務架構的蓬勃興起,分佈式的快速發展,本地加鎖已經不能知足咱們的業務需求,若是還經過本地加鎖的方式鎖定資源,在分佈式環境中是無用的。因而人們爲了在分佈式環境中也能實現本地鎖的效果,也是紛紛各出其招。node
Martin Kleppmann 是英國劍橋大學的分佈式系統的研究員,以前和 Redis 之父 Antirez 進行過關於 RedLock(紅鎖,後續有講到)是否安全的激烈討論。Martin 認爲通常咱們使用分佈式鎖有兩個場景:mysql
在瞭解分佈式鎖以前,咱們首先要了解操做系統級別的鎖(特指 Linux 系統)和 Java 編髮編程時遇到的鎖。對 Linux 鎖和 Java鎖有大概的瞭解後,咱們深刻分析分佈式鎖的實現機制。若是還想深刻了解 Linux 的鎖相關的信息,可查閱參考文章。linux
在現代操做系統裏,同一時間可能有多個內核執行命令在執行,所以內核其實像多進程多線程編程同樣也須要一些同步機制來同步各執行單元對共享數據的訪問。尤爲是在多核CPU 系統上,更須要一些同步機制來同步不一樣處理器上的執行單元對共享的數據的訪問。在主流的 Linux 內核中包含了幾乎全部現代的操做系統具備的同步機制,這些同步機制包括:算法
在不少書寫Java併發的文章中,咱們常常看到有這些鎖的概念。這些概念中,並不全指鎖的狀態,有的是指所得特性,有的是指所得設計。本文僅僅簡要敘述鎖的概念,不過多涉及Java鎖的實現,這部份內容放在《Javaer不得不說的 Java 「鎖」事》一文中。sql
獨享鎖 / 共享鎖:數據庫
互斥鎖 / 讀寫鎖:apache
樂觀鎖 / 悲觀鎖:編程
偏向鎖 / 輕量級鎖 / 重量級鎖:
對系統內核鎖和Java鎖有初步的瞭解以後,咱們總結髮現,所必需的要有如下特色:
通常實現分佈式鎖有如下幾個方式:
下面就 MySQL 和 zk curator 客戶端加鎖的實現方式逐一列舉,關於 Redis、Zk 原生客戶端、etcd 等其餘方式的分佈式鎖的實現原理,放在後面的章節。
MySQL實現分佈式鎖相對簡單,建立一張鎖資源表。
CREATE TABLE resource_lock ( `id` BIGINT(20) UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT, `resource_name` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '資源名稱', `node_info` VARCHAR(128) NULL DEFAULT NULL COMMENT '', `count` INT(10) NOT NULL DEFAULT '0' COMMENT '', `description` VARCHAR(128) NULL DEFAULT NULL COMMENT '', `gmt_create` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '', `gmt_modify` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '', UNIQUE KEY `uk_resource` (`resource_name`) ) ENGINE = InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '資源加鎖表';
前面分佈式鎖所說的 lock(),trylock(long timeout),trylock() 這幾個方法能夠用下面的僞代碼實現。
lock
通常是阻塞式的獲取鎖,阻塞知道獲取到鎖或者異常結束,那麼咱們能夠寫一個死循環來執行其操做:
public void lock() { while(true) { if (mysqlLock.lock(resoureName)) { return; } // 休眠3ms後重試 LockSuprot.parkNanos(1000*1000*3); } }
mysqlLock.lcok()
內部是一個SQL,爲了達到可重入鎖的效果那麼咱們應該先進行查詢,若是有值,那麼須要比較 node_info 是否一致,這裏的 node_info 能夠用機器 IP 和線程名字來表示,若是一致那麼就加可重入鎖 count 的值,若是不一致那麼就返回false。若是沒有值那麼直接插入一條數據。僞代碼以下:
public class MysqlLock { @Resource private MysqlLockMapper mysqlLockMapper; private NodeInfo nodeInfo; public MysqlLock(NodeInfo nodeInfo) { this.nodeInfo = nodeInfo; } @Transcation public boolean lock(String resourceName) { MyResource result = mysqlLockMapper.existsResource(resourceName); if (result != null) { if (Objects.equeals(nodeInfo, result.getNodeInfo())) { mysqlLockMapper.updateResourceCount(resourceName); return true; } else { return false; } } else { mysqlLockMapper.insertResource(resourceName, nodeInfo); } } }
須要注意的是這一段代碼須要加事務,必需要保證這一系列操做的原子性。
上面代碼對應的三條 Mybatis 版的SQL語句以下:
-- mysqlLockMapper.existsResource select * from resource_lock where resource_name = ${resourceName} for update -- mysqlLockMapper.updateResourceCount update resource_lock set count = count + 1 where resource_name = ${resourceName} -- mysqlLockMapper.insertResource insert into resource_lock(`resource_name`,`node_info`,`count`,`description`) values(#{resourceName}, ${nodeInfo}, 1, '')
tryLock() 是非阻塞獲取鎖,若是獲取不到那麼就會立刻返回,代碼能夠以下:
public boolean tryLock() { return mysqlLock.lock(resourceName); }
public boolean trylock(long millsecs) { // 記錄超時時間 long deadline = System.currentTimeMillis() + millsecs; while(true) { if (mysqlLock.tryLock()) { return true; } deadline = deadline - millsecs; // 避免網絡延遲引發加鎖失敗,增長自旋超時閾值,可設置爲300ms if (deadline <= spinTimeoutThreshold) { return false; } if (millsecs <= 0) { return false; } } }
mysqlLock.lock 和上面同樣,可是要注意的是 select … for update 這個是阻塞的獲取行鎖,若是同一個資源併發量較大仍是有可能會退化成阻塞的獲取鎖。
public boolean unlock() { MyResource result = mysqlLockMapper.existsResource(resourceName); if (result != null) { if (Objects.equeals(nodeInfo, result.getNodeInfo())) { if (result.getCount() > 1) { // count - 1 mysqlLockMapper.decrementResource(resourceName); } else { mysqlLockMapper.deleteResource(resourceName); } } else { return false; } } else { return false; } }
上面新增兩條 Mybatis 版本的SQL語句:
-- mysqlLockMapper.decrementResource(resourceName) update resource_lock set count = count - 1 where resource_name = ${resourceName} -- mysqlLockMapper.deleteResource(resourceName) delete from resource_lock where resource_name = ${resourceName}
咱們注意到,鎖的釋放是經過 delete 語句刪除資源鎖的,若是加鎖的客戶端因爲某些緣由掛掉了,鎖就一直存在。這時,咱們能夠經過定時任務,在加鎖的時候添加任務到任務系統,也能夠經過定時任務檢查釋放鎖。
ZooKeeper也是咱們常見的實現分佈式鎖方法,ZooKeeper 是以 Paxos 算法爲基礎分佈式應用程序協調服務。Zk 的數據節點和文件目錄相似,因此咱們能夠用此特性實現分佈式鎖。咱們以某個資源爲目錄,而後這個目錄下面的節點就是咱們須要獲取鎖的客戶端,未獲取到鎖的客戶端註冊須要註冊 Watcher 到上一個客戶端,能夠用下圖表示。
/lock是咱們用於加鎖的目錄,/resource_name是咱們鎖定的資源,其下面的節點按照咱們加鎖的順序排列。
Curator 封裝了 Zookeeper 底層的 API,使咱們更加容易方便的對 Zookeeper 進行操做,而且它封裝了分佈式鎖的功能,這樣咱們就不須要再本身實現了。
Curator 實現了可重入鎖(InterProcessMutex),也實現了不可重入鎖(InterProcessSemaphoreMutex)。在可重入鎖中還實現了讀寫鎖。
Curator-Recipes實現了五種分佈式鎖:
下面就分佈式可重入鎖舉例。
InterProcessMutex 是 Curator 實現的可重入鎖,建立 InterProcessMutex 實例
InterProcessMutex 提供了兩個構造方法,傳入一個 CuratorFramework 實例和一個要使用的節點路徑,InterProcessMutex 還容許傳入一個自定義的驅動類,默認是使用 StandardLockInternalsDriver。
public InterProcessMutex(CuratorFramework client, String path); public InterProcessMutex(CuratorFramework client, String path, LockInternalsDriver driver);
使用 acquire 方法獲取鎖, acquire 方法有兩種:
public void acquire() throws Exception;
獲取鎖,一直阻塞到獲取到鎖爲止。獲取鎖的線程在獲取鎖後仍然能夠調用 acquire()
獲取鎖(可重入)。 鎖獲取使用完後,調用了幾回 acquire()
,就得調用幾回 release()
釋放。
public boolean acquire(long time, TimeUnit unit) throws Exception;
與 acquire()相似,等待 time * unit 時間獲取鎖,若是仍然沒有獲取鎖,則直接返回 false。
public class FakeLimitedResource { //總共250張火車票 private Integer ticket = 250; public void use() throws InterruptedException { try { System.out.println("火車票還剩"+(--ticket)+"張!"); }catch (Exception e){ e.printStackTrace(); } } }
public class ExampleClientThatLocks { /** 鎖 */ private final InterProcessMutex lock; /** 共享資源 */ private final FakeLimitedResource resource; /** 客戶端名稱 */ private final String clientName; public ExampleClientThatLocks(CuratorFramework client, String lockPath, FakeLimitedResource resource, String clientName) { this.resource = resource; this.clientName = clientName; lock = new InterProcessMutex(client, lockPath); } public void doWork(long time, TimeUnit unit) throws Exception { if ( !lock.acquire(time, unit) ) { throw new IllegalStateException(clientName + " could not acquire the lock"); } try { System.out.println(clientName + " has the lock"); //操做資源 resource.use(); } finally { System.out.println(clientName + " releasing the lock"); lock.release(); //老是在Final塊中釋放鎖。 } } }
public class LockingExample { private static final int QTY = 5; // 併發操做線程數 private static final int REPETITIONS = QTY * 10; // 資源總量 private static final String CONNECTION_STRING = "127.0.0.1:2181"; private static final String PATH = "/locks"; public static void main(String[] args) throws Exception { //FakeLimitedResource模擬某些外部資源,這些外部資源一次只能由一個進程訪問 final FakeLimitedResource resource = new FakeLimitedResource(); ExecutorService service = Executors.newFixedThreadPool(QTY); try { for ( int i = 0; i < QTY; ++i ){ final int index = i; Callable<Void> task = new Callable<Void>() { @Override public Void call() throws Exception { CuratorFramework client = CuratorFrameworkFactory.newClient(CONNECTION_STRING, new ExponentialBackoffRetry(1000, 3,Integer.MAX_VALUE)); try { client.start(); ExampleClientThatLocks example = new ExampleClientThatLocks(client, PATH, resource, "Client " + index); for ( int j = 0; j < REPETITIONS; ++j ) { example.doWork(10, TimeUnit.SECONDS); } }catch ( InterruptedException e ){ Thread.currentThread().interrupt(); }catch ( Exception e ){ e.printStackTrace(); }finally{ CloseableUtils.closeQuietly(client); } return null; } }; service.submit(task); } service.shutdown(); service.awaitTermination(10, TimeUnit.MINUTES); }catch (Exception e){ e.printStackTrace(); } } }
起五個線程,即五個窗口賣票,五個客戶端分別有50張票能夠賣,先是嘗試獲取鎖,操做資源後,釋放鎖。
加鎖的流程具體以下:
線程經過 acquire() 獲取鎖時,可經過 release()進行釋放,若是該線程屢次調用了 acquire() 獲取鎖,則若是隻調用一次 release() 該鎖仍然會被該線程持有。
note:同一個線程中InterProcessMutex實例是可重用的,也就是不須要在每次獲取鎖的時候都new一個InterProcessMutex實例,用同一個實例就好。
解鎖的具體流程:
Curator提供了讀寫鎖,其實現類是 InterProcessReadWriteLock,這裏的每一個節點都會加上前綴:
private static final String READ_LOCK_NAME = "__READ__"; private static final String WRITE_LOCK_NAME = "__WRIT__";
根據不一樣的前綴區分是讀鎖仍是寫鎖,對於讀鎖,若是發現前面有寫鎖,那麼須要將 watcher 註冊到和本身最近的寫鎖。寫鎖的邏輯和咱們以前分析的依然保持不變。
Zookeeper不須要配置鎖超時,因爲咱們設置節點是臨時節點,咱們的每一個機器維護着一個ZK的session,經過這個session,ZK能夠判斷機器是否宕機。若是咱們的機器掛掉的話,那麼這個臨時節點對應的就會被刪除,因此咱們不須要關心鎖超時。
client1 獲取了鎖而且設置了鎖的超時時間,可是 client1 以後出現了 STW,這個 STW 時間比較長,致使分佈式鎖進行了釋放,client2 獲取到了鎖,這個時候 client1 恢復了鎖,那麼就會出現 client1,client2 同時獲取到鎖,這個時候分佈式鎖不安全問題就出現了。這個其實不只僅侷限於 RedLock,對於咱們的 ZK,Mysql 同樣的有一樣的問題。
對於這個問題能夠看見基本全部的都會出現問題,Martin 給出了一個解法,對於 ZK 這種他會生成一個自增的序列,那麼咱們真正進行對資源操做的時候,須要判斷當前序列是不是最新,有點相似於咱們樂觀鎖。固然這個解法Redis做者進行了反駁,你既然都能生成一個自增的序列了那麼你徹底不須要加鎖了,也就是能夠按照相似於Mysql樂觀鎖的解法去作。
Martin 以爲 RedLock 不安全很大的緣由也是由於時鐘的跳躍,由於鎖過時強依賴於時間,可是 ZK 不須要依賴時間,依賴每一個節點的 Session。Redis做者也給出瞭解答:對於時間跳躍分爲人爲調整和 NTP 自動調整。
這一塊不是他們討論的重點,我本身以爲,對於這個問題的優化能夠控制網絡調用的超時時間,把全部網絡調用的超時時間相加,那麼咱們鎖過時時間其實應該大於這個時間,固然也能夠經過優化網絡調用好比串行改爲並行,異步化等。能夠參考下面兩篇文章:
並行化-你的高併發大殺器,異步化-你的高併發大殺器
該文首發《虛懷若谷》我的博客,轉載前請務必署名,轉載請標明出處。
古之善爲道者,微妙玄通,深不可識。夫惟不可識,故強爲之容:豫兮若冬涉川,猶兮若畏四鄰,儼兮其若客,渙兮若冰之釋,敦兮其若樸,曠兮其若谷,混兮其若濁。
孰能濁以靜之徐清?孰能安以動之徐生?
保此道不欲盈。夫惟不盈,故能敝而新成。
請關注個人微信公衆號:下雨就像彈鋼琴,Thanks♪(・ω・)ノ