造了一个 Redis 分布锁的轮子,没想到还学到这么多东西!!!
手擼分布式鎖
這篇文章本來是準備寫下 Mysql 查詢左匹配的問題,但是還沒研究出來。那就先寫下最近在鼓搗一個東西,使用 Redis 實現可重入分布鎖。
看到這里,有的朋友可能會提出來使用 redisson 不香嗎,為什么還要自己實現?
哎,redisson 真的很香,但是現有項目中沒辦法使用,只好自己手擼一個可重入的分布式鎖了。
雖然用不了 redisson,但是我可以研究其源碼,最后實現的可重入分布鎖參考了 redisson 實現方式。
分布式鎖
分布式鎖特性就要在于排他性,同一時間內多個調用方加鎖競爭,只能有一個調用方加鎖成功。
Redis 由于內部單線程的執行,內部按照請求先后順序執行,沒有并發沖突,所以只會有一個調用方才會成功獲取鎖。
而且 Redis 基于內存操作,加解鎖速度性能高,另外我們還可以使用集群部署增強 Redis 可用性。
加鎖
使用 Redis 實現一個簡單的分布式鎖,非常簡單,可以直接使用 SETNX 命令。
SETNX 是『SET if Not eXists』,如果不存在,才會設置,使用方法如下:
不過直接使用 SETNX 有一個缺陷,我們沒辦法對其設置過期時間,如果加鎖客戶端宕機了,這就導致這把鎖獲取不了了。
有的同學可能會提出,執行 SETNX 之后,再執行 EXPIRE 命令,主動設置過期時間,偽碼如下:
var?result?=?setnx?lock?"client" if(result==1){//?有效期?30?sexpire?lock?30 }不過這樣還是存在缺陷,加鎖代碼并不能原子執行,如果調用加鎖語句,還沒來得及設置過期時間,應用就宕機了,還是會存在鎖過期不了的問題。
不過這個問題在 Redis 2.6.12 版本 就可以被完美解決。這個版本增強了 SET 命令,可以通過帶上 NX,EX 命令原子執行加鎖操作,解決上述問題。參數含義如下:
EX second ?:設置鍵的過期時間,單位為秒
NX 當鍵不存在時,進行設置操作,等同與 SETNX 操作
使用 SET 命令實現分布式鎖只需要一行代碼:
SET?lock_name?anystring?NX?EX?lock_time解鎖
解鎖相比加鎖過程,就顯得非常簡單,只要調用 DEL 命令刪除鎖即可:
DEL lock_name不過這種方式卻存在一個缺陷,可能會發生錯解鎖問題。
假設應用 1 加鎖成功,鎖超時時間為 30s。由于應用 1 業務邏輯執行時間過長,30 s 之后,鎖過期自動釋放。
這時應用 2 接著加鎖,加鎖成功,執行業務邏輯。這個期間,應用 1 終于執行結束,使用 DEL 成功釋放鎖。
這樣就導致了應用 1 錯誤釋放應用 2 的鎖,另外鎖被釋放之后,其他應用可能再次加鎖成功,這就可能導致業務重復執行。
為了使鎖不被錯誤釋放,我們需要在加鎖時設置隨機字符串,比如 UUID。
SET?lock_name?uuid?NX?EX?lock_time釋放鎖時,需要提前獲取當前鎖存儲的值,然后與加鎖時的 uuid 做比較,偽代碼如下:
var?value=?get?lock_name if?value?==?uuid//?釋放鎖成功 else//?釋放鎖失敗上述代碼我們不能通過 Java 代碼運行,因為無法保證上述代碼原子化執行。
幸好 Redis 2.6.0 增加執行 Lua 腳本的功能,lua 代碼可以運行在 Redis 服務器的上下文中,并且整個操作將會被當成一個整體執行,中間不會被其他命令插入。
這就保證了腳本將會以原子性的方式執行,當某個腳本正在運行的時候,不會有其他腳本或 Redis 命令被執行。在其他的別的客戶端看來,執行腳本的效果,要么是不可見的,要么就是已完成的。
EVAL 與 EVALSHA
EVAL
Redis 可以使用 EVAL 執行 LUA 腳本,而我們可以在 LUA 腳本中執行判斷求值邏輯。EVAL 執行方式如下:
EVAL?script?numkeys?key?[key?...]?arg?[arg?...]numkeys 參數用于鍵名參數,即后面 key 數組的個數。
key [key ...] 代表需要在腳本中用到的所有 Redis key,在 Lua 腳本使用使用數組的方式訪問 key,類似如下 KEYS[1] , KEYS[2]。注意 Lua 數組起始位置與 Java 不同,Lua 數組是從 1 開始。
命令最后,是一些附加參數,可以用來當做 Redis Key 值存儲的 Value 值,使用方式如 KEYS 變量一樣,類似如下:ARGV[1] 、 ARGV[2] 。
用一個簡單例子運行一下 EVAL 命令:
eval?"return?{KEYS[1],KEYS[2],ARGV[1],ARGV[2],ARGV[3]}"?2?key1?key2?first?second?third運行效果如下:
可以看到 KEYS 與 ARGVS內部數組可以不一致。
在 Lua 腳本可以使用下面兩個函數執行 Redis 命令:
redis.call()
redis.pcall()
兩個函數作用法與作用完全一致,只不過對于錯誤的處理方式不一致,感興趣的小伙伴可以具體點擊以下鏈接,查看錯誤處理一章。
http://doc.redisfans.com/script/eval.html
下面我們統一在 Lua 腳本中使用 redis.call(),執行以下命令:
eval?"return?redis.call('set',KEYS[1],ARGV[1])"?1?foo?樓下小黑哥運行效果如下:
EVALSHA
EVAL 命令每次執行時都需要發送 Lua 腳本,但是 Redis 并不會每次都會重新編譯腳本。
當 Redis 第一次收到 Lua 腳本時,首先將會對 Lua 腳本進行 ?sha1 獲取簽名值,然后內部將會對其緩存起來。后續執行時,直接通過 sha1 計算過后簽名值查找已經編譯過的腳本,加快執行速度。
雖然 Redis 內部已經優化執行的速度,但是每次都需要發送腳本,還是有網絡傳輸的成本,如果腳本很大,這其中花在網絡傳輸的時間就會相應的增加。
所以 Redis 又實現了 EVALSHA 命令,原理與 EVAL 一致。只不過 EVALSHA 只需要傳入腳本經過 sha1計算過后的簽名值即可,這樣大大的減少了傳輸的字節大小,減少了網絡耗時。
EVALSHA命令如下:
evalsha?c686f316aaf1eb01d5a4de1b0b63cd233010e63d?1?foo?樓下小黑哥運行效果如下:
“SCRIPT FLUSH 命令用來清除所有 Lua 腳本緩存。
可以看到,如果之前未執行過 EVAL命令,直接執行 EVALSHA 將會報錯。
優化執行 EVAL
我們可以結合使用 EVAL 與 EVALSHA,優化程序。下面就不寫偽碼了,以 Jedis 為例,優化代碼如下:
//連接本地的?Redis?服務 Jedis?jedis?=?new?Jedis("localhost",?6379); jedis.auth("1234qwer");System.out.println("服務正在運行:?"?+?jedis.ping());String?lua_script?=?"return?redis.call('set',KEYS[1],ARGV[1])"; String?lua_sha1?=?DigestUtils.sha1DigestAsHex(lua_script);try?{Object?evalsha?=?jedis.evalsha(lua_sha1,?Lists.newArrayList("foo"),?Lists.newArrayList("樓下小黑哥")); }?catch?(Exception?e)?{Throwable?current?=?e;while?(current?!=?null)?{String?exMessage?=?current.getMessage();//?包含?NOSCRIPT,代表該?lua?腳本從未被執行,需要先執行?eval?命令if?(exMessage?!=?null?&&?exMessage.contains("NOSCRIPT"))?{Object?eval?=?jedis.eval(lua_script,?Lists.newArrayList("foo"),?Lists.newArrayList("樓下小黑哥"));break;}} } String?foo?=?jedis.get("foo"); System.out.println(foo);上面的代碼看起來還是很復雜吧,不過這是使用原生 jedis 的情況下。如果我們使用 Spring Boot 的話,那就沒這么麻煩了。Spring 組件執行的 Eval 方法內部就包含上述代碼的邏輯。
不過需要注意的是,如果 Spring-Boot 使用 Jedis 作為連接客戶端,并且使用Redis ?Cluster 集群模式,需要使用 ?2.1.9 以上版本的spring-boot-starter-data-redis,不然執行過程中將會拋出:
org.springframework.dao.InvalidDataAccessApiUsageException:?EvalSha?is?not?supported?in?cluster?environment.詳細情況可以參考這個修復的 IssueAdd support for scripting commands with Jedis Cluster
優化分布式鎖
講完 Redis 執行 LUA 腳本的相關命令,我們來看下如何優化上面的分布式鎖,使其無法釋放其他應用加的鎖。
“以下代碼基于 spring-boot ?2.2.7.RELEASE 版本,Redis 底層連接使用 Jedis。
加鎖的 Redis 命令如下:
SET?lock_name?uuid?NX?EX?lock_time加鎖代碼如下:
/***?非阻塞式加鎖,若鎖存在,直接返回**?@param?lockName??鎖名稱*?@param?request???唯一標識,防止其他應用/線程解鎖,可以使用?UUID?生成*?@param?leaseTime?超時時間*?@param?unit??????時間單位*?@return*/ public?Boolean?tryLock(String?lockName,?String?request,?long?leaseTime,?TimeUnit?unit)?{//?注意該方法是在?spring-boot-starter-data-redis?2.1?版本新增加的,若是之前版本?可以執行下面的方法return?stringRedisTemplate.opsForValue().setIfAbsent(lockName,?request,?leaseTime,?unit); }由于setIfAbsent方法是在 spring-boot-starter-data-redis 2.1 版本新增加,之前版本無法設置超時時間。如果使用之前的版本的,需要如下方法:
/***?適用于?spring-boot-starter-data-redis?2.1?之前的版本**?@param?lockName*?@param?request*?@param?leaseTime*?@param?unit*?@return*/ public?Boolean?doOldTryLock(String?lockName,?String?request,?long?leaseTime,?TimeUnit?unit)?{Boolean?result?=?stringRedisTemplate.execute((RedisCallback<Boolean>)?connection?->?{RedisSerializer?valueSerializer?=?stringRedisTemplate.getValueSerializer();RedisSerializer?keySerializer?=?stringRedisTemplate.getKeySerializer();Boolean?innerResult?=?connection.set(keySerializer.serialize(lockName),valueSerializer.serialize(request),Expiration.from(leaseTime,?unit),RedisStringCommands.SetOption.SET_IF_ABSENT);return?innerResult;});return?result; }解鎖需要使用 Lua 腳本:
--?解鎖代碼 --?首先判斷傳入的唯一標識是否與現有標識一致 --?如果一致,釋放這個鎖,否則直接返回 if?redis.call('get',?KEYS[1])?==?ARGV[1]?thenreturn?redis.call('del',?KEYS[1]) elsereturn?0 end這段腳本將會判斷傳入的唯一標識是否與 Redis 存儲的標示一致,如果一直,釋放該鎖,否則立刻返回。
釋放鎖的方法如下:
/***?解鎖*?如果傳入應用標識與之前加鎖一致,解鎖成功*?否則直接返回*?@param?lockName?鎖*?@param?request?唯一標識*?@return*/ public?Boolean?unlock(String?lockName,?String?request)?{DefaultRedisScript<Boolean>?unlockScript?=?new?DefaultRedisScript<>();unlockScript.setLocation(new?ClassPathResource("simple_unlock.lua"));unlockScript.setResultType(Boolean.class);return?stringRedisTemplate.execute(unlockScript,?Lists.newArrayList(lockName),?request); }“
由于公號外鏈無法直接跳轉,關注『程序通事』,回復分布式鎖獲取源代碼。
Redis 分布式鎖的缺陷
無法重入
由于上述加鎖命令使用了 SETNX ,一旦鍵存在就無法再設置成功,這就導致后續同一線程內繼續加鎖,將會加鎖失敗。
如果想將 Redis 分布式鎖改造成可重入的分布式鎖,有兩種方案:
本地應用使用 ThreadLocal 進行重入次數計數,加鎖時加 1,解鎖時減 1,當計數變為 0 釋放鎖
第二種,使用 Redis Hash 表存儲可重入次數,使用 Lua 腳本加鎖/解鎖
第一種方案可以參考這篇文章分布式鎖的實現之 redis 篇。第二個解決方案,下一篇文章就會具體來聊聊,敬請期待。
鎖超時釋放
假設線程 A 加鎖成功,鎖超時時間為 30s。由于線程 A 內部業務邏輯執行時間過長,30s 之后鎖過期自動釋放。
此時線程 B 成功獲取到鎖,進入執行內部業務邏輯。此時線程 A 還在執行執行業務,而線程 B 又進入執行這段業務邏輯,這就導致業務邏輯重復被執行。
這個問題我覺得,一般由于鎖的超時時間設置不當引起,可以評估下業務邏輯執行時間,在這基礎上再延長一下超時時間。
如果超時時間設置合理,但是業務邏輯還有偶發的超時,個人覺得需要排查下業務執行過長的問題。
如果說一定要做到業務執行期間,鎖只能被一個線程占有的,那就需要增加一個守護線程,定時為即將的過期的但未釋放的鎖增加有效時間。
加鎖成功后,同時創建一個守護線程。守護線程將會定時查看鎖是否即將到期,如果鎖即將過期,那就執行 EXPIRE 等命令重新設置過期時間。
說實話,如果要這么做,真的挺復雜的,感興趣的話可以參考下 ?redisson watchdog 實現方式。
Redis 分布式鎖集群問題
為了保證生產高可用,一般我們會采用主從部署方式。采用這種方式,我們可以將讀寫分離,主節點提供寫服務,從節點提供讀服務。
Redis 主從之間數據同步采用異步復制方式,主節點寫入成功后,立刻返回給客戶端,然后異步復制給從節點。
如果數據寫入主節點成功,但是還未復制給從節點。此時主節點掛了,從節點立刻被提升為主節點。
這種情況下,還未同步的數據就丟失了,其他線程又可以被加鎖了。
針對這種情況, Redis 官方提出一種 ?RedLock 的算法,需要有 N 個Redis 主從節點,解決該問題,詳情參考:
https://redis.io/topics/distlock。
這個算法自己實現還是很復雜的,幸好 redisson 已經實現的 RedLock,詳情參考:redisson redlock
總結
本來這篇文章是想寫 Redis 可重入分布式鎖的,可是沒想到寫分布式鎖的實現方案就已經寫了這么多,再寫下去,文章可能就很長,所以拆分成兩篇來寫。
幫大家再次總結一下本文內容。
簡單的 Redis 分布式鎖的實現方式還是很簡單的,我們可以直接用 SETNX/DEL ?命令實現加解鎖。
不過這種實現方式不夠健壯,可能存在應用宕機,鎖就無法被釋放的問題。
所以我們接著引入以下命令以及 Lua 腳本增強 Redis 分布式鎖。
SET?lock_name?anystring?NX?EX?lock_time最后 Redis 分布鎖還是存在一些缺陷,在這里提出一些解決方案,感興趣同學可以自己實現一下。
有道無術,術可成;有術無道,止于術
歡迎大家關注Java之道公眾號
好文章,我在看??
總結
以上是生活随笔為你收集整理的造了一个 Redis 分布锁的轮子,没想到还学到这么多东西!!!的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: NYOJ 833 取石子(七)
- 下一篇: NYOJ 837 Wythoff Gam