1. 程式人生 > 其它 >位元組面試官:mysql排它鎖之行鎖

位元組面試官:mysql排它鎖之行鎖

位元組面試官:mysql排它鎖之行鎖

一、背景

我們日常在電商網站購物時經常會遇到一些高併發的場景,例如電商 App 上經常出現的秒殺活動、限量優惠券搶購,還有我們去哪兒網的火車票搶票系統等,這些場景有一個共同特點就是訪問量激增,雖然在系統設計時會通過限流、非同步、排隊等方式優化,但整體的併發還是平時的數倍以上,為了避免併發問題,防止庫存超賣,給使用者提供一個良好的購物體驗,這些系統中都會用到鎖的機制。

對於單程序的併發場景,可以使用程式語言及相應的類庫提供的鎖,如 Java 中的 synchronized 語法以及 ReentrantLock 類等,避免併發問題。

如果在分散式場景中,實現不同客戶端的執行緒對程式碼和資源的同步訪問,保證在多執行緒下處理共享資料的安全性,就需要用到分散式鎖技術。

那麼何為分散式鎖呢?分散式鎖是控制分散式系統或不同系統之間共同訪問共享資源的一種鎖實現,如果不同的系統或同一個系統的不同主機之間共享了某個資源時,往往需要互斥來防止彼此干擾保證一致性。

一個相對安全的分散式鎖,一般需要具備以下特徵:

  • 互斥性。互斥是鎖的基本特徵,同一時刻鎖只能被一個執行緒持有,執行臨界區操作。
  • 超時釋放。通過超時釋放,可以避免死鎖,防止不必要的執行緒等待和資源浪費,類似於 MySQL 的 InnoDB 引擎中的 innodblockwait_timeout 引數配置。
  • 可重入性。一個執行緒在持有鎖的情況可以對其再次請求加鎖,防止鎖線上程執行完臨界區操作之前釋放。
  • 高效能和高可用。加鎖和釋放鎖的過程效能開銷要儘可能的低,同時也要保證高可用,防止分散式鎖意外失效。

可以看出實現分散式鎖,並不是鎖住資源就可以了,還需要滿足一些額外的特徵,避免出現死鎖、鎖失效等問題。

二、分散式鎖的實現方式

目前實現分散式鎖的方式有很多,常見的主要有:

  • Memcached 分散式鎖

利用 Memcached 的 add 命令。此命令是原子性操作,只有在 key 不存在的情況下,才能 add 成功,也就意味著執行緒得到了鎖。

  • Zookeeper 分散式鎖

利用 Zookeeper 的順序臨時節點,來實現分散式鎖和等待佇列。ZooKeeper 作為一個專門為分散式應用提供方案的框架,它提供了一些非常好的特性,如 ephemeral 型別的 znode 自動刪除的功能,同時 ZooKeeper 還提供 watch 機制,可以讓分散式鎖在客戶端用起來就像一個本地的鎖一樣:加鎖失敗就阻塞住,直到獲取到鎖為止。

  • Chubby

Google 公司實現的粗粒度分散式鎖服務,有點類似於 ZooKeeper,但也存在很多差異。Chubby 通過 sequencer 機制解決了請求延遲造成的鎖失效的問題。

  • Redis 分散式鎖

基於 Redis 單機實現的分散式鎖,其方式和 Memcached 的實現方式類似,利用 Redis 的 SETNX 命令,此命令同樣是原子性操作,只有在 key 不存在的情況下,才能 set 成功。而基於 Redis 多機實現的分散式鎖Redlock,是 Redis 的作者 antirez 為了規範 Redis 分散式鎖的實現,提出的一個更安全有效的實現機制。

本文主要討論分析基於Redis的分散式鎖的幾種實現方式以及存在的問題。

三、Redis分散式鎖

使用 Redis 作為分散式鎖,本質上要實現的目標就是一個程序在 Redis 裡面佔據了僅有的一個“茅坑”,當別的程序也想來佔坑時,發現已經有人蹲在那裡了,就只好放棄或者等待稍後再試。

目前基於 Redis 實現分散式鎖主要有兩大類,一類是基於單機,另一類是基於 Redis 多機,不管是哪種實現方式,均需要實現加鎖、解鎖、鎖超時這三個分散式鎖的核心要素。

1、基於Redis單機實現的分散式鎖

1)使用 SETNX 指令

最簡單的加鎖方式就是直接使用 Redis 的 SETNX 指令,該指令只在 key 不存在的情況下,將 key 的值設定為 value,若 key 已經存在,則 SETNX 命令不做任何動作。key 是鎖的唯一標識,可以按照業務需要鎖定的資源來命名。

