博客 / 詳情

返回

Redisson 使用手冊:從 API 誤區到看門狗失效,在此終結分佈式鎖的噩夢

寫在前面

在上一篇《分佈式鎖的代價與選擇:為什麼我們最終擁抱了Redisson?》中,我們聊到了手寫 SETNX 的"茹毛飲血"時代。既然選擇了 Redisson,就意味着我們已經告別了那些讓人提心吊膽的死鎖噩夢。

很多時候,我們以為只是調用了一個簡單的 lock.lock(),但背後其實是一整套複雜的自動續期Lua 腳本原子執行發佈訂閲機制在默默支撐。

這篇文章不講虛的,我們從常用的 API 起手,一路通過生產環境的避坑實戰,最後鑽進底層數據結構與 Lua 源碼裏,把 Redisson 徹底扒個乾乾淨淨。


一、不僅是 Lock 這麼簡單:核心 API 全景

Redisson 之所以受歡迎,是因為它把分佈式鎖封裝成了我們最熟悉的 java.util.concurrent.locks.Lock 接口風格,極大地降低了學習成本。但除了最基礎的 lock(),還有核心功能是你必須掌握的。

1. 基礎那把鎖:RLock

這是 90% 場景下的默認選擇。它對應 Redis 底層的 Hash 結構。

RLock lock = redisson.getLock("order:1001");
lock.lock(); // 阻塞式等待,默認 30秒過期,自帶看門狗
try {
   // 業務邏輯
} finally {
   lock.unlock();
}

2. 更聰明的鎖:tryLock (⚡️推薦)

在實際業務中,我們往往不希望線程無限死等,浪費資源。這裏有兩種常見姿勢:

姿勢 A:要等待 + 啓用看門狗 (最常用)

只指定 waitTime,不指定 leaseTime。這是既想要非阻塞(或有限等待),又想要自動續期的最佳實踐。

// 參數1:wait time,我只願意排隊 3秒,拿不到就走人
// 參數2:時間單位
// 重點:沒傳 leaseTime,所以看門狗機制會自動生效!
boolean res = lock.tryLock(3, TimeUnit.SECONDS);

if (res) {
   try {
     // 處理業務(哪怕跑 5分鐘 也不怕鎖過期)
   } finally {
     lock.unlock();
   }
} else {
   log.warn("搶鎖失敗,別擠了!");
}

姿勢 B:要等待 + 自動過期 (慎用)

指定了 leaseTime,看門狗會失效。

// 參數1:wait time,排隊 3秒
// 參數2:lease time,上鎖後 10秒 自動強制釋放(注意:指定 leaseTime 會讓看門狗失效!)
// 參數3:時間單位
boolean res = lock.tryLock(3, 10, TimeUnit.SECONDS);

if (res) {
   try {
     // 處理業務,必須保證在 10秒 內完成!
   } finally {
     lock.unlock();
   }
}

3. 文明的排隊:公平鎖 FairLock

默認的鎖是非公平的(Non-Fair),線程搶鎖全靠 CPU 調度,誰快誰得。但如果你的業務要求"先來後到"(比如搶票排隊),請務必使用公平鎖。

// 內部利用 Redis 的 List(作為線程等待隊列)和 Hash(作為超時記錄)實現
RLock fairLock = redisson.getFairLock("ticket:queue");
fairLock.lock();

4. 讀多寫少的神器:讀寫鎖 ReadWriteLock

這個場景太經典了:商品詳情頁,讀的人多(10000次/秒),改庫存的人少(1次/秒)。如果全互斥,性能直接崩盤。

RReadWriteLock rwLock = redisson.getReadWriteLock("product:stock:101");

// 讀鎖:多個線程可以同時加讀鎖,只要沒有寫鎖
rwLock.readLock().lock();

// 寫鎖:必須等所有讀鎖和寫鎖都釋放了才能加,全互斥
rwLock.writeLock().lock();

5. 聯鎖 MultiLock (原子性加多把鎖)

有時候我們需要同時鎖定多個資源,比如"庫存"和"餘額",要麼都鎖住,要麼都不鎖,防止死鎖

RLock lock1 = redisson.getLock("lock:order");
RLock lock2 = redisson.getLock("lock:stock");
// 同時加鎖:lock1 lock2
RedissonMultiLock lock = new RedissonMultiLock(lock1, lock2);
lock.lock();

二、扒開底層:Hash 結構與 Lua 腳本

以下源碼基於 Redisson 3.16+ 版本(目前生產環境主流版本)分析。

Redisson 為什麼能實現可重入鎖?為什麼它比我們自己寫的 SETNX 強?
答案藏在 Redis 的數據結構裏。Redisson 並沒有使用簡單的 String 類型,而是使用了 Hash

1. Redis 裏的樣子

假設我們對 order:1001 加鎖,Redis 裏實際存儲的數據長這樣:

KEY: order:1001
TYPE: Hash

