Redis分散式鎖常見坑點分析

来源:https://www.cnblogs.com/wayn111/archive/2023/03/03/17175404.html
-Advertisement-
Play Games

日常開發中,基於 Redis 天然支持分散式鎖,大家線上上分散式項目中都使用過 Redis 鎖。本文主要針對日常開發中加鎖過程中某些異常場景進行講解與分析。本文講解示例代碼都在 https://github.com/wayn111/newbee-mall-pro 項目 test 目錄下 RedisL ...


日常開發中,基於 Redis 天然支持分散式鎖,大家線上上分散式項目中都使用過 Redis 鎖。本文主要針對日常開發中加鎖過程中某些異常場景進行講解與分析。本文講解示例代碼都在 https://github.com/wayn111/newbee-mall-pro 項目 test 目錄下 RedisLockTest 類中。

版本聲明:

一、任務超時,鎖已經過期

這個異常場景說實話發生概率很低,大部分情況下加鎖時任務執行都會很快,鎖還沒到期,任務自己就會刪除鎖。除非說任務調用第三方介面不穩定導致超時、資料庫查詢突然變得非常慢就可能會產生這個異常場景。

那怎麼處理這個異常嘞?大部分人可能都會回答添加一個定時任務,在定時任務內檢測鎖快過期時,進行續期操作。OK,這麼做好像是可以解決這個異常,那麼博主在這裡給出自己的見解。

1.1 先說一個暴論:如果料想到有這類異常產生,為什麼不在加鎖時,就把加鎖過期時間設置大一點

不管所續期還是增大加鎖時長,都會導致一個問題,其他線程會遲遲獲取不到鎖,一直被阻塞。那結果都一樣,為什麼不直接增大加鎖時間?

想法是好的,但是實際上,加鎖時間的設置是我們主觀臆斷的,我們無法保證這個加鎖代碼的執行時間一定在我們的鎖過期時間內。作為一個嚴謹的程式員,我們需要對我們的代碼有客觀認知,任務執行可能幾千上億萬次都是正常,但就是那麼一次它執行超時了,可能由於外部依賴、當前運行環境的異常導致。

1.2 直接不設置過期時間,任務不執行完,不釋放鎖

如果在加鎖時就不設置過期時間的話,理論上好像是可以解決這個問題,任務不執行完,鎖就不會釋放。但是作為程式員,總覺得哪裡怪怪的,任務不執行完,鎖就不會釋放!

仔細想想,我們一般在 try 中進行加鎖 在 finally 進行鎖釋放,這個好像也沒毛病哦。但是實際針對一些極端異常場景下,如果任務執行過程中,伺服器宕機、程式突然被殺掉、網路斷連等都可能造成這個鎖釋放不了,另一個任務就一直獲取不到鎖。

這個方案程式正常的情況下,可以滿足我們的要求,但是一旦發生異常將導致鎖無法釋放的後果,也就是說只要我們解決這個鎖在異常場景下無法釋放的問題,這個方案還是OK的。博主這裡直接給出方案:

在不設置過期時間的加鎖操作成功時,給一個預設過期時間比如三十秒,同時啟動一個定時任務,給我們的鎖進行自動續期,每隔 預設過期時間 / 3 秒後執行一次續期操作,發生鎖剩餘時長小於 預設過期時間 / 2 就重新賦值過期時長為三十秒。這樣的話,可以保證鎖必須由任務執行完才能釋放,當程式異常發生時,仍然能保證鎖會在三十秒內釋放。

1.3 設置過期時間,任務不執行完,不釋放鎖

這個方案本質上與方案二的解決方案相同,還是啟動定時任務進行續期操作,流程這裡不做多餘講述。需要註意的就是加鎖指定過期時間會比較符合我們的客觀認知。實際上他的底層邏輯跟方案二相同,無非就是定時任務執行間隔,鎖剩餘時長續期判斷要根據過期時間來計算。


綜合來看:方案三會最合適,符合我們的客觀認知,跟我們之前對 Redis 的使用邏輯較為相近。

二、線程B加鎖執行中未釋放鎖,線程A釋放了線程B的鎖

