分散式鎖實踐之一:基於 Redis 的實現
什麼是分散式鎖?
我們日常工作中(以及面試中)經常說到的併發問題,一般都是指程序內的併發問題,JDK 的併發包也是用以解決 JVM 程序內多執行緒併發問題的工具。但是,程序之間、以及跨伺服器程序之間的併發問題,要如何應對?這時,就需要藉助分散式鎖來協調多程序 / 服務之間的互動。
分散式鎖聽起來很高冷、很高大上,但它本質上也是鎖,因此,它也具有鎖的基本特徵:
-
原子性
-
互斥性
除此之外,分散式的鎖有什麼不一樣呢?簡單來說就是:
-
獨立性
因為分散式鎖需要協調其他程序 / 服務的互動,所以它本身應該是一個獨立的、職責單一的程序 / 服務。
-
可用性
因為分散式鎖是協調多程序 / 服務互動的基礎元件,所以它的可用性直接影響了一組程序 / 服務的可用性,同時也要避免:效能、飢餓、死鎖這些潛在問題。
程序鎖和分散式鎖的區別:
圖示 -- 程序級別的鎖:
圖示 -- 分散式鎖:
分散式鎖的業界最佳實踐應該非大名鼎鼎的 ZooKeeper 莫屬了。但殺雞焉用牛刀?在直接使用 ZooKeeper 實現分散式鎖方式之前,我們先通過 Redis 來演練一下分散式鎖演算法,畢竟 Redis 相對來說簡單、輕量很多,我們可以通過這個實踐來詳細探討分散式鎖的特性。這之後再對比地去看 ZooKeeper 的實現方式,相信會更加容易地理解。
怎麼實現分散式鎖?
由於 Redis 是高效能的分散式 KV 儲存器,它本身就具備了分散式特性,所以我們只需要專注於實現鎖的基本特徵就好了。
首先來看看如何設計鎖記錄的資料模型:
key | value |
---|---|
lock name | lock owner |
舉個例子,“登錄檔的分散式寫鎖”:
lock name | lock owner |
---|---|
registry_write | 10.10.10.110:25349 |
注意,為保證鎖的互斥性,lock owner 標識必需保證全域性唯一,不會如例子中顯示的那樣簡單。
原子性
因為 Redis 提供的方法可以認為是併發安全的,所以只要保證加、解鎖操作是原子操作就可以了。也就是說,只使用一個Redis方法來完成加、解鎖操作的話,那就能夠保證原子性。
-
加鎖操作:
set(lockName, lockOwner, ...)
set
是原子的,所以呼叫一次set
也是原子的。 -
解鎖操作:
eval(deleteScript, ...)
這裡你也許會疑惑,為什麼不直接使用 del(key)
來實現解鎖?因為解鎖的時候,需要先判斷你是不是加鎖的程序,不是加鎖者是無權解鎖的。如果任何程序都能夠解鎖,那鎖還有什麼意義?
因為 “先判斷是不是加鎖者、然後再解鎖” 是兩步的複合操作,而 Redis 並沒有提供一個可以實現這個複合操作的直接方法,我們只能通過在 delete script
裡面進行復合操作來繞過這個問題:因為執行一條指令碼的 eval
方法是原子的,所以這個解鎖操作的也是原子的。
互斥性
互斥性是說,一旦有一個程序加鎖成功能,那麼在該程序解鎖之前,其他的程序都不能加鎖。
在實現互斥性的同時,注意不能打破鎖的原子性。
-
加鎖操作:
set(lockName, lockOwner, "NX", ...)
第 3 個引數
NX
的含義:只有當lockName(key)
不存在時才會設定該鍵值。 -
解鎖操作:
eval(
eval(
"if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) else return 0 end" , List ( lockName ), List ( lockOwner)
)
當解鎖者等於鎖的持有者時,才會刪除該鍵值。
超時
解鎖權唯一屬於鎖的持有者,如果持有者程序異常退出,就永遠無法解鎖了。針對這種情況,我們可以在加鎖時設定一個過期時間,超過這個時間沒有解鎖,鎖會自動失效,這樣其他程序就能進行加鎖了。
-
加鎖操作:
set(lockName, lockOwner, "NX", "PX", expireTime)
"PX"
:過期時間單位:"EX" -- 秒,"PX" -- 毫秒expireTime
: 過期時間
程式碼片段 1 :加鎖、解鎖
// 由Scala編寫 case class RedisLock(client: JedisClient, lockName: String, locker: String) { private val LOCK_SUCCESS = "OK" private val SET_IF_NOT_EXISTS = "NX" private val EXPIRE_TIME_UNIT = "PX" private val RELEASE_SUCCESS = 1L def tryLock(expire: Duration): Boolean = { val res = client.con.set( lockName, // key locker, // value SET_IF_NOT_EXISTS, // nxxx EXPIRE_TIME_UNIT, // expire time unit expire.toMillis // expire time ) val isLock = LOCK_SUCCESS.equals(res) println(s"${locker} : ${if (isLock) "lock ok" else "lock fail"}") isLock } def unlock: Boolean = { val cmd = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) else return 0 end" val res = client.con.eval( cmd, List(lockName), // keys List(locker) // args ) val isUnlock = RELEASE_SUCCESS.equals(res) println(s"${locker} : ${if (isUnlock) "unlock ok" else "unlock fail"}") isUnlock } }
測試加鎖:
object TryLockDemo extends App { val client = JedisContext.client val lock1 = RedisLock(client, "LOCK", "LOCKER_1") // Try lock lock1.tryLock(1000.millis) Thread.sleep(2000.millis.toMillis) // Try lock after expired lock1.tryLock(1000.millis) // Unlock lock1.unlock }
測試結果:
LOCKER_1 : lock ok# 加鎖成功,1秒後鎖失效LOCKER_1 : lock ok
# 2秒之後,鎖已過期釋放,所以成功加鎖
LOCKER_1 : unlock ok # 解鎖成功阻塞加鎖
到目前為止,我們實現了簡單的加解鎖功能:
-
通過
tryLock()
方法嘗試加鎖,會立即返回加鎖的結果 -
鎖擁有者通過
unlock()
方法解鎖
但在實際的加鎖場景中,如果加鎖失敗了(鎖被佔用或網路錯誤等異常情況),我們希望鎖工具有同步等待(或者說重試)的能力。面對這個需求,一般會想到兩種解決方案:
-
簡單暴力輪詢
-
Pub / Sub 訂閱通知模式
因為 Redis 本身有極好的讀效能,所以暴力輪詢不失為一種簡單高效的實現方式,接下來就讓我們來嘗試下實現阻塞加鎖方法。
先來推演一下演算法過程:
-
設定阻塞加鎖的超時時間
timeout
-
如果已超時,則返回失敗
false
-
如果未超時,則通過
tryLock()
方法嘗試加鎖 -
如果加鎖成功,返回成功
true
-
如果加鎖失敗,休眠一段時間
frequency
後,重複第 2 步
程式碼片段 2 :阻塞加鎖
def lock(expire: Duration, timeout: Duration, frequency: Duration = 500.millis): Boolean = { var isTimeout = false TimeoutUtil.delay(timeout.toMillis).map(_ => isTimeout = true) while (!isTimeout) { if (tryLock(expire)) { return true } Thread.sleep(frequency.toMillis) } println(s"${locker} : timeout") return false; }
程式碼片段 -- 超時工具類:
object TimeoutUtil { def delay(millis: Long): Future[Unit] = { val promise = Promise[Unit]() val timer = new Timer timer.schedule(new TimerTask { override def run(): Unit = { promise.success() timer.cancel() } }, millis) promise.future } }
測試阻塞加鎖:
object LockDemo extends App { val client = JedisContext.client val lock1 = RedisLock(client, "LOCK", "LOCKER_1") val lock2 = RedisLock(client, "LOCK", "LOCKER_2") // Lock lock1.lock(3000.millis, 1000.millis) lock2.lock(3000.millis, 1000.millis) lock2.lock(3000.millis, 3000.millis) // Unlock lock1.unlock lock2.unlock }
測試結果:
LOCKER_1 : lock ok# LOCKER_1 加鎖成功,3 秒後鎖失效 LOCKER_2 : lock fail# LOCKER_2 嘗試加鎖失敗 LOCKER_2 : lock fail# LOCKER_2 重試,嘗試加鎖失敗 LOCKER_2 : timeout# LOCKER_2 重試超時,返回失敗 LOCKER_2 : lock fail# LOCKER_2 嘗試加鎖失敗 LOCKER_2 : lock fail# LOCKER_2 重試,嘗試加鎖失敗 LOCKER_2 : lock fail LOCKER_2 : lock fail LOCKER_2 : lock ok# 3 秒時間到,鎖失效,LOCKER_2 加鎖成功 LOCKER_1 : unlock fail # LOCKER_1 解鎖失敗,因為此時鎖被 LOCKER_2 佔有 LOCKER_2 : unlock ok# LOCKER_2 解鎖成功
更進一步
這個分散式鎖的實現,有一個比較明顯的缺陷,就是等待鎖的程序無法實時的知道鎖狀態的變化,從而及時的做出響應。我們不妨思考一下,通過什麼方式可以實時、高效的獲得鎖的狀態?
作為分散式鎖的業界標準,ZooKeeper 以及相關的工具庫提供了更加直接、高效的支援,那麼 ZooKeeper 是怎樣的思路?具體又是如何實現的?欲知後事如何,且聽下回分解:ZooKeeper 分散式鎖實踐。
全文完
以下文章您可能也會感興趣:
-
OpenResty 不完全指南
-
ConcurrentHashMap 的 size 方法原理分析
-
從 ThreadLocal 的實現看雜湊演算法
-
所謂 Serverless,你理解對了嗎?