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