寫在前面
在上一篇《分佈式鎖的代價與選擇:為什麼我們最終擁抱了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]);
這段腳本完美解釋了:
- 原子性:這一大坨邏輯在 Redis 裏是原子執行的,不會插隊。
- 可重入:通過
hexists判斷是不是自己,是的話就hincrby。 - 互斥性:如果既不是新鎖,也不是自己的鎖,直接返回剩餘時間,讓你可以去睡一會兒再來。
三、拆開看門狗的黑盒:源碼漫遊
經常聽説"看門狗",它到底長什麼樣?
其實,它本質上是一個 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/3(默認10秒),檢查一次。
- 無限遞歸:只要檢查到鎖還在,就重置過期時間,並註冊下一次檢查。
- 生死綁定:這個任務跑在客户端進程裏,如果客户端宕機,任務停止,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(); // 直接解鎖
}
⚠️ 後果:
- 如果業務執行超時,鎖已經被自動釋放了,你再去
unlock會拋出IllegalMonitorStateException。 - 如果不小心解了別人的鎖(雖然 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 架構天生的短板。
- Client A 在 Master 節點拿到了鎖。
- Master 還沒來得及把鎖同步給 Slave,就宕機了。
- Slave 升級為新的 Master。
- 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 依然可能失效。
建議:
如果你在做銀行核心賬務系統,請用 Zookeeper 或 Etcd。
除此之外的 99% 的場景,Redisson 配合主從集羣 已經足夠優秀了。
結語
很多時候,我們在技術選型時容易陷入"既要又要"的怪圈。但軟件工程的本質,就是權衡(Trade-off)。
Redisson 不是神,它只是一把被打磨得足夠鋒利的刀。它不能解決所有的一致性問題,但它在易用性、性能和可靠性之間找到了一個極佳的平衡點。
希望這篇文章能幫你不僅"會用"鎖,更能"懂"鎖。願你的系統在洪峯流量下,依然穩如泰山;願你的代碼,既有邏輯的骨架,又有温度的血肉。、
文章的最後,想和你多聊兩句。
技術之路,常常是熱鬧與孤獨並存。那些深夜的調試、靈光一閃的方案、還有踩坑爬起後的頓悟,如果能有人一起聊聊,該多好。
為此,我建了一個小花園——我的微信公眾號「[努力的小鄭]」。
這裏沒有高深莫測的理論堆砌,只有我對後端開發、系統設計和工程實踐的持續思考與沉澱。它更像我的數字筆記本,記錄着那些值得被記住的解決方案和思維火花。
如果你覺得今天的文章還有一點啓發,或者單純想找一個同行者偶爾聊聊技術、談談思考,那麼,歡迎你來坐坐。
願你前行路上,總有代碼可寫,有夢可追,也有燈火可親。