比如在某商城的秒殺活動中對某一商品加鎖,那麼 key 可以設定為 lock_resource_id ,value 可以設定為任意值,在資源使用完成後,使用 DEL 刪除該 key 對鎖進行釋放,整個過程如下:

很顯然,這種獲取鎖的方式很簡單,但也存在一個問題,就是我們上面提到的分散式鎖三個核心要素之一的鎖超時問題,即如果獲得鎖的程序在業務邏輯處理過程中出現了異常,可能會導致 DEL 指令一直無法執行,導致鎖無法釋放,該資源將會永遠被鎖住。

所以,在使用 SETNX 拿到鎖以後,必須給 key 設定一個過期時間,以保證即使沒有被顯式釋放,在獲取鎖達到一定時間後也要自動釋放,防止資源被長時間獨佔。由於 SETNX 不支援設定過期時間,所以需要額外的 EXPIRE 指令,整個過程如下:

這樣實現的分散式鎖仍然存在一個嚴重的問題,由於 SETNX 和 EXPIRE 這兩個操作是非原子性的, 如果程序在執行 SETNX 和 EXPIRE 之間發生異常,SETNX 執行成功,但 EXPIRE 沒有執行,導致這把鎖變得“長生不老”,這種情況就可能出現前文提到的鎖超時問題,其他程序無法正常獲取鎖。

2)使用 SET 擴充套件指令

為了解決 SETNX 和 EXPIRE 兩個操作非原子性的問題,可以使用 Redis 的 SET 指令的擴充套件引數,使得 SETNX 和 EXPIRE 這兩個操作可以原子執行,整個過程如下:

在這個 SET 指令中:

  • NX 表示只有當 lock_resource_id 對應的 key 值不存在的時候才能 SET 成功。保證了只有第一個請求的客戶端才能獲得鎖,而其它客戶端在鎖被釋放之前都無法獲得鎖。
  • EX 10 表示這個鎖10秒鐘後會自動過期,業務可以根據實際情況設定這個時間的大小。

但是這種方式仍然不能徹底解決分散式鎖超時問題:

  • 鎖被提前釋放。假如執行緒 A 在加鎖和釋放鎖之間的邏輯執行的時間過長(或者執行緒 A 執行過程中被堵塞),以至於超出了鎖的過期時間後進行了釋放,但執行緒 A 在臨界區的邏輯還沒有執行完,那麼這時候執行緒 B 就可以提前重新獲取這把鎖,導致臨界區程式碼不能嚴格的序列執行。
  • 鎖被誤刪。假如以上情形中的執行緒A執行完後,它並不知道此時的鎖持有者是執行緒 B,執行緒A會繼續執行 DEL 指令來釋放鎖,如果執行緒 B 在臨界區的邏輯還沒有執行完,執行緒 A 實際上釋放了執行緒 B 的鎖。

為了避免以上情況,建議不要在執行時間過長的場景中使用 Redis 分散式鎖,同時一個比較安全的做法是在執行 DEL 釋放鎖之前對鎖進行判斷,驗證當前鎖的持有者是否是自己。

具體實現就是在加鎖時將 value 設定為一個唯一的隨機數(或者執行緒 ID ),釋放鎖時先判斷隨機數是否一致,然後再執行釋放操作,確保不會錯誤地釋放其它執行緒持有的鎖,除非是鎖過期了被伺服器自動釋放,整個過程如下:

但判斷 value 和刪除 key 是兩個獨立的操作,並不是原子性的,所以這個地方需要使用 Lua 指令碼進行處理,因為 Lua 指令碼可以保證連續多個指令的原子性執行。

基於 Redis 單節點的分散式鎖基本完成了,但是這並不是一個完美的方案,只是相對完全一點,因為它並沒有完全解決當前執行緒執行超時鎖被提前釋放後,其它執行緒乘虛而入的問題。

3)使用 Redisson 的分散式鎖

怎麼能解決鎖被提前釋放這個問題呢?

可以利用鎖的可重入特性,讓獲得鎖的執行緒開啟一個定時器的守護執行緒,每 expireTime/3 執行一次,去檢查該執行緒的鎖是否存在,如果存在則對鎖的過期時間重新設定為 expireTime,即利用守護執行緒對鎖進行“續命”,防止鎖由於過期提前釋放。

當然業務要實現這個守護程序的邏輯還是比較複雜的,可能還會出現一些未知的問題。

