用Redis構(gòu)建一把高性能的鎖
背景:
筆者所在的公司,上周末經(jīng)歷了一場(chǎng)大促活動(dòng)后,系統(tǒng)暴露出這樣一個(gè)問(wèn)題:分布式鎖使用的zk鎖,由于當(dāng)天大促用戶(hù)量比較多,系統(tǒng)瘋狂的加鎖釋放鎖,***zk承受不住這么大的壓力宕機(jī)。由于馬上就要618,為了避免再次發(fā)生這樣的事情,公司決定把所有系統(tǒng)的zk鎖都替換為高性能的Redis鎖。
在這里簡(jiǎn)單的提一下,zk鎖性能比redis低的原因:zk中的角色分為leader,flower,每次寫(xiě)請(qǐng)求只能請(qǐng)求leader,leader會(huì)把寫(xiě)請(qǐng)求廣播到所有flower,如果flower都成功才會(huì)提交給leader,其實(shí)這里相當(dāng)于一個(gè)2PC的過(guò)程。在加鎖的時(shí)候是一個(gè)寫(xiě)請(qǐng)求,當(dāng)寫(xiě)請(qǐng)求很多時(shí),zk會(huì)有很大的壓力,***導(dǎo)致服務(wù)器響應(yīng)很慢。
正題:
什么情況下需要加鎖?
當(dāng)多個(gè)線(xiàn)程、用戶(hù)同時(shí)競(jìng)爭(zhēng)同一個(gè)資源時(shí),需要加鎖。比如,下訂單減庫(kù)存,搶票,選課,搶紅包等。如果在此處沒(méi)有鎖的控制,會(huì)導(dǎo)致很?chē)?yán)重的問(wèn)題,下訂單減庫(kù)存的時(shí)候不加鎖,會(huì)導(dǎo)致商品超賣(mài);搶票的時(shí)候不加鎖,會(huì)導(dǎo)致兩個(gè)人搶到同一個(gè)位置;選課的時(shí)候沒(méi)有鎖的控制,導(dǎo)致選課成功的人數(shù)大于教室的座位數(shù);搶紅包時(shí)沒(méi)有鎖的控制,搶到紅包的金額大于紅包的實(shí)際金額。
什么是分布式鎖?
學(xué)過(guò)JAVA多線(xiàn)程的朋友都知道,為了防止多個(gè)線(xiàn)程同時(shí)執(zhí)行同一段代碼,可以用synchronized關(guān)鍵字或JAVA API中ReentrantLock類(lèi)來(lái)控制。
但是目前幾乎任何一個(gè)系統(tǒng)都往往部署多臺(tái)機(jī)器的,單機(jī)部署的應(yīng)用很少,synchronized和ReentrantLock發(fā)揮不出任何作用,此時(shí)就需要一把全局的鎖,來(lái)代替JAVA中的synchronized和ReentrantLock。
當(dāng)Thread1線(xiàn)程獲取到鎖,執(zhí)行鎖中的代碼,其他線(xiàn)程或其他機(jī)器再次請(qǐng)求該鎖,發(fā)現(xiàn)鎖被Thread1占用,加鎖失敗。當(dāng)Thread1釋放鎖,其他線(xiàn)程則可以獲取到鎖并執(zhí)行相應(yīng)的操作。
我們可以用Jedis中是setnx命令來(lái)構(gòu)建這把鎖,首先,我列舉一些錯(cuò)誤的構(gòu)建鎖的方式:
錯(cuò)誤例子1
- Long lock= jedis.setnx(key,value);
- if(lock>0){
- //執(zhí)行業(yè)務(wù)邏輯
- }
通過(guò)setnx命令創(chuàng)建一個(gè)key、value,如果key不存在,則加鎖成功。這樣做有什么問(wèn)題呢?如果執(zhí)行加鎖操作成功,在釋放鎖的時(shí)候,系統(tǒng)宕機(jī),導(dǎo)致這個(gè)key永遠(yuǎn)不會(huì)被del掉,也就是說(shuō)其他線(xiàn)程一直獲取不到鎖,
導(dǎo)致死鎖發(fā)生。為了避免這種情況,請(qǐng)看下面的代碼
錯(cuò)誤例子2
- Long lock= jedis.setnx(key,value);
- if(lock>0){
- jedis.expire(key,expireTime);
- }
和上面的例子類(lèi)似,唯一不同的是這里多了一步設(shè)置key過(guò)期時(shí)間的操作。如果在del的時(shí)候系統(tǒng)宕機(jī),等過(guò)期時(shí)間一到,Redis會(huì)刪除這個(gè)key。
其他線(xiàn)程可以再次獲取鎖。這樣就可以萬(wàn)無(wú)一失了嗎?這里有一個(gè)問(wèn)題,如果在***步setnx成功后,突然網(wǎng)絡(luò)閃斷,expire命令執(zhí)行失敗,同樣也有死鎖的風(fēng)險(xiǎn)。這兩步并不具備原子性,不保證全部成功或全部失敗。
正確的構(gòu)建方式
- public static boolean getDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
- String result = jedis.set(lockKey, requestId, "NX", "EX", expireTime);
- if (LOCK_SUCCESS.equals(result)) {
- return true;
- }
- return false;
- }
參數(shù)解釋?zhuān)?/p>
key:鍵
value:值
nx:如果當(dāng)前key存在,則set失敗,否則成功
ex:設(shè)置key的過(guò)期時(shí)間
expireTime:key的過(guò)期時(shí)間,時(shí)間到了,Redis會(huì)自動(dòng)刪除key和value。
這個(gè)命令,將上面的錯(cuò)誤例子2中的兩個(gè)操作合為一個(gè)原子操作,保證了同時(shí)成功或同時(shí)失敗。
解鎖方式:
錯(cuò)誤例子1:
- jedis.del(key);
執(zhí)行這個(gè)操作的線(xiàn)程,不去判斷鎖的擁有者就刪除鎖。
還記的set命令可以設(shè)置value嗎?在獲取鎖的操作時(shí),主要是判斷key是否存在,那么value有什么用呢??如果在刪除鎖的時(shí)候,不去判斷當(dāng)前鎖的擁有者,任何線(xiàn)程都可以釋放鎖。這個(gè)時(shí)候,value值就起到作用了。
錯(cuò)誤例子2:
- if(value==jedis.get(key)){
- jedis.del(key);
- }
我們?cè)诩渔i的時(shí)候,可以將value設(shè)置成唯一標(biāo)識(shí)當(dāng)前線(xiàn)程的一個(gè)值,這個(gè)值可以是一個(gè)UUID,當(dāng)釋放鎖的時(shí)間,判斷value是否和set時(shí)的值相同,如果相同,則說(shuō)明加鎖和釋放鎖是同一個(gè)線(xiàn)程,允許釋放。否則釋放鎖失敗。
這樣就可以絕對(duì)安全了嗎??答案當(dāng)然是否定的。這步操作,同樣不具備原子性。如果ThreadA在執(zhí)行value==jedis.get(key)返回true后的瞬間,del命令還沒(méi)來(lái)的及執(zhí)行,key過(guò)期了,而此時(shí)ThreadB獲取到鎖,之后ThreadA執(zhí)行del命令,把ThreadB的鎖釋放掉了。
所以要保證兩部操作的原子性,我們不得不利用簡(jiǎn)單的Lua腳本。
正確的解鎖姿勢(shì):
- public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
- String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
- Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
- if (RELEASE_SUCCESS.equals(result)) {
- return true;
- }
- return false;
- }
Redis在2.6后內(nèi)部?jī)?nèi)嵌Lua腳本解釋器,所以我們可以通過(guò)簡(jiǎn)單的Lua腳本來(lái)保證上述操作的原子性。代碼中的Lua腳本的的意思是:我們把LockKey賦值給KEYS[1],把RequestId賦值給ARGV[1],如果key中的值等于RequestId,返回true否則返回false。這樣就保證了釋放鎖操作時(shí)原子的,并且當(dāng)前客戶(hù)端只會(huì)釋放當(dāng)前客戶(hù)端的鎖。