整理分佈式鎖:業務場景&分佈式鎖家族&實現原理

一、引入業務場景

業務場景一出現php

由於小T剛接手項目,正在吭哧吭哧對熟悉着代碼、部署架構。在看代碼過程當中發現,下單這塊代碼可能會出現問題,這但是分佈式部署的,若是多個用戶同時購買同一個商品,就可能致使商品出現 庫存超賣 (數據不一致) 現象,對於這種狀況代碼中並無作任何控制。html

原來一問才知道,之前他們都是售賣的虛擬商品,沒啥庫存一說,因此當時沒有考慮那麼多...java

此次不同啊,此次是售賣的實體商品,那就有庫存這麼一說了,起碼要保證不能超過庫存設定的數量吧。node

小T大眼對着屏幕,屏住呼吸,還好提早發現了這個問題,趕忙想辦法修復,不賺錢還賠錢,老闆不得瘋了,還想不想幹了~git

業務場景二出現github

小T下面的一位兄弟正在壓測,發現個小問題,由於在終端設備上跟鵝廠有緊密合做,調用他們的接口時須要獲取到access_token,可是這個access_token過時時間是2小時,過時後須要從新獲取。redis

壓測時發現當到達過時時間時,日誌看刷出來好幾個不同的access_token,由於這個服務也是分佈式部署的,多個節點同時發起了第三方接口請求致使。算法

雖然以最後一次獲取的access_token爲準,也沒什麼不良反作用,可是會致使屢次沒必要要的對第三方接口的調用,也會短期內形成access_token的 重複無效獲取(重複工做)spring

業務場景三出現sql

下單完成後,還要通知倉儲物流,待用戶支付完成,支付回調有可能會將多條訂單消息發送到MQ,倉儲服務會從MQ消費訂單消息,此時就要 保證冪等性,對訂單消息作 去重 處理。

以上便於你們理解爲何要用分佈式鎖才能解決,勾勒出的幾個業務場景。

上面的問題無一例外,都是針對共享資源要求串行化處理,才能保證安全且合理的操做。

用一張圖來體驗一下:

 

 

此時,使用Java提供的Synchronized、ReentrantLock、ReentrantReadWriteLock...,僅能在單個JVM進程內對多線程對共享資源保證線程安全,在分佈式系統環境下通通都很差使,心情是否是拔涼呀。這個問題得請教 分佈式鎖 家族來支持一下,據說他們家族內有不少成員,每一個成員都有這個分佈式鎖功能,接下來就開始探索一下。

二、分佈式鎖家族成員介紹

爲何須要分佈式鎖才能解決?

聽聽 Martin 大佬們給出的說法:

Martin kleppmann 是英國劍橋大學的分佈式系統的研究員,曾經跟 Redis 之父 Antirez 進行過關於 RedLock (Redis裏分佈式鎖的實現算法)是否安全的激烈討論。

他們討論了啥,整急眼了?
都能單獨寫篇文章了

請你本身看 Maritin 博客文章:

https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

效率:

使用分佈式鎖能夠避免多個客戶端重複相同的工做,這些工做會浪費資源。好比用戶支付完成後,可能會收到屢次短信或郵件提醒。

好比業務場景二,重複獲取access_token。

對共享資源的操做是冪等性操做,不管你操做多少次都不會出現不一樣結果。
本質上就是爲了不對共享資源重複操做,從而提升效率。

正確性:

使用分佈式鎖一樣能夠避免鎖失效的發生,一旦發生會引發正確性的破壞,可能會致使數據不一致,數據缺失或者其餘嚴重的問題。

好比業務場景一,商品庫存超賣問題。

對共享資源的操做是非冪等性操做,多個客戶端操做共享資源會致使數據不一致。

分佈式鎖有哪些特色呢?

如下是分佈式鎖的一些特色,分佈式鎖家族成員並不必定都知足這個要求,實現機制不大同樣。

互斥性: 分佈式鎖要保證在多個客戶端之間的互斥。

可重入性:同一客戶端的相同線程,容許重複屢次加鎖。

鎖超時:和本地鎖同樣支持鎖超時,防止死鎖。

非阻塞: 能與 ReentrantLock 同樣支持 trylock() 非阻塞方式得到鎖。

支持公平鎖和非公平鎖:公平鎖是指按照請求加鎖的順序得到鎖,非公平鎖真好相反請求加鎖是無序的。

分佈式鎖家族實現者介紹

分佈式鎖家族實現者一覽:

 

 

思惟導圖作了一個簡單分類,不必定特別準確,幾乎包含了分佈式鎖各個組件實現者。

下面讓他們分別來作下自我介紹:

一、數據庫

排它鎖(悲觀鎖):基於 select * from table where xx=yy for update SQL語句來實現,有不少缺陷,通常不推薦使用,後文介紹。

