別吵吵,分布式鎖也是鎖
Tomcat的鎖
Tomcat是這個(gè)系統(tǒng)的核心組成部分, 每當(dāng)有用戶(hù)請(qǐng)求過(guò)來(lái),Tomcat就會(huì)從線(xiàn)程池里找個(gè)線(xiàn)程來(lái)處理,有的執(zhí)行登錄,有的查看購(gòu)物車(chē),有的下訂單,看著屬下們盡心盡職地工作,完成人類(lèi)的請(qǐng)求,Tomcat就很有成就感。
與此同時(shí),它也很得意,所有的業(yè)務(wù)邏輯盡在掌握。MySQL算啥!不就是一個(gè)保存數(shù)據(jù)的地方嗎? Redis算啥!不就是一個(gè)加快速度的緩存嗎?
沒(méi)有他們,我也能找到替代品,而我不可替代的, Tomcat經(jīng)常這么想。
昨天MySQL偶然說(shuō)起隔壁機(jī)器入駐了一個(gè)叫做Node.js的家伙,居然只用一個(gè)線(xiàn)程來(lái)執(zhí)行JavaScript代碼,實(shí)現(xiàn)各種業(yè)務(wù)邏輯,JavaScript也能到后端來(lái)?還用回調(diào)? 這不是胡鬧嗎?不過(guò)得小心,別被他把業(yè)務(wù)都給搶走了。
想到此處,Tomcat立刻去查看各個(gè)線(xiàn)程活干得怎么樣,有沒(méi)有人故意偷懶。
線(xiàn)程0x9527和0x7954又在吵架了,原因非常簡(jiǎn)單,他們倆都去做扣減庫(kù)存的操作:讀取庫(kù)存,修改庫(kù)存,寫(xiě)回?cái)?shù)據(jù)庫(kù)。
線(xiàn)程的并發(fā)執(zhí)行導(dǎo)致三個(gè)操作交織在了一起,***數(shù)據(jù)出現(xiàn)了不一致。
Tomcat說(shuō):“你們?cè)趺锤愕模瑸槭裁匆褞?kù)存讀出來(lái),直接update 庫(kù)存不行嗎? 讓MySQL老頭兒去保證正確性。要學(xué)會(huì)甩鍋啊!”
0x7954回答道:“沒(méi)辦法,張大胖的代碼就是這么寫(xiě)的,好像是業(yè)務(wù)要求的,扣減庫(kù)存之前要檢查庫(kù)存夠不夠。”
Tomcat一陣牙疼, 不由得想起了Redis的處理辦法, 對(duì)于每個(gè)讀寫(xiě)緩存的請(qǐng)求,Redis都把他們給排成了隊(duì),用一個(gè)線(xiàn)程挨個(gè)去處理,肯定沒(méi)有這個(gè)并發(fā)的問(wèn)題了。
可是自己這里不行啊,訪問(wèn)數(shù)據(jù)庫(kù)是極慢的操作,如果只用一個(gè)線(xiàn)程,一個(gè)個(gè)地處理請(qǐng)求,所有的請(qǐng)求都得等待,人類(lèi)會(huì)急死的。
沒(méi)辦法,Tomcat扔給他們倆一個(gè)Java對(duì)象:“這是一把鎖,以后誰(shuí)先搶到誰(shuí)才能執(zhí)行扣減庫(kù)存的三個(gè)操作。”
“如果搶不到怎么辦?”
“阻塞等待,別人釋放了鎖,JVM自然會(huì)喚醒你,然后再去搶! 什么時(shí)候搶到,什么時(shí)候執(zhí)行。”
分布式的鎖
張大胖覺(jué)得有點(diǎn)不對(duì)勁, 這幾天程序執(zhí)行怎么有點(diǎn)兒慢了呢?
他還以為是機(jī)器性能不夠,就申請(qǐng)了幾臺(tái)新機(jī)器,又安裝了幾個(gè)Tomcat,組成了一個(gè)集群。
這下可好,三個(gè)Tomcat, 每個(gè)Tomcat都有一把鎖來(lái)控制對(duì)庫(kù)存的訪問(wèn)。
在Tomcat這個(gè)JVM進(jìn)程內(nèi)部,同一個(gè)時(shí)刻只有一個(gè)幸運(yùn)兒線(xiàn)程可以扣減庫(kù)存,可是現(xiàn)在有三個(gè)Tomcat,出現(xiàn)了三個(gè)幸運(yùn)兒。
這三個(gè)幸運(yùn)兒在扣減庫(kù)存的時(shí)候,仍然會(huì)出現(xiàn)0x7954和0x9527那樣的錯(cuò)誤,只不過(guò)現(xiàn)在他們互不知曉,連吵架的機(jī)會(huì)都沒(méi)有了。
三個(gè)Tomcat都覺(jué)得頭大,在這個(gè)分布式的環(huán)境中,多個(gè)進(jìn)程在運(yùn)行,原來(lái)那種進(jìn)程內(nèi)的鎖已經(jīng)失效,當(dāng)務(wù)之急是找一個(gè)客觀、公正、獨(dú)立的第三方來(lái)實(shí)現(xiàn)鎖的功能。
MySQL提議: “到我這里來(lái)找鎖啊!”
“你那里能提供一個(gè)鎖服務(wù)? 暴露出來(lái)讓我們使用? ” Tomcat A問(wèn)道。
“不不,不是一個(gè)鎖服務(wù),我給你們一個(gè)數(shù)據(jù)庫(kù)表,這個(gè)表中的字段lock_name有個(gè)唯一性約束。”
“你的意思是,我們的線(xiàn)程每次想獲得鎖的時(shí)候,都去數(shù)據(jù)庫(kù)插入一條數(shù)據(jù)? ” Tomcat A 反映很快。
- insert into locks(lock_name,...) values('stock',...);
“對(duì)啊,我的唯一性約束只能保證一個(gè)成功,其他的都失敗,就相當(dāng)于獲得鎖了。 當(dāng)然那個(gè)線(xiàn)程的操作完成以后,需要釋放鎖。”
- delete from locks where lock_name='stock'
這倒是一個(gè)簡(jiǎn)單的辦法, 但也是一個(gè)重量級(jí)的辦法:每次獲得鎖都得訪問(wèn)一次數(shù)據(jù)庫(kù)!
假設(shè)來(lái)自TomcatA的0x9527捷足先登,插入了一條數(shù)據(jù),獲得了鎖, 那來(lái)自Tomcat B的0x7954操作肯定失敗,這時(shí)候0x7954該怎么辦? 能阻塞等待TomcatB來(lái)喚醒他嗎? 不行,因?yàn)檫BTomcatB 都不知道0x9527什么時(shí)候操作完成, 除非MySQL來(lái)通知各個(gè)Tomcat, 這是肯定不行的。
那0x7954@TomcatB只能做一件事情: 等待一會(huì)兒,然后重試! 如此循環(huán)下去,直到獲得鎖為止。
可是如果0x9527獲得了鎖,在執(zhí)行的過(guò)程中TomcatA 掛掉了,那數(shù)據(jù)庫(kù)記錄一直存在,無(wú)人刪除,那鎖就永遠(yuǎn)也無(wú)法釋放了! 還得弄一個(gè)清理者, 清理那些過(guò)期沒(méi)釋放的鎖, 這實(shí)在是太麻煩了。
Redis
這時(shí)候Redis說(shuō)道:“千萬(wàn)別上MySQL的賊船!他的辦法太笨重了,不就是找個(gè)第三方來(lái)保存鎖的信息嗎? 用我的緩存多好!”
“Redis這小子操作的是內(nèi)存,速度會(huì)快很多!” Tomcat B說(shuō)道。
“對(duì),MySQL不是給你們提供了一張表讓你們插入數(shù)據(jù)嗎? 我這里不用那么麻煩,你們Tomcat的線(xiàn)程,都可以嘗試到我的緩存中設(shè)置一個(gè)值,比如stock_lock=true, 誰(shuí)先設(shè)置成功,誰(shuí)就獲得了鎖,可以去扣減庫(kù)存。”
“ 如果有多個(gè)線(xiàn)程去設(shè)置,你能保證只有一個(gè)成功,別的都失敗嗎? ”
Redis拍拍胸脯: “絕對(duì)保證!”
(碼農(nóng)翻身老劉注:其實(shí)就是setnx命令了)
MySQL撇撇嘴:“和我的方案本質(zhì)上是一樣的。人家Tomcat 的線(xiàn)程對(duì)庫(kù)存做了修改以后,也還得去解鎖,去刪除這個(gè)stock_lock。”
Redis說(shuō):“我這里還能設(shè)置過(guò)期時(shí)間,如果Tomcat A上線(xiàn)程獲得了鎖,然后Tomcat A掛掉了, 到了過(guò)期時(shí)間,我就可以自動(dòng)把這個(gè)stock_lock刪除,別的線(xiàn)程又可以獲得鎖了!”
“嗯,是比MySQL先進(jìn),并且速度更快,我們還是用這個(gè)鎖吧。” 三個(gè)Tomcat都表示同意。
定期自動(dòng)釋放的問(wèn)題
“且慢,這個(gè)自動(dòng)刪除過(guò)期的鎖有問(wèn)題啊 !” MySQL突然反擊。
“什么問(wèn)題?” Redis沒(méi)想到數(shù)據(jù)庫(kù)老頭兒還想負(fù)隅頑抗。
“假設(shè)Tomcat A上的0x9527獲得了鎖, 去執(zhí)行扣減庫(kù)存的操作,然后由于某種原因被阻塞了,阻塞的時(shí)間超過(guò)了過(guò)期時(shí)間,鎖被你釋放掉了,最終還是會(huì)出現(xiàn)不一致!”
“你這是吹毛求疵,絕對(duì)是小概率事件!” Redis叫道!
“再說(shuō)了,用你的數(shù)據(jù)庫(kù)方案,也得定期清理那些鎖,道理是一樣的。”
行鎖
第二天, MySQL高興得去找Tomcat:“兄弟們,我昨天晚上和Quartz(一個(gè)著名的定時(shí)執(zhí)行框架)聊了半宿,他告訴了我一個(gè)新的用數(shù)據(jù)庫(kù)實(shí)現(xiàn)分布式鎖的辦法, 行鎖。”
“看到?jīng)]有, 通過(guò)添加一個(gè)for udpate ,這個(gè)SQL語(yǔ)句會(huì)把這一行給鎖定,就是獲得了鎖! 只要事務(wù)一提交,這個(gè)行鎖就自動(dòng)釋放了。”
“那沒(méi)有獲得鎖的別的線(xiàn)程呢? ”
“自然是阻塞住了,等到別的線(xiàn)程釋放了行鎖,它可以自動(dòng)去獲取,代碼中都不用循環(huán)重試,你看,之前的方案都做不到這一點(diǎn)吧。” MySQL說(shuō)道。
“那要是有個(gè)線(xiàn)程遲遲不釋放行鎖,會(huì)發(fā)生什么問(wèn)題?” Tomcat最關(guān)心這個(gè)。
“那其他線(xiàn)程都會(huì)等待,并且占用著數(shù)據(jù)庫(kù)連接不釋放,嗯,如果連接被占用得過(guò)多,連接池就要出問(wèn)題了......” MySQL底氣不足了,這可是個(gè)致命的問(wèn)題。
“哈哈,看你出的什么餿注意!還是用我的鎖吧!” Redis笑道。
“那人家Quartz為什么可以用?”MySQL不死心。
“估計(jì)Quartz業(yè)務(wù)單一,并且鎖釋放得很快,不會(huì)出問(wèn)題吧。”
CAS
正在這時(shí),Node.js悄悄地走過(guò)來(lái), 把數(shù)據(jù)庫(kù)老頭兒拉走了:“前輩,別給他們一般見(jiàn)識(shí),不就是扣減庫(kù)存嗎,用啥分布式鎖!, 咱們這么做:”
#old_num = 先獲取現(xiàn)有的庫(kù)存數(shù)量
- #new_num = #old_num - 10
- update stock set stock_num = #new_num where product_id=#product_id and stock_num = #old_num
MySQL眼前一亮, 是啊,每次把這個(gè)#old_num 作為條件傳進(jìn)去調(diào)用update語(yǔ)句,如果能成功,說(shuō)明在這段時(shí)間內(nèi)沒(méi)有別的線(xiàn)程更新庫(kù)存;
如果不成功,那就重新執(zhí)行這三條語(yǔ)句,直到成功為止, 就這個(gè)么簡(jiǎn)單, 完全不用鎖,真是太爽了。
過(guò)了幾天,Tomcat他們也聽(tīng)說(shuō)了這個(gè)方案, 驚訝地說(shuō):“這不就是我們Java常用的Compare And Set(CAS)嗎?”
總結(jié)
與此同時(shí),張大胖開(kāi)始做總結(jié):分布式鎖和進(jìn)程內(nèi)的鎖本質(zhì)上是一樣的。
1. 要互斥,同一時(shí)刻只能被一臺(tái)機(jī)器上的一個(gè)線(xiàn)程獲得。
2. ***支持阻塞,然后喚醒,這樣那些等待的線(xiàn)程不用循環(huán)重試。
3. ***可以重入(本文沒(méi)有涉及,參見(jiàn)《編程世界的那把鎖》)
4. 獲得鎖和釋放鎖速度要快
5. 對(duì)于分布式鎖,需要找到一個(gè)集中的“地方”(數(shù)據(jù)庫(kù),Redis, Zookeeper等)來(lái)保存鎖,這個(gè)地方***是高可用的。
6. 考慮到“不可靠的”分布式環(huán)境, 分布式鎖需要設(shè)定過(guò)期時(shí)間
7. CAS的思想很重要。
【本文為51CTO專(zhuān)欄作者“劉欣”的原創(chuàng)稿件,轉(zhuǎn)載請(qǐng)通過(guò)作者微信公眾號(hào)coderising獲取授權(quán)】