【劍指Offer】Redis 分散式鎖的實現原理看這篇就夠了

麻花不是花花發表於2020-09-26

在這裡插入圖片描述

前言

分散式鎖相信大家一定不會陌生, 想要用好或者自己寫一個卻沒那麼簡單

想要達到上述的條件, 一定要 掌握分散式鎖的應用場景, 以及分散式鎖的不同實現, 不同實現之間有什麼區別

分散式鎖場景

如果想真正瞭解分散式鎖, 需要結合一定場景; 舉個例子, 某夕夕上搶購 AirPods Pro 的 100 元優惠券

如果使用下面這段程式碼當作搶購優惠券的後臺程式, 我們一起看一下, 可能存在什麼樣的問題

很明顯的就是這段流程在併發場景下並不安全, 會導致優惠券發放超過預期, 類似電商搶購超賣問題

想一哈有什麼方式可以避免這種分散式下超量問題?

互斥加鎖, Java 中互斥鎖的語義就是 同一時間, 只允許一個客戶端對資源進行操作

比如 Java 中的關鍵字 Synchronized, 以及 JUC Lock 包下的 ReentrantLock 都可以實現互斥鎖

JVM 鎖

如圖所示, 加入 JVM synchronized 鎖確實可以解決單機下併發問題

但是生產環境為了保證服務高可用, 起碼要 部署兩臺服務, 這樣的話 synchronized 就不起作用了, 因為它的 作用域只是單個 JVM

分散式情況下只能通過 分散式鎖 來解決多個服務資源共享的問題了

如果死磕單服務, 那沒的說, 分散式鎖就是浮雲 ☁️

分散式鎖

分散式鎖的定義:

保證同一時間只能有一個客戶端對共享資源進行操作

比對剛才舉的例子, 不論部署多少臺優惠券服務, 只會有 一臺服務能夠對優惠券數量進行增刪操作

另外有幾點要求也是必須要滿足的:

1、**不會發生死鎖。**即使有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證後續其他客戶端能加鎖

2、**具有容錯性。**只要大部分的Redis節點正常執行,客戶端就可以加鎖和解鎖

3、**解鈴還須繫鈴人。**加鎖和解鎖必須是同一個客戶端,客戶端自己不能把別人加的鎖給解了

分散式鎖實現大致分為三種, Redis、Zookeeper、資料庫, 文章以 Redis 展開分散式鎖的討論

分散式鎖演進史

先來構思下分散式鎖實現思路

首先我們必須保證同一時間只有一個客戶端(部署的優惠券服務)運算元量加減

其次本次 客戶端操作完成後, 需要讓 其它客戶端繼續執行

1、客戶端一存放一個標誌位, 如果新增成功, 操作減優惠券數量操作

2、客戶端二新增標誌位失敗, 本次減庫存操作失敗(或繼續嘗試獲取等)

3、客戶端一優惠券操作完成後, 需要將標誌位釋放, 以便其餘客戶端對庫存進行操作

第一版 setnx

向 Redis 中新增一個 lockKey 鎖標誌位, 如果新增成功則能夠繼續向下執行扣減優惠券數量操作, 最後再釋放此標誌位

由於使用的是 Spring 提供的 Redis 封裝的 Start 包, 所有有些命令與 Redis 原生命令不相符

setIfAbsent(key, val) -> setnx(key, val)

加了簡單的幾行程式碼, 一個簡單的分散式鎖的雛形就出來了

第二版 expire

上面第一版基於 setnx 命令實現分散式鎖的缺陷也是很明顯的, 那就是 一定情況下可能發生死鎖

畫個圖, 舉個例子說明哈

上圖說明, 執行緒1在成功獲取鎖後, 執行流程時異常結束, 沒有執行釋放鎖操作, 這樣就會 產生死鎖

如果方法執行異常導致的執行緒被回收, 那麼可以將解鎖操作放到 finally 塊中

但是還有存在死鎖問題, 如果獲得鎖的執行緒在執行中, 服務被強制停止或伺服器當機, 鎖依然不會得到釋放

這種極端情況下我們還是要考慮的, 畢竟不能只想著服務沒問題對吧

對 Redis 的 鎖標誌位加上過期時間 就能很好的防止死鎖問題, 繼續更改下程式程式碼

雖然 小紅旗處 對分散式鎖新增了過期時間, 但依然無法避免極端情況下的死鎖問題

那就是如果在客戶端加鎖成功後, 還沒有設定過期時間時當機

如果想要避免新增鎖時死鎖, 那就對新增鎖標誌位 & 新增過期時間命令 保證一個原子性, 要麼一起成功, 要麼一起失敗

第三版 set

我們的新增鎖原子命令就要登場了, 從 Redis 2.6.12 版本起, 提供了可選的 字串 set 複合命令

SET key value [expiration EX seconds|PX milliseconds] [NX|XX]

可選引數如下:

  • EX: 設定超時時間,單位是秒
  • PX: 設定超時時間,單位是毫秒
  • NX: IF NOT EXIST 的縮寫,只有 KEY不存在的前提下 才會設定值
  • XX: IF EXIST 的縮寫,只有在 KEY存在的前提下 才會設定值

繼續完善分散式鎖的應用程式, 程式碼如下:

我使用的 2.0.9.RELEASE 版本的 SpringBoot, RedisTemplate 中不支援 set 複合命令, 所以臨時換個 Jedis 來實現

加鎖以及設定過期時間確實保證了原子性, 但是這樣的分散式鎖就沒有問題了麼?

我們根據圖片以及流程描述設想一下這個場景

1、執行緒一獲取鎖成功, 設定過期時間五秒, 接著執行業務邏輯

