目錄
?1. 分布式鎖的概念
2.基于數(shù)據(jù)庫做分布式鎖
2.1 基于表主鍵唯一做分布式鎖
2.2 基于表字段版本號做分布式鎖
2.3 基于數(shù)據(jù)庫排他鎖做分布式鎖
3.使用Redis做分布式鎖
3.1 redis實現(xiàn)分布式鎖的基本原理
3.2 問題一:增加超時機制,防止長期持有的情況
3.3 問題2:重入的問題
3.4 問題三:優(yōu)化輪詢加鎖的問題
3.4 總結(jié)
?1. 分布式鎖的概念
從現(xiàn)在開始,我們來研究分布式鎖相關(guān)的問題。在單機環(huán)境下,我們可以使用JDK提供的大量工具來完成線程安全與高并發(fā)相關(guān)的任務(wù),這其中最重要的一個就是鎖。
在單進程的系統(tǒng)中,當存在多個線程可以同時改變某個變量(可變共享變量)時,就需要對變量或代碼塊做同步,使其在修改這種變量時能夠線性執(zhí)行消除并發(fā)修改變量。而同步的本質(zhì)是通過鎖來實現(xiàn)的。為了實現(xiàn)多個線程在一個時刻同一個代碼塊只能有一個線程可執(zhí)行,那么需要在某個地方做個標記,這個標記必須每個線程都能看到,當標記不存在時可以設(shè)置該標記,其余后續(xù)線程發(fā)現(xiàn)已經(jīng)有標記了則等待擁有標記的線程結(jié)束同步代碼塊取消標記后再去嘗試設(shè)置標記。這個標記可以理解為鎖。
不同地方實現(xiàn)鎖的方式也不一樣,只要能滿足所有線程都能看得到標記即可。如 Java 中 synchronize 是在對象頭設(shè)置標記,JUC包里的Lock 接口的實現(xiàn)類基本上都只是某一個 volitile 修飾的 int 型變量其保證每個線程都能擁有對該 int 的可見性和原子修改,linux 內(nèi)核中也是利用互斥量或信號量等內(nèi)存數(shù)據(jù)做標記。
除了利用內(nèi)存數(shù)據(jù)做鎖其實任何互斥的都能做鎖(只考慮互斥情況),如流水表中流水號與時間結(jié)合做冪等校驗可以看作是一個不會釋放的鎖,或者使用某個文件是否存在作為鎖等。只需要滿足在對標記進行修改能保證原子性和內(nèi)存可見性即可。
如果我們思考一下,如果是在分布式場景下,又該如何實現(xiàn)共享變量的安全訪問呢?很多時候我們需要保證一個方法在同一時間內(nèi)只能被同一個線程執(zhí)行。在單機環(huán)境中,通過 Java 提供的并發(fā) API 我們可以解決,但是在分布式環(huán)境下,就沒有那么簡單啦。其中最典型的問題是:
- 分布式與單機情況下最大的不同在于其不是多線程而是多進程。
- 多線程由于可以共享堆內(nèi)存,因此可以簡單的采取內(nèi)存作為標記存儲位置。而進程之間甚至可能都不在同一臺物理機上,因此需要將標記存儲在一個所有進程都能看到的地方。
為此,我們就需要來設(shè)計分布式環(huán)境下可以保證資源安全訪問的鎖。具體來說,應(yīng)該滿足如下幾個要求:
- 在分布式模型下,數(shù)據(jù)只有一份(或有限制),此時需要利用鎖的技術(shù)控制某一時刻修改數(shù)據(jù)的進程數(shù)。
- 與單機模式下的鎖不僅需要保證進程可見,還需要考慮進程與鎖之間的網(wǎng)絡(luò)問題。分布式情況下之所以問題變得復(fù)雜,主要就是需要考慮到網(wǎng)絡(luò)的延時和不可靠。
- 分布式鎖還是可以將標記存在內(nèi)存,只是該內(nèi)存不是某個進程分配的內(nèi)存而是公共內(nèi)存如 Redis、Memcache。至于利用數(shù)據(jù)庫、文件等做鎖與單機的實現(xiàn)是一樣的,只要保證標記能互斥就行。
該如何實現(xiàn)分布式鎖呢?在Java領(lǐng)域,主要有三個組件可以幫助我們實現(xiàn):Mysql數(shù)據(jù)庫、redis和Zookeeper。我們分別來看如何使用這三個組件實現(xiàn)分布式鎖。
2.基于數(shù)據(jù)庫做分布式鎖
我們知道,即使在復(fù)雜的微服務(wù)環(huán)境下,我們最終要訪問的數(shù)據(jù)只會在一個地方,例如某個學(xué)生某個時間學(xué)習(xí)某課的記錄一定是在一個表里的(不考慮主備和異地?;畹囊蛩?,而不會將這個數(shù)據(jù)存放在好幾個地方,因此,不管有幾個請求,只要最后在訪問這條記錄的時候,我們實現(xiàn)安全訪問 ,其他的請求等待或者拒絕掉,就可以做到分布式鎖的效果。
這就是數(shù)據(jù)庫可以做分布式鎖的核心思想,具體來說,根據(jù)數(shù)據(jù)庫表的情況,我們又有多種方案。
2.1 基于表主鍵唯一做分布式鎖
利用主鍵唯一的特性,如果有多個請求同時提交到數(shù)據(jù)庫的話,數(shù)據(jù)庫會保證只有一個操作可以成功,那么我們就可以認為操作成功的那個線程獲得了該方法的鎖,當方法執(zhí)行完畢之后,想要釋放鎖的話,刪除這條數(shù)據(jù)庫記錄即可。
上面這種簡單的實現(xiàn)有以下幾個問題:
- 這把鎖強依賴數(shù)據(jù)庫的可用性,數(shù)據(jù)庫是一個單點,一旦數(shù)據(jù)庫掛掉,會導(dǎo)致業(yè)務(wù)系統(tǒng)不可用。
- 這把鎖沒有失效時間,一旦解鎖操作失敗,就會導(dǎo)致鎖記錄一直在數(shù)據(jù)庫中,其他線程無法再獲得到鎖。
- 這把鎖只能是非阻塞的,因為數(shù)據(jù)的 insert 操作,一旦插入失敗就會直接報錯。沒有獲得鎖的線程并不會進入排隊隊列,要想再次獲得鎖就要再次觸發(fā)獲得鎖操作。
- 這把鎖是非重入的,同一個線程在沒有釋放鎖之前無法再次獲得該鎖。因為數(shù)據(jù)中數(shù)據(jù)已經(jīng)存在了。
- 這把鎖是非公平鎖,所有等待鎖的線程憑運氣去爭奪鎖。
- 在 MySQL 數(shù)據(jù)庫中采用主鍵沖突防重,在大并發(fā)情況下有可能會造成鎖表現(xiàn)象。
當然,我們也可以有其他方式解決上面的問題。
- 數(shù)據(jù)庫是單點?搞兩個數(shù)據(jù)庫,數(shù)據(jù)之前雙向同步,一旦掛掉快速切換到備庫上。
- 沒有失效時間?只要做一個定時任務(wù),每隔一定時間把數(shù)據(jù)庫中的超時數(shù)據(jù)清理一遍。
- 非阻塞的?搞一個 while 循環(huán),直到 insert 成功再返回成功。
- 非重入的?在數(shù)據(jù)庫表中加個字段,記錄當前獲得鎖的機器的主機信息和線程信息,那么下次再獲取鎖的時候先查詢數(shù)據(jù)庫,如果當前機器的主機信息和線程信息在數(shù)據(jù)庫可以查到的話,直接把鎖分配給他就可以了。
- 非公平的?再建一張中間表,將等待鎖的線程全記錄下來,并根據(jù)創(chuàng)建時間排序,只有最先創(chuàng)建的允許獲取鎖。
- 比較好的辦法是在程序中生產(chǎn)主鍵進行防重。
不過整體來看,在復(fù)雜場景下,這個分布式鎖會非常脆弱,很容易出問題。
2.2 基于表字段版本號做分布式鎖
這個策略源于 mysql 的 mvcc 機制,使用這個策略其實本身沒有什么問題,唯一的問題就是對數(shù)據(jù)表侵入較大,我們要為每個表設(shè)計一個版本號字段,然后寫一條判斷 sql 每次進行判斷,增加了數(shù)據(jù)庫操作的次數(shù),但是在高并發(fā)的要求下,這會嚴重影響性能,對數(shù)據(jù)庫連接的開銷也是無法忍受的。
2.3 基于數(shù)據(jù)庫排他鎖做分布式鎖
在查詢語句后面增加for update
,數(shù)據(jù)庫會在查詢過程中給數(shù)據(jù)庫表增加排他鎖 (注意: InnoDB 引擎在加鎖的時候,只有通過索引進行檢索的時候才會使用行級鎖,否則會使用表級鎖。這里我們希望使用行級鎖,就要給要執(zhí)行的方法字段名添加索引,值得注意的是,這個索引一定要創(chuàng)建成唯一索引,否則會出現(xiàn)多個重載方法之間無法同時被訪問的問題。重載方法的話建議把參數(shù)類型也加上。當某條記錄被加上排他鎖之后,其他線程無法再在該行記錄上增加排他鎖。
我們可以認為獲得排他鎖的線程即可獲得分布式鎖,當獲取到鎖之后,可以執(zhí)行方法的業(yè)務(wù)邏輯,執(zhí)行完方法之后,通過connection.commit()
操作來釋放鎖。
這種方法可以有效的解決上面提到的無法釋放鎖和阻塞鎖的問題。
- 這種鎖是阻塞鎖,
for update
語句會在執(zhí)行成功后立即返回,在執(zhí)行失敗時一直處于阻塞狀態(tài),直到成功。 - 這種鎖定之后服務(wù)宕機,是否會出現(xiàn)無法釋放的問題呢?使用這種方式,服務(wù)宕機之后數(shù)據(jù)庫會自己把鎖釋放掉。
所以這種方式比較簡單在實際工程里是很多應(yīng)用的。例如在轉(zhuǎn)賬的時候,我們需要先從A扣減10,然后再給B加10,此時就可以使用for update,保證這個操作要么一定完成,要么都不執(zhí)行。
這里可能有一個問題,就是我們要使用排他鎖來進行分布式鎖的 lock,那么一個排他鎖長時間不提交,就會占用數(shù)據(jù)庫連接。一旦類似的連接變得多了,就可能把數(shù)據(jù)庫連接池撐爆。
而且,這種方式還是無法直接解決數(shù)據(jù)庫單點和可重入問題,適合處理比較簡單的分布式鎖問題。
優(yōu)點:簡單,易于理解
缺點:會有各種各樣的問題(操作數(shù)據(jù)庫需要一定的開銷,使用數(shù)據(jù)庫的行級鎖并不一定靠譜,性能不靠譜)
3.使用Redis做分布式鎖
這個是我們后面要重點學(xué)習(xí)方式,做分布式鎖是redis的一大強項。
這篇文章寫的不錯,我們直接參考啦!
3.1 redis實現(xiàn)分布式鎖的基本原理
我們主要使用redis的setnx命令來實現(xiàn)。setnx
是SET if not exists
(如果不存在,則 SET)的簡寫。
先看一個例子:
127.0.0.1:6379> setnx lock value1 #在鍵lock不存在的情況下,將鍵key的值設(shè)置為value1 (integer) 1
127.0.0.1:6379> setnx lock value2 #試圖覆蓋lock的值,返回0表示失敗
(integer) 0 127.0.0.1:6379> get lock #獲取lock的值,驗證沒有被覆蓋 "value1"
127.0.0.1:6379> del lock #刪除lock的值,刪除成功
(integer) 1
127.0.0.1:6379> setnx lock value2 #再使用setnx命令設(shè)置,返回0表示成功
(integer) 1 127.0.0.1:6379> get lock #獲取lock的值,驗證設(shè)置成功
"value2"
上面這幾個命令就是最基本的用來完成分布鎖的命令。
加鎖:使用setnx key value
命令,如果key不存在,設(shè)置value(加鎖成功)。如果已經(jīng)存在lock(也就是有客戶端持有鎖了),則設(shè)置失敗(加鎖失敗)。
解鎖:使用del
命令,通過刪除鍵值釋放鎖。釋放鎖之后,其他客戶端可以通過setnx
命令進行加鎖。
key的值可以根據(jù)業(yè)務(wù)設(shè)置,比如是用戶中心使用的,可以命令為USER_REDIS_LOCK
,value可以使用uuid保證唯一,用于標識加鎖的客戶端。保證加鎖和解鎖都是同一個客戶端。
那么接下來就可以寫一段很簡單的加鎖代碼:
private static Jedis jedis = new Jedis("127.0.0.1");
private static final Long SUCCESS = 1L;
/**
* 加鎖
*/
public boolean tryLock(String key, String requestId) {
//使用setnx命令。
//不存在則保存返回1,加鎖成功。如果已經(jīng)存在則返回0,加鎖失敗。
return SUCCESS.equals(jedis.setnx(key, requestId));
}
//刪除key的lua腳本,先比較requestId是否相等,相等則刪除
private static final String DEL_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
/**
* 解鎖
*/
public boolean unLock(String key, String requestId) {
//刪除成功表示解鎖成功
Long result = (Long) jedis.eval(DEL_SCRIPT, Collections.singletonList(key), Collections.singletonList(requestId));
return SUCCESS.equals(result);
}
上面代碼加鎖和解鎖的基本原理是:
3.2 問題一:增加超時機制,防止長期持有的情況
上面過程其實仍然是有問題的,上面的過程僅僅滿足上述的第一個條件和第三個條件,保證上鎖和解鎖都是同一個客戶端,也保證只有一個客戶端持有鎖。
但是第二點沒法保證,因為如果一個客戶端持有鎖的期間突然崩潰了,就會導(dǎo)致無法解鎖,最后導(dǎo)致出現(xiàn)死鎖的現(xiàn)象。
所以要有個超時的機制,在設(shè)置key的值時,需要加上有效時間,如果有效時間過期了,就會自動失效,就不會出現(xiàn)死鎖。然后加鎖的代碼就會變成這樣。
public boolean tryLock(String key, String requestId, int expireTime) {
//使用jedis的api,保證原子性
//NX 不存在則操作 EX 設(shè)置有效期,單位是秒
String result = jedis.set(key, requestId, "NX", "EX", expireTime);
//返回OK則表示加鎖成功
return "OK".equals(result);
}
3.3 問題2:重入的問題
但是聰明的同學(xué)肯定會問,有效時間設(shè)置多長,假如我的業(yè)務(wù)操作比有效時間長,我的業(yè)務(wù)代碼還沒執(zhí)行完就自動給我解鎖了,不就完蛋了嗎。
這個問題就有點棘手了,在網(wǎng)上也有很多討論,第一種解決方法就是靠程序員自己去把握,預(yù)估一下業(yè)務(wù)代碼需要執(zhí)行的時間,然后設(shè)置有效期時間比執(zhí)行時間長一些,保證不會因為自動解鎖影響到客戶端業(yè)務(wù)代碼的執(zhí)行。
但是這并不是萬全之策,比如網(wǎng)絡(luò)抖動這種情況是無法預(yù)測的,也有可能導(dǎo)致業(yè)務(wù)代碼執(zhí)行的時間變長,所以并不安全。
有一種方法比較靠譜一點,就是給鎖續(xù)期。在Redisson框架實現(xiàn)分布式鎖的思路,就使用watchDog機制實現(xiàn)鎖的續(xù)期。當加鎖成功后,同時開啟守護線程,默認有效期是30秒,每隔10秒就會給鎖續(xù)期到30秒,只要持有鎖的客戶端沒有宕機,就能保證一直持有鎖,直到業(yè)務(wù)代碼執(zhí)行完畢由客戶端自己解鎖,如果宕機了自然就在有效期失效后自動解鎖。
但是聰明的同學(xué)可能又會問,你這個鎖只能加一次,不可重入??芍厝腈i意思是在外層使用鎖之后,內(nèi)層仍然可以使用,那么可重入鎖的實現(xiàn)思路又是怎么樣的呢?
在Redisson實現(xiàn)可重入鎖的思路,使用Redis的哈希表存儲可重入次數(shù),當加鎖成功后,使用hset
命令,value(重入次數(shù))則是1。
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; "
如果同一個客戶端再次加鎖成功,則使用hincrby
自增加一。?
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);"
解鎖時,先判斷可重復(fù)次數(shù)是否大于0,大于0則減一,否則刪除鍵值,釋放鎖資源。
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
3.4 問題三:優(yōu)化輪詢加鎖的問題
上面的加鎖方法是加鎖后立即返回加鎖結(jié)果,如果加鎖失敗的情況下,總不可能一直輪詢嘗試加鎖,直到加鎖成功為止,這樣太過耗費性能。所以需要利用發(fā)布訂閱的機制進行優(yōu)化。
步驟如下:
- 當加鎖失敗后,訂閱鎖釋放的消息,自身進入阻塞狀態(tài)。
- 當持有鎖的客戶端釋放鎖的時候,發(fā)布鎖釋放的消息。
- 當進入阻塞等待的其他客戶端收到鎖釋放的消息后,解除阻塞等待狀態(tài),再次嘗試加鎖。
3.5?總結(jié)
以上的實現(xiàn)思路僅僅考慮在單機版Redis上,如果是集群版Redis需要考慮的問題還要再多一點。Redis由于他的高性能讀寫能力,所以在并發(fā)高的場景下使用Redis分布式鎖會多一點。
上面的問題一,二,三其實就是redis分布式鎖不斷改良發(fā)展的過程,第一個問題是設(shè)置有效期防止死鎖,并且引入守護線程給鎖續(xù)期,第二個問題是支持可重入鎖,第三個問題是加鎖失敗后阻塞等待,等鎖釋放后再次嘗試加鎖。Redisson框架解決這三個問題的思路也非常值得學(xué)習(xí)。文章來源:http://www.zghlxwxcb.cn/news/detail-815237.html
參考:阿里云開發(fā)者
?文章來源地址http://www.zghlxwxcb.cn/news/detail-815237.html
到了這里,關(guān)于【征服redis15】分布式鎖的功能與整體設(shè)計方案的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!