分布式鎖是大型應用中最常見的功能之一,基於Redis實現分布式鎖的方式有很多。本文介紹並分析常見的分布式鎖實現方式,之後結合阿里巴巴集團在使用Tair(企業版)和分布式鎖方面的業務經驗,介紹使用Tair(企業版)實現高效能分布式鎖的實踐方案。
背景資訊
分布式鎖及其應用情境
應用開發時,如果需要在同進程內的不同線程並發訪問某項資源,可以使用各種互斥鎖、讀寫鎖;如果一台主機上的多個進程需要並發訪問某項資源,則可以使用進程間同步的原語,例如訊號量、管道、共用記憶體等。但如果多台主機需要同時訪問某項資源,就需要使用一種在全域可見並具有互斥性的鎖了。這種鎖就是分布式鎖,可以在分布式情境中對資源加鎖,避免競爭資源引起的邏輯錯誤。
分布式鎖的特性
互斥性
在任意時刻,只有一個用戶端持有鎖。
不死結
分布式鎖本質上是一個基於租約(Lease)的租借鎖,如果用戶端獲得鎖後自身出現異常,鎖能夠在一段時間後自動釋放,資源不會被鎖死。
一致性
硬體故障或網路異常等外部問題,以及慢查詢、自身缺陷等內部因素都可能導致Redis發生高可用切換,replica提升為新的master。此時,如果業務對互斥性的要求非常高,鎖需要在切換到新的master後保持原狀態。
使用原生Redis實現分布式鎖
該部分介紹的實現方式同樣適用於Redis開源版。
加鎖
在Redis中加鎖非常簡便,直接使用SET命令即可。樣本及關鍵選項說明如下:
SET resource_1 random_value NX EX 5
表 1. 關鍵選項說明
參數/選項
說明
resource_1
分布式鎖的key,只要這個key存在,相應的資源就處於加鎖狀態,無法被其它用戶端訪問。
random_value
一個隨機字串,不同用戶端設定的值不能相同。
EX
設定到期時間,單位為秒。您也可以使用PX選項設定單位為毫秒的到期時間。
NX
如果需要設定的key在Redis中已存在,則取消設定。
範例程式碼為resource_1這個key設定了5秒的到期時間,如果用戶端不釋放這個key,5秒後key將到期,鎖就會被系統回收,此時其它用戶端就能夠再次為資源加鎖並訪問資源了。
解鎖
解鎖一般使用DEL命令,但可能存在下列問題。
t1時刻,App1設定了分布式鎖resource_1,到期時間為3秒。
App1由於程式慢等原因等待超過了3秒,而resource_1已經在t2時刻被釋放。
t3時刻,App2獲得這個分布式鎖。
App1從等待中恢複,在t4時刻運行
DEL resource_1
將App2持有的分布式鎖釋放了。
從上述過程可以看出,一個用戶端設定的鎖,必須由自己解開。因此用戶端需要先使用GET命令確認鎖是不是自己設定的,然後再使用DEL解鎖。在Redis中通常需要用Lua指令碼來實現自鎖自解:
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
續租
當用戶端發現在鎖的租期內無法完成操作時,就需要延長鎖的持有時間,進行續租(renew)。同解鎖一樣,用戶端應該只能續租自己持有的鎖。在Redis中可使用如下Lua指令碼來實現續租:
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("expire",KEYS[1], ARGV[2]) else return 0 end
使用Tair實現分布式鎖
使用Tair記憶體型或持久記憶體型執行個體的String增強命令,無需Lua即可實現分布式鎖。
加鎖
加鎖方式與原生Redis相同,使用SET命令:
SET resource_1 random_value NX EX 5
解鎖
直接使用Tair(企業版)的CAD命令即可實現優雅而高效的解鎖:
/* if (GET(resource_1) == my_random_value) DEL(resource_1) */ CAD resource_1 my_random_value
續租
續租可以直接使用CAS命令實現:
CAS resource_1 my_random_value my_random_value EX 10
說明CAS命令不會檢查新設定的value和原value是否相同。
基於Jedis的範例程式碼
定義CAS/CAD命令
enum TairCommand implements ProtocolCommand { CAD("CAD"), CAS("CAS"); private final byte[] raw; TairCommand(String alt) { raw = SafeEncoder.encode(alt); } @Override public byte[] getRaw() { return raw; } }
加鎖
public boolean acquireDistributedLock(Jedis jedis,String resourceKey, String randomValue, int expireTime) { SetParams setParams = new SetParams(); setParams.nx().ex(expireTime); String result = jedis.set(resourceKey,randomValue,setParams); return "OK".equals(result); }
解鎖
public boolean releaseDistributedLock(Jedis jedis,String resourceKey, String randomValue) { jedis.getClient().sendCommand(TairCommand.CAD,resourceKey,randomValue); Long ret = jedis.getClient().getIntegerReply(); return 1 == ret; }
續租
public boolean renewDistributedLock(Jedis jedis,String resourceKey, String randomValue, int expireTime) { jedis.getClient().sendCommand(TairCommand.CAS,resourceKey,randomValue,randomValue,"EX",String.valueOf(expireTime)); Long ret = jedis.getClient().getIntegerReply(); return 1 == ret; }
如何保障一致性
Redis的主從同步(replication)是非同步進行的,如果向master發送請求修改了資料後master突然出現異常,發生高可用切換,緩衝區的資料可能無法同步到新的master(原replica)上,導致資料不一致。如果丟失的資料跟分布式鎖有關,則會導致鎖的機制出現問題,從而引起業務異常。下文介紹三種保障一致性的方法。
紅鎖是Redis作者提出的一致性解決方案。紅鎖的本質是一個機率問題:如果一個主從架構的Redis在高可用切換期間丟失鎖的機率是
k%
,那麼相互獨立的N個Redis同時丟失鎖的機率是多少?如果用紅鎖來實現分布式鎖,那麼丟鎖的機率是(k%)^N
。鑒於Redis極高的穩定性,此時的機率已經完全能滿足產品的需求。說明紅鎖的實現並非這樣嚴格,一般保證
M(1<M=<N)
個同時鎖上即可,但通常仍舊可以滿足需求。紅鎖的問題在於:
加鎖和解鎖的延遲較大。
難以在叢集版或者標準版(主從架構)的Redis執行個體中實現。
佔用的資源過多,為了實現紅鎖,需要建立多個互不相關的雲Redis執行個體或者自建Redis。
使用WAIT命令。
Redis的WAIT命令會阻塞當前用戶端,直到這條命令之前的所有寫入命令都成功從master同步到指定數量的replica,命令中可以設定單位為毫秒的等待逾時時間。在雲Redis版中使用WAIT命令提高分布式鎖一致性的樣本如下:
SET resource_1 random_value NX EX 5 WAIT 1 5000
使用以上代碼,用戶端在加鎖後會等待資料成功同步到replica才繼續進行其它操作,最大等待時間為5000毫秒。執行WAIT命令後如果返回結果是1則表示同步成功,無需擔心資料不一致。相比紅鎖,這種實現方法極大地降低了成本。
需要注意的是:
WAIT只會阻塞發送它的用戶端,不影響其它用戶端。
WAIT返回正確的值表示設定的鎖成功同步到了replica,但如果在正常返回前發生高可用切換,資料還是可能丟失,此時WAIT只能用來提示同步可能失敗,無法保證資料不丟失。您可以在WAIT返回異常值後重新加鎖或者進行資料校正。
解鎖不一定需要使用WAIT,因為鎖只要存在就能保持互斥,延遲刪除不會導致邏輯問題。
使用Tair
Tair的CAS/CAD命令可以極大降低分布式鎖的開發和管理成本,提升鎖的效能。
Tair記憶體型執行個體能提供三倍於原生Redis的效能,即使是大並發的分布式鎖也不會影響正常的執行個體服務。
Tair持久記憶體型執行個體基於持久記憶體技術,掉電資料不丟失,每個寫操作將在持久化成功之後返回,保證了資料的即時持久化。同時,持久記憶體版型執行個體還支援配置主備執行個體間同步方式為半同步,保證寫入資料並同步至備節點後,才成功返回用戶端(若出現備節點故障、網路異常等情況會降級為非同步同步),保證高可用切換後資料不丟失。