說實話我仔細思考了一下這個異常場景,發現這個異常是個偽命題,如果線程 B 正在執行時,線程 A 怎麼能獲取到線程B的鎖!線程 A 獲取不到線程 B 的鎖,談何來去釋放線程 B 的鎖!如果線程 A 能獲取到線程 B 的鎖那麼這個分散式鎖的代碼一開始就已經錯了。

這裡回到這個異常場景本身,我們可以給每個線程設置請求ID,加鎖成功將請求ID設置為加鎖 key 的對應 value,線程釋放鎖時需要判斷當前線程的請求ID與
加鎖 key 的對應 value 是否相同,相同則可以釋放鎖,不相同則不允許釋放。

三、線程加鎖成功後繼續申請加鎖

這個場景主要發生在加鎖代碼內部調用棧過深,比如說加鎖成功執行方法 a,在方法 a 內又重覆申請了同一把鎖,導致線程把自己鎖住了,這個業界的主流叫法是叫鎖的可重入性。

解決方式有兩種,一是修改方法內的加鎖邏輯,不要加同一把鎖,修改方法 a 內的加鎖 key 名稱。二是針對加鎖邏輯做修改,實現可重入性。

這裡簡單介紹如何實現可重入性,給每個線程設置請求ID,加鎖成功將請求ID設置為加鎖 key 的對應 value,針對同一個線程的重覆加鎖,判斷當前線程已存在請求ID的情況下,請求ID直接與加鎖 key 的對應 value 相比較,相同則直接返回加鎖成功。

四、 代碼實踐

4.1 加鎖自動續期實踐

設置鎖過期時間為10秒,然後該任務執行15秒,代碼如下:

ps: 以下代碼都可以在 https://github.com/wayn111/newbee-mall-pro 項目 test 目錄下 RedisLockTest 類中找到

@Slf4j
@SpringBootTest
@RunWith(SpringRunner.class)
public class RedisLockTest {
    @Autowired
    private RedisLock redisLock;
    @Test
    @Test
    public void redisLockNeNewTest() {
        String key = "test";
        try {
            log.info("---申請加鎖");
            if (redisLock.lock(key, 10)) {
                // 模擬任務執行15秒
                log.info("---加鎖成功");
                Thread.sleep(15000);
                log.info("---執行完畢");
            }
        } catch (Exception e) {
            log.error(e.getMessage(), e);
        } finally {
            redisLock.unLock(key);
        }
    }
}

執行如下:

image.png

可以看出就算任務執行超過過期時間也能通過自動續期讓代碼正常執行。

4.2 多線程下其他線程無法共同申請到同一把鎖實踐

啟動兩個線程,線程 A 先加鎖, 線程 B 後枷鎖

@Test
public void redisLockReleaseSelfTest() throws IOException {
    new Thread(() -> {
        String key = "test";
        try {
            log.info("---申請加鎖");
            if (redisLock.lock(key, 10)) {
                // 模擬任務執行15秒
                log.info("---加鎖成功");
                Thread.sleep(15000);
                log.info("---執行完畢");
            } else {
                log.info("---加鎖失敗");
            }
        } catch (Exception e) {
            log.error(e.getMessage(), e);
        } finally {
            redisLock.unLock(key);
        }
    }, "thread-A").start();
    new Thread(() -> {
        String key = "test";
        try {
            Thread.sleep(100L);
            log.info("---申請加鎖");
            if (redisLock.lock(key, 10)) {
                // 模擬任務執行15秒
                log.info("---加鎖成功");
                Thread.sleep(15000);
                log.info("---執行完畢");
            } else {
                log.info("---加鎖失敗");
            }
        } catch (Exception e) {
            log.error(e.getMessage(), e);
        } finally {
            redisLock.unLock(key);
        }
    }, "thread-B").start();
    System.in.read();
}

結果如下:

image.png
可以看到,線程 A 先申請到鎖,線程 B 後申請鎖,結果線程 B 申請加鎖失敗。

4.3 鎖得可重入性實踐

當前線程加鎖成功後,線上程執行中繼續申請同一把鎖,代碼如下:

