學(xué)習(xí)完整課程請(qǐng)移步 互聯(lián)網(wǎng) Java 全棧工程師
使用場(chǎng)景
首先呈驶,我們看這樣一個(gè)場(chǎng)景:客戶下單的時(shí)候窖式,我們調(diào)用庫存中心進(jìn)行減庫存扫责,那我們一般的操作都是:
update store set num = $num where id = $id
這種通過設(shè)置庫存的修改方式吃谣,我們知道在并發(fā)量高的時(shí)候會(huì)存在數(shù)據(jù)庫的丟失更新撕予,比如 a, b 當(dāng)前兩個(gè)事務(wù)鲫惶,查詢出來的庫存都是 5,a 買了 3 個(gè)單子要把庫存設(shè)置為 2实抡,而 b 買了 1 個(gè)單子要把庫存設(shè)置為 4欠母,那這個(gè)時(shí)候就會(huì)出現(xiàn) a 會(huì)覆蓋 b 的更新,所以我們更多的都是會(huì)加個(gè)條件:
update store set num = $num where id = $id and num = $query_num
即樂觀鎖的方式來處理吆寨,當(dāng)然也可以通過版本號(hào)來處理樂觀鎖赏淌,都是一樣的,但是這是更新一個(gè)表鸟废,如果我們牽扯到多個(gè)表呢猜敢,我們希望和這個(gè)單子關(guān)聯(lián)的所有的表同一時(shí)間只能被一個(gè)線程來處理更新,多個(gè)線程按照不同的順序去更新同一個(gè)單子關(guān)聯(lián)的不同數(shù)據(jù)盒延,出現(xiàn)死鎖的概率比較大缩擂。對(duì)于非敏感的數(shù)據(jù),我們也沒有必要去都加樂觀鎖處理添寺,我們的服務(wù)都是多機(jī)器部署的胯盯,要保證多進(jìn)程多線程同時(shí)只能有一個(gè)進(jìn)程的一個(gè)線程去處理,這個(gè)時(shí)候我們就需要用到分布式鎖计露。分布式鎖的實(shí)現(xiàn)方式有很多博脑,我們今天分別通過數(shù)據(jù)庫,Zookeeper, Redis 以及 Tair 的實(shí)現(xiàn)邏輯票罐。
數(shù)據(jù)庫實(shí)現(xiàn)
加 xx 鎖
更新一個(gè)單子關(guān)聯(lián)的所有的數(shù)據(jù)叉趣,先查詢出這個(gè)單子,并加上排他鎖该押,在進(jìn)行一系列的更新操作
begin transaction疗杉;
select ...for update;
doSomething()蚕礼;
commit();
這種處理主要依靠排他鎖來阻塞其他線程烟具,不過這個(gè)需要注意幾點(diǎn):
- 查詢的數(shù)據(jù)一定要在數(shù)據(jù)庫里存在梢什,如果不存在的話,數(shù)據(jù)庫會(huì)加 gap 鎖朝聋,而 gap 鎖之間是兼容的嗡午,這種如果兩個(gè)線程都加了gap 鎖,另一個(gè)再更新的話會(huì)出現(xiàn)死鎖冀痕。不過一般能更新的數(shù)據(jù)都是存在的
- 后續(xù)的處理流程需要盡可能的時(shí)間短荔睹,即在更新的時(shí)候提前準(zhǔn)備好數(shù)據(jù),保證事務(wù)處理的時(shí)間足夠的短言蛇,流程足夠的短应媚,因?yàn)殚_啟事務(wù)是一直占著連接的,如果流程比較長會(huì)消耗過多的數(shù)據(jù)庫連接的
唯一鍵
通過在一張表里創(chuàng)建唯一鍵來獲取鎖猜极,比如執(zhí)行 saveStore 這個(gè)方法
insert table lock_store ('method_name') values($method_name)
其中 method_name
是個(gè)唯一鍵,通過這種方式也可以做到消玄,解鎖的時(shí)候直接刪除改行記錄就行跟伏。不過這種方式,鎖就不會(huì)是阻塞式的翩瓜,因?yàn)椴迦霐?shù)據(jù)是立馬可以得到返回結(jié)果的受扳。
那針對(duì)以上數(shù)據(jù)庫實(shí)現(xiàn)的兩種分布式鎖,存在什么樣的優(yōu)缺點(diǎn)呢兔跌?
優(yōu)點(diǎn)
簡單勘高,方便,快速實(shí)現(xiàn)
缺點(diǎn)
- 基于數(shù)據(jù)庫坟桅,開銷比較大华望,性能可能會(huì)存在影響
- 基于數(shù)據(jù)庫的當(dāng)前讀來實(shí)現(xiàn),數(shù)據(jù)庫會(huì)在底層做優(yōu)化仅乓,可能用到索引赖舟,可能不用到索引,這個(gè)依賴于查詢計(jì)劃的分析
Zookeeper 實(shí)現(xiàn)
獲取鎖
- 先有一個(gè)鎖跟節(jié)點(diǎn)夸楣,lockRootNode宾抓,這可以是一個(gè)永久的節(jié)點(diǎn)
- 客戶端獲取鎖,先在 lockRootNode 下創(chuàng)建一個(gè)順序的瞬時(shí)節(jié)點(diǎn)豫喧,保證客戶端斷開連接石洗,節(jié)點(diǎn)也自動(dòng)刪除
- 調(diào)用 lockRootNode 父節(jié)點(diǎn)的 getChildren() 方法,獲取所有的節(jié)點(diǎn)紧显,并從小到大排序讲衫,如果創(chuàng)建的最小的節(jié)點(diǎn)是當(dāng)前節(jié)點(diǎn),則返回 true,獲取鎖成功鸟妙,否則焦人,關(guān)注比自己序號(hào)小的節(jié)點(diǎn)的釋放動(dòng)作(exist watch)挥吵,這樣可以保證每一個(gè)客戶端只需要關(guān)注一個(gè)節(jié)點(diǎn),不需要關(guān)注所有的節(jié)點(diǎn)花椭,避免羊群效應(yīng)忽匈。
- 如果有節(jié)點(diǎn)釋放操作,重復(fù)步驟 3
釋放鎖
只需要?jiǎng)h除步驟 2 中創(chuàng)建的節(jié)點(diǎn)即可
使用 Zookeeper 的分布式鎖存在什么樣的優(yōu)缺點(diǎn)呢矿辽?
優(yōu)點(diǎn)
- 客戶端如果出現(xiàn)宕機(jī)故障的話丹允,鎖可以馬上釋放
- 可以實(shí)現(xiàn)阻塞式鎖,通過 watcher 監(jiān)聽袋倔,實(shí)現(xiàn)起來也比較簡單
- 集群模式雕蔽,穩(wěn)定性比較高
缺點(diǎn)
- 一旦網(wǎng)絡(luò)有任何的抖動(dòng),Zookeeper 就會(huì)認(rèn)為客戶端已經(jīng)宕機(jī)宾娜,就會(huì)斷掉連接批狐,其他客戶端就可以獲取到鎖。當(dāng)然 Zookeeper 有重試機(jī)制前塔,這個(gè)就比較依賴于其重試機(jī)制的策略了
- 性能上不如緩存
Redis 實(shí)現(xiàn)
我們先舉個(gè)例子嚣艇,比如現(xiàn)在我要更新產(chǎn)品的信息,產(chǎn)品的唯一鍵就是 productId
簡單實(shí)現(xiàn) 1
public boolean lock(String key, V v, int expireTime){
int retry = 0;
//獲取鎖失敗最多嘗試10次
while (retry < failRetryTimes){
//獲取鎖
Boolean result = redis.setNx(key, v, expireTime);
if (result){
return true;
}
try {
//獲取鎖失敗間隔一段時(shí)間重試
TimeUnit.MILLISECONDS.sleep(sleepInterval);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
return false;
}
public boolean unlock(String key){
return redis.delete(key);
}
public static void main(String[] args) {
Integer productId = 324324;
RedisLock<Integer> redisLock = new RedisLock<Integer>();
redisLock.lock(productId+"", productId, 1000);
}
}
這是一個(gè)簡單的實(shí)現(xiàn)华弓,存在的問題:
- 可能會(huì)導(dǎo)致當(dāng)前線程的鎖誤被其他線程釋放食零,比如 a 線程獲取到了鎖正在執(zhí)行,但是由于內(nèi)部流程處理超時(shí)或者 gc 導(dǎo)致鎖過期寂屏,這個(gè)時(shí)候b線程獲取到了鎖贰谣,a 和 b 線程處理的是同一個(gè) productId,b還在處理的過程中迁霎,這個(gè)時(shí)候 a 處理完了吱抚,a 去釋放鎖,可能就會(huì)導(dǎo)致 a 把 b 獲取的鎖釋放了考廉。
- 不能實(shí)現(xiàn)可重入
- 客戶端如果第一次已經(jīng)設(shè)置成功频伤,但是由于超時(shí)返回失敗,此后客戶端嘗試會(huì)一直失敗
針對(duì)以上問題我們改進(jìn)下:
- v 傳 requestId芝此,然后我們?cè)卺尫沛i的時(shí)候判斷一下憋肖,如果是當(dāng)前 requestId,那就可以釋放婚苹,否則不允許釋放
- 加入 count 的鎖計(jì)數(shù)岸更,在獲取鎖的時(shí)候查詢一次,如果是當(dāng)前線程已經(jīng)持有的鎖膊升,那鎖技術(shù)加 1怎炊,直接返回 true
簡單實(shí)現(xiàn) 2
private static volatile int count = 0;
public boolean lock(String key, V v, int expireTime){
int retry = 0;
//獲取鎖失敗最多嘗試10次
while (retry < failRetryTimes){
//1.先獲取鎖,如果是當(dāng)前線程已經(jīng)持有,則直接返回
//2.防止后面設(shè)置鎖超時(shí),其實(shí)是設(shè)置成功评肆,而網(wǎng)絡(luò)超時(shí)導(dǎo)致客戶端返回失敗债查,所以獲取鎖之前需要查詢一下
V value = redis.get(key);
//如果當(dāng)前鎖存在,并且屬于當(dāng)前線程持有瓜挽,則鎖計(jì)數(shù)+1盹廷,直接返回
if (null != value && value.equals(v)){
count ++;
return true;
}
//如果鎖已經(jīng)被持有了,那需要等待鎖的釋放
if (value == null || count <= 0){
//獲取鎖
Boolean result = redis.setNx(key, v, expireTime);
if (result){
count = 1;
return true;
}
}
try {
//獲取鎖失敗間隔一段時(shí)間重試
TimeUnit.MILLISECONDS.sleep(sleepInterval);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
return false;
}
public boolean unlock(String key, String requestId){
String value = redis.get(key);
if (Strings.isNullOrEmpty(value)){
count = 0;
return true;
}
//判斷當(dāng)前鎖的持有者是否是當(dāng)前線程久橙,如果是的話釋放鎖俄占,不是的話返回false
if (value.equals(requestId)){
if (count > 1){
count -- ;
return true;
}
boolean delete = redis.delete(key);
if (delete){
count = 0;
}
return delete;
}
return false;
}
public static void main(String[] args) {
Integer productId = 324324;
RedisLock<String> redisLock = new RedisLock<String>();
String requestId = UUID.randomUUID().toString();
redisLock.lock(productId+"", requestId, 1000);
}
這種實(shí)現(xiàn)基本解決了誤釋放和可重入的問題啃奴,這里說明幾點(diǎn):
- 引入 count 實(shí)現(xiàn)重入的話虎囚,看業(yè)務(wù)需要,并且在釋放鎖的時(shí)候泛释,其實(shí)也可以直接就把鎖刪除了祝拯,一次釋放搞定甚带,不需要在通過 count 數(shù)量釋放多次,看業(yè)務(wù)需要吧
- 關(guān)于要考慮設(shè)置鎖超時(shí)佳头,所以需要在設(shè)置鎖的時(shí)候查詢一次欲低,可能會(huì)有性能的考量,看具體業(yè)務(wù)吧
- 目前獲取鎖失敗的等待時(shí)間是在代碼里面設(shè)置的畜晰,可以提出來,修改下等待的邏輯即可
錯(cuò)誤實(shí)現(xiàn)
獲取到鎖之后要檢查下鎖的過期時(shí)間瑞筐,如果鎖過期了要重新設(shè)置下時(shí)間,大致代碼如下:
public boolean tryLock2(String key, int expireTime){
long expires = System.currentTimeMillis() + expireTime;
// 獲取鎖
Boolean result = redis.setNx(key, expires, expireTime);
if (result){
return true;
}
V value = redis.get(key);
if (value != null && (Long)value < System.currentTimeMillis()){
// 鎖已經(jīng)過期
String oldValue = redis.getSet(key, expireTime);
if (oldValue != null && oldValue.equals(value)){
return true;
}
}
return false;
}
這種實(shí)現(xiàn)存在的問題凄鼻,過度依賴當(dāng)前服務(wù)器的時(shí)間了,如果在大量的并發(fā)請(qǐng)求下聚假,都判斷出了鎖過期块蚌,而這個(gè)時(shí)候再去設(shè)置鎖的時(shí)候,最終是會(huì)只有一個(gè)線程膘格,但是可能會(huì)導(dǎo)致不同服務(wù)器根據(jù)自身不同的時(shí)間覆蓋掉最終獲取鎖的那個(gè)線程設(shè)置的時(shí)間峭范。
Tair 實(shí)現(xiàn)
通過 Tair 來實(shí)現(xiàn)分布式鎖和 Redis 的實(shí)現(xiàn)核心差不多,不過 Tair 有個(gè)很方便的 api瘪贱,感覺是實(shí)現(xiàn)分布式鎖的最佳配置纱控,就是 Put api 調(diào)用的時(shí)候需要傳入一個(gè) version,就和數(shù)據(jù)庫的樂觀鎖一樣菜秦,修改數(shù)據(jù)之后甜害,版本會(huì)自動(dòng)累加,如果傳入的版本和當(dāng)前數(shù)據(jù)版本不一致球昨,就不允許修改尔店。