樂觀鎖:表中添加一個時間戳或者版本號的字段來實現,update xx set version = new... where id = y and version = old 當更新不成功,客戶端重試,從新讀取最新的版本號或時間戳,再次嘗試更新,相似 CAS 機制,推薦使用。

二、Redis

特色:CAP模型屬於AP | 無一致性算法 | 性能好

開發經常使用,若是你的項目中正好使用了redis,不想引入額外的分佈式鎖組件,推薦使用。

業界也提供了多個現成好用的框架予以支持分佈式鎖,好比Redissonspring-integration-redis、redis自帶的setnx命令,推薦直接使用。

另外,可基於redis命令和redis lua支持的原子特性,自行實現分佈式鎖。

三、Zookeeper

特色:CAP模型屬於CP | ZAB一致性算法實現 | 穩定性好

開發經常使用,若是你的項目中正好使用了zk集羣,推薦使用。

業界有Apache Curator框架提供了現成的分佈式鎖功能,現成的,推薦直接使用。

另外,可基於Zookeeper自身的特性和原生Zookeeper API自行實現分佈式鎖。

四、其餘

Chubby,Google開發的粗粒度分佈鎖的服務,可是並無開源,開放出了論文和一些相關文檔能夠進一步瞭解,出門百度一下獲取文檔,不作過多討論。

Tair,是阿里開源的一個分佈式KV存儲方案,沒有用過,不作過多討論。

Etcd,CAP模型中屬於CPRaft一致性算法實現,沒有用過,不作過多討論。

Hazelcast,是基於內存的數據網格開源項目,提供彈性可擴展的分佈式內存計算,而且被公認是提升應用程序性能和擴展性最好的方案,聽上去很牛逼,可是沒用過,不作過多討論。

固然了,上面推薦的經常使用分佈式鎖Zookeeper和Redis,使用時還須要根據具體的業務場景,作下權衡,實現功能上都能達到你要的效果,原理上有很大的不一樣。

畫外音: 你對哪一個熟悉,原理也都瞭解,hold住,你就用哪一個。

三、分佈式鎖成員實現原理剖析

數據庫悲觀鎖實現

以「悲觀的心態」操做資源,沒法得到鎖成功,就一直阻塞着等待。

一、有一張資源鎖表

CREATE TABLE `resource_lock` ( `id` int(4) NOT NULL AUTO_INCREMENT COMMENT '主鍵', `resource_name` varchar(64) NOT NULL DEFAULT '' COMMENT '鎖定的資源名', `owner` varchar(64) NOT NULL DEFAULT '' COMMENT '鎖擁有者', `desc` varchar(1024) NOT NULL DEFAULT '備註信息', `update_time` timestamp NOT NULL DEFAULT '' COMMENT '保存數據時間,自動生成', PRIMARY KEY (`id`), UNIQUE KEY `uidx_resource_name` (`resource_name `) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='鎖定中的資源'; 

resource_name 鎖資源名稱必須有惟一索引。

二、使用姿式

必須添加事務,查詢和更新操做保證原子性,在一個事務裏完成。

僞代碼實現:

@Transaction public void lock(String name) { ResourceLock rlock = exeSql("select * from resource_lock where resource_name = name for update"); if (rlock == null) { exeSql("insert into resource_lock(reosurce_name,owner,count) values (name, 'ip',0)"); } }

使用 for update 鎖定的資源。
若是執行成功,會當即返回,執行插入數據庫,後續再執行一些其餘業務邏輯,直到事務提交,執行結束;
若是執行失敗,就會一直阻塞着。

你也能夠在數據庫客戶端工具上測試出來這個效果,當在一個終端執行了 for update,不提交事務。
在另外的終端上執行相同條件的 for update,會一直卡着,轉圈圈...

雖然也能實現分佈式鎖的效果,可是會存在性能瓶頸。

三、悲觀鎖優缺點

優勢:簡單易用,好理解,保障數據強一致性。

缺點一大堆,羅列一下:

1)在 RR 事務級別,select 的 for update 操做是基於間隙鎖(gap lock) 實現的,是一種悲觀鎖的實現方式,因此存在阻塞問題

2)高併發狀況下,大量請求進來,會致使大部分請求進行排隊,影響數據庫穩定性,也會耗費服務的CPU等資源

當得到鎖的客戶端等待時間過長時,會提示:

[40001][1205] Lock wait timeout exceeded; try restarting transaction

高併發狀況下,也會形成佔用過多的應用線程,致使業務沒法正常響應。

3)若是優先得到鎖的線程由於某些緣由,一直沒有釋放掉鎖,可能會致使死鎖的發生。

4)鎖的長時間不釋放,會一直佔用數據庫鏈接,可能會將數據庫鏈接池撐爆,影響其餘服務。

5) MySql數據庫會作查詢優化,即使使用了索引,優化時發現全表掃效率更高,則可能會將行鎖升級爲表鎖,此時可能就更悲劇了。

