日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當(dāng)前位置: 首頁 > 运维知识 > 数据库 >内容正文

数据库

造了一个 Redis 分布锁的轮子,没想到还学到这么多东西!!!

發(fā)布時(shí)間:2025/3/16 数据库 27 豆豆
生活随笔 收集整理的這篇文章主要介紹了 造了一个 Redis 分布锁的轮子,没想到还学到这么多东西!!! 小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,幫大家做個(gè)參考.

手?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)容,希望文章能夠幫你解決所遇到的問題。

如果覺得生活随笔網(wǎng)站內(nèi)容還不錯(cuò),歡迎將生活随笔推薦給好友。