1 認(rèn)識(shí)分布式鎖的使用場(chǎng)景
1.1 業(yè)務(wù)場(chǎng)景1
APP快速連續(xù)點(diǎn)擊會(huì)向服務(wù)器連續(xù)發(fā)起請(qǐng)求,導(dǎo)致數(shù)據(jù)庫出現(xiàn)重復(fù)數(shù)據(jù)(非阻塞鎖)
- 表單重復(fù)提交
- 重復(fù)刷單
- APP重復(fù)請(qǐng)求
1.2 業(yè)務(wù)場(chǎng)景2
庫存超賣問題
系統(tǒng)A是一個(gè)電商系統(tǒng)搜骡,目前是一臺(tái)機(jī)器部署稿壁,系統(tǒng)中有一個(gè)用戶下訂單的接口实束,但是用戶下訂單之前一定要去檢查一下庫存,確保庫存足夠了才會(huì)給用戶下單逊彭。
由于系統(tǒng)有一定的并發(fā)咸灿,所以會(huì)預(yù)先將商品的庫存保存在redis中,用戶下單的時(shí)候會(huì)更新redis的庫存侮叮。此時(shí)系統(tǒng)架構(gòu)如下:
但是這樣一來會(huì)產(chǎn)生一個(gè)問題:假如某個(gè)時(shí)刻避矢,redis里面的某個(gè)商品庫存為1,此時(shí)兩個(gè)請(qǐng)求同時(shí)到來囊榜,其中一個(gè)請(qǐng)求執(zhí)行到上圖的第3步审胸,更新數(shù)據(jù)庫的庫存為0,但是第4步還沒有執(zhí)行卸勺。而另外一個(gè)請(qǐng)求執(zhí)行到了第2步砂沛,發(fā)現(xiàn)庫存還是1,就繼續(xù)執(zhí)行第3步曙求。這樣的結(jié)果碍庵,是導(dǎo)致賣出了2個(gè)商品,然而其實(shí)庫存只有1個(gè)悟狱。
很明顯不對(duì)熬苍 !這就是典型的庫存超賣問題
此時(shí)芽淡,我們很容易想到解決方案:用鎖把2马绝、3、4步鎖住挣菲,讓他們執(zhí)行完之后富稻,另一個(gè)線程才能進(jìn)來執(zhí)行第2步。
按照上面的圖白胀,在執(zhí)行第2步時(shí)椭赋,使用Java提供的synchronized或者ReentrantLock來鎖住,然后在第4步執(zhí)行完之后才釋放鎖或杠。這樣一來哪怔,2、3向抢、4 這3個(gè)步驟就被“鎖”住了认境,多個(gè)線程之間只能串行化執(zhí)行。
但是好景不長(zhǎng)挟鸠,整個(gè)系統(tǒng)的并發(fā)飆升叉信,一臺(tái)機(jī)器扛不住了。現(xiàn)在要增加一臺(tái)機(jī)器艘希,如下圖:
假設(shè)此時(shí)兩個(gè)用戶的請(qǐng)求同時(shí)到來硼身,但是落在了不同的機(jī)器上硅急,那么這兩個(gè)請(qǐng)求是可以同時(shí)執(zhí)行了,還是會(huì)出現(xiàn)庫存超賣的問題佳遂。為什么呢营袜?因?yàn)樯蠄D中的兩個(gè)A系統(tǒng),運(yùn)行在兩個(gè)不同的JVM里面丑罪,他們加的鎖只對(duì)屬于自己JVM里面的線程有效荚板,對(duì)于其他JVM的線程是無效的。
因此吩屹,這里的問題是Java提供的原生鎖機(jī)制在多機(jī)部署場(chǎng)景下失效了這是因?yàn)閮膳_(tái)機(jī)器加的鎖不是同一個(gè)鎖(兩個(gè)鎖在不同的JVM里面)啸驯。
那么,我們只要保證兩臺(tái)機(jī)器加的鎖是同一個(gè)鎖祟峦,問題不就解決了嗎?此時(shí)徙鱼,就該分布式鎖隆重登場(chǎng)了宅楞,分布式鎖的思路是:在整個(gè)系統(tǒng)提供一個(gè)全局、唯一的獲取鎖的“東西”袱吆,然后每個(gè)系統(tǒng)在需要加鎖時(shí)厌衙,都去問這個(gè)“東西”拿到一把鎖,這樣不同的系統(tǒng)拿到的就可以認(rèn)為是同一把鎖绞绒。
至于這個(gè)“東西”婶希,可以是Redis、Zookeeper蓬衡,也可以是數(shù)據(jù)庫喻杈。文字描述不太直觀,我們來看下圖:
通過上面的分析狰晚,我們知道了庫存超賣場(chǎng)景在分布式部署系統(tǒng)的情況下使用Java原生的鎖機(jī)制無法保證線程安全筒饰,所以我們需要用到分布式鎖的方案。
1.3 業(yè)務(wù)場(chǎng)景…
經(jīng)典場(chǎng)景案例
- 秒殺
- 車票
- 退款
- 訂單
…
無論是超賣壁晒,還是重復(fù)退款瓷们,都是沒有對(duì)需要保護(hù)的資源或業(yè)務(wù)進(jìn)行完善的保護(hù)而造成的,從設(shè)計(jì)方面一定要避免這種情況的發(fā)生
2 分布式鎖基本概念及基本特性
2.1 什么是分布式鎖
- 單機(jī)鎖(線程鎖)
synchronized秒咐、Lock - 分布式鎖(多服務(wù)共享鎖)
在分布式的部署環(huán)境下谬晕,通過鎖機(jī)制來讓多客戶端互斥的對(duì)共享資源進(jìn)行訪問
2.2 分布式鎖的基本概念
- 基本概念
- 多任務(wù)環(huán)境中才需要
- 任務(wù)都需要對(duì)同一共享資源進(jìn)行寫操作;
- 對(duì)資源的訪問是互斥的(串行化)
- 狀態(tài)
- 任務(wù)通過競(jìng)爭(zhēng)獲取鎖才能對(duì)該資源進(jìn)行操作(①競(jìng)爭(zhēng)鎖)携取;
- 當(dāng)有一個(gè)任務(wù)在對(duì)資源進(jìn)行更新時(shí)(②占有鎖)攒钳,
- 其他任務(wù)都不可以對(duì)這個(gè)資源進(jìn)行操作(③任務(wù)阻塞),
- 直到該任務(wù)完成更新(④釋放鎖)歹茶;
- 特點(diǎn)
- 排他性:在同一時(shí)間只會(huì)有一個(gè)客戶端能獲取到鎖夕玩,其它客戶端無法同時(shí)獲取
- 避免死鎖:這把鎖在一段有限的時(shí)間之后你弦,一定會(huì)被釋放(正常釋放或異常釋放)
- 高可用:獲取或釋放鎖的機(jī)制必須高可用且性能佳
2.3 鎖和事務(wù)的區(qū)別
鎖
單進(jìn)程的系統(tǒng)中,存在多線程同時(shí)操作一個(gè)公共變量燎孟,此時(shí)需要加鎖對(duì)變量進(jìn)行同步操作禽作,保證多線程的操作線性執(zhí)行消除并發(fā)修改。解決的是單進(jìn)程中的多線程并發(fā)問題揩页。
分布式鎖
只要的應(yīng)用場(chǎng)景是在集群模式的多個(gè)相同服務(wù)旷偿,可能會(huì)部署在不同機(jī)器上,解決進(jìn)程間安全問題爆侣,防止多進(jìn)程同時(shí)操作一個(gè)變量或者數(shù)據(jù)庫萍程。解決的是多進(jìn)程的并發(fā)問題
事務(wù)
解決一個(gè)會(huì)話過程中,上下文的修改對(duì)所有數(shù)據(jù)庫表的操作要么全部成功兔仰,要不全部失敗茫负。所以應(yīng)用在service層。解決的是一個(gè)會(huì)話中的操作的數(shù)據(jù)一致性乎赴。
分布式事務(wù)
解決一個(gè)聯(lián)動(dòng)操作忍法,比如一個(gè)商品的買賣分為添加商品到購物車、修改商品庫存榕吼,此時(shí)購物車服務(wù)和商品庫存服務(wù)可能部署在兩臺(tái)電腦饿序,這時(shí)候需要保證對(duì)兩個(gè)服務(wù)的操作都全部成功或者全部回退。解決的是組合服務(wù)的數(shù)據(jù)操作的一致性問題
3 DB實(shí)現(xiàn)分布式鎖方案
3.1 樂觀鎖
3.2 悲觀鎖
4 Redis實(shí)現(xiàn)分布式鎖方案
4.1 獲取鎖
根據(jù)以上圖示及思考羹蚣,可的以下加鎖代碼∶
public static void rongGetock(Jedis jedis String lockkey String requestid,int expireTime){
Long result= jedis.setnx(lockKey,rquestid);
if(result == 1){
// 若在這里程序突然崩潰原探,則無法設(shè)置過期時(shí)間,將發(fā)生死鎖
jedis.expire(lockKey, expireTime);
}
}
非原子操作
setnx和expire的非原子性
解決方案
SET my_key my_value NX PX milliseconds (加鎖)
/**
* 嘗試獲取分布式鎖
* @param jedis Redis 客戶端
* @param lockKey 鎖
* @param requestid 請(qǐng)求標(biāo)識(shí)顽素。
* @param expireTime 超期時(shí)間
*/
public bolean trySetDitibutedLock(Jedis jedis, String lockKey,
String requestid, int expireTime){
String result = jedis.set(lockkey, requestid, SET_IF_NOTEXIST,
SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCESS.equals(result)) {
return true;
}
return false;
}
4.2 釋放鎖
錯(cuò)誤刪除鎖
線程成功得到了鎖咽弦,并且設(shè)置的超時(shí)時(shí)間是30秒。
線程A執(zhí)行的很慢很慢戈抄,過了30秒都沒執(zhí)行完离唬,這時(shí)候鎖過期自動(dòng)釋放,線程B得到了鎖划鸽。
線程A執(zhí)行完了任務(wù)输莺,線程A接著執(zhí)行del指令來釋放鎖。
但這時(shí)候線程B還沒執(zhí)行完裸诽,線程A實(shí)際上刪除的是線程B加的鎖嫂用。
解決方案
加鎖的時(shí)候把當(dāng)前的線程ID當(dāng)做value,并在刪除之前驗(yàn)證key對(duì)應(yīng)的value是不是自己線程的ID丈冬。
String threadId= Thread.currentThread().getld()
// 加鎖
set(key,threadId,30,NX);
// 解鎖
if(threadId.equals(redisClient.get(key)){
del(key)
}
但是嘱函,這樣做又隱含了一個(gè)新的問題,if判斷和釋放鎖是兩個(gè)獨(dú)立操作埂蕊,不是原子性往弓。
Lua腳本釋放鎖疏唾,保證釋放鎖的方法的原子性
/*
* @param jedis Redis 客戶端
* @param lockKey 鎖
* @param requestld 請(qǐng)求標(biāo)識(shí)
* @return 是否釋放成功
*/
public static bolean 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, Colletions.singletonList(lockKey),Colletions.sigletonList(requestd));
if (RELEASE_SUCCESS.equals(result)){
return true;
}
return false;
}
鎖續(xù)航問題
獲得鎖的線程開啟一個(gè)守護(hù)線程,用來給快要過期的鎖“續(xù)航”
過去了29秒函似,線程A還沒執(zhí)行完槐脏,這時(shí)候守護(hù)線程會(huì)執(zhí)行expire指令,為這把鎖“續(xù)命”20秒撇寞。
守護(hù)線程從第29秒開始執(zhí)行顿天,每20秒執(zhí)行一次
當(dāng)線程A執(zhí)行完任務(wù),會(huì)顯式關(guān)掉守護(hù)線程蔑担。
@Test
public void executiveBusiness() {
String lockKey = "order";
String lockValue = Thread.currentThread().getId()+"";
long time = 30 ;
while(true) {
if (tryLock(lockKey, lockValue, time)) {
Thread daemonThread = new Thread("守護(hù)線程") {
@Override
public void run() {
int i = 0;
while(true){
if(redisTemplate.opsForValue().getOperations().getExpire(lockKey).intValue()<1){
while (i++ <= 3) {//續(xù)命三次
redisTemplate.expire(lockKey, 20, TimeUnit.SECONDS);//每次續(xù)命20秒
}
}
try {
Thread.sleep(1000);//每秒查詢一次
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
daemonThread.setDaemon(true);
daemonThread.start();
try {
Thread.sleep(31000);//業(yè)務(wù)執(zhí)行31秒
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
public Boolean tryLock(String lockKey ,String lockeValue, long time){
return redisTemplate.opsForValue().setIfAbsent(lockKey, lockeValue, time, TimeUnit.SECONDS);
}
4.3 要點(diǎn)回顧
- 一定要用SET key value NX PX milliseconds 命令
- value要具有唯一性
- 釋放鎖一定要使用lua腳本
4.4 Redis分布式鎖的可靠性思考
redis有3種部署方式:
- 單機(jī)模式
- master-slave + sentinel選舉模式
- redis cluster模式
RedLock
分布式緩存鎖—Redlock
5 Zookeeper實(shí)現(xiàn)分布式鎖方案
5.1 Zookeeper實(shí)現(xiàn)分布式鎖邏輯
5.2 Zookeeper實(shí)現(xiàn)分布式鎖的實(shí)現(xiàn)流程
client1獲取鎖:
<center class="half">
</center>
client2獲取鎖:
<center class="half">
</center>
client3獲取鎖:
<center class="half">
</center>
client1釋放鎖:
<center class="half">
</center>
client2獲取鎖及釋放鎖:
<center class="half">
</center>
- 性能上可能并沒有緩存服務(wù)那么高牌废,因?yàn)槊看卧趧?chuàng)建鎖和釋放鎖的過程中,都要?jiǎng)討B(tài)創(chuàng)建啤握、銷毀臨時(shí)節(jié)點(diǎn)來實(shí)現(xiàn)鎖功能
- ZK 中創(chuàng)建和刪除節(jié)點(diǎn)只能通過 Leader 服務(wù)器來執(zhí)行鸟缕,然后將數(shù)據(jù)同步到所有的 Follower機(jī)器上
- 取舍
5.3 分布式鎖可靠性思考
6 三種分布式鎖方案小結(jié)
上面幾種方式,哪種方式都無法做到完美排抬。就像CAP一樣叁扫,在復(fù)雜性、可靠性畜埋、性能等方面無法同時(shí)滿足。所以畴蒲,根據(jù)不同的應(yīng)用場(chǎng)景選擇最適合自己的才是王道悠鞍。
- 從理解的難易程度角度(從低到高)
數(shù)據(jù)庫 > 緩存 > Zookeeper - 從實(shí)現(xiàn)的復(fù)雜性角度(從低到高)
Zookeeper >= 緩存 > 數(shù)據(jù)庫 - 從性能角度(從高到低)
緩存 > Zookeeper >= 數(shù)據(jù)庫 - 從可靠性角度(從高到低)
Zookeeper > 緩存 > 數(shù)據(jù)庫
7 冪等性接口設(shè)計(jì)
7.1 冪等操作
多個(gè)線程(并發(fā))操作同一個(gè)接口(同一個(gè)方法),對(duì)最終的結(jié)果沒有影響模燥,這樣的操作叫做冪等性操作咖祭。
基本的CURD操作,哪些是冪等性的操作蔫骂?
1么翰、查詢 --- 冪等性操作
select* from user where id = 1
如∶ 1000線程同時(shí)訪問以上SQL語句,得到結(jié)果都一樣辽旋,對(duì)最終的結(jié)果沒有影響浩嫌。
2、添加 -- 非冪等性操作
insert into user values(xx);
如∶1000線程同時(shí)訪問以上SQL語句补胚,對(duì)操作結(jié)果有影響码耐,將會(huì)向數(shù)據(jù)庫插入新的數(shù)據(jù)。
3溶其、更新 -- 非冪等性操作
update user set. where id = 1
如∶1000 線程同時(shí)訪問以上SQL語句骚腥,對(duì)操作結(jié)果有影響,將會(huì)改變數(shù)據(jù)瓶逃。
4束铭、刪除 -- 冪等性操作
delete from user where id =1
7.2 應(yīng)用場(chǎng)景
在什么業(yè)務(wù)場(chǎng)景下才使用冪等性接口?
1廓块、項(xiàng)目分層架構(gòu)模式下,由于網(wǎng)絡(luò)抖動(dòng)契沫,超時(shí)請(qǐng)求重發(fā)(退款接口)
2带猴、SOA、微服務(wù)架構(gòu)模式下埠褪,跨服務(wù)調(diào)用(為了保證服務(wù)高可用浓利,采用了超時(shí)重發(fā)機(jī)制)的超時(shí)重發(fā)
3、利用消息中間件將任務(wù)進(jìn)行異步處理時(shí)钞速,任務(wù)消息一旦重發(fā)贷掖,消費(fèi)者業(yè)務(wù)操作重復(fù)處理
7.3 如何設(shè)計(jì)冪等性接口
以退款為例。在單機(jī)模式下渴语,并發(fā)請(qǐng)求退款接口苹威,退款接口當(dāng)中先校驗(yàn)是否存在重復(fù)性的id,然后再?zèng)Q定是否進(jìn)行退款處理驾凶。
重復(fù)性的校驗(yàn)借助于第三方的庫牙甫,比如使用MySQL、Redis调违。
如果是MySQL窟哺,可以設(shè)計(jì)一張去重表,把orderId設(shè)計(jì)為表的主鍵技肩,根據(jù)orderId查詢?nèi)ブ乇砬夜臁H绻淮嬖诘脑拕t可以執(zhí)行退款操作,先將數(shù)據(jù)插入虚婿,然后執(zhí)行退款旋奢;如果存在的話,則證明已經(jīng)退過款了然痊,則直接返回至朗。當(dāng)然,由于是并發(fā)操作剧浸,項(xiàng)目中需要設(shè)計(jì)本地鎖锹引。
如果使用Redis則直接利用分布式鎖的特點(diǎn)即可使用,不需要使用本地鎖唆香。
如果項(xiàng)目是集群服務(wù)粤蝎,利用MySQL進(jìn)行重復(fù)性校驗(yàn),則需要借助本地鎖保證接口的冪等性了袋马。