控制策略與庫存扣減選型實(shí)戰(zhàn))
如果你是 Java 面試官在同一個(gè)候選人面前連續(xù)拋三個(gè)問題——“悲觀鎖和樂觀鎖怎么實(shí)現(xiàn)”“兩者區(qū)別是什么”“庫存扣減你選哪個(gè)”——第一輪通常能聽到標(biāo)準(zhǔn)答案synchronized 是悲觀鎖CAS 是樂觀鎖數(shù)據(jù)庫里可以用版本號(hào)實(shí)現(xiàn)樂觀鎖。這個(gè)回答不算錯(cuò)但只答到了 API 層。再追問一句“你選的方案在沖突率高和沖突率低兩種場(chǎng)景下代價(jià)分別是多少”很多人會(huì)停住。原因在于悲觀鎖和樂觀鎖本質(zhì)上不是兩個(gè)類也不是兩個(gè)關(guān)鍵字而是兩種并發(fā)控制策略。它們的核心分歧不是“用不用鎖”而是“你愿意在沖突發(fā)生之前付出多少代價(jià)還是在沖突發(fā)生之后再去補(bǔ)救”。哪怕這兩年 Java 生態(tài)里 Spring AI、大模型應(yīng)用的討論很熱鬧并發(fā)編程依然是面試?yán)镒钅芎Y人的一塊。把這個(gè)底層邏輯想清楚面試題、線上問題排查和技術(shù)選型其實(shí)是一條線。1. 先把你對(duì)鎖的理解拔高一層這不是 API是策略1.1 悲觀鎖的默認(rèn)假設(shè)沖突一定會(huì)發(fā)生悲觀鎖的思維模型是我操作的數(shù)據(jù)很可能同時(shí)被別人修改所以我必須先把我關(guān)心的資源保護(hù)起來確保在我讀完、算完、寫完的整個(gè)過程中別人碰不到它。這句話里有三個(gè)關(guān)鍵詞讀、算、寫。悲觀鎖保護(hù)的是一段完整操作過程而不只是一個(gè)“更新語句”。鎖定期間其他人只能等待。這種等待在單機(jī) Java 里是線程被阻塞在數(shù)據(jù)庫里是事務(wù)等待行鎖或表鎖。阻塞本身不是問題問題是阻塞時(shí)間取決于臨界區(qū)代碼要跑多久。比如這段代碼public synchronized void deductStock(Long skuId, int num) { // 注意這里的同步塊鎖的是當(dāng)前對(duì)象不是數(shù)據(jù)庫行 Stock stock stockMapper.selectById(skuId); if (stock.getCount() num) { throw new BizException(庫存不足); } stock.setCount(stock.getCount() - num); stockMapper.updateById(stock); }看起來邏輯完整實(shí)際有兩個(gè)隱蔽問題。第一synchronized 鎖的是當(dāng)前 Java 對(duì)象在多實(shí)例部署時(shí)完全無效即使單實(shí)例它也鎖住了所有走這個(gè)方法的事務(wù)而不是只鎖某一款商品的庫存。第二持鎖期間做了數(shù)據(jù)庫查詢和更新鎖的生命周期被網(wǎng)絡(luò) IO 拉長。并發(fā)一高整個(gè)接口的 TPS 會(huì)被拖垮。不是說 synchronized 不能用在這里而是你要先分清鎖保護(hù)的邊界是“對(duì)象”還是“數(shù)據(jù)行”鎖的生命周期覆蓋的是“內(nèi)存計(jì)算”還是“跨網(wǎng)絡(luò) IO”。這是在悲觀鎖場(chǎng)景里最常見的錯(cuò)誤。1.2 樂觀鎖的默認(rèn)假設(shè)沖突是少數(shù)情況樂觀鎖不做提前保護(hù)。它先把任務(wù)做完然后在“寫回”的那一步檢查在我操作期間有沒有別人改過這個(gè)數(shù)據(jù)如果有就放棄或者重試。對(duì)應(yīng)的 Java 實(shí)現(xiàn)就是 CAS比較當(dāng)前值與預(yù)期值一樣就更新不一樣就說明有人搶先了。數(shù)據(jù)庫層面就是版本號(hào)或狀態(tài)條件更新更新時(shí)帶上 version 當(dāng)前版本如果影響行數(shù)是 0說明版本已經(jīng)變了。這兩種策略其實(shí)是兩種人生哲學(xué)一個(gè)覺得“路上一定會(huì)堵車所以我提前兩個(gè)小時(shí)出門”一個(gè)覺得“路上大概率不堵我先出門真堵了再改路線”。不能說哪個(gè)更好要看你在什么城市、什么時(shí)間出門。1.3 為什么很多人的理解停留在“實(shí)現(xiàn)”層面主要原因是 Java 并發(fā)編程的學(xué)習(xí)順序。大多數(shù)人都是先學(xué) synchronized再學(xué) ReentrantLock然后學(xué) AtomicInteger最后學(xué)數(shù)據(jù)庫悲觀鎖、樂觀鎖。學(xué)的是 API而 API 背后是一類策略。面試官想聽到的恰恰是策略層的東西也就是你怎么把業(yè)務(wù)映射到某種策略上。所以這篇文章先不講“哪個(gè)鎖更好用”而是先把兩種策略的代價(jià)模型講清楚。后面的所有實(shí)現(xiàn)和場(chǎng)景題都是從這個(gè)模型推出來的。2. Java 里的悲觀鎖不止 synchronized關(guān)鍵在鎖的邊界2.1 synchronized 的常見寫法與鎖升級(jí)synchronized 是 JVM 原生支持的悲觀鎖實(shí)現(xiàn)使用簡(jiǎn)單不需要手動(dòng)釋放。很多資料都會(huì)講它有一個(gè)鎖升級(jí)過程偏向鎖、輕量級(jí)鎖、重量級(jí)鎖JVM 會(huì)根據(jù)競(jìng)爭(zhēng)程度自動(dòng)升級(jí)。這部分細(xì)節(jié)在不同 JDK 版本上有調(diào)整背題時(shí)可以了解落地時(shí)不要依賴某個(gè)版本的鎖行為。由此產(chǎn)生一個(gè)誤解很多人以為 synchronized 天生很慢。實(shí)際上在低競(jìng)爭(zhēng)場(chǎng)景下JVM 會(huì)做大量優(yōu)化未必會(huì)膨脹到重量級(jí)鎖。反過來在高競(jìng)爭(zhēng)場(chǎng)景下重量級(jí)鎖會(huì)讓線程阻塞喚醒性能會(huì)明顯下降。所以評(píng)估 synchronized 性能不能簡(jiǎn)單說“快”或“慢”要看競(jìng)爭(zhēng)烈度。這里有一個(gè)更實(shí)際的經(jīng)驗(yàn)很多人用 synchronized 時(shí)習(xí)慣直接加在方法上。如果這個(gè)方法里只有幾行內(nèi)存計(jì)算問題不大如果方法里有數(shù)據(jù)庫查詢、遠(yuǎn)程調(diào)用鎖的整體代價(jià)就要重新評(píng)估。鎖粒度不是越小越好但“一個(gè)方法整體加鎖”通常是過度設(shè)計(jì)。2.2 ReentrantLock可超時(shí)、可中斷、可公平ReentrantLock 是 java.util.concurrent 包提供的悲觀鎖比 synchronized 更靈活lock()與unlock()成對(duì)出現(xiàn)必須手動(dòng)釋放。tryLock(timeout, unit)可以等待有限時(shí)間。可以響應(yīng)中斷。構(gòu)造時(shí)可以選公平鎖但公平鎖通常不是性能最優(yōu)解。Lock lock new ReentrantLock(); if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 臨界區(qū)代碼 } finally { lock.unlock(); } } else { // 獲取失敗后的降級(jí)邏輯 }這段代碼的價(jià)值不是“換一個(gè)更高級(jí)的鎖”而是它給了你一個(gè)失敗出口。悲觀鎖最怕的就是獲取不到鎖就一直等下去。tryLock 讓調(diào)用方可以在有限時(shí)間內(nèi)決定是重試、降級(jí)還是直接報(bào)錯(cuò)。真實(shí)系統(tǒng)里“拿不到鎖怎么辦”往往比“怎么拿到鎖”更重要。2.3 悲觀鎖真正燒錢的地方持鎖時(shí)間數(shù)據(jù)庫悲觀鎖的經(jīng)典寫法是SELECT * FROM stock WHERE sku_id ? FOR UPDATE;FOR UPDATE 會(huì)對(duì)命中的行加寫鎖鎖會(huì)一直持有到事務(wù)提交或回滾。這里要特別注意這個(gè)鎖是數(shù)據(jù)庫層的行鎖不是 Java 層的對(duì)象鎖。多實(shí)例部署時(shí)它能正常工作前提是大家都操作同一張表、同一行數(shù)據(jù)而且走的是同一個(gè)數(shù)據(jù)庫。還要留意如果查詢沒有走索引數(shù)據(jù)庫有可能從行鎖退化成更粗粒度的鎖影響范圍會(huì)突然變大。最需要警惕的是事務(wù)里往往不只是“查一行、改一行”。一個(gè)事務(wù)如果包含多個(gè)查詢、外部接口調(diào)用、甚至網(wǎng)絡(luò)請(qǐng)求鎖的持有時(shí)間會(huì)被拉得很長。鎖等待鏈一長數(shù)據(jù)庫連接池就會(huì)被占滿最后表現(xiàn)為整個(gè)服務(wù)不可用。注意用悲觀鎖時(shí)鎖內(nèi)只放必須的操作鎖外的內(nèi)容越少越好。持鎖時(shí)間而不是鎖本身才是悲觀鎖真正的成本來源。3. Java 里的樂觀鎖CAS、版本號(hào)與 ABA3.1 CAS 是怎么工作的CAS 全稱是 Compare And Swap是一組 CPU 指令級(jí)的原子操作。它做的事情非常簡(jiǎn)單比較內(nèi)存里的當(dāng)前值是否等于預(yù)期值等于就更新為新值不等于就返回失敗。Java 里最常用的是java.util.concurrent.atomic包下的類AtomicInteger stockCount new AtomicInteger(100); while (true) { int current stockCount.get(); if (current 0) { throw new BizException(庫存不足); } if (stockCount.compareAndSet(current, current - 1)) { break; } }注意這個(gè) while 循環(huán)。CAS 失敗后必須重試否則扣減動(dòng)作就丟失了。這個(gè)循環(huán)在低沖突率下幾乎一次通過在高沖突率下會(huì)變成空轉(zhuǎn)CPU 占用上升。這就是樂觀鎖的典型代價(jià)模型平時(shí)幾乎無鎖開銷一旦沖突發(fā)生調(diào)用方要自己承擔(dān)重試成本。它不是沒有成本而是把成本從“沖突前”挪到了“沖突后”。3.2 從 AtomicInteger 到數(shù)據(jù)庫版本號(hào)單機(jī)內(nèi)存里可以用 AtomicInteger但多實(shí)例部署時(shí)Java 堆內(nèi)的原子變量無法跨進(jìn)程生效。這時(shí)數(shù)據(jù)庫的版本號(hào)機(jī)制就很有用UPDATE stock SET count count - 1, version version 1 WHERE sku_id ? AND version 5;如果影響行數(shù)為 1說明更新成功如果影響行數(shù)為 0說明 version 已經(jīng)不是 5說明別人搶先改過了。為什么用 version 而不用 time 或 count因?yàn)榘姹咎?hào)只做一件事判斷這個(gè)數(shù)據(jù)是否發(fā)生過變化。它不需要可讀不需要精確到毫秒只要保證“每次修改都會(huì) 1”。時(shí)間戳可能出現(xiàn)同一毫秒內(nèi)兩次修改count 作為新舊值比較時(shí)也可能被業(yè)務(wù)數(shù)字干擾都不如單調(diào)遞增的 version 語義干凈。在 Spring 生態(tài)里MyBatis-Plus 通過 Version 注解和樂觀鎖插件做這件事JPA 也有 Version。這些框架只是幫你生成帶版本條件的 UPDATE 語句核心原理沒有變。不管框架多方便你都要清楚沒有自帶的重試機(jī)制數(shù)據(jù)庫也不會(huì)幫你重試。3.3 ABA 問題為什么要用版本號(hào)而不是時(shí)間戳CAS 有一個(gè)著名的 ABA 問題線程 A 讀到值是 1線程 B 把它改成 2又改回 1線程 A 再 CAS 時(shí)發(fā)現(xiàn)還是 1于是判定“沒有人改過”。實(shí)際上數(shù)據(jù)已經(jīng)被改過兩次。解決思路不是用“當(dāng)前值”做比較而是用“帶變化次數(shù)的值”做比較。AtomicStampedReference 就是把值和版本號(hào)打包在一起。數(shù)據(jù)庫里的 version 字段同理它的核心價(jià)值是提供一個(gè)“值相同但已發(fā)生過變化”的識(shí)別器。所以在面試?yán)镎劦綐酚^鎖時(shí)如果能主動(dòng)提到 ABA 問題會(huì)明顯比只說“版本號(hào)防止超賣”更有區(qū)分度。它不是面試官想要的標(biāo)準(zhǔn)答案而是區(qū)分記憶型選手和理解型選手的分界線。4. 悲觀鎖 vs 樂觀鎖差異不是快慢而是失敗代價(jià)模型4.1 一張表看清八個(gè)維度對(duì)比維度悲觀鎖樂觀鎖核心假設(shè)沖突很可能發(fā)生沖突是少數(shù)加鎖時(shí)機(jī)操作前先加鎖寫入時(shí)檢查沖突沖突處理等待對(duì)方釋放重試或放棄典型 Java 實(shí)現(xiàn)synchronized、ReentrantLockAtomicInteger、AtomicStampedReference數(shù)據(jù)庫實(shí)現(xiàn)SELECT ... FOR UPDATEversion 條件更新沖突前代價(jià)加鎖、阻塞、喚醒幾乎為零沖突后代價(jià)排隊(duì)等待可能死鎖重試?yán)速M(fèi)一次操作更適用的場(chǎng)景寫多讀少、高沖突讀多寫少、低沖突這張表里最有價(jià)值的不是前幾行而是最后兩行沖突前代價(jià)和沖突后代價(jià)。4.2 沖突前代價(jià) vs 沖突后代價(jià)可以把這兩種策略想象成兩種過閘機(jī)的方式。悲觀鎖是“一個(gè)人進(jìn)去閘機(jī)就鎖上后面所有人都排隊(duì)等他出來再放行”。樂觀鎖是“所有人都直接進(jìn)每個(gè)人進(jìn)的時(shí)候刷一下卡如果閘機(jī)提示剛才已經(jīng)有人進(jìn)過就退回去再排一次”。排隊(duì)等待是沖突前的代價(jià)退回去重來是沖突后的代價(jià)。當(dāng)沖突概率很低時(shí)悲觀鎖會(huì)為“幾乎不會(huì)發(fā)生的沖突”持續(xù)支付加鎖、阻塞、喚醒的代價(jià)樂觀鎖在“幾乎不會(huì)發(fā)生的沖突”上則幾乎不花錢。當(dāng)沖突概率變高時(shí)樂觀鎖的重試次數(shù)會(huì)急劇上升大量請(qǐng)求會(huì)把數(shù)據(jù)庫 IO 和 CPU 燒在“反復(fù)嘗試”上悲觀鎖雖然也差但鎖機(jī)制本身有排隊(duì)語義等待是可控的不會(huì)像自旋一樣空轉(zhuǎn)。所以真正重要的不是“哪個(gè)快”而是“哪個(gè)代價(jià)模型更符合你的業(yè)務(wù)”。這也是面試官想聽到的東西。4.3 一句面試點(diǎn)評(píng)最加分的話面試?yán)镎f“樂觀鎖性能好、悲觀鎖性能差”是一個(gè)危險(xiǎn)的結(jié)論。更好的說法是“我理解悲觀鎖和樂觀鎖的本質(zhì)是兩種沖突處理策略。悲觀鎖在沖突前就支付加鎖成本適合高沖突、臨界區(qū)復(fù)雜、不能容忍重試的業(yè)務(wù)樂觀鎖把成本放到?jīng)_突后適合低沖突、臨界區(qū)短、寫入路徑可控的業(yè)務(wù)。在庫存扣減這種高競(jìng)爭(zhēng)場(chǎng)景我會(huì)優(yōu)先評(píng)估數(shù)據(jù)庫行鎖加短事務(wù)如果熱點(diǎn)特別集中再考慮排隊(duì)或分桶?!边@句話之所以加分是因?yàn)樗磉_(dá)的是選型邏輯而不只是背誦結(jié)論。5. 場(chǎng)景題實(shí)戰(zhàn)庫存扣減、訂單狀態(tài)和 Spring 事務(wù)5.1 庫存扣減為什么樂觀鎖方案要帶重試庫存扣減是面試最高頻的場(chǎng)景題。它真正難的地方不是鎖本身而是扣減動(dòng)作必須原子完成不能超賣同時(shí)不能把整個(gè)表的鎖都拖住。悲觀鎖方案是BEGIN; SELECT * FROM stock WHERE sku_id ? FOR UPDATE; -- 業(yè)務(wù)校驗(yàn)比如 stock.count 1 UPDATE stock SET count count - 1 WHERE sku_id ?; COMMIT;樂觀鎖方案是UPDATE stock SET count count - 1, version version 1 WHERE sku_id ? AND version ?; -- 影響行數(shù)為 0 則重試樂觀鎖方案看似簡(jiǎn)單但有一個(gè)隱藏要求調(diào)用方必須有重試機(jī)制。數(shù)據(jù)庫不會(huì)幫你重試。如果更新影響行數(shù)為 0不能直接把失敗拋給用戶要在業(yè)務(wù)層重新查一次、再試一次并且限制最大重試次數(shù)。重試次數(shù)設(shè)置得太高沖突高的時(shí)候每個(gè)請(qǐng)求會(huì)打很多次數(shù)據(jù)庫設(shè)置得太低用戶會(huì)頻繁看到失敗。實(shí)際工程里需要結(jié)合壓測(cè)找出一個(gè)平衡值。通常我會(huì)建議先給一個(gè)保守上限比如 3 到 5 次再根據(jù)日志里的“更新影響行數(shù)為 0”的次數(shù)做調(diào)整。5.2 Spring 里的事務(wù)邊界決定了鎖的生命周期在 Spring 的 Transactional 方法里鎖的生命周期和事務(wù)邊界綁定。悲觀鎖尤其明顯FOR UPDATE 取得的行鎖要等事務(wù)提交才釋放。這里有一個(gè)典型坑在事務(wù)里先調(diào)用遠(yuǎn)程接口再做扣減。這會(huì)讓行鎖一直掛著等遠(yuǎn)程接口返回。遠(yuǎn)程接口一旦變慢數(shù)據(jù)庫連接和行鎖都會(huì)被長時(shí)間占用很快把連接池耗盡。正確做法是遠(yuǎn)程調(diào)用放在事務(wù)外或者先做本地預(yù)校驗(yàn)真正扣減時(shí)再進(jìn)短事務(wù)把事務(wù)和鎖的持續(xù)時(shí)間壓到最短。特別注意事務(wù)邊界決定鎖生命周期鎖生命周期決定并發(fā)上限。在 Spring 場(chǎng)景里關(guān)注事務(wù)邊界往往比糾結(jié)“用悲觀鎖還是樂觀鎖”更關(guān)鍵。5.3 分布式環(huán)境下鎖都是有邊界的悲觀鎖的 Java 實(shí)現(xiàn)比如 synchronized 和 ReentrantLock只在單 JVM 內(nèi)有效多實(shí)例部署時(shí)必須換成數(shù)據(jù)庫行鎖、Redis 分布式鎖或 Zookeeper 鎖。樂觀鎖因?yàn)椴灰蕾?JVM 堆內(nèi)存多實(shí)例下反而更容易遷移只要共享存儲(chǔ)上的版本字段語義保持一致。但分布式鎖還有一個(gè)確定性問題鎖超時(shí)。如果持有鎖的線程在鎖過期之后才完成操作其他線程就會(huì)進(jìn)入臨界區(qū)重復(fù)執(zhí)行。這屬于“鎖的邊界小于操作時(shí)間”。所以真正工程化的鎖方案要回答三個(gè)問題鎖的獲取是否原子、鎖的釋放是否可靠、鎖的超時(shí)是否覆蓋操作的最壞時(shí)間。這三個(gè)問題只要有一個(gè)答不上來方案就還不能上線。6. 從背八股到答好場(chǎng)景題一套可復(fù)用的選型框架6.1 四步選型法我在實(shí)際項(xiàng)目里總結(jié)過一套選型流程核心是四步第一步估算沖突概率。同一個(gè)數(shù)據(jù)被并發(fā)修改的頻率有多高秒殺場(chǎng)景的同一個(gè) SKU 就是高沖突普通評(píng)論點(diǎn)贊就是低沖突。第二步評(píng)估臨界區(qū)成本。操作是一句 UPDATE 就能完成還是包含查詢、校驗(yàn)、計(jì)算、遠(yuǎn)程調(diào)用臨界區(qū)越貴悲觀鎖的持有代價(jià)越大。第三步設(shè)計(jì)失敗路徑。樂觀鎖失敗后能不能重試重試一次要付出多少數(shù)據(jù)庫開銷悲觀鎖拿不到鎖時(shí)是排隊(duì)等待、有限等待還是快速失敗第四步做壓測(cè)回歸。別只測(cè)正常路徑。要模擬沖突高峰、鎖等待超時(shí)、連接池耗盡、重試風(fēng)暴這些異常路徑。只有異常路徑能扛住方案才算落地。這套流程不復(fù)雜但它能把一個(gè)“八股題”變成一個(gè)“工程決策題”。面試和線上問題排查都用得上。6.2 問題排查鏈路如果在線上遇到鎖相關(guān)的問題建議按下面的順序排查先看現(xiàn)象是線程阻塞、接口超時(shí)、TPS 下降還是數(shù)據(jù)被覆蓋、更新丟失、超賣再確認(rèn)臨界區(qū)鎖保護(hù)的代碼范圍對(duì)不對(duì)鎖內(nèi)是否包含網(wǎng)絡(luò)請(qǐng)求、長 SQL 或者不必要的計(jì)算再確認(rèn)鎖的生命周期事務(wù)是否正常提交異常時(shí)鎖是否被正確釋放連接是否歸還到連接池再確認(rèn)并發(fā)模型請(qǐng)求量多大沖突概率多高是否存在熱點(diǎn) key 或熱點(diǎn)行多實(shí)例是不是各鎖各的最后看實(shí)現(xiàn)邊界用的是 JVM 鎖還是數(shù)據(jù)庫鎖有沒有鎖超時(shí)有沒有重試機(jī)制ABA 有沒有被處理這個(gè)鏈路的核心思想是先搞清楚是哪一層出了問題再?zèng)Q定改哪一層而不是一上來就換鎖。很多人一遇到性能問題就怪鎖選錯(cuò)了實(shí)際上問題常常出在事務(wù)邊界、鎖粒度或者連接池配置上。6.3 面試回答的黃金結(jié)構(gòu)最后回到最開始的問題如果面試官問你悲觀鎖和樂觀鎖怎么答才完整我會(huì)建議按這個(gè)順序組織答案一句話定義悲觀鎖認(rèn)為沖突很可能發(fā)生所以操作前先加鎖樂觀鎖認(rèn)為沖突是少數(shù)所以操作完再校驗(yàn)。說實(shí)現(xiàn)Java 里 synchronized、ReentrantLock、數(shù)據(jù)庫 FOR UPDATE 是悲觀鎖AtomicInteger、CAS、版本號(hào)更新是樂觀鎖。說關(guān)鍵區(qū)別沖突前代價(jià)和沖突后代價(jià)不同悲觀鎖適合高沖突、臨界區(qū)復(fù)雜樂觀鎖適合低沖突、臨界區(qū)短。說一個(gè)場(chǎng)景題庫存扣減怎么選、重試怎么做、事務(wù)邊界怎么控。說邊界單機(jī)鎖、數(shù)據(jù)庫鎖、分布式鎖的適用范圍完全不同沒有最優(yōu)的鎖只有最匹配的代價(jià)模型。這套結(jié)構(gòu)的好處是面試官無論往哪個(gè)方向追問你都有下一層可以展開。它不是一個(gè)標(biāo)準(zhǔn)答案而是一個(gè)能證明你“真的理解并發(fā)”的思考路徑。悲觀鎖和樂觀鎖從來不是一道背誦題。它真正考察的是你在面對(duì)并發(fā)沖突時(shí)能不能先判斷代價(jià)再?zèng)Q定策略。把這套代價(jià)模型放進(jìn)腦子里下一次再聽到“鎖”這個(gè)字你會(huì)多一層理解。