基于 Redis 構(gòu)建簡(jiǎn)單分布式鎖的局限
簡(jiǎn)介
業(yè)務(wù)中,常有分布式鎖的需求,常見(jiàn)的解決方案便是基于 Redis 作為中心節(jié)點(diǎn)實(shí)現(xiàn)偽分布式效果,因?yàn)榇嬖谥行墓?jié)點(diǎn),所以我將其定義為偽分布式。
回歸主題,這篇文章,主要理一下,基于 Redis 實(shí)現(xiàn)簡(jiǎn)單分布式鎖的一些問(wèn)題,Redis 支持 RedLock(紅鎖)等復(fù)雜的實(shí)現(xiàn),以后的文章再討論。
基于 SETNX 命令實(shí)現(xiàn)分布式鎖
使用 SETNX 命令構(gòu)建分布式鎖是最常見(jiàn)的實(shí)現(xiàn)方式,具體而言:
1. 通過(guò) SETNX key value 向 Redis 新增一個(gè)值,SETNX 命令只有當(dāng) key 不存在時(shí),才會(huì)插入值并返回成功,否則返回失敗,而 KEY 便可以作為分布式鎖的鎖名,通?;跇I(yè)務(wù)來(lái)決定該鎖名;
2. 通過(guò) DEL key 命令刪除 key,從而實(shí)現(xiàn)釋放鎖的效果,當(dāng)鎖釋放后,其他線程才可以通過(guò) SETNX 獲得鎖(相同的 KEY);
3. 利用 EXPIRE key timeout 對(duì) KEY 設(shè)置超時(shí)時(shí)間,從而實(shí)現(xiàn)鎖的超時(shí)自動(dòng)釋放的效果,避免資源一直被占用。
redis-py (https://github.com/redis/redis-py) 這個(gè)庫(kù)便基于這種形式實(shí)現(xiàn) Redis 分布式鎖,將其源碼中相關(guān)代碼復(fù)制出來(lái),如下:
# 獲得分布式鎖
def do_acquire(self, token):
# 利用SETNX實(shí)現(xiàn)分布式鎖
if self.redis.setnx(self.name, token):
if self.timeout:
timeout = int(self.timeout * 1000) # 轉(zhuǎn)成毫秒
# 設(shè)置分布式超時(shí)時(shí)間
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實(shí)現(xiàn)鎖的釋放
pipe.delete(name)
self.redis.transaction(execute_release, name)
這種方式,存在一些問(wèn)題,下文進(jìn)行簡(jiǎn)單的分析。
SETNX 與 EXPIRE 非原子性問(wèn)題
SETNX 與 EXPIRE 是兩個(gè)操作,在 Redis 中不是原子操作。
如果 SETNX 成功(即獲得鎖),但在通過(guò) EXPIRE 設(shè)置鎖超時(shí)時(shí)間時(shí),服務(wù)器掛機(jī)、網(wǎng)絡(luò)中斷等問(wèn)題,導(dǎo)致 EXPIRE 沒(méi)有成功執(zhí)行,此時(shí)鎖就變成了沒(méi)有超時(shí)時(shí)間的鎖了,如果業(yè)務(wù)邏輯沒(méi)有處理好鎖的釋放,則容易出現(xiàn)死鎖。
Redis 官方考慮到了這種情況,讓 SET 命令可以直接設(shè)置 Timeout 并實(shí)現(xiàn) SETNX 效果,SET 支持的語(yǔ)法變?yōu)椋篠ETEX key value NX timeout,這樣就不再需要通過(guò) EXPIRE 設(shè)置超時(shí)時(shí)間,從而實(shí)現(xiàn)原子性了。
當(dāng)然,在 Redis 官方還沒(méi)有實(shí)現(xiàn)這一功能時(shí),很多開(kāi)源庫(kù)也考慮到了這個(gè)問(wèn)題,然后使用 Lua 腳本實(shí)現(xiàn) SETEX 與 EXPIRE 兩個(gè)操作的原子性。
因?yàn)橛脩粝M远x若干指令來(lái)完成特定的業(yè)務(wù),Redis 官方為這些用戶提供了 Lua 腳本支持,用戶可以向 Redis 服務(wù)器發(fā)送 Lua 腳本執(zhí)行自定義的邏輯,Redis 服務(wù)器會(huì)單線程原子性的執(zhí)行 Lua 腳本。
鎖誤解除
鎖誤解除也是常見(jiàn)的情況。
假設(shè)現(xiàn)在有 A、B 兩個(gè)線程在工作并競(jìng)爭(zhēng)同一把鎖,線程 A 獲得了鎖,并將鎖的超時(shí)時(shí)間設(shè)置完成 30s,但線程 A 在處理業(yè)務(wù)邏輯時(shí),因?yàn)閿?shù)據(jù)庫(kù) SQL 超時(shí),原本 20s 就可以完成的任務(wù),現(xiàn)在需要 40s 才能完成,當(dāng)線程 A 花費(fèi) 30s 時(shí),鎖會(huì)自動(dòng)釋放,此時(shí)線程 B 會(huì)獲得這把鎖,當(dāng)線程 A 處理完業(yè)務(wù)邏輯時(shí),會(huì)通過(guò) DEL 去釋放鎖,此時(shí)釋放的是線程 B 的鎖,直觀如下圖所示:
解決方法便是添加唯一標(biāo)識(shí),在釋放鎖時(shí),校驗(yàn) KEY 對(duì)應(yīng)的唯一標(biāo)識(shí)是否被當(dāng)前線程持有,在 redis-py 中,通過(guò) UUID 生成了當(dāng)前線程的唯一標(biāo)識(shí) token,并在釋放鎖時(shí),判斷當(dāng)前線程是否擁有相同的 token,相關(guān)代碼如下 (你會(huì)發(fā)現(xiàn)與上面復(fù)制出來(lái)的代碼不同,這是因?yàn)榕f文中使用的 redis-py 版本為 2.10.6,現(xiàn)在使用的 redis-py 版本為 3.5.3,相關(guān)的 bug 已經(jīng)被修改了,舊文的代碼,只是為了引出問(wèn)題):
class Lock(object):
def __init__(self, redis, name, timeout=None, sleep=0.1,
blocking=True, blocking_timeout=None, thread_local=True):
# 線程本地存儲(chǔ)
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會(huì)通過(guò)set方法存入到Redis中
if self.redis.set(self.name, token, nx=True, px=timeout):
return True
return False
redis-py 基于 uuid 庫(kù)生成 token,并將其存到當(dāng)前線程的本地存儲(chǔ)空間中(獨(dú)立于其他線程),在釋放時(shí),判斷當(dāng)前線程的 token 與加鎖時(shí)存儲(chǔ)的 token 釋放相同,redis-py 中利用 Lua 來(lái)實(shí)現(xiàn)這個(gè)過(guò)程,相關(guān)代碼如下:
def release(self):
"Releases the already acquired lock"
# 從線程本地存儲(chǔ)中獲得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來(lái)釋放鎖,并實(shí)現(xiàn)判斷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 代碼中,通過(guò) get 獲得 KEY 的 value,這個(gè) value 就是 token,然后判斷與傳入的 token 是否相同,不相同的話,便不會(huì)執(zhí)行 DEL 命令,即不會(huì)釋放鎖。
鎖超時(shí)導(dǎo)致的并發(fā)
這種情況與鎖誤解除類似,同樣假設(shè)有線程 A、B,線程 A 獲得鎖并設(shè)置過(guò)期時(shí)間 30s,當(dāng)線程 A 執(zhí)行時(shí)間超過(guò) 30s 時(shí),鎖過(guò)期釋放,此時(shí)線程 B 獲得鎖,如果線程 A 與線程 B 是在業(yè)務(wù)上是有順序依賴的,此時(shí)出現(xiàn)了并發(fā)情況,便會(huì)導(dǎo)致業(yè)務(wù)結(jié)果的錯(cuò)誤,直觀如下圖:
線程 A、B 同時(shí)執(zhí)行導(dǎo)致業(yè)務(wù)錯(cuò)誤是我們不希望出現(xiàn)的,對(duì)于這種情況,有兩種解決方案:
1. 增大鎖的過(guò)期時(shí)間,讓業(yè)務(wù)邏輯有充足的執(zhí)行時(shí)間;
2. 添加守護(hù)線程,當(dāng)鎖過(guò)期時(shí),添加過(guò)期時(shí)間。
建議使用第一種方案,簡(jiǎn)單直接,此外,可以添加單一線程,對(duì) Redis 的 key 做監(jiān)控,對(duì)于時(shí)長(zhǎng)特別長(zhǎng)的 key,做監(jiān)控報(bào)警。
輪詢等待的效率問(wèn)題
依舊是線程 A、B,當(dāng)線程 A 獲得鎖時(shí),線程 B 也想獲得鎖,此時(shí)就需要等待,直到線程 A 釋放鎖或者鎖過(guò)期自己釋放了,看 redis-py 的源碼,其等待的邏輯就是一個(gè)死循環(huán),相關(guān)代碼如下:
def acquire(self, blocking=None, blocking_timeout=None, token=None):
# ...省略部分代碼
# 死循環(huán)等待獲得鎖
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
# 阻塞睡眠一段時(shí)間
mod_time.sleep(sleep)
簡(jiǎn)單而言,這種方式就是在客戶端輪詢,未獲得鎖時(shí),就等待一段時(shí)間再嘗試去獲得鎖,直到成功獲得鎖或等待超時(shí),這種方式實(shí)現(xiàn)簡(jiǎn)單,但當(dāng)并發(fā)量比較大時(shí),輪詢的方式會(huì)耗費(fèi)比較多資源,影響服務(wù)器性能。
更好的一種方式是使用 Redis 發(fā)布訂閱功能,當(dāng)線程 B 獲取鎖失敗時(shí),訂閱鎖釋放的消息,當(dāng)線程 A 執(zhí)行完業(yè)務(wù)釋放鎖時(shí),會(huì)發(fā)送鎖釋放信息,線程 B 獲得信息后,再去獲取鎖,這樣就不需要一直輪詢了,而是直接休眠等待到鎖釋放消息則可。
Redis 集群主從切換
比較復(fù)雜的項(xiàng)目會(huì)使用多個(gè) Redis 服務(wù)構(gòu)建集群,Redis 集群采用主從方式部署,簡(jiǎn)單而言,通過(guò)算法選擇出 Redis 集群中的主節(jié)點(diǎn),所有寫(xiě)操作都會(huì)落到主節(jié)點(diǎn)上,主節(jié)點(diǎn)會(huì)將指令記錄在 buffer 中,再通過(guò)異步的方式將 buffer 中的指令同步到其他從節(jié)點(diǎn),從節(jié)點(diǎn)執(zhí)行相同的指令,便會(huì)獲得與主節(jié)點(diǎn)相同的數(shù)據(jù)結(jié)構(gòu)。
當(dāng)我們基于 Redis 集群來(lái)構(gòu)建分布式鎖時(shí),可能會(huì)出現(xiàn)主從切換導(dǎo)致鎖丟失的問(wèn)題。
依舊以例子來(lái)說(shuō)明,客戶端 A 通過(guò) Redis 集群成功加鎖,這個(gè)操作首先會(huì)發(fā)生在主節(jié)點(diǎn),但由于某些問(wèn)題,當(dāng)前 Redis 集群的主節(jié)點(diǎn) down 了,此時(shí)根據(jù)相應(yīng)的算法,Redis 集群會(huì)從從節(jié)點(diǎn)中選出新的主節(jié)點(diǎn),這個(gè)過(guò)程對(duì)客戶端 A 而言是透明的,但如果在主從切換時(shí),客戶端 A 在舊主節(jié)點(diǎn)加鎖的指令還未同步它就 down 了,那么新的主節(jié)點(diǎn)就不會(huì)有客戶端 A 加速的信息,此時(shí),如果有新的客戶端 B 要加鎖,便可以輕松加上。
Redis 集群腦裂腦裂
這次確實(shí)挺抽象的,簡(jiǎn)單而言,Redis 集群中因?yàn)榫W(wǎng)絡(luò)問(wèn)題,某些從節(jié)點(diǎn)無(wú)法感知到主節(jié)點(diǎn)了,此時(shí)這些從節(jié)點(diǎn)會(huì)認(rèn)為主節(jié)點(diǎn) down 了,便會(huì)選出新的主節(jié)點(diǎn),而客戶端卻可以連接上兩個(gè)主節(jié)點(diǎn),從而會(huì)出現(xiàn)兩個(gè)客戶端擁有同一把鎖的情況。
結(jié)尾復(fù)雜分布式系統(tǒng)中鎖的問(wèn)題一直是個(gè)設(shè)計(jì)難題,學(xué)無(wú)止境呀。