@Test
public void redisLockReEntryTest() {
    String key = "test";
    try {
        log.info("---申請加鎖");
        if (redisLock.lock(key, 10)) {
            // 模擬任務執行15秒
            log.info("---加鎖第一次成功");
            if (redisLock.lock(key, 10)) {
                // 模擬任務執行15秒
                log.info("---加鎖第二次成功");
                Thread.sleep(15000);
                log.info("---加鎖第二次執行完畢");
            } else {
                log.info("---加鎖第二次失敗");
            }
            Thread.sleep(15000);
            log.info("---加鎖第一次執行完畢");
        } else {
            log.info("---加鎖第一次失敗");
        }
    } catch (Exception e) {
        log.error(e.getMessage(), e);
    } finally {
        redisLock.unLock(key);
    }
}

結果如下:

image.png

4.4 加鎖邏輯講解

直接貼出本文最核心 RedisLock 類全部代碼:

@Slf4j
@Component
public class RedisLock {
    @Autowired
    public RedisTemplate redisTemplate;
    /**
     * 預設鎖過期時間20秒
     */
    public static final Integer DEFAULT_TIME_OUT = 30;
    /**
     * 保存線程id-ThreadLocal
     */
    private ThreadLocal<String> stringThreadLocal = new ThreadLocal<>();
    /**
     * 保存定時任務(watch-dog)-ThreadLocal
     */
    private ThreadLocal<ExecutorService> executorServiceThreadLocal = new ThreadLocal<>();
    /**
     * 加鎖,不指定過期時間
     *
     * @param key key名稱
     * @return boolean
     */
    public boolean lock(String key) {
        return lock(key, null);
    }

    /**
     * 加鎖
     *
     * @param key     key名稱
     * @param timeout 過期時間
     * @return boolean
     */
    public boolean lock(String key, Integer timeout) {
        Integer timeoutTmp;
        if (timeout == null) {
            timeoutTmp = DEFAULT_TIME_OUT;
        } else {
            timeoutTmp = timeout;
        }
        String nanoId;
        if (stringThreadLocal.get() != null) {
            nanoId = stringThreadLocal.get();
        } else {
            nanoId = IdUtil.nanoId();
            stringThreadLocal.set(nanoId);
        }
        RedisScript<Long> redisScript = new DefaultRedisScript<>(buildLuaLockScript(), Long.class);
        Long execute = (Long) redisTemplate.execute(redisScript, Collections.singletonList(key), nanoId, timeoutTmp);
        boolean flag = execute != null && execute == 1;
        if (flag) {
            ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
            executorServiceThreadLocal.set(scheduledExecutorService);
            scheduledExecutorService.scheduleWithFixedDelay(() -> {
                RedisScript<Long> renewRedisScript = new DefaultRedisScript<>(buildLuaRenewScript(), Long.class);
                Long result = (Long) redisTemplate.execute(renewRedisScript, Collections.singletonList(key), nanoId, timeoutTmp);
                if (result != null && result == 2) {
                    ThreadUtil.shutdownAndAwaitTermination(scheduledExecutorService);
                }
            }, 0, timeoutTmp / 3, TimeUnit.SECONDS);
        }
        return flag;
    }

    /**
     * 釋放鎖
     *
     * @param key key名稱
     * @return boolean
     */
    public boolean unLock(final String key) {
        String nanoId = stringThreadLocal.get();
        RedisScript<Long> redisScript = new DefaultRedisScript<>(buildLuaUnLockScript(), Long.class);
        Long execute = (Long) redisTemplate.execute(redisScript, Collections.singletonList(key), nanoId);
        boolean flag = execute != null && execute == 1;
        if (flag) {
            if (executorServiceThreadLocal.get() != null) {
                ThreadUtil.shutdownAndAwaitTermination(executorServiceThreadLocal.get());
            }
        }
        return flag;
    }

    private String buildLuaLockScript() {
        return """
                local key = KEYS[1]
                local value = ARGV[1]
                local time_out = ARGV[2]
                local result = redis.call('get', key)
                if result == value then
                    return 1;
                end
                local lock_result = redis.call('setnx', key, value)
                if tonumber(lock_result) == 1 then
                    redis.call('expire', key, time_out)
                    return 1;
                else
                    return 0;
                end
                """;
    }

