背景
目前開發過程中,按照公司規范,需要依賴框架中的緩存組件。不得不說,做組件的大牛對CRUD操作的封裝,連接池、緩存路由、緩存安全性的管控都處理的無可挑剔。但是有一個小問題,該組件沒有對分布式鎖做實現,那就要想辦法依靠緩存組件自己去實現一個分布式鎖了。
什么,為啥要自己實現?有現成的開源組件直接拿過來用不就行了,比如Spring-Integration-Redis提供RedisLockRegistry,Redisson,不比自己去實現快的多。那我得聲明一下,本人也不喜歡重復造輪子。具體原因呢,首先是項目中的緩存組件是不能替換的,連接池還可能沒有辦法復用,其次就是如果對開源組件實現原理不熟悉,那么出了問題,維護起來又需要更多成本。
先說一下當前需要分布式鎖的兩個場景,一個是微信端access_token刷新(分布式鎖可以保證access_token只刷新一次,刷新完成之后放入緩存,其他請求直接從緩存讀取);一個是分布式部署的定時任務(分布式鎖可以保證同一時刻只有一個節點的定時任務執行)。
什么是分布式鎖
在單機部署的情況下,要想保證特定業務在順序執行,通過JDK提供的synchronized關鍵字、Semaphore、ReentrantLock,或者我們也可以基於AQS定制化鎖。單機部署的情況下,鎖是在多線程之間共享的,但是分布式部署的情況下,鎖是多進程之間共享的。那么分布式鎖要保證鎖資源的唯一性,可以在多進程之間共享。
分布式鎖特性
- 保證同一個方法在某一時刻只能在一台機器里一個進程中一個線程執行;
- 要保證是可重入鎖(避免死鎖);
- 要保證獲取鎖和釋放鎖的高可用;
分布式鎖實現方案對比
- Mysql:一般項目都會用到緩存,不可能都用數據庫,強依賴數據庫不現實。雖然實現樂觀鎖和悲觀鎖很簡單,但是性能不佳。
- Redis:首先集群可以提高可用性,其次借助Redis實現分布式鎖也很簡單,另外有很多框架已經幫我們實現好了,直接拿來用就可以了,很方便。同時定期失效的機制可以解決因網絡抖動鎖刪除失敗的問題,所以我比較傾向Redis實現。
- Zookeeper:和Mysql一樣,不可能為了用分布式鎖而去新增並維護一套Zookeeper集群,其次實現起來還是比較復雜的,實現不好的話還會引起“羊群效應”。如果不是原有系統就依賴Zookeeper,同時壓力不大的情況下,一般不使用Zookeeper實現分布式鎖。
分布式鎖考慮要點
- 鎖釋放(finally);
- 鎖超時設置;
- 鎖刷新(定時任務,每2/3的鎖生命周期執行);
- 如果鎖超時了,防止刪除其他線程的鎖(其他線程會拿到鎖),考慮 value值用線程id標識,當前線程釋放鎖的時候要判斷是否為當前線程的線程id;
- 可重入;
Redis分布式鎖
RedisLockRegistry
RedisLockRegistry是spring-integration-redis中提供redis分布式鎖實現類。主要是通過redis鎖+本地鎖雙重鎖的方式實現的一個比較好的鎖。
OBTAIN_LOCK_SCRIPT是一個上鎖的lua腳本。KEYS[1]代表當前鎖的key值,ARGV[1]代表當前的客戶端標識,ARGV[2]代表過期時間。
基本邏輯是:根據KEYS[1]從redis中拿到對應的客戶端標識,如已存在的客戶端標識和ARGV[1]相等,那么重置過期時間為ARGV[2];如果值不存在,設置KEYS[1]對應的值為ARGV[1],並且過期時間是ARGV[2]。
獲取鎖的過程也很簡單,首先通過本地鎖(localLock,對應的是ReentrantLock實例)獲取鎖,然后通過RedisTemplate執行OBTAIN_LOCK_SCRIPT腳本獲取redis鎖。
為什么要使用本地鎖呢,首先是為了鎖的可重入,其次是減輕redis服務壓力。
釋放鎖的過程也比較簡單,第一步通過本地鎖判斷當前線程是否持有鎖,第二步通過本地鎖判斷當前線程持有鎖的計數。
如果當前線程持有鎖的計數 > 1,說明本地鎖被當前線程多次獲取,這時只釋放本地鎖(釋放之后當前線程持有鎖的計數-1)。
如果當前線程持有鎖的計數 = 1,釋放本地鎖和redis鎖。
RedisLockRegistry使用如上所示。
首先定義RedisLockRegistry對應的Bean,需要依賴redis的ConnectionFactory。
然后在服務層中注入RedisLockRegistry實例。
通過lock方法和unlock方法將業務邏輯包起來,需要注意的是unlock方法要寫在finally代碼塊中。
Redisson
Redisson是架設在Redis基礎上的一個Java駐內存數據網格(In-Memory Data Grid)。
充分的利用了Redis鍵值數據庫提供的一系列優勢,基於Java實用工具包中常用接口,為使用者提供了一系列具有分布式特性的常用工具類。
使得原本作為協調單機多線程並發程序的工具包獲得了協調分布式多機多線程並發系統的能力,大大降低了設計和研發大規模分布式系統的難度。
同時結合各富特色的分布式服務,更進一步簡化了分布式環境中程序相互之間的協作。
首先感受一下通過Redisson Api使用redis分布式鎖。
定義RedissonBuilder,通過redis集群地址構建RedissonClient。
定義RedissonClient類型的Bean。
業務代碼里,通過RedissonClient獲取分布式鎖。
由於對Redisson分布式鎖實現原理了解的也不是很透徹,這里推薦一篇文章:Redisson 分布式鎖實現分析。
Redisson和RedisLockRegistry對比
- RedisLockRegistry通過本地鎖(ReentrantLock)和redis鎖,雙重鎖實現,Redission通過Netty Future機制、Semaphore (jdk信號量)、redis鎖實現。
- RedisLockRegistry和Redssion都是實現的可重入鎖。
- RedisLockRegistry對鎖的刷新沒有處理,Redisson通過Netty的TimerTask、Timeout 工具完成鎖的定期刷新任務。
- RedisLockRegistry僅僅是實現了分布式鎖,而Redisson處理分布式鎖,還提供了了隊列、集合、列表等豐富的API。
動手實現分布式鎖
實現原理
本地鎖(ReentrantLock)+ redis鎖
獲取鎖lua腳本
鎖刷新lua腳本
鎖釋放lua腳本
本地鎖定義
每一個lock key對應唯一的一個本地鎖
線程標識定義
分布式環境下,每一個線程對應一個唯一標識
鎖刷新定時任務定義
通過JDK ConcurrentTaskScheduler完成定時任務執行,ScheduledFuture完成定時任務銷毀。其中taskId對應線程標識。
定義分布式鎖注解
分布式鎖切面
通過RedisLock注解實例lockInfo獲取到鎖key值、鎖過期時間信息。
獲取鎖過程
- 通過lockInfo.key()方法獲取到鎖key值,通過鎖key值拿到對應的本地鎖(ReentrantLock)
- 本地鎖獲取鎖對象
- 進入獲取redis鎖的循環
- 通過緩存服務組件執行獲取鎖的lua腳本
- 如果獲取到redis鎖,判斷當前線程是否第一次獲取到鎖並且開啟了鎖刷新,相應的注冊鎖刷新定時任務
- 如果沒有獲取到redis鎖,休眠lockInfo.sleep()毫秒的時間,再次重試
釋放鎖過程
- 獲取到當前鎖key值對應的本地鎖
- 判斷當前線程是否為本地鎖鎖的持有者
- 如果本地鎖的重入次數大於1,則只釋放本地鎖
- 如果本地鎖的重入次數等於1,釋放本地鎖和redis鎖
分布式鎖測試
定義測試類,測試方法注上@RedisLock注解,制定鎖的key值為 "redis-lock-test",測試方法內隨機休眠。
開啟20個線程,同時調用測試方法。
多線程redis分布式鎖測試結果如下。
定義可重入測試類,方法內獲取當前代理對象,遞歸調用測試方法。
測試方法中,調用可重入測試類注有@RedisLock的測試方法。
分布式鎖可重入測試結果如下。
分布式鎖實際應用
定義access_token刷新服務
refreshAccessToken方法上標注@RedisLock注解,表明此方法在分布式環境下會串行執行。
首先從緩存里獲取access_token。
如果緩存里的access_token為空或者和失效的access_token相等,通過TokenAPI生成新的access_token並放入緩存。
如果緩存里的access_token不為空並且和失效的access_token不相等,直接返回緩存里的access_token。
定義access_token獲取服務
如果緩存中的access_token為空,直接刷新access_token並放入緩存。
如果緩存中的access_token不為空且和失效的access_token相等則刷新access_token並放入緩存,否則直接返回緩存中的access_token。
分布式鎖應用場景
在分布式環境下,涉及線程間並發問題和進程間並發問題都是可以通過分布式鎖解決的。如果是單節點線程之間共享資源的並發問題可以通過JDK提供的線程鎖來解決,如果是多節點多線程之間共享資源的並發問題就需要借助分布式鎖。比如最常見的秒殺、搶紅包,后台服務中涉及到庫存扣減、金額扣減、以及其他高並發串行化場景的操作都可用分布式鎖來解決問題。本文講述的例子主要是應用在微信公眾號和微信小程序access_token刷新、微信分享jsapi_ticket刷新,分布式鎖可以保證access_token和jsapi_ticket在高並發下只有一個線程去執行刷新動作,避免多次刷新后access_token或者jsapi_ticket失效的問題。