博客 / 詳情

返回

分佈式鎖的代價與選擇:為什麼我們最終擁抱了Redisson?

寫在前面的話

不知道你有沒有過這種經歷:在本地開發測試時一切順風順水,邏輯嚴絲合縫。可一旦代碼部署到線上,面對高併發的真實流量,各種匪夷所思的數據異常就開始冒頭了。

我最早遇到的"庫存超賣"就是這樣一個典型案例。從最初相信 Java 自帶的鎖,到後來手寫 Redis 鎖,再到最後折騰出穩定方案,這個過程其實就是對"併發"二字理解不斷加深的過程。

今天想聊聊這塊內容,不堆砌概念,只講講這條路是怎麼一步步走過來的。


一、一切的起點:synchronized 的舒適區

剛開始寫代碼時,思維往往停留在"單機"模式。遇到需要控制併發的地方,直覺反應就是加個 synchronized 關鍵字。

1. 曾經寫過的代碼

// 簡單的庫存扣減
public synchronized void deductStock(String productId) {
    // 1. 查詢庫存
    Product product = stockMapper.selectById(productId);
    // 2. 判斷並扣減
    if (product.getStock() > 0) {
        product.setStock(product.getStock() - 1);
        stockMapper.updateById(product);
    }
}

2. 這個方案能用嗎?

能用,但有前提。

如果你的系統是一個簡單的後台管理系統,或者是一個單節點部署的內部工具,併發量極低,那麼 synchronized 完全足夠。它簡單、高效,且無需引入外部依賴,是解決單機併發問題的"如意金箍棒"。

3. 為什麼後來不行了?

問題的關鍵在於”跨進程“。
當業務發展,服務需要部署兩台甚至更多服務器時,每台服務器都有一個獨立的 JVM。

  • 服務器 A 的 synchronized 鎖住了它自己的線程。
  • 服務器 B 的 synchronized 鎖住了它自己的線程。
  • 結果:A 和 B 同時放行了一個請求,扣減了同一件商品。庫存立刻變負數。

這時候我們意識到:我們需要一把能管得住所有服務器的"大鎖"。


二、初嘗分佈式鎖:Redis SETNX 的嘗試

既然 JVM 內部的鎖不管用了,那自然要找一個所有服務器都能訪問到的第三方組件來存這把鎖。Redis 因為其高性能和簡單的 API,成了首選。

1. 最直觀的寫法

Redis 有個命令叫 SETNX (SET if Not Exists)。這名字聽起來就天生是為了搶佔資源設計的。

# 誰先執行成功,誰就搶到了鎖
SETNX lock:product:101 1

邏輯很簡單:

  1. 多個服務器同時發 SETNX 命令。
  2. 只有一個能返回 1(成功),其他的返回 0(失敗)。
  3. 搶到鎖的執行業務,做完之後 DEL 刪除鎖。

2. 現實中的意外

這個方案最大的隱患在於“刪鎖”這步。

如果代碼在執行業務邏輯時,服務器突然斷電了,或者進程崩潰了,導致 DEL 命令沒來得及發出。
後果:這把鎖就像"幽靈"一樣永遠存在於 Redis 裏。後續所有針對這個商品的請求,都會因為拿不到鎖而被死死卡住。

改進方案:必須加過期時間。

SETNX lock:product:101 1
EXPIRE lock:product:101 10  # 10秒後自動過期

3. 還是不夠完美

SETNXEXPIRE 是兩條命令,不是原子操作。如果在第一句和第二句之間由於網絡抖動或者服務重啓斷開了,鎖依然會變成"死鎖"。

適用場景
這種簡單的 SETNX 方案,在很早期的 Redis 版本或者一些非核心業務(比如簡單的定時任務去重)中還可以見到,但在對於數據準確性要求極高的交易核心鏈路,它顯然過於脆弱了。


三、進階:原子性與"鎖不住"的尷尬

吸取了死鎖的教訓,後來 Redis 官方推出了原子命令,或者我們通用 Lua 腳本來保證操作原子性。

1. 修復死鎖問題

# 一條命令搞定加鎖和過期時間
SET lock:product:101 uuid NX PX 10000

這就解決了原子性問題。只要鎖加上了,由於有過期時間,哪怕服務器爆炸,鎖最終也會自動消失,系統能自動恢復。

2. 引入了新問題:鎖因為超時提前釋放了

假設我們將鎖的過期時間設為 10秒
但那天的數據庫特別卡,業務邏輯執行了 15秒

這就出現了一個嚴重的邏輯漏洞:

  1. T0秒:線程 A 加鎖成功。
  2. T10秒:鎖自動過期釋放。
  3. T11秒:線程 B 進場,發現沒鎖,加鎖成功。
  4. T15秒:線程 A 終於執行完了,發起 DEL 刪除鎖。
    • 關鍵點:此時 A 刪掉的,其實是 B 的鎖

這就導致了連鎖崩潰:鎖失效 -> A 刪 B 的鎖 -> B 裸奔 -> B 刪 C 的鎖...

適用場景
這種方案適用於業務執行時間非常短且穩定的場景。但只要涉及網絡調用(如第三方支付、跨服務調用),執行時間不可控,這種固定過期時間的方案就始終懸着一把劍。


四、最終方案:Redisson 的守候

為了解決"鎖過期時間不好估算"的痛點,Redisson 帶着它的看門狗(WatchDog) 機制出現了。這也許是目前 Java 生態中最成熟的分佈式鎖方案。

1. 什麼是看門狗?

