Redis 分散式鎖|從青銅到鑽石的五種演進方案
本篇主要內容如下:

一、本機鎖定的問題
#首先我們來回顧下本地鎖定的問題:
目前題目微服務被拆分成了四個微服務。前端請求進來時,會被轉送到不同的微服務。假如前端接收了10 W 個請求,每個微服務接收2.5 W 個請求,假如快取失效了,每個微服務在存取資料庫時加鎖,透過鎖(synchronzied
或lock
)來鎖住自己的執行緒資源,從而防止快取擊穿
。
這是一種本地加鎖
的方式,在分散式
情況下會帶來資料不一致的問題:例如服務A 取得資料後,更新緩存key =100,服務B 不受服務A 的鎖定限制,並發去更新快取key = 99,最後的結果可能是99 或100,但這是未知的狀態,與期望結果不一致 。流程圖如下:

二、什麼是分散式鎖定
基於上面本地鎖定的問題,我們需要一個支援分散式叢集環境下的鎖定:查詢DB 時,只有一個執行緒可以訪問,其他執行緒都需要等待第一個執行緒釋放鎖定資源後,才能繼續執行。
生活中的案例:可以把鎖看成房門外的一把鎖定
,所有並發線程比作人
,他們都想進入房間,房間內只能有一個人進入。當有人進入後,將門反鎖,其他人必須等待,直到進去的人出來。

我們來看下分散式鎖定的基本原理,如下圖所示:

我們來分析下上圖的分散式鎖定:
1.前端將 10W 的高並發請求轉送給四個題目微服務。 2.每個微服務處理 2.5 W 個請求。 3.每個處理請求的執行緒在執行業務之前,需要先搶佔鎖定。可以理解為「佔坑」。 4.取得到鎖定的執行緒在執行完業務後,釋放鎖定。可以理解為「釋放坑位」。 5.未取得的執行緒需要等待鎖定釋放。 6.釋放鎖定後,其他執行緒搶佔鎖定。 7.重複步驟 4、5、6。
大白話解釋:所有請求的線程都去同一個地方「佔坑」
,如果有坑位,就執行業務邏輯,沒有坑位,就需要其他線程釋放“坑位”。這個坑位是所有執行緒可見的,可以把這個坑位放到 Redis 快取或資料庫,這篇講的就是如何用 Redis 做「分散式坑位」
。
三、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> redis-cli
然后执行 SETNX 命令:将 wukong
这个 key 对应的 value 设置成 1111
。
set wukong 1111 NX
返回 OK
,表示设置成功。重复执行该命令,返回 nil
表示设置失败。

四、青铜方案
我们先用 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.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.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.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 抢占的锁。
那如何规避这个风险呢?钻石方案登场。
七、钻石方案
上面的线程 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。
八、總結
本篇透過本地鎖定的問題引申出分散式鎖定的問題。接著介紹了五種分散式鎖的方案,由淺入深講解了不同方案的改進之處。
從上面幾種方案的不斷演進的過程中,知道了系統中哪些地方可能存在異常情況,以及該如何更好地進行處理。
舉一反三,這種不斷演進的思維模式也可以運用到其他技術中。
以下總結下上面五種方案的缺陷和改進之處。
青銅方案:
缺陷:業務程式碼出現例外或伺服器當機,沒有執行主動刪除鎖的邏輯,就造成了死鎖。 改進:設定鎖的自動過期時間,過一段時間後,自動刪除鎖,這樣其他執行緒就能取得到鎖了。
白銀方案:
#缺陷:佔鎖和設定鎖定過期時間是逐步兩步驟執行的,不是原子操作。 改進:佔鎖和設定鎖定過期時間保證原子操作。
黃金方案:
#缺陷:主動刪除鎖定時,因鎖的值都是相同的,將其他客戶端佔用的鎖刪除了。 改進:每次佔用的鎖,隨機設為較大的值,主動刪除鎖定時,比較鎖的值和自己設定的值是否相等。
鉑金方案:
#缺陷:取得鎖定、比較鎖定的值、刪除鎖定,這三步驟是非原子性的。中途又可能鎖自動過期了,又被其他客戶端搶佔了鎖,導致刪鎖時把其他客戶端佔用的鎖刪了。 改進:使用 Lua 腳本進行取得鎖定、比較鎖定、刪除鎖定的原子操作。
鑽石方案:
缺陷:非專業的分散式鎖定方案。 改進:Redission 分散式鎖定。
王者方案,下篇見~
以上是Redis 分散式鎖|從青銅到鑽石的五種演進方案的詳細內容。更多資訊請關注PHP中文網其他相關文章!

