回覆 PDF 領取資料 html
這是悟空的第 99 篇原創文章前端
做者 | 悟空聊架構java
來源 | 悟空聊架構(ID:PassJava666)git
轉載請聯繫受權(微信ID:PassJava)github
上篇咱們講到如何用本地內存:《緩存實戰(上篇)》 來作緩存從而加強系統的性能,另外探討了加鎖解決緩存擊穿的問題。可是本地加鎖
的方式在分佈式的場景下就不適用了,因此本文咱們來探討下如何引入分佈式鎖
解決本地鎖的問題。本篇全部代碼和業務基於個人開源項目 PassJava。redis
本篇主要內容以下:docker
1、本地鎖的問題
首先咱們來回顧下本地鎖的問題:數據庫
目前題目微服務被拆分紅了四個微服務。前端請求進來時,會被轉發到不一樣的微服務。假如前端接收了 10 W 個請求,每一個微服務接收 2.5 W 個請求,假如緩存失效了,每一個微服務在訪問數據庫時加鎖,經過鎖(synchronzied
或 lock
)來鎖住本身的線程資源,從而防止緩存擊穿
。小程序
這是一種本地加鎖
的方式,在分佈式
狀況下會帶來數據不一致的問題:好比服務 A 獲取數據後,更新緩存 key =100,服務 B 不受服務 A 的鎖限制,併發去更新緩存 key = 99,最後的結果多是 99 或 100,但這是一種未知的狀態,與指望結果不一致。流程圖以下所示:後端
2、什麼是分佈式鎖
基於上面本地鎖的問題,咱們須要一種支持分佈式集羣環境下的鎖:查詢 DB 時,只有一個線程能訪問,其餘線程都須要等待第一個線程釋放鎖資源後,才能繼續執行。
生活中的案例:能夠把鎖當作房門外的一把鎖
,全部併發線程比做人
,他們都想進入房間,房間內只能有一我的進入。當有人進入後,將門反鎖,其餘人必須等待,直到進去的人出來。
咱們來看下分佈式鎖的基本原理,以下圖所示:
咱們來分析下上圖的分佈式鎖:
-
1.前端將 10W 的高併發請求轉發給四個題目微服務。
-
2.每一個微服務處理 2.5 W 個請求。
-
3.每一個處理請求的線程在執行業務以前,須要先搶佔鎖。能夠理解爲「佔坑」。
-
4.獲取到鎖的線程在執行完業務後,釋放鎖。能夠理解爲「釋放坑位」。
-
5.未獲取到的線程須要等待鎖釋放。
-
6.釋放鎖後,其餘線程搶佔鎖。
-
7.重複執行步驟 四、五、6。
大白話解釋:全部請求的線程都去同一個地方「佔坑」
,若是有坑位,就執行業務邏輯,沒有坑位,就須要其餘線程釋放「坑位」。這個坑位是全部線程可見的,能夠把這個坑位放到 Redis 緩存或者數據庫,這篇講的就是如何用 Redis 作「分佈式坑位」
。
3、Redis 的 SETNX
Redis 做爲一個公共可訪問的地方,正好能夠做爲「佔坑」的地方。
用 Redis 實現分佈式鎖的幾種方案,咱們都是用 SETNX 命令(設置 key 等於某 value)。只是高階方案傳的參數個數不同,以及考慮了異常狀況。
咱們來看下這個命令,SETNX
是set If not exist
的簡寫。意思就是當 key 不存在時,設置 key 的值,存在時,什麼都不作。
在 Redis 命令行中是這樣執行的:
set <key> <value> NX
咱們能夠進到 redis 容器中來試下 SETNX
命令。
先進入容器:
docker exec -it <容器 id> redid-cli
而後執行 SETNX 命令:將 wukong
這個 key 對應的 value 設置成 1111
。
set wukong 1111 NX
返回 OK
,表示設置成功。重複執行該命令,返回 nil
表示設置失敗。
4、青銅方案
咱們先用 Redis 的 SETNX 命令來實現最簡單的分佈式鎖。
3.1 青銅原理
咱們來看下流程圖:
-
多個併發線程都去 Redis 中申請鎖,也就是執行 setnx 命令,假設線程 A 執行成功,說明當前線程 A 得到了。
-
其餘線程執行 setnx 命令都會是失敗的,因此須要等待線程 A 釋放鎖。
-
線程 A 執行完本身的業務後,刪除鎖。
-
其餘線程繼續搶佔鎖,也就是執行 setnx 命令。由於線程 A 已經刪除了鎖,因此又有其餘線程能夠搶佔到鎖了。
代碼示例以下,Java 中 setnx 命令對應的代碼爲 setIfAbsent
。
setIfAbsent 方法的第一個參數表明 key,第二個參數表明值。
// 1.先搶佔鎖 Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "123"); if(lock) { // 2.搶佔成功,執行業務 List<TypeEntity> typeEntityListFromDb = getDataFromDB(); // 3.解鎖 redisTemplate.delete("lock"); return typeEntityListFromDb; } else { // 4.休眠一段時間 sleep(100); // 5.搶佔失敗,等待鎖釋放 return getTypeEntityListByRedisDistributedLock(); }
一個小問題:那爲何須要休眠一段時間?
由於該程序存在遞歸調用,可能會致使棧空間溢出。
3.2 青銅方案的缺陷
青銅之因此叫青銅,是由於它是最初級的,確定會帶來不少問題。
設想一種家庭場景:晚上小空一我的開鎖進入了房間,打開了電燈💡,而後忽然斷電
了,小空想開門出去,可是找不到門鎖位置,那小明就進不去了,外面的人也進不來。
從技術的角度看:setnx 佔鎖成功,業務代碼出現異常或者服務器宕機,沒有執行刪除鎖的邏輯,就形成了死鎖
。
那如何規避這個風險呢?
設置鎖的自動過時時間
,過一段時間後,自動刪除鎖,這樣其餘線程就能獲取到鎖了。
4、白銀方案
4.1 生活中的例子
上面提到的青銅方案會有死鎖問題,那咱們就用上面的規避風險的方案來設計下,也就是咱們的白銀方案。
仍是生活中的例子:小空開鎖成功後,給這款智能鎖設置了一個沙漏倒計時⏳
,沙漏完後,門鎖自動打開。即便房間忽然斷電,過一段時間後,鎖會自動打開,其餘人就能夠進來了。
4.2 技術原理圖
和青銅方案不一樣的地方在於,在佔鎖成功後,設置鎖的過時時間,這兩步是分步執行的。以下圖所示:
4.3 示例代碼
清理 redis key 的代碼以下
// 在 10s 之後,自動清理 lock redisTemplate.expire("lock", 10, TimeUnit.SECONDS);
完整代碼以下:
// 1.先搶佔鎖 Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "123"); if(lock) { // 2.在 10s 之後,自動清理 lock redisTemplate.expire("lock", 10, TimeUnit.SECONDS); // 3.搶佔成功,執行業務 List<TypeEntity> typeEntityListFromDb = getDataFromDB(); // 4.解鎖 redisTemplate.delete("lock"); return typeEntityListFromDb; }
4.4 白銀方案的缺陷
白銀方案看似解決了線程異常或服務器宕機形成的鎖未釋放的問題,但仍是存在其餘問題:
由於佔鎖和設置過時時間是分兩步執行的,因此若是在這兩步之間發生了異常,則鎖的過時時間根本就沒有設置成功。
因此和青銅方案有同樣的問題:鎖永遠不能過時。
5、黃金方案
5.1 原子指令
上面的白銀方案中,佔鎖和設置鎖過時時間是分步兩步執行的,這個時候,咱們能夠聯想到什麼:事務的原子性(Atom)。
原子性:多條命令要麼都成功執行,要麼都不執行。
將兩步放在一步中執行:佔鎖+設置鎖過時時間。
Redis 正好支持這種操做:
# 設置某個 key 的值並設置多少毫秒或秒 過時。 set <key> <value> PX <多少毫秒> NX 或 set <key> <value> EX <多少秒> NX
而後能夠經過以下命令查看 key 的變化
ttl <key>
下面演示下如何設置 key 並設置過時時間。注意:執行命令以前須要先刪除 key,能夠經過客戶端或命令刪除。
# 設置 key=wukong,value=1111,過時時間=5000ms
set wukong 1111 PX 5000 NX
# 查看 key 的狀態
ttl wukong
執行結果以下圖所示:每運行一次 ttl 命令,就能夠看到 wukong 的過時時間就會減小。最後會變爲 -2(已過時)。
5.2 技術原理圖
黃金方案和白銀方案的不一樣之處:獲取鎖的時候,也須要設置鎖的過時時間,這是一個原子操做,要麼都成功執行,要麼都不執行。以下圖所示:
5.3 示例代碼
設置 lock
的值等於 123
,過時時間爲 10 秒。若是 10
秒 之後,lock 還存在,則清理 lock。
setIfAbsent("lock", "123", 10, TimeUnit.SECONDS);
5.4 黃金方案的缺陷
咱們仍是舉生活中的例子來看下黃金方案的缺陷。
5.4.1 用戶 A 搶佔鎖
-
用戶 A 先搶佔到了鎖,並設置了這個鎖 10 秒之後自動開鎖,鎖的編號爲
123
。 -
10 秒之後,A 還在執行任務,此時鎖被自動打開了。
5.4.2 用戶 B 搶佔鎖
-
用戶 B 看到房間的鎖打開了,因而搶佔到了鎖,設置鎖的編號爲
123
,並設置了過時時間10 秒
。 -
因房間內只容許一個用戶執行任務,因此用戶 A 和 用戶 B 執行任務
產生了衝突
。 -
用戶 A 在
15 s
後,完成了任務,此時 用戶 B 還在執行任務。 -
用戶 A 主動打開了編號爲
123
的鎖。 -
用戶 B 還在執行任務,發現鎖已經被打開了。
-
用戶 B 很是生氣: 我還沒執行完任務呢,鎖怎麼開了?
5.4.3 用戶 C 搶佔鎖
-
用戶 B 的鎖被 A 主動打開後,A 離開房間,B 還在執行任務。
-
用戶 C 搶佔到鎖,C 開始執行任務。
-
因房間內只容許一個用戶執行任務,因此用戶 B 和 用戶 C 執行任務產生了衝突。
從上面的案例中咱們能夠知道,由於用戶 A 處理任務所須要的時間大於鎖自動清理(開鎖)的時間,因此在自動開鎖後,又有其餘用戶搶佔到了鎖。當用戶 A 完成任務後,會把其餘用戶搶佔到的鎖給主動打開。
這裏爲何會打開別人的鎖?由於鎖的編號都叫作 「123」
,用戶 A 只認鎖編號,看見編號爲 「123」
的鎖就開,結果把用戶 B 的鎖打開了,此時用戶 B 還未執行完任務,固然生氣了。
6、鉑金方案
6.1 生活中的例子
上面的黃金方案的缺陷也很好解決,給每一個鎖設置不一樣的編號不就行了~
以下圖所示,B 搶佔的鎖是藍色的,和 A 搶佔到綠色鎖不同。這樣就不會被 A 打開了。
作了個動圖,方便理解:

