寫在前面的
在大型的系統(tǒng)程序中,分布式是繞不開的話題,無論成熟的上市公司,還是剛剛起步的創(chuàng)業(yè)公司,沒有一家公司對外的服務(wù)業(yè)務(wù),可以部署在一臺服務(wù)器,啟動一個程序就能滿足日常需求的。多個程序共同提供服務(wù)一定會產(chǎn)生資源的競爭,也會在同一個時刻對一個數(shù)據(jù)進(jìn)行修改,分布式鎖正是為了解決分布式系統(tǒng)中資源的競爭問題。
Redis分布式鎖
我將按照分布式問題的產(chǎn)生、解決和優(yōu)化的順序,分成六部分介紹Redis的分布式鎖。下面跟隨著我的介紹,理清思路,按照我的敘述順序依次思考,堅持讀到最后一定會有幫助的。正是因為有了這樣的順序的思考和問題的產(chǎn)生,Redis的分布式鎖才會越來越強(qiáng)大,越來越完善。
一個業(yè)務(wù)背景
假設(shè)我們的系統(tǒng)是圖書館管理系統(tǒng),部署在兩個不同服務(wù)器上(也就是啟動了兩個相同服務(wù)),兩個服務(wù)為服務(wù)A和服務(wù)B。現(xiàn)在圖書館為了鼓勵更多的會員參與到讀書中,搞了一次活動,會員可以登入到系統(tǒng)中使用自己的系統(tǒng)積分兌換禮品,活動規(guī)定7個積分可以兌換一個紀(jì)念書簽。
此時一位同學(xué)Q登入到系統(tǒng)中,查詢到自己目前積分為10,決定申請兌換紀(jì)念書簽,但是在兌換過程中,因為網(wǎng)絡(luò)的問題,同學(xué)Q連續(xù)點擊了兩次兌換按鈕,并且這兩次兌換請求恰好被分別發(fā)送到了服務(wù)A和服務(wù)B上。
最終正確的結(jié)果應(yīng)該是,同學(xué)Q余下3個積分,并且成功兌換一個紀(jì)念書簽。
競爭狀態(tài)
在上面的業(yè)務(wù)場景中(假定紀(jì)念書簽無限多),服務(wù)A和服務(wù)B都接受到了兌換紀(jì)念書簽的請求,顯然兩個服務(wù)只能有一個成功完成兌換服務(wù),而另外一個服務(wù)因為紀(jì)念書簽已經(jīng)被兌換,同學(xué)Q余下的3個積分不能繼續(xù)兌換而返回兌換失敗。
這里涉及到服務(wù)A和服務(wù)B是如何處理請求,并且為什么會產(chǎn)生競爭?如下圖,服務(wù)A接受到請求正常處理,查詢同學(xué)Q的當(dāng)前積分為10,接下扣除7個積分兌換紀(jì)念書簽,數(shù)據(jù)庫中同學(xué)Q的信息更新成功。
而在沒有分布式鎖控制的情況下,問題出現(xiàn)在服務(wù)B上,圖中紅色的請求(服務(wù)B查詢同學(xué)Q當(dāng)前積分),如果發(fā)起在綠色圓點以下的時間區(qū)間,此時查詢到的同學(xué)Q剩余3積分,那么服務(wù)B很正常會因為積分不夠兌換,直接返回失??;但是如果紅色的請求恰好發(fā)生在紅色圓點與綠色圓點之間的時間區(qū)間上,此時服務(wù)A正在處理尚未結(jié)束,數(shù)據(jù)庫中同學(xué)Q的積分仍舊是10,服務(wù)B讀取Q的積分為10后,將繼續(xù)正常執(zhí)行兌換流程,之后服務(wù)A和服務(wù)B都將會兌換成功,并且同學(xué)Q的積分會被扣除14,最終積分為-4,并且成功兌換了兩個紀(jì)念書簽。

