分布式锁设计与实现
分布式鎖的實現由多種方式,但是不管怎樣,分布式鎖一般要有以下特點:
?排他性:任意時刻,只能有一個client能獲取到鎖
?容錯性:分布式鎖服務一般要滿足AP,也就是說,只要分布式鎖服務集群節點大部分存活,client就可以進行加鎖解鎖操作
?避免死鎖:分布式鎖一定能得到釋放,即使client在釋放之前崩潰或者網絡不可達
除了以上特點之外,分布式鎖最好也能滿足可重入、高性能、阻塞鎖特性(AQS這種,能夠及時從阻塞狀態喚醒)等
分布式鎖方案對比
| 一致性算法 | 無 | paxos/ZAB | raft |
| CAP | AP | CP | CP/AP |
| 高可用 | 主從 | N+1可用(奇數個) | N+1可用 |
| 接口類型 | 客戶端 | 客戶端 | http/grpc |
| 實現 | set命令 | 臨時節點 | restful API |
關于分布式一致性協議,參考我之前的整理:幾種常見的分布式一致性協議介紹
- redis無法保證數據一致性
- zk的性能比較差,擴展能力,社區活躍度低于etcd(ZK比較成熟)
- 可以選擇基于etcd
DB鎖
在數據庫新建一張表用于控制并發控制,表結構可以如下所示:
CREATE TABLE `lock_table` ( `id` int(11) unsigned NOT NULL COMMENT '主鍵', `key_id` bigint(20) NOT NULL COMMENT '分布式key', `memo` varchar(43) NOT NULL DEFAULT '' COMMENT '可記錄操作內容', `update_time` datetime NOT NULL COMMENT '更新時間', PRIMARY KEY (`id`,`key_id`), UNIQUE KEY `key_id` (`key_id`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8;key_id作為分布式key用來并發控制,memo可用來記錄一些操作內容(比如memo可用來支持重入特性,標記下當前加鎖的client和加鎖次數)。將key_id設置為唯一索引,保證了針對同一個key_id只有一個加鎖(數據插入)能成功。此時lock和unlock偽代碼如下:
def lock : exec sql: insert into lock_table(key_id, memo, update_time) values (key_id, memo, NOW()) if result == true : return true else : return false def unlock : exec sql: delete from lock_table where key_id = 'key_id' and memo = 'memo'注意,偽代碼中的lock操作是非阻塞鎖,也就是tryLock,如果想實現阻塞(或者阻塞超時)加鎖,只修反復執行lock偽代碼直到加鎖成功為止即可。基于DB的分布式鎖其實有一個問題,那就是如果加鎖成功后,client端宕機或者由于網絡原因導致沒有解鎖,那么其他client就無法對該key_id進行加鎖并且無法釋放了。為了能夠讓鎖失效,需要在應用層加上定時任務,去刪除過期還未解鎖的記錄,比如刪除2分鐘前未解鎖的偽代碼如下:
def clear_timeout_lock : exec sql : delete from lock_table where update_time < ADDTIME(NOW(),'-00:02:00')因為單實例DB的TPS一般為幾百,所以基于DB的分布式性能上限一般也是1k以下,一般在并發量不大的場景下該分布式鎖是滿足需求的,不會出現性能問題。不過DB作為分布式鎖服務需要考慮單點問題,對于分布式系統來說是不允許出現單點的,一般通過數據庫的同步復制,以及使用vip切換Master就能解決這個問題。
以上DB分布式鎖是通過insert來實現的,如果加鎖的數據已經在數據庫中存在,那么用select xxx where key_id = xxx for udpate方式來做也是可以的。
Redis鎖
Redis鎖是通過以下命令對資源進行加鎖:
set key_id key_value NX PX expireTime其中,set nx命令只會在key不存在時給key進行賦值,px用來設置key過期時間,key_value一般是隨機值,用來保證釋放鎖的安全性(釋放時會判斷是否是之前設置過的隨機值,只有是才釋放鎖)。由于資源設置了過期時間,一定時間后鎖會自動釋放。
set nx保證并發加鎖時只有一個client能設置成功(Redis內部是單線程,并且數據存在內存中,也就是說redis內部執行命令是不會有多線程同步問題的),此時的lock/unlock偽代碼如下:
def lock: if (redis.call('set', KEYS[1], ARGV[1], 'ex', ARGV[2], 'nx')) then return true end return false def unlock: if (redis.call('get', KEYS[1]) == ARGV[1]) then redis.call('del', KEYS[1]) return true end return false分布式鎖服務中的一個問題
如果一個獲取到鎖的client因為某種原因導致沒能及時釋放鎖,并且redis因為超時釋放了鎖,另外一個client獲取到了鎖,此時情況如下圖所示:
那么如何解決這個問題呢,一種方案是引入鎖續約機制,也就是獲取鎖之后,釋放鎖之前,會定時進行鎖續約,比如以鎖超時時間的1/3為間隔周期進行鎖續約。
關于開源的redis的分布式鎖實現有很多,比較出名的有redisson[1]、百度的dlock[2],關于分布式鎖,筆者也寫了一個簡易版的分布式鎖redis-lock,主要是增加了鎖續約和可同時針對多個key加鎖的機制。
對于高可用性,一般可以通過集群或者master-slave來解決,redis鎖優勢是性能出色,劣勢就是由于數據在內存中,一旦緩存服務宕機,鎖數據就丟失了。像redis自帶復制功能,可以對數據可靠性有一定的保證,但是由于復制也是異步完成的,因此依然可能出現master節點寫入鎖數據而未同步到slave節點的時候宕機,鎖數據丟失問題。
?
ETCD 分布式鎖的基本原理
分布式環境下,多臺機器上多個進程對同一個共享資源(數據、文件等)進行操作,如果不做互斥,就有可能出現“余額扣成負數”,或者“商品超賣”的情況。為了解決這個問題,需要分布式鎖服務。
首先,來看一下分布式鎖應該具備哪些條件:
- 互斥性:在任意時刻,對于同一個鎖,只有一個客戶端能持有,從而保證只有一個客戶端能夠操作同一個共享資源;
- 安全性:即不會形成死鎖,當一個客戶端在持有鎖的期間崩潰而沒有主動解鎖的情況下,其持有的鎖也能夠被正確釋放,并保證后續其它客戶端能加鎖;
- 可用性:當提供鎖服務的節點發生宕機等不可恢復性故障時,“熱備” 節點能夠接替故障的節點繼續提供服務,并保證自身持有的數據與故障節點一致。
- 對稱性:對于任意一個鎖,其加鎖和解鎖必須是同一個客戶端,即,客戶端 A 不能把客戶端 B 加的鎖給解了。
Etcd 實現分布式鎖的基礎
Etcd 的高可用性、強一致性不必多說,前面章節中已經闡明,本節主要介紹 Etcd 支持的以下機制:Watch 機制、Lease 機制、Revision 機制和 Prefix 機制,正是這些機制賦予了 Etcd 實現分布式鎖的能力。
- Lease機制:即租約機制(TTL,Time To Live),Etcd 可以為存儲的 key-value 對設置租約,當租約到期,key-value 將失效刪除;同時也支持續約,通過客戶端可以在租約到期之前續約,以避免 key-value 對過期失效。Lease 機制可以保證分布式鎖的安全性,為鎖對應的 key 配置租約,即使鎖的持有者因故障而不能主動釋放鎖,鎖也會因租約到期而自動釋放。
- Revision機制:每個 key 帶有一個 Revision 號,每進行一次事務加一,因此它是全局唯一的,如初始值為 0,進行一次 put(key, value),key 的 Revision 變為 1;同樣的操作,再進行一次,Revision 變為 2;換成 key1 進行 put(key1, value) 操作,Revision 將變為 3。這種機制有一個作用:通過 Revision 的大小就可以知道進行寫操作的順序。在實現分布式鎖時,多個客戶端同時搶鎖,根據 Revision 號大小依次獲得鎖,可以避免 “羊群效應” (也稱 “驚群效應”),實現公平鎖。
- Prefix機制:即前綴機制,也稱目錄機制。例如,一個名為 /mylock 的鎖,兩個爭搶它的客戶端進行寫操作,實際寫入的 key 分別為:key1="/mylock/UUID1",key2="/mylock/UUID2",其中,UUID 表示全局唯一的 ID,確保兩個 key 的唯一性。很顯然,寫操作都會成功,但返回的 Revision 不一樣,那么,如何判斷誰獲得了鎖呢?通過前綴 /mylock 查詢,返回包含兩個 key-value 對的的 KeyValue 列表,同時也包含它們的 Revision,通過 Revision 大小,客戶端可以判斷自己是否獲得鎖,如果搶鎖失敗,則等待鎖釋放(對應的 key 被刪除或者租約過期),然后再判斷自己是否可以獲得鎖;
- Watch機制:即監聽機制,Watch 機制支持 Watch 某個固定的 key,也支持 Watch 一個范圍(前綴機制),當被 Watch 的 key 或范圍發生變化,客戶端將收到通知;在實現分布式鎖時,如果搶鎖失敗,可通過 Prefix 機制返回的 KeyValue 列表獲得 Revision 比自己小且相差最小的 key(稱為 pre-key),對 pre-key 進行監聽,因為只有它釋放鎖,自己才能獲得鎖,如果 Watch 到 pre-key 的 DELETE 事件,則說明 pre-key 已經釋放,自己已經持有鎖。
etcd實現分布式鎖流程
下面描述使用 Etcd 實現分布式鎖的業務流程,假設對某個共享資源設置的鎖名為:/lock/mylock
步驟 1: 準備
客戶端連接 Etcd,以?/lock/mylock?為前綴創建全局唯一的 key,假設第一個客戶端對應的?key="/lock/mylock/UUID1",第二個為?key="/lock/mylock/UUID2";客戶端分別為自己的 key 創建租約 - Lease,租約的長度根據業務耗時確定,假設為 15s;
步驟 2: 創建定時任務作為租約的“心跳”
當一個客戶端持有鎖期間,其它客戶端只能等待,為了避免等待期間租約失效,客戶端需創建一個定時任務作為“心跳”進行續約。此外,如果持有鎖期間客戶端崩潰,心跳停止,key 將因租約到期而被刪除,從而鎖釋放,避免死鎖。
步驟 3: 客戶端將自己全局唯一的 key 寫入 Etcd
進行 put 操作,將步驟 1 中創建的 key 綁定租約寫入 Etcd,根據 Etcd 的 Revision 機制,假設兩個客戶端 put 操作返回的 Revision 分別為 1、2,客戶端需記錄 Revision 用以接下來判斷自己是否獲得鎖。
步驟 4: 客戶端判斷是否獲得鎖
客戶端以前綴?/lock/mylock?讀取 keyValue 列表(keyValue 中帶有 key 對應的 Revision),判斷自己 key 的 Revision 是否為當前列表中最小的,如果是則認為獲得鎖;否則監聽列表中前一個 Revision 比自己小的 key 的刪除事件,一旦監聽到刪除事件或者因租約失效而刪除的事件,則自己獲得鎖。
步驟 5: 執行業務
獲得鎖后,操作共享資源,執行業務代碼。
步驟 6: 釋放鎖
完成業務流程后,刪除對應的key釋放鎖。
參考:http://www.xuyasong.com/?p=1789
參考:https://www.jianshu.com/p/8a4dc6d900cf
?
與50位技術專家面對面20年技術見證,附贈技術全景圖總結
- 上一篇: 接口设计原则
- 下一篇: 浅拷贝与深拷贝的区别