分布式鎖
分布式鎖是鎖的一種,都是為了解決多線程/多進程環境下,對共享資源的訪問沖突問題。
不過,像 Java 的 synchronized
或者 C++ 的 mutex
這種鎖,都是進程內的鎖,而分布式鎖則是跨越進程/機器的鎖。也就是可以針對多個進程、多臺服務器的的共享資源進行加鎖。
考慮買票的場景,現在車站提供了若干個車次,每個車次的票數都是固定的。
現在存在多個服務器節點,都可能需要處理這個買票的邏輯:先查詢指定車次的余票,如果余票 > 0
,則設置余票值 -= 1
。
上述場景如果任何處理,就會存在 “線程安全問題”。
當多個客戶端同時購買一個車次的票時,此時余票數量還未作出相應的更新。
假設此時只剩一張余票,當多個客戶端都判斷 余票 > 0
成功后,導致余票 “被賣出去了多張”,但實際只剩一張了,這就是是出現 “超賣” 的情況。
為了解決這個問題,引入分布式鎖。
實現分布式鎖
引入 SETNX
既然普通的鎖無法跨越多個進程/機器,那就只能單獨找一臺服務器作為鎖的管理者,該服務器可以使用 Redis 來實現分布式鎖。
某個客戶端執行買票請求前,先訪問 Redis,在 Redis 上設置一個鍵值對。比如 key
就是車次,value
隨便設置個值 (比如 1)。
當另一個客戶端也想要訪問買票請求時,也先訪問 Redis,在 Redis 上設置一個 key
為該車次的鍵值對,如果發現 key
已經設置過了,說明有其他客戶端正在處理該車次的票,則該客戶端要么直接放棄,要么等待。
而當該客戶端處理完買票請求后,直接刪除該 key
即可。就相當于是解鎖操作。
如何實現?可以使用 Redis 的 setnx
命令,該命令的作用是設置一個鍵值對,當且僅當鍵不存在時才設置成功。
引入過期時間
但是上述方案存在問題,如果某個客戶端設置完鎖 key
后,突然掛掉了,此時鎖一直存在,其他客戶端就無法獲取到鎖,導致其他客戶端無法正常處理買票請求。
所以,我們可以給鎖引入一個過期時間,比如 1 秒,即這個鎖的最長持有時間,如果鎖的過期時間到了,則自動釋放鎖。
如何實現?可以使用 Redis 的 set ex nx
命令,在設置鎖的同時把過期時間加上。
能否先設置鎖,然后通過 expire
的方式設置過期時間?答案不行的,因為這是兩條命令了,就不是原子的了,就有概率發生 “線程安全問題”。
引入校驗ID
上述依然存在問題,是否可能會出現 服務器1
設置鎖,服務器2
誤刪鎖。答案是可能的,保不齊代碼哪里會出現 bug,導致鎖被誤刪。
正常的解鎖操作是必須由加鎖的這一方執行的。
所以,我們可以給每個服務器設置一個唯一的 ID,在設置鎖,將這個鎖的 value
設置為這個 ID。
在解鎖時,先獲取到該鎖,校驗其 value
是否與當前服務器的 ID 相同,如果相同,則執行解鎖操作。如果不相同,則忽略該操作。
引入 Lua 腳本
上述解鎖的判斷依然可能存在問題,根本原因還是可能造成 “線程安全問題”。
校驗和刪除這兩個操作,不是原子的,則可能會出現下圖的情況:
如果發生上述情況,命令按照上圖的順序執行,會導致 服務器1
刪除鎖之后,服務器3
來加鎖了,但是馬上又被 服務器2
給刪了。
我們可以使用事務將這兩個操作打包成一個原子的操作,但是,Redis 的事務比較雞肋,形同虛設。
Redis 官方文檔明確說,Lua
腳本可以作為事務的替代方案。Redis 在執行 Lua
腳本時,就相當于是執行一條命令,只有全部命令都執行完了,才會服務其他客戶端。
使用 Lua
腳本完成上述功能:
if redis.call("setnx", KEYS[1]) == ARGV[1] thenreturn redis.call("expire", KEYS[1])
elsereturn 0
end
上述代碼可以編寫成一個 .lua
后綴的文件,由 redis-cli
或者 redis-plus-plus
或者 jedis
等客戶端加載,并發送給 Redis 服務器,由 Redis 服務器來執行這段邏輯。
一個 lua
腳本會被 Redis 服務器以原子的方式來執行。
引入看門狗
上述的方案依然存在問題,就是鎖的過期時間較為固定。
如果鎖的過期時間設置的過短,可能業務邏輯還沒處理完就釋放鎖了。
如果鎖的過期時間設置的過長,導致鎖的持有時間太長,導致其他客戶端無法正常處理請求。
所以我們采用動態續約的機制,引入一個 “看門狗” 線程,每隔一段時間,如果當前業務還沒執行完,就續上鎖的過期時間。
引入 Redlock 算法
實踐中的 Redis 一般是以集群的方式部署的(至少是主從的形式,而不是單機)。那么就可能出現以下比較極端的情況:
服務器1
向master
節點進行加鎖操作。這個寫入key
的過程剛剛完成,master
就掛了;slave
節點升級成了新的master
節點。- 但是由于剛才寫入的這個
key
尚未來得及同步給slave
,此時就相當于服務器1
的加鎖操作形同虛設了,服務器2
仍然可以進行加鎖(即給新的 master 寫入key
。因為新的master
不包含剛才的key
)。
為了解決這個問題,Redis 官方提出了 Redlock
算法。
我們引入一組 Redis 節點。其中每一組 Redis 節點都包含一個主節點和若干從節點。并且組和組之間存儲的數據都是一致的,相互之間是 “備份” 關系(而并非是數據集合的一部分。這點有別于 Redis cluster)。
加鎖的時候,按照一定的順序,寫多個 master
節點。在寫鎖的時候需要設定操作的 “超時時間”。比如 50ms
。即如果 setnx
操作超過了 50ms
還沒有成功,就視為加鎖失敗。
如果給某個節點加鎖失敗,就立即再嘗試下一個節點。
當加鎖成功的節點數超過總節點數的一半,才視為加鎖成功。
這樣的話,即使有某些節點掛了,也不影響鎖的正確性。
同理,釋放鎖的時候,也需要把所有節點都進行解鎖操作。(即使是之前超時的節點,也要嘗試解鎖,盡量保證邏輯嚴密)。
簡而言之,Redlock 算法的核心就是,加鎖操作不能只寫給一個 Redis 節點,而要寫給多個!!!
分布式系統中任何一個節點都是不可靠的。最終的加鎖成功結論是 “少數服從多數的”。
證邏輯嚴密)。
簡而言之,Redlock 算法的核心就是,加鎖操作不能只寫給一個 Redis 節點,而要寫給多個!!!
分布式系統中任何一個節點都是不可靠的。最終的加鎖成功結論是 “少數服從多數的”。
由于一個分布式系統不至于大部分節點都同時出現故障,因此這樣的可靠性要比單個節點來說靠譜不少。