Redis分布式鎖解決
問題的解決方式是在紅色圓點與綠色圓點的時間區(qū)間上(查詢同學(xué)Q積分的時間點到成功更新同學(xué)Q積分的時間點),應(yīng)該只允許服務(wù)A執(zhí)行,而服務(wù)B等待,直到綠色圓點的時間點(也就是服務(wù)A執(zhí)行完成)到了,服務(wù)B再執(zhí)行兌換邏輯。這時就需要分布式鎖,當(dāng)服務(wù)A和服務(wù)B接受到請求時,首先申請分布式鎖,只有獲得分布式鎖的服務(wù)才能執(zhí)行兌換邏輯,并且在執(zhí)行兌換完成之后釋放鎖,另外一個服務(wù)等待鎖釋放之后獲取鎖,進(jìn)入兌換邏輯。
Redis分布式鎖原理是通過命令SETNX key value實現(xiàn)的。SETNX命令只在鍵 key不存在的情況下, 將鍵key的值設(shè)置為value;若鍵key已經(jīng)存在,則SETNX命令不做任何動作。顯然,服務(wù)A和服務(wù)B同時執(zhí)行SETNX命令,只有一個服務(wù)可以設(shè)值成功,當(dāng)服務(wù)兌換邏輯執(zhí)行結(jié)束,再刪除鍵key(釋放鎖),這樣下一次服務(wù)執(zhí)行SETNX命令時可以正常申請到鎖。
Redis分布式鎖改進(jìn)
服務(wù)A獲得了分布式鎖,服務(wù)B等待鎖。如果這時服務(wù)A因為某些原因無法釋放分布式鎖(無法執(zhí)行刪除鍵key的操作),那么服務(wù)B(包括將來恢復(fù)的服務(wù)A)將永遠(yuǎn)無法獲得鎖去執(zhí)行兌換邏輯。
所以在執(zhí)行SETNX命令時,需要對設(shè)置的鍵key加上一個過期時間,當(dāng)?shù)竭_(dá)設(shè)定的過期時間,服務(wù)A仍舊沒有釋放鎖,此時Redis主動刪除鍵key完成對鎖的釋放。這樣即使服務(wù)A最終無法釋放分布式鎖,將來服務(wù)仍舊可以獲得鎖正常運行。設(shè)置過期時間在Redis中執(zhí)行命令SET key value NX PX milliseconds,其中PX milliseconds代表將鍵的過期時間設(shè)置為milliseconds毫秒,NX代表只在鍵不存在時,才對鍵進(jìn)行設(shè)置操作。
這里順便介紹一下Redis是如何刪除過期鍵的。Redis將過期時間存儲在一個過期字典中,處理過期鍵的策略分為兩種,積極的方式(an active way)和消極的方式(a passive way):
- 積極的方式:定時掃描過期字典,判斷存儲在過期字典中的鍵key是否已經(jīng)過期,清理過期的鍵key。
- 消極的方式:平時對過期的鍵不進(jìn)行處理,只有當(dāng)Redis接受到訪問請求,獲取鍵key時候,去過期字典中檢查鍵key是否已經(jīng)過期,如果過期刪除鍵key,未過期則正常返回。
兩種策略都有利弊,積極的方式可以保證過期的鍵被盡快清除,節(jié)省內(nèi)存,卻對CPU不友好,占用了系統(tǒng)CPU時間,無疑會降低系統(tǒng)的吞吐量;消極的方式剛好相反,節(jié)約了CPU時間,卻因為大量過期的鍵不能及時清除導(dǎo)致內(nèi)存浪費。
Redis結(jié)合了積極和消極兩種方式,最大限度利用兩者的優(yōu)點,規(guī)避缺陷。下面介紹一個Redis官網(wǎng)介紹的清理過期鍵的算法——A trivial probabilistic algorithm。算法的Redis官網(wǎng)介紹
- 算法執(zhí)行每秒掃描10次;
- 在過期字典中隨機(jī)測試20個鍵
- 刪除20個鍵中過期的鍵
- 如果刪除的過期鍵數(shù)量占測試的總鍵數(shù)百分率超過25%(20*25%=5,換言之如果過期鍵超過5個),則返回第2步再次執(zhí)行。
Redis分布式鎖繼續(xù)改進(jìn)
目前為止Redis的分布式鎖已經(jīng)出具規(guī)模了,服務(wù)A獲得鎖,即使無法釋放,仍舊可以可以依賴過期時間的設(shè)置,由Redis自動清除??墒牵绻鸕edis自動釋放服務(wù)A持有的分布式鎖之后,服務(wù)A又恢復(fù)了正常功能,那么當(dāng)它嘗試去釋放鎖的時候,發(fā)現(xiàn)鎖已經(jīng)不在了,當(dāng)然這還不會影響到系統(tǒng)的正確性。但是如果在服務(wù)A嘗試釋放鎖之前,服務(wù)B獲得了鎖呢?
如下時序圖:

