1.概述
1.1 背景
分布式鎖在很多場(chǎng)景中是非常有用的原語(yǔ)委煤, 不同的進(jìn)程必須以獨(dú)占資源的方式實(shí)現(xiàn)資源共享就是一個(gè)典型的例子坑匠。
由于外圍的實(shí)現(xiàn)存在著各種各樣的問(wèn)題, Redis 作者提出了一種 RedLock算法來(lái)約定分布式鎖需要注意的事項(xiàng)肠缨。
當(dāng)前java版的實(shí)現(xiàn)是 Redisson 框架烦衣。
1.2 Redis分布式鎖的基本原則
>> 安全屬性(Safety property): 獨(dú)享(相互排斥)渐排。
在任意一個(gè)時(shí)刻史隆,只有一個(gè)客戶端持有鎖绵估。
>> 活性A(Liveness property A): 無(wú)死鎖爆捞。
即便持有鎖的客戶端崩潰(crashed)或者網(wǎng)絡(luò)被分裂(gets partitioned)奉瘤,鎖仍然可以被獲取。
>> 活性B(Liveness property B): 容錯(cuò)煮甥。
只要大部分Redis節(jié)點(diǎn)都活著盗温,客戶端就可以獲取和釋放鎖.
1.3 單點(diǎn)問(wèn)題 & Master-Slave問(wèn)題
#基本實(shí)現(xiàn)
#加鎖
實(shí)現(xiàn)Redis分布式鎖的最簡(jiǎn)單的方法就是在Redis中創(chuàng)建一個(gè)key,
這個(gè)key有一個(gè)失效時(shí)間(TTL)成肘,以保證鎖最終會(huì)被自動(dòng)釋放掉(這個(gè)對(duì)應(yīng)特性2)卖局。
#解鎖
當(dāng)客戶端釋放資源(解鎖)的時(shí)候,會(huì)刪除掉這個(gè)key双霍。
#單點(diǎn)問(wèn)題 & Master-Slave問(wèn)題
從表面上看砚偶,似乎效果還不錯(cuò),但是這里有一個(gè)問(wèn)題:
這個(gè)架構(gòu)中存在一個(gè)嚴(yán)重的單點(diǎn)失敗問(wèn)題洒闸。如果Redis掛了怎么辦染坯?
你可能會(huì)說(shuō),可以通過(guò)增加一個(gè)slave節(jié)點(diǎn)解決這個(gè)問(wèn)題丘逸。
但這通常是行不通的单鹿。這樣做,我們不能實(shí)現(xiàn)資源的獨(dú)享,因?yàn)镽edis的主從同步通常是異步的深纲。
#Master-Slave問(wèn)題
在這種場(chǎng)景(主從結(jié)構(gòu))中存在明顯的競(jìng)態(tài):
>> 客戶端A從master獲取到鎖
>> 在master將鎖同步到slave之前仲锄,master宕掉了。
>> slave節(jié)點(diǎn)被晉級(jí)為master節(jié)點(diǎn)
>> 客戶端B取得了同一個(gè)資源被客戶端A已經(jīng)獲取到的另外一個(gè)鎖囤萤。安全失效昼窗!
1.4 Redis單機(jī)版(version > 2.6)的正確實(shí)現(xiàn)方法
1.4.1 加鎖
SET resource_name my_random_value NX PX 30000
這是一個(gè)原子命令(redis客戶端已支持)。
需注意key對(duì)應(yīng)的value是“my_random_value”(一個(gè)隨機(jī)值)涛舍,這個(gè)值在所有的客戶端必須是唯一的澄惊。
1.4.2 解鎖
value的值必須是隨機(jī)數(shù)主要是為了更安全的釋放鎖,釋放鎖的時(shí)候使用腳本告訴Redis:
只有key存在并且存儲(chǔ)的值和我指定的值一樣才能告訴我刪除成功。
#為保證兩個(gè)操作的原子性, 這里需要使用 lua 腳本實(shí)現(xiàn)掸驱。
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
#解鎖時(shí), 校驗(yàn) value 是否一致的原因
假設(shè)客戶端A取得資源鎖肛搬,但是緊接著被一個(gè)其他操作阻塞了,當(dāng)客戶端A運(yùn)行完畢其他操作后要釋放鎖時(shí)毕贼,
原來(lái)的鎖早已超時(shí)并且被Redis自動(dòng)釋放温赔,并且在這期間資源鎖又被客戶端B再次獲取到。
如果僅使用DEL命令將key刪除鬼癣,那么這種情況就會(huì)把客戶端B的鎖給刪除掉陶贼。
使用Lua腳本就不會(huì)存在這種情況,因?yàn)槟_本僅會(huì)刪除value等于客戶端A的value的key(value相當(dāng)于客戶端的一個(gè)簽名)待秃。
1.5 Redis 官網(wǎng)關(guān)于鎖的探討
1.5.1 加鎖
1.5.1.1 Redlock算法
假設(shè)有5個(gè)Redis master(防止單點(diǎn)故障)拜秧。這些節(jié)點(diǎn)完全互相獨(dú)立,不存在主從復(fù)制或者其他集群協(xié)調(diào)機(jī)制章郁。
在每個(gè)實(shí)例上使用與在Redis單實(shí)例下獲取和釋放鎖獲取和釋放鎖的方法枉氮。
#為了取到鎖,客戶端應(yīng)該執(zhí)行以下操作:
>> 1.獲取當(dāng)前Unix時(shí)間暖庄,以毫秒為單位聊替。
>> 2.依次嘗試從N個(gè)實(shí)例,使用相同的key和隨機(jī)值獲取鎖培廓。
當(dāng)向Redis設(shè)置鎖時(shí),客戶端應(yīng)該設(shè)置一個(gè)網(wǎng)絡(luò)連接和響應(yīng)超時(shí)時(shí)間惹悄,這個(gè)超時(shí)時(shí)間應(yīng)該小于鎖的失效時(shí)間。
例如你的鎖自動(dòng)失效時(shí)間為10秒医舆,則超時(shí)時(shí)間應(yīng)該在5-50毫秒之間俘侠。
這樣可以避免服務(wù)器端Redis已經(jīng)掛掉的情況下,客戶端還在死死地等待響應(yīng)結(jié)果蔬将。
如果服務(wù)器端沒(méi)有在規(guī)定時(shí)間內(nèi)響應(yīng)爷速,客戶端應(yīng)該盡快嘗試另外一個(gè)Redis實(shí)例。
>> 3.客戶端使用當(dāng)前時(shí)間減去開始獲取鎖時(shí)間(步驟1記錄的時(shí)間)就得到獲取鎖使用的時(shí)間霞怀。
當(dāng)且僅當(dāng)從大多數(shù)(這里是3個(gè)節(jié)點(diǎn))的Redis節(jié)點(diǎn)都取到鎖惫东,
并且使用的時(shí)間小于鎖失效時(shí)間時(shí),鎖才算獲取成功毙石。
>> 4.如果取到了鎖廉沮,key的真正有效時(shí)間等于有效時(shí)間減去獲取鎖所使用的時(shí)間(步驟3計(jì)算的結(jié)果)。
>> 5.如果因?yàn)槟承┰蛐炀兀@取鎖失敗(沒(méi)有在至少N/2+1個(gè)Redis實(shí)例取到鎖或者取鎖時(shí)間已經(jīng)超過(guò)了有效時(shí)間),
客戶端應(yīng)該在所有的Redis實(shí)例上進(jìn)行解鎖(即便某些Redis實(shí)例根本就沒(méi)有加鎖成功)滞时。
1.5.1.2 系統(tǒng)時(shí)鐘的影響 & 自動(dòng)續(xù)約機(jī)制
#算法基于這樣一個(gè)假設(shè)
雖然多個(gè)進(jìn)程之間沒(méi)有時(shí)鐘同步,但每個(gè)進(jìn)程都以相同的時(shí)鐘頻率前進(jìn)滤灯,
時(shí)間差相對(duì)于失效時(shí)間來(lái)說(shuō)幾乎可以忽略不計(jì)坪稽。
每個(gè)計(jì)算機(jī)都有一個(gè)本地時(shí)鐘曼玩,我們可以容忍多個(gè)計(jì)算機(jī)之間有較小的時(shí)鐘漂移。
#注意點(diǎn) (時(shí)鐘漂移 & 閏秒現(xiàn)象 ---> 正確配置NTP):
只有在鎖的有效時(shí)間(在步驟3計(jì)算的結(jié)果)范圍內(nèi)客戶端能夠做完它的工作窒百,
鎖的安全性才能得到保證(鎖的實(shí)際有效時(shí)間通常要比設(shè)置的短黍判,因?yàn)橛?jì)算機(jī)之間有時(shí)鐘漂移的現(xiàn)象)。
#自動(dòng)續(xù)約(Redisson使用了watchdog機(jī)制來(lái)實(shí)現(xiàn))
>> 在工作進(jìn)行的過(guò)程中篙梢,當(dāng)發(fā)現(xiàn)鎖剩下的有效時(shí)間很短時(shí)顷帖,
可以再次向redis的所有實(shí)例發(fā)送一個(gè)Lua腳本,讓key的有效時(shí)間延長(zhǎng)一點(diǎn)(前提還是key存在并且value是之前設(shè)置的value)渤滞。
>> 客戶端擴(kuò)展TTL時(shí)必須像首次取得鎖一樣在大多數(shù)實(shí)例上擴(kuò)展成功才算再次取到鎖贬墩,
并且是在有效時(shí)間內(nèi)再次取到鎖(算法和獲取鎖是非常相似的)。
>> 這樣做從技術(shù)上將并不會(huì)改變算法的正確性蔼水,所以擴(kuò)展鎖的過(guò)程中
仍然需要達(dá)到獲取到N/2+1個(gè)實(shí)例這個(gè)要求震糖,否則活性特性之一就會(huì)失效。
1.5.1.3 失敗重試(注意腦裂現(xiàn)象)
當(dāng)客戶端無(wú)法取到鎖時(shí)趴腋,應(yīng)該在一個(gè)隨機(jī)延遲后重試,
防止多個(gè)客戶端在同時(shí)搶奪同一資源的鎖(這樣會(huì)導(dǎo)致腦裂,沒(méi)有人會(huì)取到鎖)论咏。
同樣优炬,客戶端取得大部分Redis實(shí)例鎖所花費(fèi)的時(shí)間越短,腦裂出現(xiàn)的概率就會(huì)越低(必要的重試)厅贪,
所以蠢护,理想情況一下,客戶端應(yīng)該同時(shí)(并發(fā)地)向所有Redis發(fā)送SET命令养涮。
需要強(qiáng)調(diào)葵硕,當(dāng)客戶端從大多數(shù)Redis實(shí)例獲取鎖失敗時(shí),應(yīng)該盡快地釋放(部分)已經(jīng)成功取到的鎖贯吓,
這樣其他的客戶端就不必非得等到鎖過(guò)完“有效時(shí)間”才能取到懈凹。
然而,如果已經(jīng)存在網(wǎng)絡(luò)分裂悄谐,客戶端已經(jīng)無(wú)法和Redis實(shí)例通信介评,
此時(shí)就只能等待key的自動(dòng)釋放了,等于被懲罰了爬舰。
1.5.2 釋放鎖
#這個(gè)釋放鎖指的是已當(dāng)前獲取到鎖的客戶端向所有實(shí)例發(fā)送解鎖命令
釋放鎖比較簡(jiǎn)單们陆,向所有的Redis實(shí)例發(fā)送釋放鎖命令即可,不用關(guān)心之前有沒(méi)有從Redis實(shí)例成功獲取到鎖.
1.5.3 一些問(wèn)題
#redis沒(méi)設(shè)置 slave 節(jié)點(diǎn)
假設(shè)我們的redis沒(méi)用使用備份情屹。一個(gè)客戶端獲取到了3個(gè)實(shí)例的鎖坪仇。
此時(shí),其中一個(gè)已經(jīng)被客戶端取到鎖的redis實(shí)例被重啟垃你,
在這個(gè)時(shí)間點(diǎn)椅文,就可能出現(xiàn)3個(gè)節(jié)點(diǎn)沒(méi)有設(shè)置鎖喂很,此時(shí)如果有另外一個(gè)客戶端來(lái)設(shè)置鎖,
鎖就可能被再次獲取到雾袱,這樣鎖的互相排斥的特性就被破壞掉了恤筛。
#如果我們啟用了AOF持久化,情況會(huì)好很多芹橡。
我們可用使用SHUTDOWN命令關(guān)閉然后再次重啟毒坛。
因?yàn)镽edis到期是語(yǔ)義上實(shí)現(xiàn)的,所以當(dāng)服務(wù)器關(guān)閉時(shí)林说,實(shí)際上還是經(jīng)過(guò)了時(shí)間煎殷,
所有(保持鎖)需要的條件都沒(méi)有受到影響. 沒(méi)有受到影響的前提是redis優(yōu)雅的關(guān)閉。
停電了怎么辦腿箩?
如果redis是每秒執(zhí)行一次fsync豪直,那么很有可能在redis重啟之后,key已經(jīng)丟棄珠移。
理論上弓乙,如果我們想在Redis重啟地任何情況下都保證鎖的安全,我們必須開啟fsync=always的配置钧惧。
這反過(guò)來(lái)將完全破壞與傳統(tǒng)上用于以安全的方式實(shí)現(xiàn)分布式鎖的同一級(jí)別的CP系統(tǒng)的性能.
然而情況總比一開始想象的好一些暇韧。
當(dāng)一個(gè)redis節(jié)點(diǎn)重啟后,只要它不參與到任意當(dāng)前活動(dòng)的鎖浓瞪,
沒(méi)有被當(dāng)做“當(dāng)前存活”節(jié)點(diǎn)被客戶端重新獲取到,算法的安全性仍然是有保障的懈玻。
為了達(dá)到這種效果,我們只需要將新的redis實(shí)例乾颁,在一個(gè)TTL時(shí)間內(nèi)涂乌,
對(duì)客戶端不可用即可,在這個(gè)時(shí)間內(nèi)英岭,所有客戶端鎖將被失效或者自動(dòng)釋放.
使用"延遲重啟"可以在不采用持久化策略的情況下達(dá)到同樣的安全湾盒,
然而這樣做有時(shí)會(huì)讓系統(tǒng)轉(zhuǎn)化為徹底不可用。
比如大部分的redis實(shí)例都崩潰了巴席,系統(tǒng)在TTL時(shí)間內(nèi)任何鎖都將無(wú)法加鎖成功历涝。
Martin Kleppmann 與 antirez 關(guān)于 RedLock 算法的互懟
http://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
http://antirez.com/news/101
1.6 關(guān)于redis分布式鎖的結(jié)論
#使用建議
1.分布式鎖的redis采用單機(jī)部署,分布式鎖專用
2.根據(jù)RedLock算法思想漾唉,意思是不能只在一個(gè)redis實(shí)例上創(chuàng)建鎖荧库,應(yīng)該是在多個(gè)redis實(shí)例上創(chuàng)建鎖,**n / 2 + 1**赵刑,
必須在大多數(shù)redis節(jié)點(diǎn)上都成功創(chuàng)建鎖分衫,才能算這個(gè)整體的RedLock加鎖成功,避免說(shuō)僅僅在一個(gè)redis實(shí)例上加鎖而帶來(lái)的問(wèn)題般此。
要求: 搭建幾臺(tái)獨(dú)立的redis機(jī)器, 互相之間不通信, 不構(gòu)成主從/哨兵/集群關(guān)系.
3.如果對(duì)鎖比較關(guān)注蚪战,一致性要求比較高牵现,可以使用ZK實(shí)現(xiàn)的分布式鎖
#其他方案
1.如果考慮各種網(wǎng)絡(luò)、宕機(jī)等原因邀桑,很多問(wèn)題需要考慮瞎疼,問(wèn)題會(huì)變的復(fù)雜,
其實(shí)分布式鎖的應(yīng)用場(chǎng)景不多壁畸,很多情況可以繞開分布式鎖贼急,使用其他方式解決,比如 隊(duì)列捏萍,異步太抓,響應(yīng)式
2.分布式鎖的場(chǎng)景,更多的應(yīng)用是一個(gè)操作不能同時(shí)多處進(jìn)行令杈,不能短時(shí)間內(nèi)重復(fù)執(zhí)行走敌,需要冪等操作等場(chǎng)景,
比如:防止快速的重復(fù)提交逗噩,mq與定時(shí)任務(wù)雙線更改狀態(tài)掉丽,防止消息重復(fù)消費(fèi) 等等。
這些情況一般使用setNx即可解決异雁。
3.減庫(kù)存其實(shí)也用不到分布式鎖, 可用redis+lua實(shí)現(xiàn)机打。
2.分布式鎖的開源實(shí)現(xiàn)框架-Redisson
2.1 概述
redisson 是 redis 官方的分布式鎖組件。
#Redisson的一些特點(diǎn)
1.redisson所有指令都通過(guò)lua腳本執(zhí)行片迅,redis支持lua腳本原子性執(zhí)行
2.redisson設(shè)置一個(gè)key的默認(rèn)過(guò)期時(shí)間為30s,如果某個(gè)客戶端持有一個(gè)鎖超過(guò)了30s怎么辦?
>> redisson中有一個(gè)watchdog的概念皆辽,翻譯過(guò)來(lái)就是看門狗柑蛇,
它會(huì)在你獲取鎖之后,每隔10秒幫你把key的超時(shí)時(shí)間設(shè)為30s
>> 這樣的話驱闷,就算一直持有鎖也不會(huì)出現(xiàn)key過(guò)期了耻台,其他線程獲取到鎖的問(wèn)題了。
3.redisson的“看門狗”邏輯保證了沒(méi)有死鎖發(fā)生空另。
如果機(jī)器宕機(jī)了盆耽,看門狗也就沒(méi)了。
此時(shí)就不會(huì)延長(zhǎng)key的過(guò)期時(shí)間扼菠,到了30s之后就會(huì)自動(dòng)過(guò)期了摄杂,其他線程可以獲取到鎖。
#ps
lua 腳本的執(zhí)行是原子性的循榆,再加上 Redis 執(zhí)行命令是單線程的析恢,
所以在 lua 腳本執(zhí)行完之前,其他的命令都得等著秧饮。
Redisson中的watchdog.png
https://www.cnblogs.com/thisiswhy/p/12596069.html (這里有Redisson 實(shí)現(xiàn)分布式鎖的分析, 挺好的, 本文不再分析)
2.2 基于lua腳本的無(wú)鎖化 or 基于 Redisson 的分布式鎖控制并發(fā)
package com.zy.redis5.single;
import org.assertj.core.util.Lists;
import org.junit.Test;
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RScript;
import org.redisson.api.RedissonClient;
import org.redisson.client.codec.Codec;
import org.redisson.client.codec.StringCodec;
import org.redisson.config.Config;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* 此處 demo 以 扣減庫(kù)存為例, 給出了兩種分布式解決方案
* 方案1:
* 先將商品及庫(kù)存數(shù)全量加載到 redis 中, 然后借助 lua 腳本實(shí)現(xiàn)原子性的扣減庫(kù)存, 注意這里的原子性是從 redis 中扣減庫(kù)存
* 方案2:
* 借助 redisson 的分布式鎖框架, 獲取全局資源操作權(quán)限, 然后操作 DB 庫(kù)存, 由于首先于 DB 的 qps, 所以并發(fā)效果并不會(huì)很好
* Redis當(dāng)做分布式鎖服務(wù)器時(shí)映挂,可使用獲取鎖和釋放鎖的響應(yīng)時(shí)間泽篮,每秒鐘可用執(zhí)行多少次 acquire / release 操作作為性能指標(biāo)。
*
* 說(shuō)明:
* 可以自行寫一個(gè) controller, 啟動(dòng)一個(gè)項(xiàng)目, 借助 jmeter 等工具, 驗(yàn)證下并發(fā)情況
*/
public class RedisSingleAtomicLuaOrDistributedLock {
private static RedissonClient client;
private static Codec codec;
private static final String KEY = "apple";
private static final String LOCK_KEY = "lockKey";
private static List<Object> keyList = Lists.newArrayList();
private int count = 20;
static {
Config config = new Config();
config.useSingleServer()
.setDatabase(10)
.setAddress("redis://192.168.0.156:6379");
client = Redisson.create(config);
// FIXME 這里定義了 StringCodec 類型的編解碼器, 是因?yàn)槠淠J(rèn)的編解碼器是: MarshallingCodec
// FIXME 而當(dāng)使用 lua 腳本時(shí), 要調(diào)用 lua 的 tonumber 函數(shù) 將庫(kù)存(string類型) 轉(zhuǎn)為 number 類型時(shí),
// FIXME 如果用默認(rèn)的編解碼器, 將會(huì)得到 nil 的結(jié)果, 會(huì)出錯(cuò).
// FIXME 故這里使用了 StringCodec 來(lái)解決, 也可以用 IntegerCodec 或 LongCodec.
codec = StringCodec.INSTANCE;
keyList.add(KEY);
}
/**************************** 方案1: 將數(shù)據(jù)全量加載至 redis 中, 在 redis 中扣減庫(kù)存, 借助 lua 腳本控制并發(fā) *******************************/
@Test
public void step01() {
String luaScript = "return redis.call('set',KEYS[1],ARGV[1]);";
Object result = client.getScript(codec).eval(RScript.Mode.READ_WRITE, luaScript, RScript.ReturnType.VALUE, keyList, 999);
System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>..");
System.out.println(result);
System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>..");
}
@Test
public void step02() {
String luaScript = "return redis.call('get', KEYS[1]);";
Object result = client.getScript(codec).eval(RScript.Mode.READ_ONLY, luaScript, RScript.ReturnType.VALUE, keyList);
System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>..");
System.out.println(result);
System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>..");
}
@Test
public void step03() {
String luaScript =
"if (redis.call('exists', KEYS[1]) == 0) then " +
"return 0; " +
"end;" +
"local count = redis.call('get', KEYS[1]); " +
"local decrementCount = ARGV[1]; " +
"local a = tonumber(count); " +
"local b = tonumber(decrementCount); " +
"if (a < b) then " +
"return 0; " +
"end; " +
"redis.call('set', KEYS[1], (a - b)); " +
"return 1; ";
Object result = client.getScript(codec).eval(RScript.Mode.READ_ONLY, luaScript, RScript.ReturnType.VALUE, keyList, 3);
System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>..");
System.out.println(result);
System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>..");
}
/**************************** 方案2: 借助 redis 分布式鎖 腳本控制并發(fā) *******************************/
@Test
public void fn04() throws InterruptedException {
ExecutorService executor = Executors.newCachedThreadPool();
int tobeDecreasedCount = 3;
for (int i = 0; i < 10; i++) {
executor.submit(() -> {
RLock lock = client.getLock(LOCK_KEY);
boolean b = lock.tryLock();
if (b) {
try {
int count = getCount();
if (count > tobeDecreasedCount) {
decreaseCount(count, tobeDecreasedCount);
}
} finally {
lock.unlock();
}
}
});
}
TimeUnit.SECONDS.sleep(10L);
System.out.println("剩余庫(kù)存量是: " + getCount());
}
private int getCount() {
return count;
}
private void decreaseCount(int count, int no) {
this.count = count - no;
}
}
參考資料
http://redis.cn/topics/distlock.html
https://redis.io/topics/distlock