在很多場景中,我們?yōu)榱吮WC數(shù)據(jù)的最終一致性钉寝,需要很多的技術(shù)方案來支持弧呐,比如分布式事務(wù)、分布式鎖等嵌纲。有的時(shí)候俘枫,我們需要保證一個(gè)方法在同
一時(shí)間內(nèi)只能被同一個(gè)線程執(zhí)行。在單機(jī)環(huán)境中逮走,Java中其實(shí)提供了很多并發(fā)處理相關(guān)的API鸠蚪,但是這些API在分布式場景中就無能為力了。也就是說單
純的Java Api并不能提供分布式鎖的能力师溅。所以針對分布式鎖的實(shí)現(xiàn)目前有多種方案:
分布式鎖一般有三種實(shí)現(xiàn)方式:1. 數(shù)據(jù)庫鎖邓嘹;2. 基于Redis的分布式鎖;3. 基于ZooKeeper的分布式鎖险胰。
分布式鎖應(yīng)該是怎么樣的
- 互斥性 可以保證在分布式部署的應(yīng)用集群中汹押,同一個(gè)方法在同一時(shí)間只能被一臺機(jī)器上的一個(gè)線程執(zhí)行。
- 這把鎖要是一把可重入鎖(避免死鎖)
- 不會(huì)發(fā)生死鎖:有一個(gè)客戶端在持有鎖的過程中崩潰而沒有解鎖起便,也能保證其他客戶端能夠加鎖
- 這把鎖最好是一把阻塞鎖(根據(jù)業(yè)務(wù)需求考慮要不要這條)
- 有高可用的獲取鎖和釋放鎖功能
- 獲取鎖和釋放鎖的性能要好
數(shù)據(jù)庫鎖
基于數(shù)據(jù)庫表
要實(shí)現(xiàn)分布式鎖棚贾,最簡單的方式可能就是直接創(chuàng)建一張鎖表窖维,然后通過操作該表中的數(shù)據(jù)來實(shí)現(xiàn)了。
當(dāng)我們要鎖住某個(gè)方法或資源時(shí)妙痹,我們就在該表中增加一條記錄铸史,想要釋放鎖的時(shí)候就刪除這條記錄。
CREATE TABLE `methodLock` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '鎖定的方法名',
`desc` varchar(1024) NOT NULL DEFAULT '備注信息',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存數(shù)據(jù)時(shí)間怯伊,自動(dòng)生成',
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='鎖定中的方法';
當(dāng)我們想要鎖住某個(gè)方法時(shí)琳轿,執(zhí)行以下SQL:
insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)
因?yàn)槲覀儗ethod_name做了唯一性約束,這里如果有多個(gè)請求同時(shí)提交到數(shù)據(jù)庫的話耿芹,數(shù)據(jù)庫會(huì)保證只有一個(gè)操作可以成功崭篡,那么我們就可以認(rèn)為
操作成功的那個(gè)線程獲得了該方法的鎖,可以執(zhí)行方法體內(nèi)容吧秕。
當(dāng)方法執(zhí)行完畢之后琉闪,想要釋放鎖的話,需要執(zhí)行以下Sql:
delete from methodLock where method_name ='method_name'
上面這種簡單的實(shí)現(xiàn)有以下幾個(gè)問題:
1砸彬、這把鎖強(qiáng)依賴數(shù)據(jù)庫的可用性颠毙,數(shù)據(jù)庫是一個(gè)單點(diǎn),一旦數(shù)據(jù)庫掛掉砂碉,會(huì)導(dǎo)致業(yè)務(wù)系統(tǒng)不可用蛀蜜。
2、這把鎖沒有失效時(shí)間增蹭,一旦解鎖操作失敗涵防,就會(huì)導(dǎo)致鎖記錄一直在數(shù)據(jù)庫中,其他線程無法再獲得到鎖沪铭。
3壮池、這把鎖只能是非阻塞的,因?yàn)閿?shù)據(jù)的insert操作杀怠,一旦插入失敗就會(huì)直接報(bào)錯(cuò)椰憋。沒有獲得鎖的線程并不會(huì)進(jìn)入排隊(duì)隊(duì)列,要想再次獲得鎖就要再次觸發(fā)獲得鎖操作赔退。
4橙依、這把鎖是非重入的,同一個(gè)線程在沒有釋放鎖之前無法再次獲得該鎖硕旗。因?yàn)閿?shù)據(jù)中數(shù)據(jù)已經(jīng)存在了窗骑。
當(dāng)然,我們也可以有其他方式解決上面的問題漆枚。
- 數(shù)據(jù)庫是單點(diǎn)创译?搞兩個(gè)數(shù)據(jù)庫,數(shù)據(jù)之前雙向同步墙基。一旦掛掉快速切換到備庫上软族。
- 沒有失效時(shí)間刷喜?只要做一個(gè)定時(shí)任務(wù),每隔一定時(shí)間把數(shù)據(jù)庫中的超時(shí)數(shù)據(jù)清理一遍立砸。
- 非阻塞的掖疮?搞一個(gè)while循環(huán),直到insert成功再返回成功颗祝。
- 非重入的浊闪?在數(shù)據(jù)庫表中加個(gè)字段,記錄當(dāng)前獲得鎖的機(jī)器的主機(jī)信息和線程信息螺戳,那么下次再獲取鎖的時(shí)候先查詢數(shù)據(jù)庫搁宾,如果當(dāng)前機(jī)器的主機(jī)信息和線程信息在數(shù)據(jù)庫可以查到的話,直接把鎖分配給他就可以了温峭。
基于數(shù)據(jù)庫的排它鎖
除了可以通過增刪操作數(shù)據(jù)表中的記錄以外猛铅,其實(shí)還可以借助數(shù)據(jù)庫中自帶的鎖來實(shí)現(xiàn)分布式的鎖字支。
我們還用剛剛創(chuàng)建的那張數(shù)據(jù)庫表凤藏。可以通過數(shù)據(jù)庫的排他鎖來實(shí)現(xiàn)分布式鎖堕伪。
在查詢語句后面增加for update揖庄,數(shù)據(jù)庫會(huì)在查詢過程中給數(shù)據(jù)庫表增加排他鎖。當(dāng)某條記錄被加上排他鎖之后欠雌,其他線程無法再在該行記錄上增加排他鎖蹄梢。
我們可以認(rèn)為獲得排它鎖的線程即可獲得分布式鎖,當(dāng)獲取到鎖之后富俄,可以執(zhí)行方法的業(yè)務(wù)邏輯禁炒,執(zhí)行完方法之后,再通過以下方法解鎖:
public void unlock(){
connection.commit();
}
通過connection.commit()操作來釋放鎖霍比。
這種方法可以有效的解決上面提到的無法釋放鎖和阻塞鎖的問題幕袱。
- 阻塞鎖? for update語句會(huì)在執(zhí)行成功后立即返回悠瞬,在執(zhí)行失敗時(shí)一直處于阻塞狀態(tài)们豌,直到成功。
- 鎖定之后服務(wù)宕機(jī)浅妆,無法釋放望迎?使用這種方式,服務(wù)宕機(jī)之后數(shù)據(jù)庫會(huì)自己把鎖釋放掉凌外。
但是還是無法直接解決數(shù)據(jù)庫單點(diǎn)和可重入問題辩尊。
總結(jié):
總結(jié)一下使用數(shù)據(jù)庫來實(shí)現(xiàn)分布式鎖的方式,這兩種方式都是依賴數(shù)據(jù)庫的一張表康辑,一種是通過表中的記錄的存在情況確定當(dāng)前是否有鎖存在对省,另外一種是通過數(shù)據(jù)庫的排他鎖來實(shí)現(xiàn)分布式鎖蝗拿。
**數(shù)據(jù)庫實(shí)現(xiàn)分布式鎖的優(yōu)點(diǎn): **直接借助數(shù)據(jù)庫,容易理解蒿涎。
**數(shù)據(jù)庫實(shí)現(xiàn)分布式鎖的缺點(diǎn): **會(huì)有各種各樣的問題哀托,在解決問題的過程中會(huì)使整個(gè)方案變得越來越復(fù)雜。
操作數(shù)據(jù)庫需要一定的開銷劳秋,性能問題需要考慮仓手。
樂觀鎖
樂觀鎖假設(shè)認(rèn)為數(shù)據(jù)一般情況下不會(huì)造成沖突,只有在進(jìn)行數(shù)據(jù)的提交更新時(shí)玻淑,才會(huì)檢測數(shù)據(jù)的沖突情況嗽冒,如果發(fā)現(xiàn)沖突了,則返回錯(cuò)誤信息
實(shí)現(xiàn)方式:
時(shí)間戳(timestamp)記錄機(jī)制實(shí)現(xiàn):給數(shù)據(jù)庫表增加一個(gè)時(shí)間戳字段類型的字段补履,當(dāng)讀取數(shù)據(jù)時(shí)添坊,將timestamp字段的值一同讀出,數(shù)據(jù)每更新一次箫锤,timestamp也同步更新贬蛙。當(dāng)對數(shù)據(jù)做提交更新操作時(shí),檢查當(dāng)前數(shù)據(jù)庫中數(shù)據(jù)的時(shí)間戳和自己更新前取到的時(shí)間戳進(jìn)行對比谚攒,若相等阳准,則更新,否則認(rèn)為是失效數(shù)據(jù)馏臭。
若出現(xiàn)更新沖突野蝇,則需要上層邏輯修改,啟動(dòng)重試機(jī)制
同樣也可以使用version的方式括儒。
性能對比
(1) 悲觀鎖實(shí)現(xiàn)方式是獨(dú)占數(shù)據(jù)绕沈,其它線程需要等待,不會(huì)出現(xiàn)修改的沖突帮寻,能夠保證數(shù)據(jù)的一致性乍狐,但是依賴數(shù)據(jù)庫的實(shí)現(xiàn),且在線程較多時(shí)出現(xiàn)等待造成效率降低的問題规婆。一般情況下澜躺,對于數(shù)據(jù)很敏感且讀取頻率較低的場景,可以采用悲觀鎖的方式
(2) 樂觀鎖可以多線程同時(shí)讀取數(shù)據(jù)抒蚜,若出現(xiàn)沖突掘鄙,也可以依賴上層邏輯修改,能夠保證高并發(fā)下的讀取嗡髓,適用于讀取頻率很高而修改頻率較少的場景
(3) 由于庫存回寫數(shù)據(jù)屬于敏感數(shù)據(jù)且讀取頻率適中操漠,所以建議使用悲觀鎖優(yōu)化
基于redis的分布式鎖
相比較于基于數(shù)據(jù)庫實(shí)現(xiàn)分布式鎖的方案來說,基于緩存來實(shí)現(xiàn)在性能方面會(huì)表現(xiàn)的更好一點(diǎn)。而且很多緩存是可以集群部署的浊伙,可以解決單點(diǎn)問題撞秋。
首先,為了確保分布式鎖可用嚣鄙,我們至少要確保鎖的實(shí)現(xiàn)同時(shí)滿足以下四個(gè)條件:
- 互斥性吻贿。在任意時(shí)刻,只有一個(gè)客戶端能持有鎖哑子。
- 不會(huì)發(fā)生死鎖舅列。即使有一個(gè)客戶端在持有鎖的期間崩潰而沒有主動(dòng)解鎖,也能保證后續(xù)其他客戶端能加鎖卧蜓。
- 具有容錯(cuò)性帐要。只要大部分的Redis節(jié)點(diǎn)正常運(yùn)行,客戶端就可以加鎖和解鎖弥奸。
- 解鈴還須系鈴人榨惠。加鎖和解鎖必須是同一個(gè)客戶端,客戶端自己不能把別人加的鎖給解了盛霎。
可以看到赠橙,我們加鎖就一行代碼:jedis.set(String key, String value, String nxxx, String expx, int time)
,這個(gè)set()方法一共有五個(gè)形參:
第一個(gè)為key摩渺,我們使用key來當(dāng)鎖简烤,因?yàn)閗ey是唯一的剂邮。
第二個(gè)為value摇幻,我們傳的是requestId,很多童鞋可能不明白挥萌,有key作為鎖不就夠了嗎绰姻,為什么還要用到value?原因就是我們在上面講到可靠性時(shí)引瀑,分布式鎖要滿足第四個(gè)條件解鈴還須系鈴人狂芋,通過給value賦值為requestId,我們就知道這把鎖是哪個(gè)請求加的了憨栽,在解鎖的時(shí)候就可以有依據(jù)帜矾。requestId可以使用
UUID.randomUUID().toString()
方法生成。第三個(gè)為nxxx屑柔,這個(gè)參數(shù)我們填的是NX屡萤,意思是SET IF NOT EXIST揉阎,即當(dāng)key不存在時(shí)分俯,我們進(jìn)行set操作弟断;若key已經(jīng)存在夹抗,則不做任何操作亚铁;
第四個(gè)為expx,這個(gè)參數(shù)我們傳的是PX娶眷,意思是我們要給這個(gè)key加一個(gè)過期的設(shè)置偎行,具體時(shí)間由第五個(gè)參數(shù)決定。
第五個(gè)為time领虹,與第四個(gè)參數(shù)相呼應(yīng)规哪,代表key的過期時(shí)間。
總的來說塌衰,執(zhí)行上面的set()方法就只會(huì)導(dǎo)致兩種結(jié)果:1. 當(dāng)前沒有鎖(key不存在)由缆,那么就進(jìn)行加鎖操作,并對鎖設(shè)置個(gè)有效期猾蒂,同時(shí)value表示加鎖的客戶端均唉。2. 已有鎖存在,不做任何操作肚菠。
心細(xì)的童鞋就會(huì)發(fā)現(xiàn)了舔箭,我們的加鎖代碼滿足我們可靠性里描述的三個(gè)條件。首先蚊逢,set()加入了NX參數(shù)层扶,可以保證如果已有key存在,則函數(shù)不會(huì)調(diào)用成功烙荷,也就是只有一個(gè)客戶端能持有鎖镜会,滿足互斥性。其次终抽,由于我們對鎖設(shè)置了過期時(shí)間戳表,即使鎖的持有者后續(xù)發(fā)生崩潰而沒有解鎖,鎖也會(huì)因?yàn)榈搅诉^期時(shí)間而自動(dòng)解鎖(即key被刪除)昼伴,不會(huì)發(fā)生死鎖匾旭。最后,因?yàn)槲覀儗alue賦值為requestId圃郊,代表加鎖的客戶端請求標(biāo)識价涝,那么在客戶端在解鎖的時(shí)候就可以進(jìn)行校驗(yàn)是否是同一個(gè)客戶端。由于我們只考慮Redis單機(jī)部署的場景持舆,所以容錯(cuò)性我們暫不考慮色瘩。
錯(cuò)誤實(shí)例:
使用jedis.setnx()
和jedis.expire()
組合實(shí)現(xiàn)加鎖
public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {
Long result = jedis.setnx(lockKey, requestId);
if (result == 1) {
// 若在這里程序突然崩潰,則無法設(shè)置過期時(shí)間逸寓,將發(fā)生死鎖 jedis.expire(lockKey, expireTime);
}
}
setnx()方法作用就是SET IF NOT EXIST居兆,expire()方法就是給鎖加一個(gè)過期時(shí)間。乍一看好像和前面的set()方法結(jié)果一樣席覆,然而由于這是兩條Redis命令史辙,不具有原子性,如果程序在執(zhí)行完setnx()之后突然崩潰,導(dǎo)致鎖沒有設(shè)置過期時(shí)間聊倔。那么將會(huì)發(fā)生死鎖晦毙。網(wǎng)上之所以有人這樣實(shí)現(xiàn),是因?yàn)榈桶姹镜膉edis并不支持多參數(shù)的set()方法耙蔑。
解鎖:
首先獲取鎖對應(yīng)的value值见妒,檢查是否與requestId相等,如果相等則刪除鎖(解鎖)
總結(jié):
可以使用緩存來代替數(shù)據(jù)庫來實(shí)現(xiàn)分布式鎖甸陌,這個(gè)可以提供更好的性能须揣,同時(shí),很多緩存服務(wù)都是集群部署的钱豁,可以避免單點(diǎn)問題耻卡。并且很多緩存服務(wù)都提供了可以用來實(shí)現(xiàn)分布式鎖的方法,比如redis的setnx方法等牲尺。并且卵酪,這些緩存服務(wù)也都提供了對數(shù)據(jù)的過期自動(dòng)刪除的支持,可以直接設(shè)置超時(shí)時(shí)間來控制鎖的釋放谤碳。
使用緩存實(shí)現(xiàn)分布式鎖的優(yōu)點(diǎn)
性能好溃卡,實(shí)現(xiàn)起來較為方便。
使用緩存實(shí)現(xiàn)分布式鎖的缺點(diǎn)
通過超時(shí)時(shí)間來控制鎖的失效時(shí)間并不是十分的靠譜蜒简。
基于Zookeeper實(shí)現(xiàn)分布式鎖
基于zookeeper臨時(shí)有序節(jié)點(diǎn)可以實(shí)現(xiàn)的分布式鎖瘸羡。大致思想即為:每個(gè)客戶端對某個(gè)方法加鎖時(shí),在zookeeper上的與該方法對應(yīng)的指定節(jié)點(diǎn)的目錄下搓茬,生成一個(gè)唯一的
瞬時(shí)有序節(jié)點(diǎn)犹赖。 判斷是否獲取鎖的方式很簡單,只需要判斷有序節(jié)點(diǎn)中序號最小的一個(gè)垮兑。 當(dāng)釋放鎖的時(shí)候冷尉,只需將這個(gè)瞬時(shí)節(jié)點(diǎn)刪除即可漱挎。同時(shí)系枪,其可以避免服務(wù)宕機(jī)導(dǎo)
致的鎖無法釋放,而產(chǎn)生的死鎖問題磕谅。
來看下Zookeeper能不能解決前面提到的問題私爷。
- 鎖無法釋放?使用Zookeeper可以有效的解決鎖無法釋放的問題膊夹,因?yàn)樵趧?chuàng)建鎖的時(shí)候衬浑,客戶端會(huì)在ZK中創(chuàng)建一個(gè)臨時(shí)節(jié)點(diǎn),一旦客戶端獲取到鎖之后突然掛掉(
Session連接斷開)放刨,那么這個(gè)臨時(shí)節(jié)點(diǎn)就會(huì)自動(dòng)刪除掉工秩。其他客戶端就可以再次獲得鎖。
- 非阻塞鎖?使用Zookeeper可以實(shí)現(xiàn)阻塞的鎖助币,客戶端可以通過在ZK中創(chuàng)建順序節(jié)點(diǎn)浪听,并且在節(jié)點(diǎn)上綁定監(jiān)聽器,一旦節(jié)點(diǎn)有變化眉菱,Zookeeper會(huì)通知客戶端迹栓,客戶
端可以檢查自己創(chuàng)建的節(jié)點(diǎn)是不是當(dāng)前所有節(jié)點(diǎn)中序號最小的,如果是俭缓,那么自己就獲取到鎖克伊,便可以執(zhí)行業(yè)務(wù)邏輯了。
- 不可重入华坦?使用Zookeeper也可以有效的解決不可重入的問題愿吹,客戶端在創(chuàng)建節(jié)點(diǎn)的時(shí)候,把當(dāng)前客戶端的主機(jī)信息和線程信息直接寫入到節(jié)點(diǎn)中惜姐,下次想要獲取鎖的
時(shí)候和當(dāng)前最小的節(jié)點(diǎn)中的數(shù)據(jù)比對一下就可以了洗搂。如果和自己的信息一樣,那么自己直接獲取到鎖载弄,如果不一樣就再創(chuàng)建一個(gè)臨時(shí)的順序節(jié)點(diǎn)耘拇,參與排隊(duì)。
- 單點(diǎn)問題宇攻?使用Zookeeper可以有效的解決單點(diǎn)問題惫叛,ZK是集群部署的,只要集群中有半數(shù)以上的機(jī)器存活逞刷,就可以對外提供服務(wù)嘉涌。
可以直接使用zookeeper第三方庫Curator客戶端,這個(gè)客戶端中封裝了一個(gè)可重入的鎖服務(wù)夸浅。
Zookeeper實(shí)現(xiàn)的分布式鎖其實(shí)存在一個(gè)缺點(diǎn)仑最,那就是性能上可能并沒有緩存服務(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ī)器上预皇。
**使用Zookeeper實(shí)現(xiàn)分布式鎖的優(yōu)點(diǎn): **有效的解決單點(diǎn)問題,不可重入問題婉刀,非阻塞問題以及鎖無法釋放的問題吟温。實(shí)現(xiàn)起來較為簡單。
**使用Zookeeper實(shí)現(xiàn)分布式鎖的缺點(diǎn) : **性能上不如使用緩存實(shí)現(xiàn)分布式鎖突颊。 需要對ZK的原理有所了解鲁豪。
三種方案的比較
**從理解的難易程度角度(從低到高): **數(shù)據(jù)庫 > 緩存 > Zookeeper
從實(shí)現(xiàn)的復(fù)雜性角度(從低到高)**: **Zookeeper >= 緩存 > 數(shù)據(jù)庫
**從性能角度(從高到低): **緩存 > Zookeeper >= 數(shù)據(jù)庫
**從可靠性角度(從高到低): **Zookeeper > 緩存 > 數(shù)據(jù)庫
參考:
http://wudashan.cn/2017/10/23/Redis-Distributed-Lock-Implement/