6)不支持可重入特性,而且超時等待時間是全局的,不能隨便改動。

數據庫樂觀鎖實現

樂觀鎖,以「樂觀的心態」來操做共享資源,沒法得到鎖成功,不要緊過一會重試一下看看唄,再不行就直接退出,嘗試必定次數仍是不行?也能夠之後再說,不用一直阻塞等着。

一、有一張資源表

爲表添加一個字段,版本號或者時間戳均可以。經過版本號或者時間戳,來保證多線程同時間操做共享資源的有序性和正確性。

CREATE TABLE `resource` ( `id` int(4) NOT NULL AUTO_INCREMENT COMMENT '主鍵', `resource_name` varchar(64) NOT NULL DEFAULT '' COMMENT '資源名', `share` varchar(64) NOT NULL DEFAULT '' COMMENT '狀態', `version` int(4) NOT NULL DEFAULT '' COMMENT '版本號', `desc` varchar(1024) NOT NULL DEFAULT '備註信息', `update_time` timestamp NOT NULL DEFAULT '' COMMENT '保存數據時間,自動生成', PRIMARY KEY (`id`), UNIQUE KEY `uidx_resource_name` (`resource_name `) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='資源';

二、使用姿式

僞代碼實現:

Resrouce resource = exeSql("select * from resource where resource_name = xxx"); boolean succ = exeSql("update resource set version= 'newVersion' ... where resource_name = xxx and version = 'oldVersion'"); if (!succ) { // 發起重試 }

實際代碼中能夠寫個while循環不斷重試,版本號不一致,更新失敗,從新獲取新的版本號,直到更新成功。

三、樂觀鎖優缺點

優勢:簡單易用,保障數據一致性。

缺點:

1)加行鎖的性能上有必定的開銷

2)高併發場景下,線程內的自旋操做 會耗費必定的CPU資源。

另外,好比在更新數據狀態的一些場景下,不考慮冪等性的狀況下,能夠直接利用 行鎖 來保證數據一致性,示例:update table set state = 1 where id = xxx and state = 0;

樂觀鎖就相似 CAS Compare And Swap 更新機制,推薦閱讀 <<一文完全搞懂CAS>>


基於Redis分佈式鎖實現

基於SetNX實現分佈式鎖

基於Redis實現的分佈式鎖,性能上是最好的,實現上也是最複雜的。

前文中提到的 RedLock 是 Redis 之父 Antirez 提出來的分佈式鎖的一種 「健壯」 的實現算法,但爭議也較多,通常不推薦使用。

Redis 2.6.12 以前的版本中採用 setnx + expire 方式實現分佈式鎖,示例代碼以下所示:

public static boolean lock(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; }

若是 lockKey 存在,則返回失敗,不然返回成功。設置成功以後,爲了能在完成同步代碼以後成功釋放鎖,方法中使用 expire() 方法給 lockKey 設置一個過時時間,確認 key 值刪除,避免出現鎖沒法釋放,致使下一個線程沒法獲取到鎖,即死鎖問題。

可是 setnx + expire 兩個命令放在程序裏執行,不是原子操做,容易出事。

若是程序設置鎖以後,此時,在設置過時時間以前,程序崩潰了,若是 lockKey 沒有設置上過時時間,將會出現死鎖問題

解決以上問題 ,有兩個辦法:

1)方式一:lua腳本

咱們也能夠經過 Lua 腳原本實現鎖的設置和過時時間的原子性,再經過 jedis.eval() 方法運行該腳本:

// 加鎖腳本,KEYS[1] 要加鎖的key,ARGV[1]是UUID隨機值,ARGV[2]是過時時間
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"; // 解鎖腳本,KEYS[1]要解鎖的key,ARGV[1]是UUID隨機值 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";

2)方式二:set原生命令

在 Redis 2.6.12 版本後 SETNX 增長了過時時間參數:

SET lockKey anystring NX PX max-lock-time

程序實現代碼以下:

public static boolean lock(Jedis jedis, String lockKey, String requestId, int expireTime) { String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); if ("OK".equals(result)) { return true; } return false; }

雖然 SETNX 方式可以保證設置鎖和過時時間的原子性,可是若是咱們設置的過時時間比較短,而執行業務時間比較長,就會存在鎖代碼塊失效的問題,失效後其餘客戶端也能獲取到一樣的鎖,執行一樣的業務,此時可能就會出現一些問題。

咱們須要將過時時間設置得足夠長,來保證以上問題不會出現,可是設置多長時間合理,也須要依具體業務來權衡。若是其餘客戶端必需要阻塞拿到鎖,須要設計循環超時等待機制等問題,感受還挺麻煩的是吧。

Spring企業集成模式實現分佈式鎖