服務(wù)A首先獲得鎖,之后在綠色圓點的時間點被Redis自動釋放,接著服務(wù)B獲得鎖,而服務(wù)A恢復(fù)運行后在藍(lán)色圓點時間點釋放了鎖,注意此時服務(wù)A釋放的是服務(wù)B所有的鎖。那么從藍(lán)色圓點的時間點起,服務(wù)B就處在沒有分布式鎖保護(hù)的狀態(tài)下工作,如果另外的請求在服務(wù)B未執(zhí)行完成之前到來,就會出現(xiàn)在第二部分我們說的的競爭狀態(tài),這樣系統(tǒng)就可以產(chǎn)生錯誤。
由此看來,我們的Redis分布式鎖仍舊需要繼續(xù)改進(jìn),保證服務(wù)A只能刪除自己創(chuàng)建的鎖,而不可以刪除服務(wù)B的鎖。如何實現(xiàn)呢?回到最初的Redis命令SET key value NX PX milliseconds,在設(shè)置鍵key的時候,生成一個標(biāo)識字符串token設(shè)置在value中,并記錄下token,當(dāng)釋放鎖的時候比較Redis中鍵key下的value是否與token一致,一致則表明是當(dāng)前服務(wù)所有的鎖,不一致則是其他服務(wù)產(chǎn)生的鎖。
釋放鎖時,需要對Redis執(zhí)行查詢,再比較,最后刪除的邏輯,這些操作執(zhí)行要保證原子性,即要么全部執(zhí)行,要么都不執(zhí)行,并且在執(zhí)行的過程中,其他線程不能打斷也不能修改值。從Redis 2.6.0版本起,引入了Lua腳本對Redis的操作,Lua腳本具有原子性,那么上面的操作邏輯放在Lua腳本中實現(xiàn)就在合適不過了。Lua釋放分布式鎖腳本(Redis官網(wǎng)分布式鎖),如下圖:

Redis分布式鎖拓展
上面介紹Redis分布式鎖,都是從問題出發(fā),探討如何設(shè)計和實現(xiàn)分布式鎖,介紹原理以及解決方法。現(xiàn)在從Redis服務(wù)器角度出發(fā),假如Redis服務(wù)器突然宕機(jī),不再能提供分布式鎖服務(wù),又應(yīng)該如何保證服務(wù)正確的前提下恢復(fù)呢?
- Redis將數(shù)據(jù)存儲于內(nèi)存中,一旦宕機(jī)內(nèi)存中數(shù)據(jù)消失,為了重啟時可以恢復(fù)數(shù)據(jù),Redis開發(fā)了一套自己的持久化方式,持久化設(shè)置是解決服務(wù)器恢復(fù)時數(shù)據(jù)正確的一種方式;
- 另外對于分布式鎖,因為其具有過期時間,可以采用延時重啟策略解決數(shù)據(jù)問題。就是服務(wù)器宕機(jī)之后,并不是馬上重啟,而是過一段時間,等待所有的分布式鎖均過期后,再進(jìn)行重啟(如果提前重啟,因為Redis中不再具有鎖,那么服務(wù)可能在原來持有鎖的服務(wù)尚未執(zhí)行完成就獲得了鎖,如此就可能產(chǎn)生分布式數(shù)據(jù)錯誤)。這樣所有在宕機(jī)之前具有鎖的服務(wù)可以正常執(zhí)行完成,而其他服務(wù)因為Redis沒有恢復(fù)不能獲取鎖所以不會執(zhí)行。