動圖演示
靜態圖更高清,能夠看看:
6.2 技術原理圖
與黃金方案的不一樣之處:
-
設置鎖的過時時間時,還須要設置惟一編號。
-
主動刪除鎖的時候,須要判斷鎖的編號是否和設置的一致,若是一致,則認爲是本身設置的鎖,能夠進行主動刪除。
6.3 代碼示例
// 1.生成惟一 id String uuid = UUID.randomUUID().toString(); // 2. 搶佔鎖 Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 10, TimeUnit.SECONDS); if(lock) { System.out.println("搶佔成功:" + uuid); // 3.搶佔成功,執行業務 List<TypeEntity> typeEntityListFromDb = getDataFromDB(); // 4.獲取當前鎖的值 String lockValue = redisTemplate.opsForValue().get("lock"); // 5.若是鎖的值和設置的值相等,則清理本身的鎖 if(uuid.equals(lockValue)) { System.out.println("清理鎖:" + lockValue); redisTemplate.delete("lock"); } return typeEntityListFromDb; } else { System.out.println("搶佔失敗,等待鎖釋放"); // 4.休眠一段時間 sleep(100); // 5.搶佔失敗,等待鎖釋放 return getTypeEntityListByRedisDistributedLock(); }
-
1.生成隨機惟一 id,給鎖加上惟一值。
-
2.搶佔鎖,並設置過時時間爲 10 s,且鎖具備隨機惟一 id。
-
3.搶佔成功,執行業務。
-
4.執行完業務後,獲取當前鎖的值。
-
5.若是鎖的值和設置的值相等,則清理本身的鎖。
6.4 鉑金方案的缺陷
上面的方案看似很完美,但仍是存在問題:第 4 步和第 5 步並非原子性的。
-
時刻:0s。線程 A 搶佔到了鎖。
-
時刻:9.5s。線程 A 向 Redis 查詢當前 key 的值。
-
時刻:10s。鎖自動過時。
-
時刻:11s。線程 B 搶佔到鎖。
-
時刻:12s。線程 A 在查詢途中耗時長,終於拿多鎖的值。
-
時刻:13s。線程 A 仍是拿本身設置的鎖的值和返回的值進行比較,值是相等的,清理鎖,可是這個鎖實際上是線程 B 搶佔的鎖。
那如何規避這個風險呢?鑽石方案登場。
7、鑽石方案
上面的線程 A 查詢鎖和刪除鎖的邏輯不是原子性
的,因此將查詢鎖和刪除鎖這兩步做爲原子指令操做就能夠了。
7.1 技術原理圖
以下圖所示,紅色圈出來的部分是鑽石方案的不一樣之處。用腳本進行刪除,達到原子操做。
7.2 代碼示例
那如何用腳本進行刪除呢?
咱們先來看一下這段 Redis 專屬腳本:
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
這段腳本和鉑金方案的獲取key,刪除key的方式很像。先獲取 KEYS[1] 的 value,判斷 KEYS[1] 的 value 是否和 ARGV[1] 的值相等,若是相等,則刪除 KEYS[1]。
那麼這段腳本怎麼在 Java 項目中執行呢?
分兩步:先定義腳本;用 redisTemplate.execute 方法執行腳本。
// 腳本解鎖 String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end"; redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList("lock"), uuid);
上面的代碼中,KEYS[1] 對應「lock」
,ARGV[1] 對應 「uuid」
,含義就是若是 lock 的 value 等於 uuid 則刪除 lock。
而這段 Redis 腳本是由 Redis 內嵌的 Lua 環境執行的,因此又稱做 Lua 腳本。
那鑽石方案是否是就完美了呢?有沒有更好的方案呢?
下篇,咱們再來介紹另一種分佈式鎖的王者方案:Redisson。
8、總結
本篇經過本地鎖的問題引伸出分佈式鎖的問題。而後介紹了五種分佈式鎖的方案,由淺入深講解了不一樣方案的改進之處。
從上面幾種方案的不斷演進的過程當中,知道了系統中哪些地方可能存在異常狀況,以及該如何更好地進行處理。
觸類旁通,這種不斷演進的思惟模式也能夠運用到其餘技術中。
下面總結下上面五種方案的缺陷和改進之處。
青銅方案:
-
缺陷:業務代碼出現異常或者服務器宕機,沒有執行主動刪除鎖的邏輯,就形成了死鎖。
-
改進:設置鎖的自動過時時間,過一段時間後,自動刪除鎖,這樣其餘線程就能獲取到鎖了。
白銀方案:
-
缺陷:佔鎖和設置鎖過時時間是分步兩步執行的,不是原子操做。
-
改進:佔鎖和設置鎖過時時間保證原子操做。
黃金方案:
-
缺陷:主動刪除鎖時,因鎖的值都是相同的,將其餘客戶端佔用的鎖刪除了。
-
改進:每次佔用的鎖,隨機設爲較大的值,主動刪除鎖時,比較鎖的值和本身設置的值是否相等。
鉑金方案:
-
缺陷:獲取鎖、比較鎖的值、刪除鎖,這三步是非原子性的。中途又可能鎖自動過時了,又被其餘客戶端搶佔了鎖,致使刪鎖時把其餘客戶端佔用的鎖刪了。
-
改進:使用 Lua 腳本進行獲取鎖、比較鎖、刪除鎖的原子操做。
鑽石方案:
-
缺陷:非專業的分佈式鎖方案。
-
改進:Redission 分佈式鎖。
王者方案,下篇見~
上述全部代碼都基於 PassJava 開源項目,後端、前端、小程序都上傳到同一個倉庫裏面了,你們能夠經過 github 或 碼雲訪問。地址以下:
Github: https://github.com/Jackson0714/PassJava-Platform
碼雲:https://gitee.com/jayh2018/PassJava-Platform
配套教程:www.passjava.cn
參考資料:
http://redis.cn/commands/set.html
https://www.bilibili.com/video/BV1np4y1C7Yf
- END -
寫了兩本 PDF, 回覆 分佈式 或 PDF 下 載。
個人 JVM 專欄已上架,回覆 JVM 領取
我是悟空,努力變強,變身超級賽亞人!
本文分享自微信公衆號 - 悟空聊架構(PassJava666)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。