數(shù)據(jù)一致性實(shí)戰(zhàn):事務(wù)隔離、鎖機(jī)制與樂觀鎖悲觀鎖選型)
1. 從一個(gè)真實(shí)場景說起為什么并發(fā)會讓數(shù)據(jù)對不上但凡做過后端開發(fā)的人大概率都遇到過這樣的場景系統(tǒng)上線初期一切正常用戶量一上來財(cái)務(wù)對賬就開始出問題——賬戶余額莫名其妙多了一筆或者少了一筆庫存扣成了負(fù)數(shù)優(yōu)惠券被同一個(gè)人領(lǐng)了兩次。排查日志的時(shí)候發(fā)現(xiàn)每條SQL單獨(dú)執(zhí)行都沒毛病但組合在一起在高并發(fā)下就亂了套。這就是典型的并發(fā)場景下的數(shù)據(jù)一致性問題。MySQL作為最主流的關(guān)系型數(shù)據(jù)庫本身提供了完整的事務(wù)機(jī)制和鎖機(jī)制來應(yīng)對這類問題但很多人只是知道有事務(wù)這回事真正到了業(yè)務(wù)里卻不知道怎么用、用哪種隔離級別、鎖該加在哪里。我見過不少項(xiàng)目代碼里寫滿了Transactional注解結(jié)果該出問題還是出問題因?yàn)槭聞?wù)的粒度和鎖的范圍根本沒搞對。這篇內(nèi)容我打算用一個(gè)賬戶轉(zhuǎn)賬庫存扣減的模擬實(shí)例把MySQL在并發(fā)場景下保證數(shù)據(jù)一致性的核心機(jī)制從頭到尾拆一遍。涉及的知識點(diǎn)包括事務(wù)的ACID特性、四種隔離級別的實(shí)際表現(xiàn)、行鎖與間隙鎖的行為差異、樂觀鎖與悲觀鎖的選型對比以及在實(shí)際業(yè)務(wù)中怎么組合使用這些手段。適合有一定SQL基礎(chǔ)、正在做交易類或庫存類業(yè)務(wù)的后端開發(fā)者參考也適合想系統(tǒng)梳理MySQL并發(fā)知識的朋友。我不會只講概念每個(gè)機(jī)制都會配上可復(fù)現(xiàn)的SQL示例和實(shí)測結(jié)果你可以直接在自己的測試庫上跑一遍驗(yàn)證。文章里提到的參數(shù)配置和代碼寫法都是我在實(shí)際項(xiàng)目里驗(yàn)證過的方案踩過的坑也會一并說清楚。2. 先把底層邏輯理清楚事務(wù)與隔離級別到底在解決什么2.1 ACID里真正難保證的是哪幾個(gè)事務(wù)的四個(gè)特性——原子性Atomicity、一致性Consistency、隔離性Isolation、持久性Durability——教科書上背得滾瓜爛熟但實(shí)際開發(fā)中最容易出問題的其實(shí)是隔離性。原子性靠undo log回滾就能保證持久性靠redo log刷盤也能保證一致性是最終目的而隔離性才是并發(fā)場景下所有麻煩的根源。為什么這么說因?yàn)槎鄠€(gè)事務(wù)同時(shí)操作同一批數(shù)據(jù)時(shí)如果沒有隔離機(jī)制就會出現(xiàn)四類問題臟讀讀到別的事務(wù)還沒提交的數(shù)據(jù)、不可重復(fù)讀同一個(gè)事務(wù)內(nèi)兩次讀同一行結(jié)果不一樣、幻讀同一個(gè)事務(wù)內(nèi)兩次范圍查詢結(jié)果集行數(shù)不一樣、丟失更新兩個(gè)事務(wù)同時(shí)改一行后提交的覆蓋了先提交的。這四類問題里丟失更新是業(yè)務(wù)層面最致命的因?yàn)樗苯訉?dǎo)致數(shù)據(jù)算錯(cuò)而且往往悄無聲息。注意很多人把不可重復(fù)讀和幻讀搞混。簡單區(qū)分——不可重復(fù)讀針對的是同一行數(shù)據(jù)的值變了幻讀針對的是結(jié)果集的行數(shù)變了。前者用行鎖能解決后者需要間隙鎖或表鎖。2.2 四種隔離級別的實(shí)際表現(xiàn)差異MySQL的InnoDB引擎支持四種隔離級別默認(rèn)是可重復(fù)讀REPEATABLE READ。這個(gè)默認(rèn)值很關(guān)鍵因?yàn)楹芏嗥渌麛?shù)據(jù)庫默認(rèn)是讀已提交READ COMMITTED行為差異很大。隔離級別臟讀不可重復(fù)讀幻讀實(shí)現(xiàn)機(jī)制讀未提交READ UNCOMMITTED可能可能可能幾乎不加鎖讀已提交READ COMMITTED不可能可能可能MVCC快照讀可重復(fù)讀REPEATABLE READ不可能不可能InnoDB基本避免MVCC間隙鎖串行化SERIALIZABLE不可能不可能不可能全部加鎖這里有個(gè)容易踩的坑在讀已提交級別下每次SELECT都會生成一個(gè)新的ReadView所以同一事務(wù)內(nèi)兩次查詢可能看到不同的數(shù)據(jù)。而在可重復(fù)讀級別下ReadView是在第一次SELECT時(shí)生成的后續(xù)查詢復(fù)用這個(gè)快照所以能看到一致的數(shù)據(jù)。這個(gè)差異在長事務(wù)里特別明顯如果你的事務(wù)執(zhí)行時(shí)間很長可重復(fù)讀級別下讀到的可能是過期數(shù)據(jù)。2.3 MVCC讀操作不加鎖的底氣從哪來InnoDB實(shí)現(xiàn)隔離級別的核心武器是MVCC多版本并發(fā)控制。它的思路很樸素每次修改數(shù)據(jù)時(shí)不直接覆蓋而是生成一個(gè)新版本舊版本保留在undo log里。讀操作根據(jù)事務(wù)的ReadView判斷應(yīng)該讀哪個(gè)版本從而實(shí)現(xiàn)讀不加鎖、讀寫不沖突。每行數(shù)據(jù)實(shí)際上有兩個(gè)隱藏字段DB_TRX_ID最后修改該行的事務(wù)ID和DB_ROLL_PTR指向undo log中舊版本的指針。當(dāng)你執(zhí)行SELECT時(shí)InnoDB會沿著版本鏈找到對當(dāng)前事務(wù)可見的那個(gè)版本。判斷可見性的規(guī)則大致是如果行的DB_TRX_ID小于當(dāng)前事務(wù)ID且不在活躍事務(wù)列表中說明這個(gè)版本是已提交的可見否則沿著DB_ROLL_PTR往前找。這套機(jī)制讓讀操作完全不用加鎖極大提升了并發(fā)讀的性能。但要注意MVCC只對快照讀普通SELECT生效如果你用的是SELECT ... FOR UPDATE或SELECT ... LOCK IN SHARE MODE那就是當(dāng)前讀會加鎖不走M(jìn)VCC。3. 實(shí)例拆解賬戶轉(zhuǎn)賬場景下的并發(fā)問題復(fù)現(xiàn)3.1 建表和初始化數(shù)據(jù)先建一張最簡單的賬戶表模擬兩個(gè)用戶之間的轉(zhuǎn)賬CREATE TABLE account ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 用戶ID, balance DECIMAL(15,2) NOT NULL DEFAULT 0.00 COMMENT 余額, version INT NOT NULL DEFAULT 0 COMMENT 版本號, PRIMARY KEY (id), UNIQUE KEY uk_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT賬戶表; INSERT INTO account (user_id, balance, version) VALUES (1001, 1000.00, 0); INSERT INTO account (user_id, balance, version) VALUES (1002, 500.00, 0);初始狀態(tài)用戶1001有1000元用戶1002有500元。現(xiàn)在要做一個(gè)轉(zhuǎn)賬操作從1001轉(zhuǎn)200元給1002正確結(jié)果應(yīng)該是1001剩800元1002有700元。3.2 不加任何控制的并發(fā)轉(zhuǎn)賬會怎樣假設(shè)有兩個(gè)線程同時(shí)執(zhí)行轉(zhuǎn)賬代碼邏輯是先查余額再判斷夠不夠再更新。用偽代碼表示-- 線程A和線程B同時(shí)執(zhí)行 SELECT balance FROM account WHERE user_id 1001; -- 都讀到1000 -- 判斷1000 200都通過 UPDATE account SET balance balance - 200 WHERE user_id 1001; UPDATE account SET balance balance 200 WHERE user_id 1002;如果兩個(gè)線程幾乎同時(shí)執(zhí)行可能出現(xiàn)兩種情況。第一種是丟失更新兩個(gè)線程都讀到1000都執(zhí)行減200最終1001的余額變成800而不是600相當(dāng)于只扣了一次。第二種更隱蔽如果UPDATE語句寫的是SET balance 800在應(yīng)用層算好再寫回那后執(zhí)行的會覆蓋先執(zhí)行的同樣只扣一次。實(shí)操心得很多團(tuán)隊(duì)出問題就出在應(yīng)用層計(jì)算再寫回這種寫法上。只要你的UPDATE語句里出現(xiàn)了具體的數(shù)值而不是balance - 200這樣的表達(dá)式就一定要警惕并發(fā)覆蓋問題。3.3 用事務(wù)包起來就萬事大吉了嗎有人會說加個(gè)事務(wù)不就行了把兩條UPDATE放在一個(gè)事務(wù)里要么都成功要么都失敗。事務(wù)確實(shí)能保證原子性但它保證不了隔離性帶來的并發(fā)覆蓋問題。因?yàn)槟J(rèn)的可重復(fù)讀級別下兩個(gè)事務(wù)各自讀到的快照可能是一樣的UPDATE時(shí)雖然會加行鎖但如果事務(wù)提交順序不當(dāng)仍然可能出現(xiàn)邏輯錯(cuò)誤。更關(guān)鍵的是事務(wù)的隔離級別和鎖的范圍決定了并發(fā)行為。如果只是簡單地把操作包在START TRANSACTION ... COMMIT里而不考慮鎖的粒度和獲取順序在高并發(fā)下依然會出問題甚至可能引發(fā)死鎖。所以事務(wù)只是基礎(chǔ)真正要解決問題還得靠鎖策略和版本控制。4. 三種主流方案實(shí)測對比悲觀鎖、樂觀鎖、原子操作4.1 悲觀鎖方案SELECT FOR UPDATE悲觀鎖的思路是先鎖住再操作在查詢余額的時(shí)候就加上排他鎖START TRANSACTION; SELECT balance FROM account WHERE user_id 1001 FOR UPDATE; -- 此時(shí)其他事務(wù)對user_id1001的FOR UPDATE查詢會阻塞 UPDATE account SET balance balance - 200 WHERE user_id 1001; UPDATE account SET balance balance 200 WHERE user_id 1002; COMMIT;FOR UPDATE會對滿足條件的行加排他鎖X鎖其他事務(wù)想對這行加鎖就得等。這樣就保證了同一時(shí)刻只有一個(gè)事務(wù)能操作這行數(shù)據(jù)徹底避免了并發(fā)覆蓋。實(shí)測下來這種方案在并發(fā)量不高、事務(wù)執(zhí)行時(shí)間短的場景下非常穩(wěn)。但有兩個(gè)明顯的代價(jià)一是鎖等待會降低吞吐量二是如果事務(wù)里還有其他耗時(shí)操作比如調(diào)用外部接口鎖持有時(shí)間會很長容易拖垮整個(gè)系統(tǒng)。注意FOR UPDATE在沒有索引的列上使用會鎖全表這是新手最容易踩的坑。一定要確保WHERE條件走了索引否則并發(fā)一上來就是災(zāi)難。4.2 樂觀鎖方案版本號控制樂觀鎖的思路是先操作提交時(shí)檢查有沒有被別人改過。在表里加一個(gè)version字段每次更新時(shí)帶上版本號-- 第一步查詢當(dāng)前余額和版本號 SELECT balance, version FROM account WHERE user_id 1001; -- 假設(shè)讀到 balance1000, version0 -- 第二步更新時(shí)校驗(yàn)版本號 UPDATE account SET balance balance - 200, version version 1 WHERE user_id 1001 AND version 0; -- 第三步檢查影響行數(shù)如果為0說明被改過重試或報(bào)錯(cuò)如果兩個(gè)線程同時(shí)讀到version0只有一個(gè)能更新成功因?yàn)楦潞髒ersion變成1另一個(gè)的UPDATE影響行數(shù)為0應(yīng)用層捕獲后可以重試。樂觀鎖的優(yōu)勢是不加鎖、吞吐量高特別適合讀多寫少的場景。缺點(diǎn)是沖突多的時(shí)候重試成本高如果并發(fā)寫非常頻繁大量請求都在重試反而比悲觀鎖更慢。另外重試邏輯需要應(yīng)用層自己實(shí)現(xiàn)增加了代碼復(fù)雜度。4.3 原子操作方案直接利用SQL的原子性還有一種更輕量的方案把判斷和更新合并到一條SQL里利用數(shù)據(jù)庫自身的原子性UPDATE account SET balance balance - 200 WHERE user_id 1001 AND balance 200;這條語句在InnoDB里是原子的balance 200的判斷和balance - 200的更新在同一個(gè)語句內(nèi)完成不會被打斷。如果影響行數(shù)為0說明余額不足轉(zhuǎn)賬失敗。這種方案性能最好、代碼最簡單但適用場景有限——它只能處理扣減這類單向操作如果業(yè)務(wù)邏輯復(fù)雜比如需要先查再算再更新就不好用一條SQL搞定。而且它無法處理需要讀取中間值的場景。4.4 三種方案的選型對照表方案加鎖方式適用場景吞吐量實(shí)現(xiàn)復(fù)雜度主要風(fēng)險(xiǎn)悲觀鎖行級排他鎖寫多讀少、事務(wù)短中低鎖等待、死鎖樂觀鎖無鎖版本校驗(yàn)讀多寫少、沖突少高中重試風(fēng)暴原子操作語句級原子簡單扣減/累加最高最低場景受限實(shí)際項(xiàng)目里我通常會組合使用核心扣減用原子操作復(fù)雜業(yè)務(wù)邏輯用悲觀鎖查詢密集的配置類數(shù)據(jù)用樂觀鎖。沒有銀彈關(guān)鍵看業(yè)務(wù)特征。5. 深入鎖機(jī)制行鎖、間隙鎖與死鎖排查5.1 行鎖到底鎖的是什么InnoDB的行鎖是加在索引上的不是加在數(shù)據(jù)行上的。這一點(diǎn)極其重要因?yàn)樗鼪Q定了鎖的范圍。如果你的WHERE條件命中了唯一索引那鎖的就是那一行如果命中的是普通索引可能會鎖多行如果沒走索引那就退化成表鎖。舉個(gè)例子account表的user_id上有唯一索引執(zhí)行SELECT * FROM account WHERE user_id 1001 FOR UPDATE鎖的就是user_id1001這一行對應(yīng)的索引記錄。但如果user_id上沒有索引這條語句會鎖住整張表的所有行并發(fā)直接歸零。5.2 間隙鎖幻讀的克星也是死鎖的溫床在可重復(fù)讀級別下InnoDB為了防止幻讀引入了間隙鎖Gap Lock。它不僅鎖住存在的記錄還鎖住記錄之間的間隙。比如表里有user_id為1001和1005兩行執(zhí)行范圍查詢WHERE user_id BETWEEN 1001 AND 1005 FOR UPDATE會鎖住1001到1005之間的所有間隙其他事務(wù)無法在這個(gè)范圍內(nèi)插入新記錄。間隙鎖解決了幻讀問題但也帶來了新的麻煩——它顯著增加了死鎖的概率。因?yàn)殚g隙鎖之間不沖突但間隙鎖和插入意向鎖會沖突兩個(gè)事務(wù)如果以不同順序獲取間隙鎖就很容易互相等待。實(shí)操心得如果你的業(yè)務(wù)不需要嚴(yán)格防止幻讀可以考慮把隔離級別降到讀已提交這樣就沒有間隙鎖了死鎖概率大幅降低。很多互聯(lián)網(wǎng)公司就是這么做的用業(yè)務(wù)邏輯來兜底幻讀問題。5.3 死鎖的定位與處理死鎖發(fā)生時(shí)MySQL會自動檢測并回滾其中一個(gè)事務(wù)報(bào)錯(cuò)信息是Deadlock found when trying to get lock。排查死鎖最直接的方式是查看SHOW ENGINE INNODB STATUS的輸出里面會顯示最近一次死鎖的詳細(xì)信息包括兩個(gè)事務(wù)分別持有什么鎖、等待什么鎖。處理死鎖的核心原則是統(tǒng)一加鎖順序。如果所有事務(wù)都按相同的順序訪問資源比如先鎖賬戶A再鎖賬戶B就不會出現(xiàn)循環(huán)等待。另外盡量縮短事務(wù)長度、減少鎖的持有時(shí)間也能有效降低死鎖概率。-- 查看當(dāng)前鎖等待情況 SELECT * FROM performance_schema.data_lock_waits; -- 查看當(dāng)前事務(wù) SELECT * FROM information_schema.innodb_trx;6. 實(shí)戰(zhàn)中的參數(shù)配置與避坑清單6.1 幾個(gè)關(guān)鍵參數(shù)的調(diào)整建議MySQL有幾個(gè)和并發(fā)、事務(wù)相關(guān)的參數(shù)默認(rèn)值不一定適合高并發(fā)場景參數(shù)默認(rèn)值建議值說明innodb_lock_wait_timeout50秒5-10秒鎖等待超時(shí)太長會拖垮連接池transaction_isolationREPEATABLE-READ按業(yè)務(wù)定讀已提交可減少間隙鎖innodb_autoinc_lock_mode12高并發(fā)插入時(shí)用交錯(cuò)模式max_connections151按需調(diào)連接數(shù)不夠會導(dǎo)致請求排隊(duì)innodb_lock_wait_timeout這個(gè)參數(shù)特別值得說。默認(rèn)50秒太長了一旦出現(xiàn)鎖等待請求會一直掛著連接池很快被占滿整個(gè)服務(wù)雪崩。我一般會調(diào)到5到10秒超時(shí)后快速失敗讓應(yīng)用層決定是重試還是報(bào)錯(cuò)。6.2 事務(wù)粒度控制的三個(gè)原則第一事務(wù)里不要做RPC調(diào)用。調(diào)用外部接口的耗時(shí)不可控可能幾百毫秒甚至幾秒鎖一直不釋放并發(fā)直接崩。正確做法是先做本地事務(wù)提交后再發(fā)消息或調(diào)接口。第二事務(wù)里不要做大批量操作。比如一次更新幾萬行鎖持有時(shí)間會很長。可以拆成小批次每批幾百行分批提交。第三事務(wù)里不要有用戶交互。有些老系統(tǒng)會在事務(wù)里等用戶輸入這是大忌鎖會一直不釋放。6.3 常見問題速查表問題現(xiàn)象可能原因排查方向解決方案余額扣成負(fù)數(shù)丟失更新檢查UPDATE是否用了表達(dá)式改用原子操作或加鎖大量鎖等待超時(shí)事務(wù)太長或鎖范圍大看innodb_trx和鎖等待表縮短事務(wù)、檢查索引偶發(fā)死鎖加鎖順序不一致看死鎖日志統(tǒng)一加鎖順序幻讀導(dǎo)致數(shù)據(jù)錯(cuò)亂隔離級別不夠確認(rèn)隔離級別升級到可重復(fù)讀或用間隙鎖樂觀鎖重試過多并發(fā)沖突高統(tǒng)計(jì)重試率改用悲觀鎖或原子操作6.4 我踩過的幾個(gè)坑第一個(gè)坑是在可重復(fù)讀級別下用SELECT做判斷。有次寫了個(gè)先查庫存夠不夠再扣減的邏輯測試環(huán)境沒問題上線后大促直接超賣。原因是兩個(gè)事務(wù)都讀到了相同的快照都判斷庫存夠然后都扣減。后來改成UPDATE ... WHERE stock num的原子寫法才解決。第二個(gè)坑是FOR UPDATE沒走索引。有張表在WHERE條件里用了函數(shù)導(dǎo)致索引失效FOR UPDATE直接鎖全表并發(fā)一上來全部阻塞。后來把函數(shù)去掉改成范圍查詢才恢復(fù)。第三個(gè)坑是長事務(wù)導(dǎo)致的連接池耗盡。有個(gè)接口在事務(wù)里調(diào)了一個(gè)第三方服務(wù)那個(gè)服務(wù)偶爾會超時(shí)幾十秒結(jié)果鎖一直不釋放連接池被占滿整個(gè)服務(wù)不可用。后來把第三方調(diào)用移到事務(wù)外面問題消失。這些坑說到底都是對鎖的范圍和事務(wù)的邊界理解不到位。MySQL的并發(fā)控制機(jī)制本身很完善問題往往出在使用方式上。把事務(wù)寫短、把鎖的范圍控制到最小、優(yōu)先用原子操作這三條原則能避開絕大多數(shù)并發(fā)問題。