引言:
目前很多系統都是使用redis作爲分佈式鎖,如果redis是單節點部署,基本上不會出現什麼問題。但如果redis是多節點的集羣部署,那麼使用redis集羣作爲分佈式鎖就會存在一些問題。這兩篇文章進行了詳細的講解。http://zhangtielei.com/posts/blog-redlock-reasoning.html http://zhangtielei.com/posts/blog-redlock-reasoning-part2.html
一、基於單節點的redis鎖
客戶端獲取鎖
SET resource_name my_random_value NX PX 30000
如果返回成功,則說明客戶端獲取鎖成功,然後就可以訪問公共資源了,如果失敗則獲取鎖失敗。對於這條命令,需要注意
- my_random_value:必須是一個隨機字符,並且唯一。如果不唯一,可能會出現以下情況:
- 客戶端1獲取資源成功
- 客戶端1阻塞超時,鎖自動釋放
- 客戶端2獲取鎖成功
- 客戶端1從阻塞中醒來,釋放了客戶端2的鎖
- 必須設置NX,表示只有resource_name不存在時纔會設置成功,保證只有第一個請求的客戶端獲取鎖成功
- PX 30000 表示過期時間爲30s,爲了保證原子操作必須在SET時設置過期時間
客戶端釋放鎖
釋放鎖時使用下面的redis lua腳本執行來保證原子性
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
只有當resource_name的值和客戶端持有的數據相等時才能夠調用del刪除resource_name,否則不進行刪除操作。從而防止一個客戶端釋放另一個客戶端持有的鎖。
安全性和可靠性:
分析一下redis鎖的原理,我們在redis實例中創建一個鍵值,同時設置該鍵值的超時時間。創建該鍵值的客戶端獲取鎖成功,訪問公共資源。同時如果客戶端宕機則鎖會自動釋放。客戶端需要釋放鎖時只需要刪除該鍵即可。但一旦單節點的Redis宕機則不能再提供服務,即使是基於Master-Slave模式的故障切換也是不安全的,例如下面場景
1. 客戶端1從Master獲取鎖
2. Master宕機,但鎖key還沒有同步到Slave上
3. Slave升級爲Master
4. 客戶端2從新的Master上獲取鎖成功
二、分佈式Redlock
分佈式Redlock是基於多個redis示例實現的鎖服務
算法實現
1. 客戶端獲取當前時間start_time
2. 客戶端按照順序依次向N個Redis節點獲取鎖操作,這個過程類似於上述單個節點獲取鎖過程。爲了防止在獲取某個Redis節點鎖超時,客戶端會設置一個很小的超時時間(timeout),timeout要遠遠小於鎖本身超時時間。
3. 當向所有Redis節點發送獲取鎖操作完成後,記錄當前時間endtime。並且獲取鎖總消耗時間elapsed_time = (endtime-starttime),可用時長:validity = ttl - elapsed_time - drift,獲取鎖成功數n。當n > (N/2+1) && validity > 0(或其他值) 獲取鎖成功,並修改佔有鎖時長爲validity
4. 如果獲取鎖失敗,則需要向所有客戶端發起釋放鎖的操作
python源碼:
import logging
import string
import random
import time
from collections import namedtuple
import redis
from redis.exceptions import RedisError
# Python 3 compatibility
string_type = getattr(__builtins__, 'basestring', str)
try:
basestring
except NameError:
basestring = str
Lock = namedtuple("Lock", ("validity", "resource", "key"))
class CannotObtainLock(Exception):
pass
class MultipleRedlockException(Exception):
def __init__(self, errors, *args, **kwargs):
super(MultipleRedlockException, self).__init__(*args, **kwargs)
self.errors = errors
def __str__(self):
return ' :: '.join([str(e) for e in self.errors])
def __repr__(self):
return self.__str__()
class Redlock(object):
default_retry_count = 3 //默認重試次數,指客戶端獲取鎖重試次數,並不是向單個redis master加鎖請求重試次數
default_retry_delay = 0.2 //默認重試間隔(實際應用中應該設置爲隨機值)
clock_drift_factor = 0.01 //不同服務器時間漂移比例因子
//釋放鎖的lua腳本
unlock_script = """
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end"""
def __init__(self, connection_list, retry_count=None, retry_delay=None):
self.servers = []
for connection_info in connection_list:
try:
if isinstance(connection_info, string_type):
server = redis.StrictRedis.from_url(connection_info)
elif type(connection_info) == dict:
server = redis.StrictRedis(**connection_info)
else:
server = connection_info
self.servers.append(server)
except Exception as e:
raise Warning(str(e))
self.quorum = (len(connection_list) // 2) + 1
if len(self.servers) < self.quorum:
raise CannotObtainLock(
"Failed to connect to the majority of redis servers")
self.retry_count = retry_count or self.default_retry_count
self.retry_delay = retry_delay or self.default_retry_delay
//向單個redis服務器加鎖請求
def lock_instance(self, server, resource, val, ttl):
try:
assert isinstance(ttl, int), 'ttl {} is not an integer'.format(ttl)
except AssertionError as e:
raise ValueError(str(e))
return server.set(resource, val, nx=True, px=ttl)
def unlock_instance(self, server, resource, val):
try:
server.eval(self.unlock_script, 1, resource, val)
except Exception as e:
logging.exception("Error unlocking resource %s in server %s", resource, str(server))
//獲取鎖機制
def get_unique_id(self):
CHARACTERS = string.ascii_letters + string.digits
return ''.join(random.choice(CHARACTERS) for _ in range(22)).encode()
def lock(self, resource, ttl):
retry = 0
val = self.get_unique_id() //隨機值
# Add 2 milliseconds to the drift to account for Redis expires
# precision, which is 1 millisecond, plus 1 millisecond min
# drift for small TTLs.
//表示不同服務器之間時間飄移 默認加鎖時長的1% + 2ms
drift = int(ttl * self.clock_drift_factor) + 2
redis_errors = list()
while retry < self.retry_count:
n = 0
start_time = int(time.time() * 1000)
del redis_errors[:]
for server in self.servers:
try:
if self.lock_instance(server, resource, val, ttl):
n += 1
except RedisError as e:
redis_errors.append(e)
//加鎖消耗時長
elapsed_time = int(time.time() * 1000) - start_time
//剩餘時長
validity = int(ttl - elapsed_time - drift)
//剩餘時長 > 0 && 加鎖成功 >= n/2+1
if validity > 0 and n >= self.quorum:
if redis_errors:
raise MultipleRedlockException(redis_errors)
return Lock(validity, resource, val)
else:
for server in self.servers:
try:
self.unlock_instance(server, resource, val)
except:
pass
retry += 1
time.sleep(self.retry_delay)
return False
//解鎖,向所有服務器發送解鎖請求
def unlock(self, lock):
redis_errors = []
for server in self.servers:
try:
self.unlock_instance(server, lock.resource, lock.key)
except RedisError as e:
redis_errors.append(e)
if redis_errors:
raise MultipleRedlockException(redis_errors)
安全性
1.訪問共享資源期間所有鎖未過期
- 根據redlock算法可知,每個redis實例鎖的過期時間爲ttl
- 客戶端獲取鎖成功後將鎖時間修改爲validity = (ttl - elapsed_time - drift)
- 此時距離第一個實例加鎖已經過去了elapsed_time,第一個實例鎖釋放剩餘時間爲 ttl - elapsed_time
- 由於drift的存在,所以在客戶端佔有資源的時間內第一個實例的鎖是不會過期的
所以,在客戶端訪問資源期間,所有實例上的鎖都不會自動過期,其他客戶端也無法獲取這個鎖。
2.故障恢復問題
多節點的Redis系統沒有單節點failover帶來的問題,但當某個節點崩潰重啓時,仍然會有問題。如下步驟:
- 客戶端1鎖住了A,B,C三個節點,由於種種原因未鎖住D和E
- C崩潰,key並未持久化到硬盤,C重啓
- 客戶端2鎖住C,D,E三個節點,獲取鎖成功。
出現上述情況時就會導致訪問資源衝突。當然,我們可以將redis的AOF持久化方式設置爲每次修改數據都進行fsync,但這樣會降低 系統性能,並且即使每次更新都執行fsync,操作系統仍不能完全保證數據持久化到硬盤上,因此antirez提出了延遲重啓(delayed restarts)的概念:當一個節點崩潰後並不直接重啓,而是過一段時間重啓。這段時間應該大於鎖的有效期。
RedLock缺陷
1.客戶端長時間阻塞問題
著名分佈式大師Martion在2016-2-8日發佈了“How to do distributed locking”博客,博客中給出了一個時序圖:
Martin指出,即使鎖服務能夠正常工作,但仍會出現問題。例如上面所示時序圖:
- client1獲取鎖成功
- client1觸發了full gc(或者阻塞時間太長或者業務耗時太長)
- 鎖超時釋放
- client2獲取鎖成功,訪問公共資源
- client1恢復,訪問公共資源,造成衝突
一般來說client在訪問公共資源時首先check是否還持有鎖,如果不持有鎖再訪問數據。但由於GC Pause可能在任何時間點發生,有可能在check之後發生,仍不能避免上述問題,即使我們使用非JAVA類語言即不存在Long GC Pause,但仍然有可能會因爲某些原因導致長阻塞。
基於fencing機制的分佈式鎖
Martin提出了一種基於fencing tocken的解決方案。fencing token是一個單調遞增的數字,客戶端在加鎖時獲取token,在訪問資源時待着token進行訪問。這樣就可以通過比較所帶的token和公共資源上token大小來避免過期的token堆資源進行訪問。如下所示時序圖
- client1獲取鎖成功,同時獲取一個Token 33
- client1進入GC Pause,鎖超時釋放
- client2獲取鎖成功,同時獲取Token 34
- client2訪問公共資源,並將Token 34 寫到資源上
- client1從GC Pause恢復,訪問公共資源,發現所攜帶的Token小於正在訪問公共資源的Token,則訪問失敗,直接返回,避免了訪問衝突
其實上述fencing機制並不能完全解決客戶端阻塞問題,因爲GC有可能發生在任何時刻。如果在check Token之後發生長時間的GC仍然有可能造成訪問資源衝突
2. 對系統計時(timing)太過於依賴
Martin在博客中指出Redlock對系統的計時(timing)太過於依賴,例如文中給出的一個示例:
- client1從Redis節點A,B,C獲取鎖,D,E獲取失敗
- 節點C上的時間向前發生了跳躍,導致其維護的鎖失效
- client2獲取C,D,E節點鎖成功
- client1和client2同時獲取了鎖
上邊這種情況的發生本質上就是Redlock對系統時鐘有比較強的依賴。redis是通過gettimeofday函數來判斷key是否過期的,而這種做法是不推薦的(https://blog.habets.se/2010/09/gettimeofday-should-never-be-used-to-measure-time.html)。當發生時間跳躍或者管理員修改了機器的本地時間,Redlock就無法保證其安全性。
三、基於ZooKeeper的分佈式鎖
很多人都認爲如果要構建一個更加安全的分佈式鎖,那麼需要使用Zookeeper,而不是Redis。另一個著名的分佈式專家Flavio Junqueira在Martin發表blog後也寫了一篇博客,介紹基於ZK的分佈式鎖,博客地址:https://fpj.me/2016/02/10/note-on-fencing-and-distributed-locks/
文中指出,基於ZK創建分佈式鎖的一種方式:client1創建臨時節點/lock,如果創建成功則說明其拿到鎖,其他客戶端無法創建。由於/lock節點是臨時節點,所以創建它的客戶端一旦崩潰就會自動刪除/lock節點。ZK是如何檢測到客戶端崩潰的呢,實際上ZK和客戶端維護者一個Session,這個Session依賴定期的心跳(heartbeat)來維持。如果ZooKeeper長時間收不到客戶端的心跳(這個時間稱爲Sesion的過期時間),那麼它就認爲Session過期了,通過這個Session所創建的所有的ephemeral類型的znode節點都會被自動刪除。
設想如下的執行序列:
- 客戶端1創建了znode節點/lock,獲得了鎖。
- 客戶端1進入了長時間的GC pause。
- 客戶端1連接到ZooKeeper的Session過期了。znode節點/lock被自動刪除。
- 客戶端2創建了znode節點/lock,從而獲得了鎖。
- 客戶端1從GC pause中恢復過來,它仍然認爲自己持有鎖。
最後,客戶端1和客戶端2都認爲自己持有了鎖,衝突了。這與之前Martin在文章中描述的由於GC pause導致的分佈式鎖失效的情況類似。對於這種情況,Flavio提出了對資源進行訪問時先進行Mark,其實類似於Martin提出的Fencing機制,每次訪問共享資源時對資源進行mark,防止舊的(比當前mark小的)客戶端訪問資源。
Zookeeper作爲分佈式鎖的另一個優勢是其具有watch機制,當客戶端創建/lock節點失敗時並不一定立即返回,其進入等待狀態。當/lock節點被刪除時ZK可以通過watch機制通知它,這樣客戶端就可以繼續完成創建節點,直到獲取鎖。顯然Redis是無法提供這樣特性的。
四、總結
1. 按照Martin提到的兩種用途,如果我們使用分佈式鎖僅僅是爲了效率,那麼我們可以選擇任何一種分佈式鎖的實現。但如果是爲了正確性,那麼我們就需要謹慎點,認真選擇。
2. 對於客戶端出現長時間GC Pause的情況,通過fencing機制可以解決,但如果客戶端在訪問公共資源時出現長時間的GC Pause,目前暫未有解決方案。
3. 相比於Redis,Zookeeper提供了更加靈活地加鎖方式,同時其也可以避免客戶端崩潰而長期持有鎖。