    private String buildLuaUnLockScript() {
        return """
                local key = KEYS[1]
                local value = ARGV[1]
                local result = redis.call('get', key)
                if result ~= value then
                    return 0;
                else
                    redis.call('del', key)
                end
                return 1;
                """;
    }

    private String buildLuaRenewScript() {
        return """
                local key = KEYS[1]
                local value = ARGV[1]
                local timeout = ARGV[2]
                local result = redis.call('get', key)
                if result ~= value then
                    return 2;
                end
                local ttl = redis.call('ttl', key)
                if tonumber(ttl) < tonumber(timeout) / 2 then
                    redis.call('expire', key, timeout)
                    return 1;
                else
                    return 0;
                end
                """;
    }
}

加鎖邏輯:這裡我把加鎖邏輯分解成三步展示給大家

  • 加鎖前:先判斷當前線程是否存在請求ID,不存在則生成,存在就直接使用
  • 加鎖中:通過 lua 腳本執行原子加鎖操作,
    加鎖時先判斷當前線程ID與加鎖 key 得 value 是否相等,相等則是同一個線程的鎖重入,直接返加鎖成功。不相等則設置加鎖 value 為請求ID以及過期時間。
  • 加鎖後:啟動一個定時任務,每隔 過期時間 / 3 秒後執行一次續期操作,發現鎖剩餘時間不足 過期時間 / 2 秒後,通過 lua 腳本進行續期操作。

解鎖邏輯:這裡我把解鎖邏輯分解成兩步展示給大家

  • 解鎖中:通過 lua 腳本執行解鎖操作,先判斷加鎖 key 的 value 是否與自身請求ID相同,相同則讓解鎖,不相同則不讓解鎖。
  • 解鎖後:刪除定時任務。

五、總結

