1. 程式人生 > >造了一個 Redis 分佈鎖的輪子,沒想到還學到這麼多東西!!!

造了一個 Redis 分佈鎖的輪子,沒想到還學到這麼多東西!!!

## 書接上文 上篇文章「[MySQL 可重複讀,差點就讓我背上了一個 P0 事故!](https://studyidea.cn/mysql-rr-bug)」釋出之後,收到很多小夥伴們的留言,從中又學習到很多,總結一下。 ![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073932644-1578286838.jpg) 上篇文章可能舉得例子有點不恰當,導致有些小夥伴沒看懂為什麼餘額會變負。 ![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073932992-1795582857.gif) 這次我們舉得實際一點,還是上篇文章 account 表,假設 **id=1,balance=1000**,不過這次我們扣款 **1000**,兩個事務的時序圖如下: ![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073933500-412750065.jpg) 這次使用兩個命令視窗真實執行一把: ![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073933872-1392031325.jpg) 注意事務 2,③處查詢到 **id=1,balance=1000**,但是實際上由於此時事務 1 已經提交,最新結果如②處所示 **id=1,balance=900**。 本來 Java 程式碼層會做一層餘額判斷: ```java if (balance - amount < 0) { throw new XXException("餘額不足,扣減失敗"); } ``` 但是此時由於 ③ 處使用快照讀,讀到是個舊值,未讀到最新值,導致這層校驗失效,從而程式碼繼續往下執行,執行了資料更新。 更新語句又採用如下寫法: ```sql UPDATE account set balance=balance-1000 WHERE id =1; ``` 這條更新語句又必須是在這條記錄的最新值的基礎做更新,更新語句執行結束,這條記錄就變成了 **id=1,balance=-1000**。 ![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073934511-1263757950.gif) 之前有朋友疑惑 t12 更新之後,再次進行快照讀,結果會是多少。 上圖執行結果 ④ 可以看到結果為 **id=1,balance=-1000**,可以看到已經查詢最新的結果記錄。 這行資料最新版本由於是事務 2 自己更新的,**自身事務更新永遠對自己可見**。 另外這次問題上本質上因為 Java 層與資料庫層資料不一致導致,有的朋友留言提出,可以在更新餘額時加一層判斷: ```sql UPDATE account set balance=balance-1000 WHERE id =1 and balance>0; ``` 然後更新完成,Java 層判斷更新有效行數是否大於 0。這種做法確實能規避這個問題。 最後這位朋友留言總結的挺好,貼上一下: ![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073934883-216786857.jpg) >先贊後看,微信搜尋「程式通事」,關注就完事了 ## 手擼分散式鎖 現在切回正文,這篇文章本來是準備寫下 Mysql 查詢左匹配的問題,但是還沒研究出來。那就先寫下最近在鼓搗一個東西,使用 Redis 實現可重入分佈鎖。 看到這裡,有的朋友可能會提出來使用 **redisson** 不香嗎,為什麼還要自己實現? 哎,**redisson** 真的很香,但是現有專案中沒辦法使用,只好自己手擼一個可重入的分散式鎖了。 雖然用不了 **redisson**,但是我可以研究其原始碼,最後實現的可重入分佈鎖參考了 **redisson** 實現方式。 ## 分散式鎖 分散式鎖特性就要在於排他性,同一時間內多個呼叫方加鎖競爭,只能有一個呼叫方加鎖成功。 Redis 由於內部單執行緒的執行,內部按照請求先後順序執行,沒有併發衝突,所以只會有一個呼叫方才會成功獲取鎖。 而且 Redis 基於記憶體操作,加解鎖速度效能高,另外我們還可以使用叢集部署增強 Redis 可用性。 ### 加鎖 使用 Redis 實現一個簡單的分散式鎖,非常簡單,可以直接使用 **SETNX** 命令。 **SETNX** 是『SET if Not eXists』,如果不存在,才會設定,使用方法如下:![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073935115-1948756209.jpg) 不過直接使用 **SETNX** 有一個缺陷,我們沒辦法對其設定過期時間,如果加鎖客戶端宕機了,這就導致這把鎖獲取不了了。 有的同學可能會提出,執行 **SETNX** 之後,再執行 **EXPIRE** 命令,主動設定過期時間,偽碼如下: ```lua var result = setnx lock "client" if(result==1){ // 有效期 30 s expire lock 30 } ``` 不過這樣還是存在缺陷,加鎖程式碼並不能原子執行,如果呼叫加鎖語句,還沒來得及設定過期時間,應用就宕機了,還是會存在鎖過期不了的問題。 不過這個問題在 Redis 2.6.12 版本 就可以被完美解決。這個版本增強了 SET 命令,可以通過帶上 NX,EX 命令原子執行加鎖操作,解決上述問題。引數含義如下: - EX second :設定鍵的過期時間,單位為秒 - NX 當鍵不存在時,進行設定操作,等同與 SETNX 操作 使用 SET 命令實現分散式鎖只需要一行程式碼: ```shell SET lock_name anystring NX EX lock_time ``` ### 解鎖 解鎖相比加鎖過程,就顯得非常簡單,只要呼叫 `DEL` 命令刪除鎖即可: ``` DEL lock_name ``` 不過這種方式卻存在一個缺陷,可能會發生錯解鎖問題。 假設應用 1 加鎖成功,鎖超時時間為 30s。由於應用 1 業務邏輯執行時間過長,30 s 之後,鎖過期自動釋放。 這時應用 2 接著加鎖,加鎖成功,執行業務邏輯。這個期間,應用 1 終於執行結束,使用 `DEL` 成功釋放鎖。 這樣就導致了應用 1 錯誤釋放應用 2 的鎖,另外鎖被釋放之後,其他應用可能再次加鎖成功,這就可能導致業務重複執行。 ![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073935698-941204302.png) 為了使鎖不被錯誤釋放,我們需要在加鎖時設定隨機字串,比如 UUID。 ```lua SET lock_name uuid NX EX lock_time ``` 釋放鎖時,需要提前獲取當前鎖儲存的值,然後與加鎖時的 uuid 做比較,虛擬碼如下: ```lua var value= get lock_name if value == uuid // 釋放鎖成功 else // 釋放鎖失敗 ``` 上述程式碼我們不能通過 Java 程式碼執行,因為無法保證上述程式碼原子化執行。 幸好 Redis 2.6.0 增加執行 Lua 指令碼的功能,lua 程式碼可以執行在 Redis 伺服器的上下文中,並且整個操作將會被當成一個整體執行,中間不會被其他命令插入。 這就保證了指令碼將會以原子性的方式執行,當某個指令碼正在執行的時候,不會有其他指令碼或 Redis 命令被執行。在其他的別的客戶端看來,執行指令碼的效果,要麼是不可見的,要麼就是已完成的。 ## EVAL 與 EVALSHA ### EVAL Redis 可以使用 EVAL 執行 LUA 指令碼,而我們可以在 LUA 指令碼中執行判斷求值邏輯。EVAL 執行方式如下: ```shell EVAL script numkeys key [key ...] arg [arg ...] ``` `numkeys` 引數用於建明引數,即後面 key 陣列的個數。 `key [key ...]` 代表需要在指令碼中用到的所有 Redis key,在 Lua 指令碼使用使用陣列的方式訪問 key,類似如下 `KEYS[1]` , `KEYS[2]`。注意 Lua 陣列起始位置與 Java 不同,Lua 陣列是從 1 開始。 命令最後,是一些附加引數,可以用來當做 Redis Key 值儲存的 Value 值,使用方式如 `KEYS` 變數一樣,類似如下:`ARGV[1]` 、 `ARGV[2]` 。 用一個簡單例子執行一下 EVAL 命令: ```shell eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2],ARGV[3]}" 2 key1 key2 first second third ``` 執行效果如下: ![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073936003-97799494.jpg) 可以看到 `KEYS` 與 `ARGVS`內部陣列可以不一致。 在 Lua 指令碼可以使用下面兩個函式執行 Redis 命令: - redis.call() - redis.pcall() 兩個函式作用法與作用完全一致,只不過對於錯誤的處理方式不一致,感興趣的小夥伴可以具體點選以下連結,檢視錯誤處理一章。 http://doc.redisfans.com/script/eval.html 下面我們統一在 Lua 指令碼中使用 `redis.call()`,執行以下命令: ```shell eval "return redis.call('set',KEYS[1],ARGV[1])" 1 foo 樓下小黑哥 ``` 執行效果如下: ![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073936258-891721940.jpg) ### EVALSHA `EVAL` 命令每次執行時都需要傳送 Lua 指令碼,但是 Redis 並不會每次都會重新編譯指令碼。 當 Redis 第一次收到 Lua 指令碼時,首先將會對 Lua 指令碼進行 **sha1** 獲取簽名值,然後內部將會對其快取起來。後續執行時,直接通過 **sha1** 計算過後簽名值查詢已經編譯過的指令碼,加快執行速度。 雖然 Redis 內部已經優化執行的速度,但是每次都需要傳送指令碼,還是有網路傳輸的成本,如果指令碼很大,這其中花在網路傳輸的時間就會相應的增加。 所以 Redis 又實現了 `EVALSHA` 命令,原理與 `EVAL` 一致。只不過 `EVALSHA` 只需要傳入指令碼經過 **sha1**計算過後的簽名值即可,這樣大大的減少了傳輸的位元組大小,減少了網路耗時。 `EVALSHA`命令如下: ```lua evalsha c686f316aaf1eb01d5a4de1b0b63cd233010e63d 1 foo 樓下小黑哥 ``` 執行效果如下: ![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073936676-406674675.jpg) >
`SCRIPT FLUSH` 命令用來清除所有 Lua 指令碼快取。 可以看到,如果之前未執行過 `EVAL`命令,直接執行 `EVALSHA` 將會報錯。 ### 優化執行 EVAL 我們可以結合使用 `EVAL` 與 `EVALSHA`,優化程式。下面就不寫偽碼了,以 Jedis 為例,優化程式碼如下: ```java //連線本地的 Redis 服務 Jedis jedis = new Jedis("localhost", 6379); jedis.auth("1234qwer"); System.out.println("服務正在執行: " + jedis.ping()); String lua_script = "return redis.call('set',KEYS[1],ARGV[1])"; String lua_sha1 = DigestUtils.sha1DigestAsHex(lua_script); try { Object evalsha = jedis.evalsha(lua_sha1, Lists.newArrayList("foo"), Lists.newArrayList("樓下小黑哥")); } catch (Exception e) { Throwable current = e; while (current != null) { String exMessage = current.getMessage(); // 包含 NOSCRIPT,代表該 lua 指令碼從未被執行,需要先執行 eval 命令 if (exMessage != null && exMessage.contains("NOSCRIPT")) { Object eval = jedis.eval(lua_script, Lists.newArrayList("foo"), Lists.newArrayList("樓下小黑哥")); break; } } } String foo = jedis.get("foo"); System.out.println(foo); ``` 上面的程式碼看起來還是很複雜吧,不過這是使用原生 jedis 的情況下。如果我們使用 Spring Boot 的話,那就沒這麼麻煩了。Spring 元件執行的 `Eval` 方法內部就包含上述程式碼的邏輯。 不過需要注意的是,如果 Spring-Boot 使用 Jedis 作為連線客戶端,並且使用Redis Cluster 叢集模式,需要使用 **2.1.9** 以上版本的**spring-boot-starter-data-redis**,不然執行過程中將會丟擲: ```verilog org.springframework.dao.InvalidDataAccessApiUsageException: EvalSha is not supported in cluster environment. ``` 詳細情況可以參考這個修復的 **Issue**[Add support for scripting commands with Jedis Cluster](https://jira.spring.io/browse/DATAREDIS-1005) ## 優化分散式鎖 講完 Redis 執行 LUA 指令碼的相關命令,我們來看下如何優化上面的分散式鎖,使其無法釋放其他應用加的鎖。 >
以下程式碼基於 spring-boot 2.2.7.RELEASE 版本,Redis 底層連線使用 Jedis。 加鎖的 Redis 命令如下: ```shell SET lock_name uuid NX EX lock_time ``` 加鎖程式碼如下: ```java /** * 非阻塞式加鎖,若鎖存在,直接返回 * * @param lockName 鎖名稱 * @param request 唯一標識,防止其他應用/執行緒解鎖,可以使用 UUID 生成 * @param leaseTime 超時時間 * @param unit 時間單位 * @return */ public Boolean tryLock(String lockName, String request, long leaseTime, TimeUnit unit) { // 注意該方法是在 spring-boot-starter-data-redis 2.1 版本新增加的,若是之前版本 可以執行下面的方法 return stringRedisTemplate.opsForValue().setIfAbsent(lockName, request, leaseTime, unit); } ``` 由於`setIfAbsent`方法是在 spring-boot-starter-data-redis 2.1 版本新增加,之前版本無法設定超時時間。如果使用之前的版本的,需要如下方法: ```java /** * 適用於 spring-boot-starter-data-redis 2.1 之前的版本 * * @param lockName * @param request * @param leaseTime * @param unit * @return */ public Boolean doOldTryLock(String lockName, String request, long leaseTime, TimeUnit unit) { Boolean result = stringRedisTemplate.execute((RedisCallback) connection -> { RedisSerializer valueSerializer = stringRedisTemplate.getValueSerializer(); RedisSerializer keySerializer = stringRedisTemplate.getKeySerializer(); Boolean innerResult = connection.set(keySerializer.serialize(lockName), valueSerializer.serialize(request), Expiration.from(leaseTime, unit), RedisStringCommands.SetOption.SET_IF_ABSENT ); return innerResult; }); return result; } ``` 解鎖需要使用 Lua 指令碼: ```lua -- 解鎖程式碼 -- 首先判斷傳入的唯一標識是否與現有標識一致 -- 如果一致,釋放這個鎖,否則直接返回 if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end ``` 這段指令碼將會判斷傳入的唯一標識是否與 Redis 儲存的標示一致,如果一直,釋放該鎖,否則立刻返回。 釋放鎖的方法如下: ```java /** * 解鎖 * 如果傳入應用標識與之前加鎖一致,解鎖成功 * 否則直接返回 * @param lockName 鎖 * @param request 唯一標識 * @return */ public Boolean unlock(String lockName, String request) { DefaultRedisScript unlockScript = new DefaultRedisScript<>(); unlockScript.setLocation(new ClassPathResource("simple_unlock.lua")); unlockScript.setResultType(Boolean.class); return stringRedisTemplate.execute(unlockScript, Lists.newArrayList(lockName), request); } ``` > 由於公號外鏈無法直接跳轉,關注『程式通事』,回覆**分散式鎖**獲取原始碼。 ## Redis 分散式鎖的缺陷 ### 無法重入 由於上述加鎖命令使用了 `SETNX` ,一旦鍵存在就無法再設定成功,這就導致後續同一執行緒內繼續加鎖,將會加鎖失敗。 如果想將 Redis 分散式鎖改造成可重入的分散式鎖,有兩種方案: - 本地應用使用 ThreadLocal 進行重入次數計數,加鎖時加 1,解鎖時減 1,當計數變為 0 釋放鎖 - 第二種,使用 Redis Hash 表儲存可重入次數,使用 Lua 指令碼加鎖/解鎖 第一種方案可以參考這篇文章[分散式鎖的實現之 redis 篇](https://xiaomi-info.github.io/2019/12/17/redis-distributed-lock/)。第二個解決方案,下一篇文章就會具體來聊聊,敬請期待。 ![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073936936-2074260268.jpg) ### 鎖超時釋放 假設執行緒 A 加鎖成功,鎖超時時間為 30s。由於執行緒 A 內部業務邏輯執行時間過長,30s 之後鎖過期自動釋放。 此時執行緒 B 成功獲取到鎖,進入執行內部業務邏輯。此時執行緒 A 還在執行執行業務,而執行緒 B 又進入執行這段業務邏輯,這就導致業務邏輯重複被執行。 這個問題我覺得,一般由於鎖的超時時間設定不當引起,可以評估下業務邏輯執行時間,在這基礎上再延長一下超時時間。 如果超時時間設定合理,但是業務邏輯還有偶發的超時,個人覺得需要排查下業務執行過長的問題。 如果說一定要做到業務執行期間,鎖只能被一個執行緒佔有的,那就需要增加一個守護執行緒,定時為即將的過期的但未釋放的鎖增加有效時間。 ![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073937138-1582903812.jpg) 加鎖成功後,同時建立一個守護執行緒。守護執行緒將會定時檢視鎖是否即將到期,如果鎖即將過期,那就執行 **EXPIRE** 等命令重新設定過期時間。 說實話,如果要這麼做,真的挺複雜的,感興趣的話可以參考下 **redisson watchdog** 實現方式。 ### Redis 分散式鎖叢集問題 為了保證生產高可用,一般我們會採用主從部署方式。採用這種方式,我們可以將讀寫分離,主節點提供寫服務,從節點提供讀服務。 Redis 主從之間資料同步採用非同步複製方式,主節點寫入成功後,立刻返回給客戶端,然後非同步複製給從節點。 如果資料寫入主節點成功,但是還未複製給從節點。此時主節點掛了,從節點立刻被提升為主節點。 這種情況下,還未同步的資料就丟失了,其他執行緒又可以被加鎖了。 針對這種情況, Redis 官方提出一種 **RedLock** 的演算法,需要有 N 個Redis 主從節點,解決該問題,詳情參考: https://redis.io/topics/distlock。 這個演算法自己實現還是很複雜的,幸好 **redisson** 已經實現的 **RedLock**,詳情參考:[redisson redlock](https://github.com/redisson/redisson/wiki/8.-%E5%88%86%E5%B8%83%E5%BC%8F%E9%94%81%E5%92%8C%E5%90%8C%E6%AD%A5%E5%99%A8#84-%E7%BA%A2%E9%94%81redlock) ## 總結 本來這篇文章是想寫 Redis 可重入分散式鎖的,可是沒想到寫分散式鎖的實現方案就已經寫了這麼多,再寫下去,文章可能就很長,所以拆分成兩篇來寫。 嘿嘿,這不下星期不用想些什麼了,真是個小機靈鬼~ ![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073937365-1826120615.jpg) 好了,幫大家再次總結一下本文內容。 簡單的 Redis 分散式鎖的實現方式還是很簡單的,我們可以直接用 SETNX/DEL 命令實現加解鎖。 不過這種實現方式不夠健壯,可能存在應用宕機,鎖就無法被釋放的問題。 所以我們接著引入以下命令以及 Lua 指令碼增強 Redis 分散式鎖。 ```shell SET lock_name anystring NX EX lock_time ``` 最後 Redis 分佈鎖還是存在一些缺陷,在這裡提出一些解決方案,感興趣同學可以自己實現一下。 下篇文章再來將將 Redis 可重入分散式鎖~ ## 參考資料 1. [分散式鎖的實現之 redis 篇](https://xiaomi-info.github.io/2019/12/17/redis-distributed-lock/) 2. [基於 Redis 的分散式鎖](https://crossoverjie.top/2018/03/29/distributed-lock/distributed-lock-redis/) > 歡迎關注我的公眾號:程式通事,獲得日常乾貨推送。如果您對我的專題內容感興趣,也可以關注我的部落格:[studyidea.cn](https://studyi