除了使用Jedis客戶端以外,徹底能夠直接用Spring官方提供的企業集成模式框架,裏面提供了不少分佈式鎖的方式,Spring提供了一個統一的分佈式鎖抽象,具體實現目前支持:

  • Gemfire
  • Jdbc
  • Zookeeper
  • Redis

早期,分佈式鎖的相關代碼存在於Spring Cloud的子項目Spring Cloud Cluster中,後來被遷到Spring Integration中。

Spring Integration 項目地址 :https://github.com/spring-projects/spring-integration

Spring強大之處在於此,對Lock分佈式鎖作了全局抽象。

抽象結構以下所示:

 

 

LockRegistry 做爲頂層抽象接口:

/** * Strategy for maintaining a registry of shared locks * * @author Oleg Zhurakousky * @author Gary Russell * @since 2.1.1 */ @FunctionalInterface public interface LockRegistry { /** * Obtains the lock associated with the parameter object. * @param lockKey The object with which the lock is associated. * @return The associated lock. */ Lock obtain(Object lockKey); } 

定義的 obtain() 方法得到具體的 Lock 實現類,分別在對應的 XxxLockRegitry 實現類來建立。

RedisLockRegistry 裏obtain()方法實現類爲 RedisLock,RedisLock內部,在Springboot2.x(Spring5)版本中是經過SET + PEXIPRE 命令結合lua腳本實現的,在Springboot1.x(Spring4)版本中,是經過SETNX命令實現的。

ZookeeperLockRegistry 裏obtain()方法實現類爲 ZkLock,ZkLock內部基於 Apache Curator 框架實現的。

JdbcLockRegistry 裏obtain()方法實現類爲 JdbcLock,JdbcLock內部基於一張INT_LOCK數據庫鎖表實現的,經過JdbcTemplate來操做。

客戶端使用方法:

private final String registryKey = "sb2"; RedisLockRegistry lockRegistry = new RedisLockRegistry(getConnectionFactory(), this.registryKey); Lock lock = lockRegistry.obtain("foo"); lock.lock(); try { // doSth... } finally { lock.unlock(); } }

下面以目前最新版本的實現,說明加鎖和解鎖的具體過程。

RedisLockRegistry$RedisLock類lock()加鎖流程:

 

 

加鎖步驟:

1)lockKey爲registryKey:path,本例中爲sb2:foo,客戶端C1優先申請加鎖。

2)執行lua腳本,get lockKey不存在,則set lockKey成功,值爲clientid(UUID),過時時間默認60秒。

3)客戶端C1同一個線程重複加鎖,pexpire lockKey,重置過時時間爲60秒。

4)客戶端C2申請加鎖,執行lua腳本,get lockKey已存在,而且跟已加鎖的clientid不一樣,加鎖失敗

5)客戶端C2掛起,每隔100ms再次嘗試加鎖。

RedisLock#lock()加鎖源碼實現:

 

 

你們能夠對照上面的流程圖配合你理解。