其實原理很樸素:既然我不知道業務要跑多久,那我能不能搞個"助理"在後台盯着?

sequenceDiagram participant Client as 客户端 participant Redisson as Redisson SDK participant Redis as Redis Server participant WatchDog as 後台看門狗 Client->>Redisson: 1. 加鎖 (lock) Redisson->>Redis: 2. SETNX + PEXPIRE (Lua腳本) Redis-->>Redisson: 3. 加鎖成功 Redisson-->>WatchDog: 4. 啓動定時任務 loop 每隔 10秒 (默認LockWatchdogTimeout/3) WatchDog->>Redis: 5. 續命 (業務還在跑?TLL重置為30s) end Client->>Redisson: 6. 業務結束,解鎖 (unlock) Redisson->>WatchDog: 7. 停止續命任務 Redisson->>Redis: 8. 刪除鎖 (DEL)

簡單來説就是:

  • 只要業務線程還在跑,"看門狗"會每隔一會兒就去 Redis 喊一聲:"大哥,還沒完呢,給我續個杯!"
  • Redis 收到通知,就把過期時間重新填滿。
  • 如果業務線程掛了,看門狗也沒了,沒人續杯,鎖自然就過期了。

2. 使用起來的感受

代碼變得異常清爽,彷彿回到了單機鎖的時代:

// 1. 獲取鎖對象
RLock lock = redisson.getLock("lock:product:101");

try {
    // 2. 加鎖(開啓看門狗,默認30秒過期,每10秒續期一次)
    lock.lock();
    
    // 3. 執行業務(哪怕跑了1分鐘,鎖也不會丟)
    complexBusinessLogic();
    
} finally {
    // 4. 釋放鎖(只有當鎖存在,且是當前線程加的鎖時,才釋放)
    if (lock.isLocked() && lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

3. 穩在哪兒?

Redisson 幫我們把最難處理的幾個點屏蔽了:

  1. 自動續期:不用糾結 expire 設置多少秒合適。
  2. 防止誤刪:解鎖時會校驗線程 ID,不會刪掉別人的鎖。
  3. 可重入:和 synchronized 一樣,同一個線程可以多次獲取同一把鎖。

適用場景
幾乎涵蓋了所有需要強一致性的分佈式併發場景。無論是秒殺扣庫存、金融賬户扣款,還是定時任務的分發執行,Redisson 都是目前最穩健的選擇。


五、集羣下的隱憂:Redlock 是救世主嗎?

講到這裏,很多細心的朋友可能會問:

"如果 Redis 是主從集羣(Cluster),主節點掛了,鎖還沒同步到從節點,從節點升級為主,鎖不就丟了嗎?"

這一針見血。
為了解決這個問題,Redis 之父 Antirez 提出了 Redlock 算法:讓客户端向 N 個獨立的 Redis 節點同時申請鎖,只要超過半數(N/2+1)申請成功,就認為獲取了鎖。

1. 為什麼我不推薦 Redlock?

在實際工程落地中,Redlock 的投入產出比(ROI)並不高

  1. 部署成本高:你需要至少 3 個(最好 5 個)完全獨立的 Redis 實例,而不是主從集羣。
  2. 性能折損:客户端要順序去多個節點加鎖,網絡開銷成倍增加。
  3. 並非絕對安全:分佈式系統的時鐘跳躍(Clock Drift)或者長 GC 依然可能打破 Redlock 的安全性(這也是著名的 Martin Kleppmann 與 Antirez 辯論的焦點)。

2. 更有性價比的選擇

如果你的業務真的無法容忍哪怕百萬分之一的"主從切換丟鎖"風險,我的建議是:

  • 方案一:獨立部署
    專門部署一個單機版 Redis 實例(不做集羣),只用來存鎖。哪怕它掛了,整個業務熔斷,也好過併發亂了。簡單粗暴,但極其有效。
  • 方案二:擁抱強一致性(CP)
    如果鎖的一致性比可用性更重要(比如涉及資金轉賬),請轉身擁抱 ZooKeeperEtcd。它們天生就是為 CP(強一致性)設計的,不要勉強 AP(高可用)的 Redis 做它不擅長的事。
  • 方案三:更通用的選擇
    在 99.9% 的業務場景下,接受 Redis 主從切換可能帶來的極短暫鎖丟失風險

想一想,主節點宕機的概率是多少?正好在宕機那幾毫秒持有鎖的概率是多少?為了解決這微乎其微的概率,引入複雜的 Redlock,往往得不償失。


六、最後的一點心得

技術方案的演進,本質上是在做取捨

  • Synchronized 勝在簡單,敗在擴展。
  • Redis SETNX 勝在性能,敗在極端情況的可靠性。
  • Redisson 勝在可靠和完備,但在集羣極端場景下依然有軟肋。
  • Zookeeper 勝在強一致,但性能和維護成本是硬傷。

在實際工作中,我們不必言必稱 Redlock,也不必因為一點點極端風險就焦慮。軟件工程沒有銀彈,只有最適合當下的選擇。

很多時候,我們從簡單方案過渡到複雜方案,並不是因為想炫技,而是在無數次"掉坑"之後,對代碼、對線上的敬畏。但同樣,在面對過度設計時,也要有敢於説"不"的底氣:如果單實例夠用,就別搞集羣;如果 Redis 夠用,就別上 Redlock。

願你的代碼,既能跑得快,又能扛得住;願你的架構,既有深度,又有温度。

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

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

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

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

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

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

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

發佈 評論

Some HTML is okay.