@鄭昀匯總
關鍵詞:
並發控制
防止並發
英文關鍵詞:
Distributed Lock
Distributed Lock Manager
電商目的:
保證整個(分布式)系統內對一個重要事物(訂單,賬戶等)的有效操作線程 ,同一時間內有且只有一個。比如交易中心有N台服務器,訂單中心有M台服務器,如何保證一個訂單的同一筆支付處理,一個賬戶的同一筆充值操作是原子性的。
基於哪些服務實現分布式鎖?
- memcache
- ZooKeeper
- Redis
- Hazelcast
- google Chubby
基於memcache的分布式鎖
memcache的所有命令都是原子性的(
internally atomic),所以利用它的add命令即可。
鄭昀列出一段簡單但埋下了問題的偽碼:
if (cache.add("lock:{orderid}", currenttimestamp, expiredtime)) {// 已獲得鎖,繼續try{do something}catch{...}cache.delete("lock.{orderid}")} else {// 或等待鎖超時,或重試,或返回}
上面代碼所暴露的常見性問題
1)如持有鎖的線程異常退出或宕機,
鎖並沒有釋放;
2)設置了key的expire,那么如果有新線程在key過期后拿到了新的鎖,原來超時的線程回來時,如果不經判斷會誤認為那是它持有的鎖,
會誤刪鎖。
1)強制釋放
在鍵值上做文章,存入的是
current UNIX time+lock timeout+1
,這樣其他線程可以通過鎖的鍵值對應的時間戳來判斷這種情況是否發生了,如果當前的時間已經大於lock.{orderid}的鍵值,說明該鎖已失效,可以被重新使用。
2)釋放自己持有的鎖時,先檢查是否已超時
持有鎖的線程在解鎖之前應該再檢查一次自己的鎖是否已經超時,再去做DELETE操作,因為可能客戶端因為某個耗時的操作而掛起,操作完的時候鎖因為超時已經被別人獲得,這時就不必解鎖了。
上面的辦法會引入新問題:
如果多個線程檢測到鎖超時,都嘗試去釋放鎖,那么就會出現
競態條件(
race condition)。
場景是,
- C0操作超時了,但它還持有着鎖,C1和C2讀取lock.{orderid}檢查時間戳,先后發現超時了。
- C1 發送delete lock.{orderid},
- C1 發送set lock.{orderid} 且成功。
- C2 發送delete lock.{orderid},
- C2 發送set lock.{orderid} 且成功。
這樣,C1和C2都認為自己拿到了鎖。
如果比較在意這種競態條件,那么推薦使用基於zookeeper或redis的解決方案。
基於ZooKeeper的分布式鎖
這主要得益於ZooKeeper為我們保證了數據的強一致性,即用戶只要完全相信每時每刻,zk集群中任意節點(一個zk server)上的相同znode的數據一定是相同的。鎖服務可以分為兩類,
一個是保持獨占,另一個是控制時序。
所謂保持獨占,就是所有試圖來獲取這個鎖的客戶端,最終只有一個可以成功獲得這把鎖。通常的做法是把zk上的一個znode看作是一把鎖,通過 create znode的方式來實現。所有客戶端都去創建 /distributed_lock 節點,最終成功創建的那個客戶端也就擁有了這把鎖。
控制時序,就是所有試圖獲取這個鎖的客戶端,最終都是會被安排執行,只是有個全局時序。做法和上面基本類似,只是這里 /distributed_lock 已經預先存在,客戶端在它下面創建臨時有序節點(這個可以通過節點的屬性控制:CreateMode.EPHEMERAL_SEQUENTIAL來指 定)。zk的父節點(/distributed_lock)維持一份sequence,保證子節點創建的時序性,從而形成了每個客戶端的全局時序。
ZooKeeper 里實現分布式鎖的基本邏輯:
- 客戶端調用create()方法創建名為“_locknode_/guid-lock-”的節點,需要注意的是,這里節點的創建類型需要設置為EPHEMERAL_SEQUENTIAL。
- 客戶端調用getChildren(“_locknode_”)方法來獲取所有已經創建的子節點,同時在這個節點上注冊上子節點變更通知的Watcher。
- 客戶端獲取到所有子節點path之后,如果發現自己在步驟1中創建的節點是所有節點中序號最小的,那么就認為這個客戶端獲得了鎖。
- 如果在步驟3中發現自己並非是所有子節點中最小的,說明自己還沒有獲取到鎖,就開始等待,直到下次子節點變更通知的時候,再進行子節點的獲取,判斷是否獲取鎖。
釋放鎖的過程相對比較簡單,就是刪除自己創建的那個子節點即可。
基於Redis的分布式鎖
接着前面的競態條件說,同樣的場景下,使用Redis的SETNX(即SET if Not eXists,類似於memcache的add)和
GETSET(先寫新值,返回舊值,原子性操作,可以用於分辨是不是首次操作)命令便可迎刃而解:
- C3發送SETNX lock.{orderid} 想要獲得鎖,由於C0還持有鎖,所以Redis返回給C3一個0,
- C3發送GET lock.{orderid} 以檢查鎖是否超時了,如果沒超時,則等待或重試。
- 反之,如果已超時,C3通過下面的操作來嘗試獲得鎖:
GETSET lock.{orderid} <current Unix time + lock timeout + 1> - 通過GETSET,C3拿到的時間戳如果仍然是超時的,那就說明,C3如願以償拿到鎖了。
- 如果在C3之前,有個叫C4的客戶端比C3快一步執行了上面的操作,那么C3拿到的時間戳是個未超時的值,這時,C3沒有如期獲得鎖,需要再次等待或重試。留意一下,盡管C3沒拿到鎖,但它改寫了C4設置的鎖的超時值,不過這一點非常微小的誤差帶來的影響可以忽略不計。
jeffkit的偽碼參考:
-
# get lock
-
lock = 0
-
while lock != 1:
-
timestamp = current Unix time + lock timeout + 1
-
lock = SETNX lock. orderid timestamp
-
if lock == 1 or (now ( ) > ( GET lock. orderid ) and now ( ) > ( GETSET lock. orderid timestamp ) ):
-
break
-
else:
-
sleep (10ms )
-
-
do_your_job ( )
-
-
# release lock
-
if now ( ) < GET lock. orderid:
-
DEL lock. orderid
參考資源:
1,jeffkit,
用Redis實現分布式鎖,基於redis;
2,rdc.taobao,
ZooKeeper典型使用場景一覽;
3,Ilya Sterin,
Distributed locking made easy,基於zookeeper;
4,遲炯,
解讀Google分布式鎖服務;
5,淘寶RDC,2012,
zookeeper分布式鎖避免羊群效應(Herd Effect);