@Override public void lock() { this.localLock.lock(); while (true) { try { while (!obtainLock()) { Thread.sleep(100); //NOSONAR } break; } catch (InterruptedException e) { /* * This method must be uninterruptible so catch and ignore * interrupts and only break out of the while loop when * we get the lock. */ } catch (Exception e) { this.localLock.unlock(); rethrowAsLockException(e); } } } // 基於Spring封裝的RedisTemplate來操做的 private boolean obtainLock() { Boolean success = RedisLockRegistry.this.redisTemplate.execute(RedisLockRegistry.this.obtainLockScript, Collections.singletonList(this.lockKey), RedisLockRegistry.this.clientId, String.valueOf(RedisLockRegistry.this.expireAfter)); boolean result = Boolean.TRUE.equals(success); if (result) { this.lockedAt = System.currentTimeMillis(); } return result; } 

執行的lua腳本代碼:

private static final String OBTAIN_LOCK_SCRIPT = "local lockClientId = redis.call('GET', KEYS[1])\n" + "if lockClientId == ARGV[1] then\n" + " redis.call('PEXPIRE', KEYS[1], ARGV[2])\n" + " return true\n" + "elseif not lockClientId then\n" + " redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])\n" + " return true\n" + "end\n" + "return false";

RedisLockRegistry$RedisLock類unlock()解鎖流程:

 

 

RedisLock#unlock()源碼實現:

@Override public void unlock() { if (!this.localLock.isHeldByCurrentThread()) { throw new IllegalStateException("You do not own lock at " + this.lockKey); } if (this.localLock.getHoldCount() > 1) { this.localLock.unlock(); return; } try { if (!isAcquiredInThisProcess()) { throw new IllegalStateException("Lock was released in the store due to expiration. " + "The integrity of data protected by this lock may have been compromised."); } if (Thread.currentThread().isInterrupted()) { RedisLockRegistry.this.executor.execute(this::removeLockKey); } else { removeLockKey(); } if (LOGGER.isDebugEnabled()) { LOGGER.debug("Released lock; " + this); } } catch (Exception e) { ReflectionUtils.rethrowRuntimeException(e); } finally { this.localLock.unlock(); } } // 刪除緩存Key private void removeLockKey() { if (this.unlinkAvailable) { try { RedisLockRegistry.this.redisTemplate.unlink(this.lockKey); } catch (Exception ex) { LOGGER.warn("The UNLINK command has failed (not supported on the Redis server?); " + "falling back to the regular DELETE command", ex); this.unlinkAvailable = false; RedisLockRegistry.this.redisTemplate.delete(this.lockKey); } } else { RedisLockRegistry.this.redisTemplate.delete(this.lockKey); } }

unlock()解鎖方法裏發現,並非直接就調用Redis的DEL命令刪除Key,這也是在Springboot2.x版本中作的一個優化,Redis4.0版本以上提供了UNLINK命令。

換句話說,最新版本分佈式鎖實現,要求是Redis4.0以上版本才能使用。

看下Redis官網給出的一段解釋:

This command is very similar to DEL: it removes the specified keys. Just like DEL a key is ignored if it does not exist. However the command performs the actual memory reclaiming in a different thread, so it is not blocking, while DEL is. This is where the command name comes from: the command just unlinks the keys from the keyspace. The actual removal will happen later asynchronously.

DEL始終在阻止模式下釋放值部分。但若是該值太大,如對於大型LIST或HASH的分配太多,它會長時間阻止Redis,爲了解決這個問題,Redis實現了UNLINK命令,即「非阻塞」刪除。若是值很小,則DEL通常與UNLINK效率上差很少。

本質上,這種加鎖方式仍是使用的SETNX實現的,並且Spring只是作了一層薄薄的封裝,支持可重入加鎖,超時等待,可中斷加鎖。

可是有個問題,鎖的過時時間不能靈活設置,客戶端初始化時,建立RedisLockRegistry時容許設置,可是是全局的。

/** * Constructs a lock registry with the supplied lock expiration. * @param connectionFactory The connection factory. * @param registryKey The key prefix for locks. * @param expireAfter The expiration in milliseconds. */ public RedisLockRegistry(RedisConnectionFactory connectionFactory, String registryKey, long expireAfter) { Assert.notNull(connectionFactory, "'connectionFactory' cannot be null"); Assert.notNull(registryKey, "'registryKey' cannot be null"); this.redisTemplate = new StringRedisTemplate(connectionFactory); this.obtainLockScript = new DefaultRedisScript<>(OBTAIN_LOCK_SCRIPT, Boolean.class); this.registryKey = registryKey; this.expireAfter = expireAfter; }

expireAfter參數是全局的,一樣會存在問題,多是鎖過時時間到了,可是業務尚未處理完,這把鎖又被另外的客戶端得到,進而會致使一些其餘問題。

通過對源碼的分析,其實咱們也能夠借鑑RedisLockRegistry實現的基礎上,自行封裝實現分佈式鎖,好比:

一、容許支持按照不一樣的Key設置過時時間,而不是全局的?

二、當業務沒有處理完成,當前客戶端啓動個定時任務探測,自動延長過時時間?

本身實現?嫌麻煩?別急別急!業界已經有現成的實現方案了,那就是 Redisson 框架!

站在Redis集羣角度看問題

從Redis主從架構上來考慮,依然存在問題。由於 Redis 集羣數據同步到各個節點時是異步的,若是在 Master 節點獲取到鎖後,在沒有同步到其它節點時,Master 節點崩潰了,此時新的 Master 節點依然能夠獲取鎖,因此多個應用服務能夠同時獲取到鎖。

基於以上的考慮,Redis之父Antirez提出了一個RedLock算法

RedLock算法實現過程分析:

假設Redis部署模式是Redis Cluster,總共有5個master節點,經過如下步驟獲取一把鎖:

1)獲取當前時間戳,單位是毫秒

2)輪流嘗試在每一個master節點上建立鎖,過時時間設置較短,通常就幾十毫秒

3)嘗試在大多數節點上創建一個鎖,好比5個節點就要求是3個節點(n / 2 +1)

4)客戶端計算創建好鎖的時間,若是創建鎖的時間小於超時時間,就算創建成功了

5)要是鎖創建失敗了,那麼就依次刪除這個鎖

6)只要有客戶端建立成功了分佈式鎖,其餘客戶端就得不斷輪詢去嘗試獲取鎖

以上過程前文也提到了,進一步分析RedLock算法的實現依然可能存在問題,也是Martain和Antirez兩位大佬爭論的焦點。

問題1:節點崩潰重啓

節點崩潰重啓,會出現多個客戶端持有鎖。

假設一共有5個Redis節點:A、B、 C、 D、 E。設想發生了以下的事件序列:

1)客戶端C1成功對Redis集羣中A、B、C三個節點加鎖成功(但D和E沒有鎖住)。

