分布式鎖+事務(wù)=災(zāi)難?不要把鎖加在事務(wù)內(nèi)?。。?/h1>
兄弟們,最近有個(gè)電商朋友跟我哭訴,他們搞了個(gè)茅臺(tái)搶購活動(dòng),結(jié)果系統(tǒng)直接炸鍋了。用戶瘋狂下單,庫存直接被扣成了負(fù)數(shù),客服電話被打爆,技術(shù)團(tuán)隊(duì)連夜搶修。我問他用了什么防超賣方案,他說:"我用了分布式鎖?。≡诳蹘齑娴姆椒ㄉ霞恿?@Transactional 注解,然后用 Redisson 的 RLock 鎖住商品 ID,應(yīng)該萬無一失啊!"
我心里咯噔一下,這場(chǎng)景我太熟悉了。就像你在超市推了個(gè)購物車去排隊(duì)結(jié)賬,結(jié)果購物車太大卡在通道里,后面的人都走不動(dòng)。鎖加在事務(wù)里,就像把購物車(鎖)和結(jié)賬流程(事務(wù))綁在一起,一旦事務(wù)執(zhí)行時(shí)間長(zhǎng),鎖就成了性能瓶頸。
一、鎖在事務(wù)里:穿著棉襖游泳的痛苦
1. 鎖的持有時(shí)間過長(zhǎng)
假設(shè)你的事務(wù)里有三個(gè)操作:查庫存、扣庫存、發(fā)消息。每個(gè)操作都需要 100ms,事務(wù)總時(shí)長(zhǎng) 300ms。而鎖的超時(shí)時(shí)間設(shè)置為 500ms,看起來沒問題。但如果數(shù)據(jù)庫突然慢了,事務(wù)執(zhí)行了 800ms,鎖就會(huì)自動(dòng)釋放。這時(shí)候另一個(gè)線程拿到鎖,繼續(xù)扣庫存,就會(huì)導(dǎo)致超賣。
這就像你租了個(gè)充電寶,租期 2 小時(shí),但你用了 3 小時(shí)才還。中間第二個(gè)小時(shí)的時(shí)候,充電寶被別人借走了,你還的時(shí)候發(fā)現(xiàn)已經(jīng)被別人還了,結(jié)果你被扣了雙倍租金。
2. 事務(wù)回滾導(dǎo)致鎖無法釋放
如果事務(wù)執(zhí)行過程中拋出異?;貪L,鎖會(huì)被釋放嗎?答案是不一定。比如你用 Redis 的 SETNX 加鎖,沒有設(shè)置過期時(shí)間,事務(wù)回滾時(shí)忘記手動(dòng)釋放鎖,這個(gè)鎖就會(huì)一直存在,導(dǎo)致其他線程永遠(yuǎn)無法獲取鎖。
這就像你在酒店退房時(shí),把房卡忘在房間里,后面的客人就無法入住了。
3. 數(shù)據(jù)庫隔離級(jí)別的坑
如果你用的是 MySQL 的可重復(fù)讀隔離級(jí)別,在事務(wù)內(nèi)查詢庫存時(shí),其他事務(wù)的修改是不可見的。但如果鎖在事務(wù)內(nèi),其他事務(wù)可能在鎖釋放后修改庫存,導(dǎo)致數(shù)據(jù)不一致。
這就像你在餐廳吃飯,點(diǎn)了一份牛排,結(jié)果服務(wù)員告訴你已經(jīng)賣完了。你剛要走,另一個(gè)服務(wù)員又端來一份牛排,說剛才查錯(cuò)了。
二、正確的姿勢(shì):鎖在事務(wù)外,事務(wù)在鎖內(nèi)
1. 先鎖后事務(wù)
正確的做法是先獲取鎖,再開啟事務(wù)。這樣鎖的持有時(shí)間只包括事務(wù)內(nèi)的操作,而不是整個(gè)方法的執(zhí)行時(shí)間。
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í)行時(shí)間長(zhǎng),鎖也會(huì)在事務(wù)結(jié)束后立即釋放,不會(huì)影響其他線程。
2. 鎖的粒度要細(xì)
不要鎖整個(gè)商品 ID,而是鎖具體的庫存項(xiàng)。比如按庫存批次加鎖,或者按 SKU 加鎖。這樣可以提高并發(fā)度,減少鎖競(jìng)爭(zhēng)。
這就像你去銀行取錢,不是鎖整個(gè)銀行,而是鎖具體的 ATM 機(jī)。
3. 數(shù)據(jù)庫層面加唯一索引
為庫存表的商品 ID 加唯一索引,防止重復(fù)扣庫存。即使鎖被釋放,數(shù)據(jù)庫也能保證數(shù)據(jù)一致性。
ALTER TABLE product_stock ADD UNIQUE INDEX uk_product_id (product_id);
這樣,當(dāng)多個(gè)線程同時(shí)扣庫存時(shí),只有一個(gè)線程能成功插入或更新記錄,其他線程會(huì)收到唯一約束沖突的錯(cuò)誤。
三、分布式鎖的選型:別用錘子釘釘子
1. Redis 分布式鎖:性能王者
Redis 的 SETNX+EXPIRE 命令可以實(shí)現(xiàn)分布式鎖,性能高,適合高并發(fā)場(chǎng)景。但要注意以下幾點(diǎn):
- 使用 Lua 腳本保證加鎖和設(shè)置超時(shí)時(shí)間的原子性
- 鎖的 value 要設(shè)置為唯一標(biāo)識(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 的臨時(shí)順序節(jié)點(diǎn)可以實(shí)現(xiàn)公平鎖,適合對(duì)可靠性要求高的場(chǎng)景。但性能較低,適合中低并發(fā)。
InterProcessMutex lock = new InterProcessMutex(client, "/locks/lock1");
try {
lock.acquire();
// 執(zhí)行業(yè)務(wù)邏輯
} finally {
lock.release();
}
ZooKeeper 會(huì)自動(dòng)處理節(jié)點(diǎn)的創(chuàng)建和刪除,即使客戶端宕機(jī),臨時(shí)節(jié)點(diǎn)也會(huì)自動(dòng)消失,避免死鎖。
3. 數(shù)據(jù)庫分布式鎖:簡(jiǎn)單但不推薦
通過數(shù)據(jù)庫的唯一索引和行鎖實(shí)現(xiàn)分布式鎖,簡(jiǎn)單易懂,但性能差,適合小型系統(tǒng)。
INSERT INTO lock_table (resource_id, lock_time) VALUES ('product_123', NOW())
ON DUPLICATE KEY UPDATE lock_time = NOW();
這種方法在高并發(fā)下會(huì)導(dǎo)致大量的鎖競(jìng)爭(zhēng),數(shù)據(jù)庫壓力大,不建議在生產(chǎn)環(huán)境中使用。
四、分布式事務(wù)的正確打開方式:別把鎖當(dāng)萬能鑰匙
1. 避免分布式事務(wù)
能不用分布式事務(wù)就不用,盡量通過本地事務(wù)和消息隊(duì)列實(shí)現(xiàn)最終一致性。比如訂單服務(wù)扣庫存后,發(fā)送一條消息給庫存服務(wù),庫存服務(wù)異步更新庫存。
這就像你在淘寶下單后,支付寶異步通知商家發(fā)貨。
2. 使用 TCC 事務(wù)
TCC(Try-Confirm-Cancel)事務(wù)模型適合長(zhǎng)事務(wù)場(chǎng)景。比如支付服務(wù)先凍結(jié)資金(Try),訂單服務(wù)扣庫存(Try),然后支付服務(wù)確認(rèn)支付(Confirm),訂單服務(wù)確認(rèn)發(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 模式將長(zhǎng)事務(wù)拆分為多個(gè)短事務(wù),每個(gè)短事務(wù)都有對(duì)應(yīng)的補(bǔ)償操作。如果某個(gè)短事務(wù)失敗,回滾之前的所有短事務(wù)。
比如用戶注冊(cè)流程:發(fā)送驗(yàn)證碼(短事務(wù) 1)→ 創(chuàng)建用戶(短事務(wù) 2)→ 發(fā)送歡迎郵件(短事務(wù) 3)。如果創(chuàng)建用戶失敗,回滾發(fā)送驗(yàn)證碼的操作。
五、常見誤區(qū):這些坑你踩過嗎?
1. 鎖的超時(shí)時(shí)間設(shè)置不合理
超時(shí)時(shí)間太短,會(huì)導(dǎo)致鎖頻繁釋放,增加重試次數(shù);太長(zhǎng),會(huì)影響并發(fā)度。應(yīng)該根據(jù)業(yè)務(wù)邏輯的平均執(zhí)行時(shí)間來設(shè)置,比如平均執(zhí)行時(shí)間的 1.5 倍。
這就像你設(shè)置自動(dòng)關(guān)機(jī)時(shí)間,太短會(huì)導(dǎo)致工作沒保存,太長(zhǎng)會(huì)浪費(fèi)電。
2. 鎖的可重入性問題
如果同一個(gè)線程多次獲取同一把鎖,會(huì)導(dǎo)致死鎖。使用支持可重入的鎖,如 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ò)延遲是不可避免的。鎖的獲取和釋放可能會(huì)因?yàn)榫W(wǎng)絡(luò)問題失敗,需要設(shè)置重試機(jī)制。
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í)行,減少鎖的持有時(shí)間。
RLock lock = redisson.getLock("product_123");
try {
lock.lock();
// 核心業(yè)務(wù)邏輯
} finally {
lock.unlock();
}
// 異步發(fā)送消息
CompletableFuture.runAsync(() -> messageService.send("庫存已扣減"));
2. 使用本地緩存
將高頻訪問的庫存數(shù)據(jù)緩存到本地,減少對(duì)數(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ā)場(chǎng)景下,使用限流組件(如 Sentinel)限制請(qǐng)求流量,避免系統(tǒng)過載。同時(shí),使用熔斷機(jī)制(如 Hystrix)在服務(wù)不可用時(shí)快速失敗,防止級(jí)聯(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)繁忙,請(qǐng)稍后再試");
}
七、總結(jié):鎖與事務(wù)的正確關(guān)系
分布式鎖和事務(wù)就像一對(duì)歡喜冤家,既相互依賴,又相互排斥。鎖可以保證數(shù)據(jù)的一致性,但如果用錯(cuò)了地方,就會(huì)成為性能瓶頸。正確的做法是:
- 鎖在事務(wù)外,事務(wù)在鎖內(nèi)
- 鎖的粒度要細(xì),避免大鎖
- 結(jié)合數(shù)據(jù)庫唯一索引和重試機(jī)制
- 選擇合適的分布式鎖方案
- 避免分布式事務(wù),使用最終一致性
鎖不是萬能的,不要把所有問題都?xì)w咎于鎖。在設(shè)計(jì)系統(tǒng)時(shí),要從架構(gòu)層面考慮性能和可用性,而不是依賴鎖來解決所有問題。