簡介
業務中,常有分布式鎖的需求,常見的解決方案便是基于 Redis 作為中心節點實現偽分布式效果,因為存在中心節點,所以我將其定義為偽分布式。
回歸主題,這篇文章,主要理一下,基于 Redis 實現簡單分布式鎖的一些問題,Redis 支持 RedLock(紅鎖)等復雜的實現,以后的文章再討論。
基于 SETNX 命令實現分布式鎖
使用 SETNX 命令構建分布式鎖是最常見的實現方式,具體而言:
1. 通過 SETNX key value 向 Redis 新增一個值,SETNX 命令只有當 key 不存在時,才會插入值并返回成功,否則返回失敗,而 KEY 便可以作為分布式鎖的鎖名,通常基于業務來決定該鎖名;
2. 通過 DEL key 命令刪除 key,從而實現釋放鎖的效果,當鎖釋放后,其他線程才可以通過 SETNX 獲得鎖(相同的 KEY);
3. 利用 EXPIRE key timeout 對 KEY 設置超時時間,從而實現鎖的超時自動釋放的效果,避免資源一直被占用。
redis-py (https://github.com/redis/redis-py) 這個庫便基于這種形式實現 Redis 分布式鎖,將其源碼中相關代碼復制出來,如下:
# 獲得分布式鎖 def do_acquire(self, token): # 利用SETNX實現分布式鎖 if self.redis.setnx(self.name, token): if self.timeout: timeout = int(self.timeout * 1000) # 轉成毫秒 # 設置分布式超時時間 self.redis.pexpire(self.name, timeout) return True return False # 釋放分布式鎖 def do_release(self, expected_token): name = self.name def execute_release(pipe): lock_value = pipe.get(name) if lock_value != expected_token: raise LockError("Cannot release a lock that's no longer owned") # 利用DEL value實現鎖的釋放 pipe.delete(name) self.redis.transaction(execute_release, name)
這種方式,存在一些問題,下文進行簡單的分析。
SETNX 與 EXPIRE 非原子性問題
SETNX 與 EXPIRE 是兩個操作,在 Redis 中不是原子操作。
如果 SETNX 成功(即獲得鎖),但在通過 EXPIRE 設置鎖超時時間時,服務器掛機、網絡中斷等問題,導致 EXPIRE 沒有成功執行,此時鎖就變成了沒有超時時間的鎖了,如果業務邏輯沒有處理好鎖的釋放,則容易出現死鎖。
Redis 官方考慮到了這種情況,讓 SET 命令可以直接設置 Timeout 并實現 SETNX 效果,SET 支持的語法變為:SETEX key value NX timeout,這樣就不再需要通過 EXPIRE 設置超時時間,從而實現原子性了。
當然,在 Redis 官方還沒有實現這一功能時,很多開源庫也考慮到了這個問題,然后使用 Lua 腳本實現 SETEX 與 EXPIRE 兩個操作的原子性。
因為用戶希望自定義若干指令來完成特定的業務,Redis 官方為這些用戶提供了 Lua 腳本支持,用戶可以向 Redis 服務器發送 Lua 腳本執行自定義的邏輯,Redis 服務器會單線程原子性的執行 Lua 腳本。
鎖誤解除
鎖誤解除也是常見的情況。
假設現在有 A、B 兩個線程在工作并競爭同一把鎖,線程 A 獲得了鎖,并將鎖的超時時間設置完成 30s,但線程 A 在處理業務邏輯時,因為數據庫 SQL 超時,原本 20s 就可以完成的任務,現在需要 40s 才能完成,當線程 A 花費 30s 時,鎖會自動釋放,此時線程 B 會獲得這把鎖,當線程 A 處理完業務邏輯時,會通過 DEL 去釋放鎖,此時釋放的是線程 B 的鎖,直觀如下圖所示:
解決方法便是添加唯一標識,在釋放鎖時,校驗 KEY 對應的唯一標識是否被當前線程持有,在 redis-py 中,通過 UUID 生成了當前線程的唯一標識 token,并在釋放鎖時,判斷當前線程是否擁有相同的 token,相關代碼如下 (你會發現與上面復制出來的代碼不同,這是因為舊文中使用的 redis-py 版本為 2.10.6,現在使用的 redis-py 版本為 3.5.3,相關的 bug 已經被修改了,舊文的代碼,只是為了引出問題):
class Lock(object): def __init__(self, redis, name, timeout=None, sleep=0.1, blocking=True, blocking_timeout=None, thread_local=True): # 線程本地存儲 self.local = threading.local() if self.thread_local else dummy() self.local.token = None def acquire(self, blocking=None, blocking_timeout=None, token=None): sleep = self.sleep if token is None: # 基于UUID算法生成唯一token token = uuid.uuid1().hex.encode() # 省略剩余代碼... def do_acquire(self, token): if self.timeout: timeout = int(self.timeout * 1000) else: timeout = None # Token會通過set方法存入到Redis中 if self.redis.set(self.name, token, nx=True, px=timeout): return True return False
redis-py 基于 uuid 庫生成 token,并將其存到當前線程的本地存儲空間中(獨立于其他線程),在釋放時,判斷當前線程的 token 與加鎖時存儲的 token 釋放相同,redis-py 中利用 Lua 來實現這個過程,相關代碼如下:
def release(self): "Releases the already acquired lock" # 從線程本地存儲中獲得token expected_token = self.local.token if expected_token is None: raise LockError("Cannot release an unlocked lock") self.local.token = None self.do_release(expected_token) def do_release(self, expected_token): # 利用Lua來釋放鎖,并實現判斷token是否相同的邏輯 if not bool(self.lua_release(keys=[self.name], args=[expected_token], client=self.redis)): raise LockNotOwnedError("Cannot release a lock" " that's no longer owned")
其中 lua_release 變量具體的值為:
LUA_RELEASE_SCRIPT = """ local token = redis.call('get', KEYS[1]) if not token or token ~= ARGV[1] then return 0 end redis.call('del', KEYS[1]) return 1 """
上述 Lua 代碼中,通過 get 獲得 KEY 的 value,這個 value 就是 token,然后判斷與傳入的 token 是否相同,不相同的話,便不會執行 DEL 命令,即不會釋放鎖。
鎖超時導致的并發
這種情況與鎖誤解除類似,同樣假設有線程 A、B,線程 A 獲得鎖并設置過期時間 30s,當線程 A 執行時間超過 30s 時,鎖過期釋放,此時線程 B 獲得鎖,如果線程 A 與線程 B 是在業務上是有順序依賴的,此時出現了并發情況,便會導致業務結果的錯誤,直觀如下圖:
線程 A、B 同時執行導致業務錯誤是我們不希望出現的,對于這種情況,有兩種解決方案:
1. 增大鎖的過期時間,讓業務邏輯有充足的執行時間;
2. 添加守護線程,當鎖過期時,添加過期時間。
建議使用第一種方案,簡單直接,此外,可以添加單一線程,對 Redis 的 key 做監控,對于時長特別長的 key,做監控報警。
輪詢等待的效率問題
依舊是線程 A、B,當線程 A 獲得鎖時,線程 B 也想獲得鎖,此時就需要等待,直到線程 A 釋放鎖或者鎖過期自己釋放了,看 redis-py 的源碼,其等待的邏輯就是一個死循環,相關代碼如下:
def acquire(self, blocking=None, blocking_timeout=None, token=None): # ...省略部分代碼 # 死循環等待獲得鎖 while True: if self.do_acquire(token): self.local.token = token return True if not blocking: return False next_try_at = mod_time.time() + sleep if stop_trying_at is not None and next_try_at > stop_trying_at: return False # 阻塞睡眠一段時間 mod_time.sleep(sleep)
簡單而言,這種方式就是在客戶端輪詢,未獲得鎖時,就等待一段時間再嘗試去獲得鎖,直到成功獲得鎖或等待超時,這種方式實現簡單,但當并發量比較大時,輪詢的方式會耗費比較多資源,影響服務器性能。
更好的一種方式是使用 Redis 發布訂閱功能,當線程 B 獲取鎖失敗時,訂閱鎖釋放的消息,當線程 A 執行完業務釋放鎖時,會發送鎖釋放信息,線程 B 獲得信息后,再去獲取鎖,這樣就不需要一直輪詢了,而是直接休眠等待到鎖釋放消息則可。
Redis 集群主從切換
比較復雜的項目會使用多個 Redis 服務構建集群,Redis 集群采用主從方式部署,簡單而言,通過算法選擇出 Redis 集群中的主節點,所有寫操作都會落到主節點上,主節點會將指令記錄在 buffer 中,再通過異步的方式將 buffer 中的指令同步到其他從節點,從節點執行相同的指令,便會獲得與主節點相同的數據結構。
當我們基于 Redis 集群來構建分布式鎖時,可能會出現主從切換導致鎖丟失的問題。
依舊以例子來說明,客戶端 A 通過 Redis 集群成功加鎖,這個操作首先會發生在主節點,但由于某些問題,當前 Redis 集群的主節點 down 了,此時根據相應的算法,Redis 集群會從從節點中選出新的主節點,這個過程對客戶端 A 而言是透明的,但如果在主從切換時,客戶端 A 在舊主節點加鎖的指令還未同步它就 down 了,那么新的主節點就不會有客戶端 A 加速的信息,此時,如果有新的客戶端 B 要加鎖,便可以輕松加上。
Redis 集群腦裂腦裂
這次確實挺抽象的,簡單而言,Redis 集群中因為網絡問題,某些從節點無法感知到主節點了,此時這些從節點會認為主節點 down 了,便會選出新的主節點,而客戶端卻可以連接上兩個主節點,從而會出現兩個客戶端擁有同一把鎖的情況。
結尾復雜分布式系統中鎖的問題一直是個設計難題,學無止境呀。
原文地址:https://mp.weixin.qq.com/s/Z7RAImH2xW60xdiWnIYCyQ