# hash 對應 value 內容
{
    "UUID:ThreadID" : 1  # 鎖的持有者 : 重入次數
}
  • KEY: 鎖的名字。
  • FIELD (Key): UUID:ThreadId。這裏由客户端生成的唯一 UUID 加上當前線程 ID 拼接而成。為什麼要加 UUID? 因為不同服務器上的 JVM 進程 ID 可能一樣,必須通過客户端啓動時生成的 UUID(ConnectionManagerId)來唯一標識一個 Redisson 實例。
  • VALUE: 1。這是重入計數器。如果同一個線程再 lock 一次,這裏變成 2。

2. 加鎖的 Lua 腳本

Redisson 為了保證一系列判斷和寫入是原子的,把它封裝在 Lua 腳本里發給 Redis。

-- KEYS[1] = 鎖名稱
-- ARGV[1] = 過期時間 (默認 30000ms)
-- ARGV[2] = 鎖持有者唯一ID (UUID:ThreadId)

-- 情況 1:鎖根本不存在
if (redis.call('exists', KEYS[1]) == 0) then
    -- 創建 Hash,設置重入次數為 1
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    -- 設置過期時間
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil; -- 返回 null 表示加鎖成功
end;

-- 情況 2:鎖存在,且持有者就是我(重入)
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    -- 重入次數 +1
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    -- 重新續期
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;

-- 情況 3:鎖存在,但不是我
-- 返回當前鎖還剩多少毫秒過期,方便客户端等待
return redis.call('pttl', KEYS[1]);

這段腳本完美解釋了:

  1. 原子性:這一大坨邏輯在 Redis 裏是原子執行的,不會插隊。
  2. 可重入:通過 hexists 判斷是不是自己,是的話就 hincrby
  3. 互斥性:如果既不是新鎖,也不是自己的鎖,直接返回剩餘時間,讓你可以去睡一會兒再來。

三、拆開看門狗的黑盒:源碼漫遊

經常聽説"看門狗",它到底長什麼樣?
其實,它本質上是一個 HashedWheelTimer(時間輪) 驅動的定時任務。

1. 啓動入口

當我們調用 lock() 不傳時間時,最終會走到這裏:

// RedissonLock.java
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
    long threadId = Thread.currentThread().getId();
    Long ttl = tryAcquire(leaseTime, unit, threadId);

    // 如果 lock 成功,ttl 會返回 null
    if (ttl == null) {
        return;
    }
    
    // 如果失敗,會訂閲一個 Redis Channel,等待鎖釋放的消息(不用死循環空轉)
    // ... 省略訂閲邏輯
}

關鍵在 tryAcquireAsync 裏:

private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
    if (leaseTime != -1) {
        // 如果你傳了時間,就按你的時間走,不啓動看門狗
        return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    }
    
    // 沒傳時間(leaseTime = -1)
    // 先設置默認 30秒 過期
    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
    
    // 加鎖成功後,開啓續期任務
    ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
        if (e == null) {
           if (ttlRemaining == null) {
               // 重點:啓動定時續期
               scheduleExpirationRenewal(threadId);
           }
        }
    });
    return ttlRemainingFuture;
}

2. 續期的無限套娃

scheduleExpirationRenewal 最終會調用 renewExpiration

private void renewExpiration() {
    // 這裏的 1/3 是硬編碼的規則
    // 默認 lockWatchdogTimeout 是 30000ms
    // 所以每 10000ms 執行一次
    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {
            
            // 執行 Lua 腳本,把 ttl 重新刷回 30秒
            RFuture<Boolean> future = renewExpirationAsync(threadId);
            
            future.onComplete((res, e) -> {
                if (res) {
                    // 如果續期成功,這就形成了遞歸調用:自己調自己
                    renewExpiration();
                }
            });
        }
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
}

核心邏輯總結

  1. 三分之一原則:每隔鎖超時時間的 1/3(默認10秒),檢查一次。
  2. 無限遞歸:只要檢查到鎖還在,就重置過期時間,並註冊下一次檢查。
  3. 生死綁定:這個任務跑在客户端進程裏,如果客户端宕機,任務停止,Redis 裏的鎖在 30秒 後自動過期。

四、我在生產環境踩過的坑:避坑實戰

API 誰都會調,但能避開坑的才是老司機。這六個坑,都是真金白銀換來的教訓。

💣 陷阱一:好心辦壞事 —— 弄死看門狗

這是新手最容易犯的錯。

❌ 錯誤姿勢

// 我怕死鎖,所以強行指定 10秒 過期
lock.lock(10, TimeUnit.SECONDS); 
// 或者
lock.tryLock(1, 10, TimeUnit.SECONDS);

⚠️ 後果
Redisson 的看門狗(WatchDog)機制只有在你未指定鎖過期時間時才會生效!
一旦你手動傳了 leaseTime,Redisson 就會認為你有自己的想法,不再插手。如果你的業務因為數據庫卡頓跑了 15秒,第 10秒 時鎖就會強制過期,其他線程長驅直入,爆發併發事故。

✅ 正確姿勢
除非你非常確定業務能在指定時間內跑完,否則儘量不要傳 leaseTime,讓看門狗幫你自動續期

💣 陷阱二:鎖粒度太粗 —— 全服暫停鍵

❌ 錯誤姿勢