其實本文得核心邏輯有許多都是參考 Redission 客戶端而寫,對於這些常見得坑點,博主結合自身思考,業界知識總結並自己實現一個分散式鎖得工具類。希望大家看了有所收穫,對日常業務中 Redis 分散式鎖的使用能有更深的理解。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 目錄 ElasticSearch 實現分詞全文檢索 - 概述 ElasticSearch 實現分詞全文檢索 - ES、Kibana、IK安裝 ElasticSearch 實現分詞全文檢索 - Restful基本操作 --待發佈 ElasticSearch 實現分詞全文檢索 - Java Spring ...
  • 摘要:本文將以Sermant的SpringBoot 註冊插件的性能測試及優化過程為例,分享在Java Agent場景如何進行更好的性能測試優化及在Java Agent下需要著重註意的性能陷阱。 作者:欒文飛 高級軟體工程師 一、背景介紹 Sermant是一個主打服務治理領域的Java Agent框架 ...
  • PHP語言線上運行編譯,是一款可線上編程編輯器,在編輯器上輸入PHP語言代碼,點擊運行,可線上編譯運行PHP語言,PHP語言代碼線上運行調試,PHP語言線上編譯,可快速線上測試您的PHP語言代碼,線上編譯PHP語言代碼發現是否存在錯誤,如果代碼測試通過,將會輸出編譯後的結果。 該線上工具由IT寶庫提 ...
  • Vue 3 備忘清單 Vue 3漸進式 JavaScript 框架 Vue 3 備忘清單的快速參考列表,包含常用 API 和示例入門,為開發人員分享快速參考備忘單。 開發速查表大綱 入門 介紹 創建應用 應用實例 通過 CDN 使用 Vue 使用 ES 模塊構建版本 模板語法 文本插值 原始 HTM ...
  • 前言 TCP三次握手和四次揮手是面試題的熱門考點,它們分別對應TCP的連接和釋放過程 1.TCP通信包含那幾步? TCP通信過程包括三個步驟:建立TCP連接通道,傳輸數據,斷開TCP連接通道 上圖主要包括三部分:*建立連接、傳輸數據、斷開連接。* 建立TCP連接很簡單,通過三次握手便可建立連接。 建 ...
  • 1.單線程 單線程:只有一個線程,即CPU只執行一個任務(一個線程) 1 class Hero{ 2 String name; 3 Hero(String name){ 4 this.name = name; 5 } 6 public void show(){ 7 System.out.printl ...
  • #使用引用 #include <iostream> using namespace std; struct Time{ int h; int m; int s; }; void timeCompute(Time &t, int sec){ //引用作為形參 t.m = t.m + (t.s + se ...
  • Problem description unsigned char key[16] = {0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01}; unsigned ...
一周排行
    -Advertisement-
    Play Games
  • GoF之工廠模式 @目錄GoF之工廠模式每博一文案1. 簡單說明“23種設計模式”1.2 介紹工廠模式的三種形態1.3 簡單工廠模式(靜態工廠模式)1.3.1 簡單工廠模式的優缺點:1.4 工廠方法模式1.4.1 工廠方法模式的優缺點:1.5 抽象工廠模式1.6 抽象工廠模式的優缺點:2. 總結:3 ...
  • 新改進提供的Taurus Rpc 功能,可以簡化微服務間的調用,同時可以不用再手動輸出模塊名稱,或調用路徑,包括負載均衡,這一切,由框架實現並提供了。新的Taurus Rpc 功能,將使得服務間的調用,更加輕鬆、簡約、高效。 ...
  • 本章將和大家分享ES的數據同步方案和ES集群相關知識。廢話不多說,下麵我們直接進入主題。 一、ES數據同步 1、數據同步問題 Elasticsearch中的酒店數據來自於mysql資料庫,因此mysql數據發生改變時,Elasticsearch也必須跟著改變,這個就是Elasticsearch與my ...
  • 引言 在我們之前的文章中介紹過使用Bogus生成模擬測試數據,今天來講解一下功能更加強大自動生成測試數據的工具的庫"AutoFixture"。 什麼是AutoFixture? AutoFixture 是一個針對 .NET 的開源庫,旨在最大程度地減少單元測試中的“安排(Arrange)”階段,以提高 ...
  • 經過前面幾個部分學習,相信學過的同學已經能夠掌握 .NET Emit 這種中間語言,並能使得它來編寫一些應用,以提高程式的性能。隨著 IL 指令篇的結束,本系列也已經接近尾聲,在這接近結束的最後,會提供幾個可供直接使用的示例,以供大伙分析或使用在項目中。 ...
  • 當從不同來源導入Excel數據時,可能存在重覆的記錄。為了確保數據的準確性,通常需要刪除這些重覆的行。手動查找並刪除可能會非常耗費時間,而通過編程腳本則可以實現在短時間內處理大量數據。本文將提供一個使用C# 快速查找並刪除Excel重覆項的免費解決方案。 以下是實現步驟: 1. 首先安裝免費.NET ...
  • C++ 異常處理 C++ 異常處理機制允許程式在運行時處理錯誤或意外情況。它提供了捕獲和處理錯誤的一種結構化方式,使程式更加健壯和可靠。 異常處理的基本概念: 異常: 程式在運行時發生的錯誤或意外情況。 拋出異常: 使用 throw 關鍵字將異常傳遞給調用堆棧。 捕獲異常: 使用 try-catch ...
  • 優秀且經驗豐富的Java開發人員的特征之一是對API的廣泛瞭解,包括JDK和第三方庫。 我花了很多時間來學習API,尤其是在閱讀了Effective Java 3rd Edition之後 ,Joshua Bloch建議在Java 3rd Edition中使用現有的API進行開發,而不是為常見的東西編 ...
  • 框架 · 使用laravel框架,原因:tp的框架路由和orm沒有laravel好用 · 使用強制路由,方便介面多時,分多版本,分文件夾等操作 介面 · 介面開發註意欄位類型,欄位是int,查詢成功失敗都要返回int(對接java等強類型語言方便) · 查詢介面用GET、其他用POST 代碼 · 所 ...
  • 正文 下午找企業的人去鎮上做貸後。 車上聽同事跟那個司機對罵,火星子都快出來了。司機跟那同事更熟一些,連我在內一共就三個人,同事那一手指桑罵槐給我都聽愣了。司機也是老社會人了,馬上聽出來了,為那個無辜的企業經辦人辯護,實際上是為自己辯護。 “這個事情你不能怪企業。”“但他們總不能讓銀行的人全權負責, ...