2)節點C Duang的一下,崩潰重啓了,但客戶端C1在節點C加鎖未持久化完,丟了。

3)節點C重啓後,客戶端C2成功對Redis集羣中C、D、 E嘗試加鎖成功了。

這樣,悲劇了吧!客戶端C1和C2同時得到了同一把分佈式鎖。

爲了應對節點重啓引起的鎖失效問題,Antirez提出了延遲重啓的概念,即一個節點崩潰後,先不當即重啓它,而是等待一段時間再重啓,等待的時間大於鎖的有效時間。

採用這種方式,這個節點在重啓前所參與的鎖都會過時,它在重啓後就不會對現有的鎖形成影響。

這其實也是經過人爲補償措施,下降不一致發生的機率。

問題2:時鐘跳躍

假設一共有5個Redis節點:A、B、 C、 D、 E。設想發生了以下的事件序列:

1)客戶端C1成功對Redis集羣中A、B、 C三個節點成功加鎖。但因網絡問題,與D和E通訊失敗。

2)節點C上的時鐘發生了向前跳躍,致使它上面維護的鎖快速過時。

3)客戶端C2對Redis集羣中節點C、 D、 E成功加了同一把鎖。

此時,又悲劇了吧!客戶端C1和C2同時都持有着同一把分佈式鎖。

爲了應對時鐘跳躍引起的鎖失效問題,Antirez提出了應該禁止人爲修改系統時間,使用一個不會進行「跳躍式」調整系統時鐘的ntpd程序。這也是經過人爲補償措施,下降不一致發生的機率。

可是...,RedLock算法並無解決,操做共享資源超時,致使鎖失效的問題。

存在這麼大爭議的算法實現,仍是不推薦使用的。

通常狀況下,本文鎖介紹的框架提供的分佈式鎖實現已經能知足大部分需求了。

小結:

上述,咱們對spring-integration-redis實現原理進行了深刻分析,還對RedLock存在爭議的問題作了分析。

除此之外,咱們還提到了spring-integration中集成了 Jdbc、Zookeeper、Gemfire實現的分佈式鎖,Gemfire和Jdbc你們感興趣能夠自行去看下。

爲啥還要提供個Jdbc分佈式鎖實現?

猜想一下,當你的應用併發量也不高,好比是個後臺業務,並且還沒依賴Zookeeper、Redis等額外的組件,只依賴了數據庫。

但你還想用分佈式鎖搞點事兒,那好辦,直接用spring-integration-jdbc便可,內部也是基於數據庫行鎖來實現的,須要你提早建好鎖表,建立表的SQL長這樣:

CREATE TABLE INT_LOCK ( LOCK_KEY CHAR(36) NOT NULL, REGION VARCHAR(100) NOT NULL, CLIENT_ID CHAR(36), CREATED_DATE DATETIME(6) NOT NULL, constraint INT_LOCK_PK primary key (LOCK_KEY, REGION) ) ENGINE=InnoDB;

具體實現邏輯也很是簡單,你們本身去看吧。

集成的Zookeeper實現的分佈式鎖,由於是基於Curator框架實現的,不在本節展開,後續會有分析。

基於Redisson實現分佈式鎖

Redisson 是 Redis 的 Java 實現的客戶端,其 API 提供了比較全面的 Redis 命令的支持。

Jedis 簡單使用阻塞的 I/O 和 Redis 交互,Redission 經過 Netty 支持非阻塞 I/O。

Redisson 封裝了鎖的實現,讓咱們像操做咱們的本地 Lock 同樣去使用,除此以外還有對集合、對象、經常使用緩存框架等作了友好的封裝,易於使用。

截止目前,Github上 Star 數量爲 11.8k,說明該開源項目值得關注和使用。

Redisson分佈式鎖Github:

https://github.com/redisson/redisson/wiki/8.-Distributed-locks-and-synchronizers

Redisson 能夠便捷的支持多種Redis部署架構:

1) Redis 單機

2) Master-Slave + Sentinel 哨兵

3) Redis-Cluster集羣

// Master-Slave配置 Config config = new Config(); MasterSlaveServersConfig serverConfig = config.useMasterSlaveServers() .setMasterAddress("") .addSlaveAddress("") .setReadMode(ReadMode.SLAVE) .setMasterConnectionPoolSize(maxActiveSize) .setMasterConnectionMinimumIdleSize(maxIdleSize) .setSlaveConnectionPoolSize(maxActiveSize) .setSlaveConnectionMinimumIdleSize(maxIdleSize) .setConnectTimeout(CONNECTION_TIMEOUT_MS) // 默認10秒 .setTimeout(socketTimeout) ; RedissonClient redisson = Redisson.create(config); RLock lock = redisson.getLock("myLock"); // 得到鎖 lock.lock(); // 等待10秒未得到鎖,自動釋放 lock.lock(10, TimeUnit.SECONDS); // 等待鎖定時間不超過100秒 // 10秒後自動釋放鎖 boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS); if (res) { try { ... } finally { lock.unlock(); } }