// 所有訂單共用一把鎖
RLock lock = redisson.getLock("LOCK_ORDER");

⚠️ 後果
這相當於把高速公路封成了獨木橋。不管有多少個用户下單,同一時間只能處理一個。性能直接歸零。

✅ 正確姿勢
鎖的粒度越細越好。只鎖那個具體產生競爭的資源 ID。

// 只鎖這個訂單
RLock lock = redisson.getLock("order:pay:" + orderId);

💣 陷阱三:解鎖的藝術 —— 誰加的鎖誰來解

❌ 錯誤姿勢

try {
    // 業務邏輯
} finally {
    lock.unlock(); // 直接解鎖
}

⚠️ 後果

  1. 如果業務執行超時,鎖已經被自動釋放了,你再去 unlock 會拋出 IllegalMonitorStateException
  2. 如果不小心解了別人的鎖(雖然 Redisson 有 ID 校驗防止誤刪,但異常處理依然重要)。

✅ 正確姿勢

if (lock.isLocked() && lock.isHeldByCurrentThread()) {
    lock.unlock();
}

💣 陷阱四:重入鎖的"遞歸噩夢"

Redisson 的鎖雖然是可重入的(Reentrant),但如果你在遞歸或嵌套調用中不注意,很容易邏輯混亂。

❌ 風險代碼

void methodA() {
    lock.lock();
    try {
        methodB(); // methodB 裏又 lock 了一次
    } finally {
        lock.unlock(); // 只解了一層
    }
}

⚠️ 後果
Redis 裏的鎖計數器(Counter)如果不歸零,鎖是不會釋放的。確保你的加鎖次數和解鎖次數嚴格匹配

💣 陷阱五:主從切換的"幽靈鎖"

這是 Redis 架構天生的短板。

  1. Client A 在 Master 節點拿到了鎖。
  2. Master 還沒來得及把鎖同步給 Slave,就宕機了。
  3. Slave 升級為新的 Master。
  4. Client B 來加鎖,發現新 Master 上沒鎖,於是也加鎖成功

⚠️ 後果
A 和 B 同時持有了鎖。
解法:如果你不能容忍這個概率(極低),請看下文的 RedLock,或者轉投 Zookeeper。對於 99% 的業務,我們選擇接受這個風險。


五、RedLock 的愛恨情仇

有些面試官特別喜歡問 RedLock,但在實際工作中,它是一個讓人愛恨交加的存在。

1. 它是為了解決什麼?

解決 Redis 主從集羣在 Failover(故障轉移)時可能丟鎖的問題。

2. 怎麼用?

你需要準備 3個或5個 完全獨立的 Redis 實例(不是 Cluster,不是 Sentinel,就是乾乾淨淨的單實例)。

RLock lock1 = redissonInstance1.getLock("lock");
RLock lock2 = redissonInstance2.getLock("lock");
RLock lock3 = redissonInstance3.getLock("lock");

// 創建紅鎖
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);

try {
    // 同時向 3個 Redis 申請鎖
    // 只要有 > 1.5個 (即2個) 申請成功,就算贏
    lock.lock();
    // 業務邏輯
} finally {
    lock.unlock();
}

3. 靈魂拷問:值得嗎?

我的看法是:不值得

  • 運維成本飆升:為了個鎖,我要多維護好幾個獨立的 Redis?
  • 性能打折:串行或者併發去多個節點請求,網絡開銷大。
  • 並非絕對安全:Martin Kleppmann 指出,如果發生 STW(Stop-The-World)GC,或者時鐘發生跳躍,RedLock 依然可能失效。

建議
如果你在做銀行核心賬務系統,請用 ZookeeperEtcd
除此之外的 99% 的場景,Redisson 配合主從集羣 已經足夠優秀了。


結語

很多時候,我們在技術選型時容易陷入"既要又要"的怪圈。但軟件工程的本質,就是權衡(Trade-off)。

Redisson 不是神,它只是一把被打磨得足夠鋒利的刀。它不能解決所有的一致性問題,但它在易用性性能可靠性之間找到了一個極佳的平衡點。

希望這篇文章能幫你不僅"會用"鎖,更能"懂"鎖。願你的系統在洪峯流量下,依然穩如泰山;願你的代碼,既有邏輯的骨架,又有温度的血肉。、


文章的最後,想和你多聊兩句。

技術之路,常常是熱鬧與孤獨並存。那些深夜的調試、靈光一閃的方案、還有踩坑爬起後的頓悟,如果能有人一起聊聊,該多好。

為此,我建了一個小花園——我的微信公眾號「[努力的小鄭]」。

這裏沒有高深莫測的理論堆砌,只有我對後端開發、系統設計和工程實踐的持續思考與沉澱。它更像我的數字筆記本,記錄着那些值得被記住的解決方案和思維火花。

如果你覺得今天的文章還有一點啓發,或者單純想找一個同行者偶爾聊聊技術、談談思考,那麼,歡迎你來坐坐。
85f114bceb12e933bb817ec5fecdfef7

願你前行路上,總有代碼可寫,有夢可追,也有燈火可親。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.