2、接著執行緒一獲取鎖後執行業務流程, 執行的時間超過了過期時間, 鎖標誌位過期進行釋放, 此時執行緒二獲取鎖成功

3、然鵝此時執行緒一執行完業務後, 開始執行釋放鎖的流程, 然後順手就把執行緒二獲取的鎖釋放了

如果線上真的發生上述問題, 那真的是xxx, 更甚者可能存線上程一將執行緒二的鎖釋放掉之後, 執行緒三獲取到鎖, 然後執行緒二執行完將執行緒三的鎖釋放

第四版 verify value

事當如今, 只能建立辨別客戶端身份的唯一值了, 將加鎖及解鎖歸一化, 上程式碼~

這一版的程式碼相當於我們新增鎖標誌位時, 同時為每個客戶端設定了 uuid 作為鎖標誌位的 val, 解鎖時需要判斷鎖的 val 是否和自己客戶端的相同, 辨別成功才會釋放鎖

但是上述程式碼執行業務邏輯如果丟擲異常, 鎖只能等待過期時間, 我們可以將解鎖操作放到 finally 塊

大眼一看, 上上下下實現了四版分散式鎖, 也該沒問題了吧

真相就是: 解鎖時, 由於判斷鎖和刪除標誌位並不是原子性的, 所以可能還是會存在誤刪

1、執行緒一獲取鎖後, 執行流程balabala… 判斷鎖也是自家的, 這時 CPU 轉頭去做別的事情了, 恰巧執行緒一的鎖過期時間到了

2、執行緒二此時順理成章的獲取到了分散式鎖, 執行業務邏輯balabala…

3、執行緒一再次分配到時間片繼續執行刪除操作

解決這種非原子操作的方式只能 將判斷元素值和刪除標誌位當作一個原子操作

第五版 lua

很不友好的是, del 刪除操作並沒有提供原子命令, 所以我們需要想點辦法

Redis在 2.6 推出了指令碼功能, 允許開發者使用 Lua 語言編寫指令碼傳到 Redis 中執行

使用 Lua 指令碼有什麼好處呢?

1、減少網路開銷

原本我們需要向 Redis 服務請求多次命令, 可以將命令寫在 Lua 指令碼中, 這樣執行只會發起一次網路請求

2、原子操作

Redis 會將 Lua 指令碼中的命令當作一個整體執行, 中間不會插入其它命令

3、複用(大家自己探索哈)

客戶端傳送的腳步會儲存 Redis 中, 其他客戶端可以複用這一指令碼而不需要使用程式碼完成相同的邏輯

那我們編寫一個簡單的 Lua 指令碼實現原子刪除操作

重點就在 Lua 指令碼這一塊, 重點說一下這塊的邏輯

script 指令碼就是我們在 Redis 中執行的 Lua 指令碼, 後面跟的兩個 List 分別是 KEYS、ARGV

cache.eval(script, Lists.newArrayList(lockKey), Lists.newArrayList(lockValue));

KEYS[1]: lockKey

ARGV[1]: lockValue

程式碼不是很多, 也比較簡單, 就是在 Java 中程式碼實現的邏輯放到了一個 Lua 指令碼中

# 獲取 KEYS[1] 對應的 Val
local cliVal = redis.call('get', KEYS[1])
# 判斷 KEYS[1] 與 ARGV[1] 是否保持一致
if(cliVal == ARGV[1]) then 
  # 刪除 KEYS[1]
  redis.call('del', KEYS[1]) 
  return 'OK' 
else
  return nil 
end

到了這種程度, 已經可以放到一些併發量不大的專案中生產使用了

TODO 列表

雖然上述程式碼已經很大程度上解決了分散式鎖可能存在的一些問題

但是下述列出的問題部分就不是客戶端程式碼範疇內的事情了

  • 如何實現可重入鎖

  • 如何解決程式碼執行鎖超時

  • 主從節點同步資料丟失導致鎖丟失問題

上述問題等下一篇介紹 Redisson 原始碼時會一一說明, 順道向大家推出一款 Redis 官方推薦的客戶端: Redisson

Jedis… 等客戶端平常使用是綽綽有餘的, 但是在功能上還是和 Redisoon 比不了

並不是推薦一定要用 Redisson, 根據不同場景選用不同客戶端

Redisson 就是為分散式提供 各種不同鎖以及多樣化的技術支援, 感興趣的小夥伴可以看一下 Giuhub 上的介紹, 挺詳細的

下一篇文章會詳細介紹 Redisson 分散式鎖的加鎖、解鎖原理

看了 Redisson 的原始碼後簡直了… 然後對之前專案的分散式鎖做了重構, 在原有基礎上增加了如下功能

  • 保證了加鎖、解鎖之間的原子性
  • 可重入的分散式鎖
  • 分散式鎖自動延期功能

文末總結

本篇文章從最簡單的分散式鎖說起, 一步一步的講述存在的問題以及解決方式, 最終得到個基本可用的分散式鎖

但是事無絕對, 對於分散式鎖的應用, 還是推薦在 程式碼以及資料庫表中新增兜底策略, 樂觀鎖等措施

另外向大家推薦了一款功能強大的 Redis 客戶端, 感興趣的小夥伴可以引入試下 ?



推薦閱讀

JDK 執行緒池使用不當引發的飢餓死鎖問題

JDK執行緒池中不超最大執行緒數快速消費任務

JDK 執行緒池如何保證核心執行緒不被銷燬

如何處理 JDK 執行緒池內執行緒執行異常

謹慎使用 Java8 新特性 ParallelStream

相關文章