使用上很是簡單,RedissonClient客戶端提供了衆多的接口實現,支持可重入鎖、、公平鎖、讀寫鎖、鎖超時、RedLock等都提供了完整實現。

lock()加鎖流程:

爲了兼容老的版本,Redisson裏都是經過lua腳本執行Redis命令的,同時保證了原子性操做。

加鎖執行的lua腳本:

 

 

Redis裏的Hash散列結構存儲的。

參數解釋:

KEY[1]:要加鎖的Key名稱,好比示例中的myLock。

ARGV[1]:針對加鎖的Key設置的過時時間

ARGV[2]:Hash結構中Key名稱,lockName爲UUID:線程ID

protected String getLockName(long threadId) { return id + ":" + threadId; }

1)客戶端C1申請加鎖,key爲myLock。

2)若是key不存在,經過hset設置值,經過pexpire設置過時時間。同時開啓Watchdog任務,默認每隔10秒中判斷一下,若是key還在,重置過時時間到30秒。

開啓WatchDog源碼:

 

 

3)客戶端C1相同線程再次加鎖,若是key存在,判斷Redis裏Hash中的lockName跟當前線程lockName相同,則將Hash中的lockName的值加1,表明支持可重入加鎖。

4)客戶單C2申請加鎖,若是key存在,判斷Redis裏Hash中的lockName跟當前線程lockName不一樣,則執行pttl返回剩餘過時時間。

5)客戶端C2線程內不斷嘗試pttl時間,此處是基於Semaphore信號量實現的,有許可當即返回,不然等到pttl時間仍是沒有獲得許可,繼續重試。

重試源碼:

 

 

 

Redisson這樣的實現就解決了,當業務處理時間比過時時間長的問題。

同時,Redisson 還本身擴展 Lock 接口,叫作 RLock 接口,擴展了不少的鎖接口,好比給 Key 設定過時時間,非阻塞+超時時間等。

void lock(long leaseTime, TimeUnit unit); boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;

redisson裏的WatchDog(看門狗)邏輯保證了沒有死鎖發生。

若是客戶端宕機了,WatchDog任務也就跟着停掉了。此時,不會對Key重置過時時間了,等掛掉的客戶端持有的Key過時時間到了,鎖自動釋放,其餘客戶端嘗試得到這把鎖。

能夠進一步看官網的關於WatchDog描述:

If Redisson instance which acquired lock crashes then such lock could hang forever in acquired state. To avoid this Redisson maintains lock watchdog, it prolongs lock expiration while lock holder Redisson instance is alive. By default lock watchdog timeout is 30 seconds and can be changed through Config.lockWatchdogTimeout setting.

unlock()解鎖過程也是一樣的,經過lua腳本執行一大坨指令的。

解鎖lua腳本:

 

 

根據剛剛對加鎖過程的分析,你們能夠自行看下腳本分析下。

基於Zookeeper實現分佈式鎖

Zookeeper 是一種提供「分佈式服務協調」的中心化服務,是以 Paxos 算法爲基礎實現的。Zookeeper數據節點和文件目錄相似,同時具備Watch機制,基於這兩個特性,得以實現分佈式鎖功能。

數據節點:

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

節點類型能夠分爲持久節點(PERSISTENT )、臨時節點(EPHEMERAL),每一個節點還能被標記爲有序性(SEQUENTIAL),一旦節點被標記爲有序性,那麼整個節點就具備順序自增的特色。

通常咱們能夠組合這幾類節點來建立咱們所須要的節點,例如,建立一個持久節點做爲父節點,在父節點下面建立臨時節點,並標記該臨時節點爲有序性。

Watch 機制:

Zookeeper 還提供了另一個重要的特性,Watcher(事件監聽器)。

ZooKeeper 容許用戶在指定節點上註冊一些 Watcher,而且在一些特定事件觸發的時候,ZooKeeper 服務端會將事件通知給用戶。

圖解Zookeeper實現分佈式鎖:

四、最後的總結

 

 

首先,咱們須要創建一個父節點,節點類型爲持久節點(PERSISTENT)如圖中的 /locks/lock_name1 節點 ,每當須要訪問共享資源時,就會在父節點下創建相應的順序子節點,節點類型爲臨時節點(EPHEMERAL),且標記爲有序性(SEQUENTIAL),而且以臨時節點名稱 + 父節點名稱 + 順序號組成特定的名字,如圖中的 /0000000001 /0000000002 /0000000003 做爲臨時有序節點。

在創建子節點後,對父節點下面的全部以臨時節點名稱 name 開頭的子節點進行排序,判斷剛剛創建的子節點順序號是不是最小的節點,若是是最小節點,則得到鎖。

