Redis分佈式鎖實戰

背景

目前開發過程當中,按照公司規範,須要依賴框架中的緩存組件。不得不說,作組件的大牛對CRUD操做的封裝,鏈接池、緩存路由、緩存安全性的管控都處理的無可挑剔。可是有一個小問題,該組件沒有對分佈式鎖作實現,那就要想辦法依靠緩存組件本身去實現一個分佈式鎖了。redis

什麼,爲啥要本身實現?有現成的開源組件直接拿過來用不就好了,好比Spring-Integration-Redis提供RedisLockRegistry,Redisson,不比本身去實現快的多。那我得聲明一下,本人也不喜歡重複造輪子。具體緣由呢,首先是項目中的緩存組件是不能替換的,鏈接池還可能沒有辦法複用,其次就是若是對開源組件實現原理不熟悉,那麼出了問題,維護起來又須要更多成本。spring

先說一下當前須要分佈式鎖的兩個場景,一個是微信端access_token刷新(分佈式鎖能夠保證access_token只刷新一次,刷新完成以後放入緩存,其餘請求直接從緩存讀取);一個是分佈式部署的定時任務(分佈式鎖能夠保證同一時刻只有一個節點的定時任務執行)。sql

什麼是分佈式鎖

在單機部署的狀況下,要想保證特定業務在順序執行,經過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]。api

獲取鎖的過程也很簡單,首先經過本地鎖(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值、鎖過時時間信息。

獲取鎖過程

  1. 經過lockInfo.key()方法獲取到鎖key值,經過鎖key值拿到對應的本地鎖(ReentrantLock)
  2. 本地鎖獲取鎖對象
  3. 進入獲取redis鎖的循環
  4. 經過緩存服務組件執行獲取鎖的lua腳本
  5. 若是獲取到redis鎖,判斷當前線程是否第一次獲取到鎖而且開啓了鎖刷新,相應的註冊鎖刷新定時任務
  6. 若是沒有獲取到redis鎖,休眠lockInfo.sleep()毫秒的時間,再次重試

釋放鎖過程

 

  1. 獲取到當前鎖key值對應的本地鎖
  2. 判斷當前線程是否爲本地鎖鎖的持有者
  3. 若是本地鎖的重入次數大於1,則只釋放本地鎖
  4. 若是本地鎖的重入次數等於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失效的問題。

相關文章
相關標籤/搜索