目前網際網路公司在生產環境用的比較廣泛的開源框架 Redisson 很好地解決了這個問題,非常的簡便易用,且支援 Redis 單例項、Redis M-S、Redis Sentinel、Redis Cluster 等多種部署架構。

其實現原理如圖所示(圖中以 Redis 叢集為例):

2、基於Redis多機實現的分散式鎖Redlock

以上幾種基於 Redis 單機實現的分散式鎖其實都存在一個問題,就是加鎖時只作用在一個 Redis 節點上,即使 Redis 通過 Sentinel 保證了高可用,但由於 Redis 的複製是非同步的,Master 節點獲取到鎖後在未完成資料同步的情況下發生故障轉移,此時其他客戶端上的執行緒依然可以獲取到鎖,因此會喪失鎖的安全性。

整個過程如下:

  • 客戶端 A 從 Master 節點獲取鎖。
  • Master 節點出現故障,主從複製過程中,鎖對應的 key 沒有同步到 Slave 節點。
  • Slave升 級為 Master 節點,但此時的 Master 中沒有鎖資料。
  • 客戶端 B 請求新的 Master 節點,並獲取到了對應同一個資源的鎖。
  • 出現多個客戶端同時持有同一個資源的鎖,不滿足鎖的互斥性。

正因為如此,在 Redis 的分散式環境中,Redis 的作者 antirez 提供了 RedLock 的演算法來實現一個分散式鎖,該演算法大概是這樣的:

假設有 N(N>=5)個 Redis 節點,這些節點完全互相獨立,不存在主從複製或者其他叢集協調機制,確保在這N個節點上使用與在 Redis 單例項下相同的方法獲取和釋放鎖。

獲取鎖的過程,客戶端應執行如下操作:

  • 獲取當前 Unix 時間,以毫秒為單位。
  • 按順序依次嘗試從5個例項使用相同的 key 和具有唯一性的 value(例如 UUID)獲取鎖。當向 Redis 請求獲取鎖時,客戶端應該設定一個網路連線和響應超時時間,這個超時時間應該小於鎖的失效時間。例如鎖自動失效時間為10秒,則超時時間應該在5-50毫秒之間。這樣可以避免伺服器端 Redis 已經掛掉的情況下,客戶端還在一直等待響應結果。如果伺服器端沒有在規定時間內響應,客戶端應該儘快嘗試去另外一個 Redis 例項請求獲取鎖。
  • 客戶端使用當前時間減去開始獲取鎖時間(步驟1記錄的時間)就得到獲取鎖使用的時間。當且僅當從大多數(N/2+1,這裡是3個節點)的 Redis 節點都取到鎖,並且使用的時間小於鎖失效時間時,鎖才算獲取成功。
  • 如果取到了鎖,key 的真正有效時間等於有效時間減去獲取鎖所使用的時間(步驟3計算的結果)。
  • 如果因為某些原因,獲取鎖失敗(沒有在至少N/2+1個 Redis 例項取到鎖或者取鎖時間已經超過了有效時間),客戶端應該在所有的 Redis 例項上進行解鎖(使用 Redis Lua 指令碼)。

釋放鎖的過程相對比較簡單:客戶端向所有 Redis 節點發起釋放鎖的操作,包括加鎖失敗的節點,也需要執行釋放鎖的操作,antirez 在演算法描述中特別強調這一點,這是為什麼呢?

原因是可能存在某個節點加鎖成功後返回客戶端的響應包丟失了,這種情況在非同步通訊模型中是有可能發生的:客戶端向伺服器通訊是正常的,但反方向卻是有問題的。雖然對客戶端而言,由於響應超時導致加鎖失敗,但是對 Redis節點而言,SET 指令執行成功,意味著加鎖成功。因此,釋放鎖的時候,客戶端也應該對當時獲取鎖失敗的那些 Redis 節點同樣發起請求。

除此之外,為了避免 Redis 節點發生崩潰重啟後造成鎖丟失,從而影響鎖的安全性,antirez 還提出了延時重啟的概念,即一個節點崩潰後不要立即重啟,而是等待一段時間後再進行重啟,這段時間應該大於鎖的有效時間。

總結

大型分散式系統猶如一個生命,系統中各個服務猶如骨骼,其中的資料猶如血液,而Kafka猶如經絡,串聯整個系統。這份Kafka原始碼筆記通過大量的設計圖展示、程式碼分析、示例分享,把Kafka的實現脈絡展示在讀者面前,幫助讀者更好地研讀Kafka程式碼。

需要免費領取這份Kafka原始碼筆記的鐵汁們,麻煩幫忙轉發一下這篇文章+關注我,然後戳這裡免費獲取!