若是不是最小節點,則阻塞等待鎖,而且得到該節點的上一順序節點,爲其註冊監聽事件,等待節點對應的操做得到鎖。當調用完共享資源後,刪除該節點,關閉 zk,進而能夠觸發監聽事件,釋放該鎖。

// 加鎖 InterProcessMutex lock = new InterProcessMutex(client, lockPath); if ( lock.acquire(maxWait, waitUnit) ) { try { // do some work inside of the critical section here } finally { lock.release(); } } public void acquire() throws Exception { if ( !internalLock(-1, null) ) { throw new IOException("Lost connection while trying to acquire lock: " + basePath); } } private boolean internalLock(long time, TimeUnit unit) throws Exception { /* Note on concurrency: a given lockData instance can be only acted on by a single thread so locking isn't necessary */ Thread currentThread = Thread.currentThread(); LockData lockData = threadData.get(currentThread); if ( lockData != null ) { // re-entering lockData.lockCount.incrementAndGet(); return true; } String lockPath = internals.attemptLock(time, unit, getLockNodeBytes()); if ( lockPath != null ) { LockData newLockData = new LockData(currentThread, lockPath); threadData.put(currentThread, newLockData); return true; } return false; } // ... 其餘代碼略

InterProcessMutex 是 Curator 實現的可重入鎖,可重入鎖源碼過程分析:

加鎖流程:

1)可重入鎖記錄在 ConcurrentMap<Thread, LockData> threadData 這個 Map 裏面。

2)若是 threadData.get(currentThread) 是有值的那麼就證實是可重入鎖,而後記錄就會加 1。

3)資源目錄下建立一個節點:好比這裏建立一個 /0000000002 這個節點,這個節點須要設置爲 EPHEMERAL_SEQUENTIAL 也就是臨時節點而且有序。

4)獲取當前目錄下全部子節點,判斷本身的節點是不是最小的節點。

5)若是是最小的節點,則獲取到鎖。若是不是最小的節點,則證實前面已經有人獲取到鎖了,那麼須要獲取本身節點的前一個節點。

6)節點 /0000000002 的前一個節點是 /0000000001,咱們獲取到這個節點以後,再上面註冊 Watcher,Watcher 調用的是 object.notifyAll(),用來解除阻塞。

7)object.wait(timeout) 或 object.wait() 進行阻塞等待

解鎖流程:

1)若是可重入鎖次數減1後,加鎖次數不爲 0 直接返回,減1後加鎖次數爲0,繼續。

2)刪除當前節點。

3)刪除 threadDataMap 裏面的可重入鎖的數據。

最後的總結

上面介紹的諸如Apache Curator、Redisson、Spring框架集成的分佈式鎖,既然是框架實現,會考慮用戶需求,儘可能設計和實現通用的分佈式鎖接口。

基本都涵蓋了以下的方式實現:

 

 

固然,Redisson和Curator都是本身定義的分佈式鎖接口實現的,易於擴展。

Curator裏自定義了InterProcessLock接口,Redisson裏自定義RLock接口,繼承了 java.util.concurrent.locks.Lock接口。

對於Redis實現的分佈式鎖:

大部分需求下,不會遇到「極端複雜場景」,基於Redis實現分佈式鎖很經常使用,性能也高。

它獲取鎖的方式簡單粗暴,獲取不到鎖直接不斷嘗試獲取鎖,比較消耗性能。

另外來講的話,redis的設計定位決定了它的數據並非強一致性的,沒有一致性算法,在某些極端狀況下,可能會出現問題,鎖的模型不夠健壯。

即使有了Redlock算法的實現,但存在爭議,某些複雜場景下,也沒法保證其實現徹底沒有問題,而且也是比較消耗性能的。

對於Zookeeper實現的分佈式鎖:

Zookeeper優勢:

天生設計定位是分佈式協調,強一致性。鎖的模型健壯、簡單易用、適合作分佈式鎖。

若是獲取不到鎖,只須要添加一個監聽器就能夠了,不用一直輪詢,性能消耗較小。

若是客戶端宕機,也不要緊,臨時節點會自動刪除,觸發監聽器通知下一個節點。

Zookeeper缺點:

如有大量的客戶端頻繁的申請加鎖、釋放鎖,對於ZK集羣的壓力會比較大。

另外,本文對spring-integration集成redis作了詳細分析,推薦能夠直接使用,更推薦直接使用 Redisson,實現了很是多的分佈式鎖各類機制,有單獨開放Springboot集成的jar包,使用上也是很是方便的。

文章開頭部分提到的幾個業務場景,通過對分佈式鎖家族的介紹和原理分析,能夠自行選擇技術方案了。

以上,必定有一款能知足你的需求,但願你們有所收穫!

文章轉載:https://www.cnblogs.com/ldws/p/12155003.html

相關文章
相關標籤/搜索