熱AI工具

Undresser.AI Undress
人工智慧驅動的應用程序,用於創建逼真的裸體照片

AI Clothes Remover
用於從照片中去除衣服的線上人工智慧工具。

Undress AI Tool
免費脫衣圖片

Clothoff.io
AI脫衣器

Video Face Swap
使用我們完全免費的人工智慧換臉工具,輕鬆在任何影片中換臉!

熱門文章

熱工具

記事本++7.3.1
好用且免費的程式碼編輯器

SublimeText3漢化版
中文版,非常好用

禪工作室 13.0.1
強大的PHP整合開發環境

Dreamweaver CS6
視覺化網頁開發工具

SublimeText3 Mac版
神級程式碼編輯軟體(SublimeText3)

Redis集群模式通過分片將Redis實例部署到多個服務器,提高可擴展性和可用性。搭建步驟如下:創建奇數個Redis實例,端口不同;創建3個sentinel實例,監控Redis實例並進行故障轉移;配置sentinel配置文件,添加監控Redis實例信息和故障轉移設置;配置Redis實例配置文件,啟用集群模式並指定集群信息文件路徑;創建nodes.conf文件,包含各Redis實例的信息;啟動集群,執行create命令創建集群並指定副本數量;登錄集群執行CLUSTER INFO命令驗證集群狀態;使

如何清空 Redis 數據:使用 FLUSHALL 命令清除所有鍵值。使用 FLUSHDB 命令清除當前選定數據庫的鍵值。使用 SELECT 切換數據庫,再使用 FLUSHDB 清除多個數據庫。使用 DEL 命令刪除特定鍵。使用 redis-cli 工具清空數據。

要從 Redis 讀取隊列,需要獲取隊列名稱、使用 LPOP 命令讀取元素,並處理空隊列。具體步驟如下:獲取隊列名稱:以 "queue:" 前綴命名,如 "queue:my-queue"。使用 LPOP 命令:從隊列頭部彈出元素並返回其值,如 LPOP queue:my-queue。處理空隊列:如果隊列為空,LPOP 返回 nil,可先檢查隊列是否存在再讀取元素。

在CentOS系統上,您可以通過修改Redis配置文件或使用Redis命令來限制Lua腳本的執行時間,從而防止惡意腳本佔用過多資源。方法一:修改Redis配置文件定位Redis配置文件:Redis配置文件通常位於/etc/redis/redis.conf。編輯配置文件:使用文本編輯器(例如vi或nano)打開配置文件:sudovi/etc/redis/redis.conf設置Lua腳本執行時間限制:在配置文件中添加或修改以下行,設置Lua腳本的最大執行時間(單位:毫秒)

使用 Redis 命令行工具 (redis-cli) 可通過以下步驟管理和操作 Redis:連接到服務器,指定地址和端口。使用命令名稱和參數向服務器發送命令。使用 HELP 命令查看特定命令的幫助信息。使用 QUIT 命令退出命令行工具。

Redis計數器是一種使用Redis鍵值對存儲來實現計數操作的機制,包含以下步驟:創建計數器鍵、增加計數、減少計數、重置計數和獲取計數。 Redis計數器的優勢包括速度快、高並發、持久性和簡單易用。它可用於用戶訪問計數、實時指標跟踪、遊戲分數和排名以及訂單處理計數等場景。

Redis數據過期策略有兩種:定期刪除:定期掃描刪除過期鍵,可通過 expired-time-cap-remove-count、expired-time-cap-remove-delay 參數設置。惰性刪除:僅在讀取或寫入鍵時檢查刪除過期鍵,可通過 lazyfree-lazy-eviction、lazyfree-lazy-expire、lazyfree-lazy-user-del 參數設置。

在Debian系統中,readdir系統調用用於讀取目錄內容。如果其性能表現不佳,可嘗試以下優化策略:精簡目錄文件數量:盡可能將大型目錄拆分成多個小型目錄,降低每次readdir調用處理的項目數量。啟用目錄內容緩存:構建緩存機制,定期或在目錄內容變更時更新緩存,減少對readdir的頻繁調用。內存緩存(如Memcached或Redis)或本地緩存(如文件或數據庫)均可考慮。採用高效數據結構:如果自行實現目錄遍歷,選擇更高效的數據結構(例如哈希表而非線性搜索)存儲和訪問目錄信
