分布式鎖+事務(wù)=災難?不要把鎖加在事務(wù)內(nèi)!!!

兄弟們,最近有個電商朋友跟我哭訴,他們搞了個茅臺搶購活動,結(jié)果系統(tǒng)直接炸鍋了。用戶瘋狂下單,庫存直接被扣成了負數(shù),客服電話被打爆,技術(shù)團隊連夜搶修。我問他用了什么防超賣方案,他說:"我用了分布式鎖啊!在扣庫存的方法上加了 @Transactional 注解,然后用 Redisson 的 RLock 鎖住商品 ID,應(yīng)該萬無一失啊!"
我心里咯噔一下,這場景我太熟悉了。就像你在超市推了個購物車去排隊結(jié)賬,結(jié)果購物車太大卡在通道里,后面的人都走不動。鎖加在事務(wù)里,就像把購物車(鎖)和結(jié)賬流程(事務(wù))綁在一起,一旦事務(wù)執(zhí)行時間長,鎖就成了性能瓶頸。
一、鎖在事務(wù)里:穿著棉襖游泳的痛苦
1. 鎖的持有時間過長
假設(shè)你的事務(wù)里有三個操作:查庫存、扣庫存、發(fā)消息。每個操作都需要 100ms,事務(wù)總時長 300ms。而鎖的超時時間設(shè)置為 500ms,看起來沒問題。但如果數(shù)據(jù)庫突然慢了,事務(wù)執(zhí)行了 800ms,鎖就會自動釋放。這時候另一個線程拿到鎖,繼續(xù)扣庫存,就會導致超賣。
這就像你租了個充電寶,租期 2 小時,但你用了 3 小時才還。中間第二個小時的時候,充電寶被別人借走了,你還的時候發(fā)現(xiàn)已經(jīng)被別人還了,結(jié)果你被扣了雙倍租金。
2. 事務(wù)回滾導致鎖無法釋放
如果事務(wù)執(zhí)行過程中拋出異常回滾,鎖會被釋放嗎?答案是不一定。比如你用 Redis 的 SETNX 加鎖,沒有設(shè)置過期時間,事務(wù)回滾時忘記手動釋放鎖,這個鎖就會一直存在,導致其他線程永遠無法獲取鎖。
這就像你在酒店退房時,把房卡忘在房間里,后面的客人就無法入住了。
3. 數(shù)據(jù)庫隔離級別的坑
如果你用的是 MySQL 的可重復讀隔離級別,在事務(wù)內(nèi)查詢庫存時,其他事務(wù)的修改是不可見的。但如果鎖在事務(wù)內(nèi),其他事務(wù)可能在鎖釋放后修改庫存,導致數(shù)據(jù)不一致。
這就像你在餐廳吃飯,點了一份牛排,結(jié)果服務(wù)員告訴你已經(jīng)賣完了。你剛要走,另一個服務(wù)員又端來一份牛排,說剛才查錯了。
二、正確的姿勢:鎖在事務(wù)外,事務(wù)在鎖內(nèi)
1. 先鎖后事務(wù)
正確的做法是先獲取鎖,再開啟事務(wù)。這樣鎖的持有時間只包括事務(wù)內(nèi)的操作,而不是整個方法的執(zhí)行時間。
RLock lock = redisson.getLock("product_123");
try {
lock.lock(); // 先獲取鎖
// 開啟事務(wù)
Product product = productRepository.findById(123).orElseThrow();
if (product.getStock() > 0) {
product.setStock(product.getStock() - 1);
productRepository.save(product);
}
} finally {
lock.unlock(); // 釋放鎖
}這樣,即使事務(wù)執(zhí)行時間長,鎖也會在事務(wù)結(jié)束后立即釋放,不會影響其他線程。
2. 鎖的粒度要細
不要鎖整個商品 ID,而是鎖具體的庫存項。比如按庫存批次加鎖,或者按 SKU 加鎖。這樣可以提高并發(fā)度,減少鎖競爭。
這就像你去銀行取錢,不是鎖整個銀行,而是鎖具體的 ATM 機。
3. 數(shù)據(jù)庫層面加唯一索引
為庫存表的商品 ID 加唯一索引,防止重復扣庫存。即使鎖被釋放,數(shù)據(jù)庫也能保證數(shù)據(jù)一致性。
ALTER TABLE product_stock ADD UNIQUE INDEX uk_product_id (product_id);這樣,當多個線程同時扣庫存時,只有一個線程能成功插入或更新記錄,其他線程會收到唯一約束沖突的錯誤。
三、分布式鎖的選型:別用錘子釘釘子
1. Redis 分布式鎖:性能王者
Redis 的 SETNX+EXPIRE 命令可以實現(xiàn)分布式鎖,性能高,適合高并發(fā)場景。但要注意以下幾點:
- 使用 Lua 腳本保證加鎖和設(shè)置超時時間的原子性
- 鎖的 value 要設(shè)置為唯一標識,防止誤釋放
- 集群模式下使用 Redlock 算法,避免腦裂問題
String luaScript = "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then " +
"redis.call('expire', KEYS[1], ARGV[2]) return 1 else return 0 end";
List<String> keys = Collections.singletonList("lock_key");
List<String> argv = Arrays.asList("unique_value", "30");
Long result = jedis.eval(luaScript, keys, argv);2. ZooKeeper 分布式鎖:可靠性之選
ZooKeeper 的臨時順序節(jié)點可以實現(xiàn)公平鎖,適合對可靠性要求高的場景。但性能較低,適合中低并發(fā)。
InterProcessMutex lock = new InterProcessMutex(client, "/locks/lock1");
try {
lock.acquire();
// 執(zhí)行業(yè)務(wù)邏輯
} finally {
lock.release();
}ZooKeeper 會自動處理節(jié)點的創(chuàng)建和刪除,即使客戶端宕機,臨時節(jié)點也會自動消失,避免死鎖。
3. 數(shù)據(jù)庫分布式鎖:簡單但不推薦
通過數(shù)據(jù)庫的唯一索引和行鎖實現(xiàn)分布式鎖,簡單易懂,但性能差,適合小型系統(tǒng)。
INSERT INTO lock_table (resource_id, lock_time) VALUES ('product_123', NOW())
ON DUPLICATE KEY UPDATE lock_time = NOW();這種方法在高并發(fā)下會導致大量的鎖競爭,數(shù)據(jù)庫壓力大,不建議在生產(chǎn)環(huán)境中使用。
四、分布式事務(wù)的正確打開方式:別把鎖當萬能鑰匙
1. 避免分布式事務(wù)
能不用分布式事務(wù)就不用,盡量通過本地事務(wù)和消息隊列實現(xiàn)最終一致性。比如訂單服務(wù)扣庫存后,發(fā)送一條消息給庫存服務(wù),庫存服務(wù)異步更新庫存。
這就像你在淘寶下單后,支付寶異步通知商家發(fā)貨。
2. 使用 TCC 事務(wù)
TCC(Try-Confirm-Cancel)事務(wù)模型適合長事務(wù)場景。比如支付服務(wù)先凍結(jié)資金(Try),訂單服務(wù)扣庫存(Try),然后支付服務(wù)確認支付(Confirm),訂單服務(wù)確認發(fā)貨(Confirm)。如果任何一步失敗,都需要回滾(Cancel)。
// 支付服務(wù)
public void tryPay(String orderId, BigDecimal amount) {
// 凍結(jié)資金
}
public void confirmPay(String orderId) {
// 扣除資金
}
public void cancelPay(String orderId) {
// 解凍資金
}
// 訂單服務(wù)
public void tryDeductStock(String orderId, String productId, int quantity) {
// 鎖定庫存
}
public void confirmDeductStock(String orderId) {
// 扣減庫存
}
public void cancelDeductStock(String orderId) {
// 釋放庫存
}3. 結(jié)合 Saga 模式
Saga 模式將長事務(wù)拆分為多個短事務(wù),每個短事務(wù)都有對應(yīng)的補償操作。如果某個短事務(wù)失敗,回滾之前的所有短事務(wù)。
比如用戶注冊流程:發(fā)送驗證碼(短事務(wù) 1)→ 創(chuàng)建用戶(短事務(wù) 2)→ 發(fā)送歡迎郵件(短事務(wù) 3)。如果創(chuàng)建用戶失敗,回滾發(fā)送驗證碼的操作。
五、常見誤區(qū):這些坑你踩過嗎?
1. 鎖的超時時間設(shè)置不合理
超時時間太短,會導致鎖頻繁釋放,增加重試次數(shù);太長,會影響并發(fā)度。應(yīng)該根據(jù)業(yè)務(wù)邏輯的平均執(zhí)行時間來設(shè)置,比如平均執(zhí)行時間的 1.5 倍。
這就像你設(shè)置自動關(guān)機時間,太短會導致工作沒保存,太長會浪費電。
2. 鎖的可重入性問題
如果同一個線程多次獲取同一把鎖,會導致死鎖。使用支持可重入的鎖,如 Redisson 的 RLock,或者在數(shù)據(jù)庫鎖表中記錄線程 ID。
// Redisson可重入鎖
RLock lock = redisson.getLock("product_123");
lock.lock();
try {
// 執(zhí)行業(yè)務(wù)邏輯
lock.lock(); // 可重入
try {
// 嵌套業(yè)務(wù)邏輯
} finally {
lock.unlock();
}
} finally {
lock.unlock();
}3. 忽略網(wǎng)絡(luò)延遲的影響
在分布式系統(tǒng)中,網(wǎng)絡(luò)延遲是不可避免的。鎖的獲取和釋放可能會因為網(wǎng)絡(luò)問題失敗,需要設(shè)置重試機制。
int retryCount = 3;
int retryInterval = 1000;
for (int i = 0; i < retryCount; i++) {
try {
if (lock.tryLock(10, TimeUnit.SECONDS)) {
// 執(zhí)行業(yè)務(wù)邏輯
break;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
if (i < retryCount - 1) {
try {
Thread.sleep(retryInterval);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}六、性能優(yōu)化:讓系統(tǒng)飛起來
1. 異步處理非核心邏輯
將發(fā)消息、寫日志等非核心操作放到鎖外面異步執(zhí)行,減少鎖的持有時間。
RLock lock = redisson.getLock("product_123");
try {
lock.lock();
// 核心業(yè)務(wù)邏輯
} finally {
lock.unlock();
}
// 異步發(fā)送消息
CompletableFuture.runAsync(() -> messageService.send("庫存已扣減"));2. 使用本地緩存
將高頻訪問的庫存數(shù)據(jù)緩存到本地,減少對數(shù)據(jù)庫的訪問次數(shù)。比如使用 Caffeine 或 Guava Cache。
LoadingCache<Long, Integer> stockCache = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.build(key -> productRepository.findStockByProductId(key));
int stock = stockCache.get(123);3. 限流和熔斷
在高并發(fā)場景下,使用限流組件(如 Sentinel)限制請求流量,避免系統(tǒng)過載。同時,使用熔斷機制(如 Hystrix)在服務(wù)不可用時快速失敗,防止級聯(lián)故障。
// Sentinel限流
@SentinelResource(value = "deductStock", blockHandler = "handleBlock")
public void deductStock(Long productId, Integer quantity) {
// 扣庫存邏輯
}
public void handleBlock(Long productId, Integer quantity, BlockException e) {
// 限流處理
throw new RuntimeException("系統(tǒng)繁忙,請稍后再試");
}七、總結(jié):鎖與事務(wù)的正確關(guān)系
分布式鎖和事務(wù)就像一對歡喜冤家,既相互依賴,又相互排斥。鎖可以保證數(shù)據(jù)的一致性,但如果用錯了地方,就會成為性能瓶頸。正確的做法是:
- 鎖在事務(wù)外,事務(wù)在鎖內(nèi)
- 鎖的粒度要細,避免大鎖
- 結(jié)合數(shù)據(jù)庫唯一索引和重試機制
- 選擇合適的分布式鎖方案
- 避免分布式事務(wù),使用最終一致性
鎖不是萬能的,不要把所有問題都歸咎于鎖。在設(shè)計系統(tǒng)時,要從架構(gòu)層面考慮性能和可用性,而不是依賴鎖來解決所有問題。

























