核實戰(zhàn)(3):事務(wù)隔離級別與 MVCC 實現(xiàn))
問題背景上一篇把索引樹講清楚了數(shù)據(jù)怎么放由頁決定怎么找由 BTree 決定。但那是單線程世界一旦多個會話同時碰同一行問題立刻換了一副面孔——你 UPDATE 之后自己看得見新值、別人卻還讀著舊值這不是 bug 而是特性有人在事務(wù)里 SELECT 了兩遍同一行拿到兩個不同總和業(yè)務(wù)報表當(dāng)場對不上最玄學(xué)的一種“我的 SELECT 明明說這行不存在UPDATE 卻命中了 1 行”。這些現(xiàn)象共用一套底層機(jī)制多版本并發(fā)控制MVCC。MySQL 默認(rèn)隔離級別是可重復(fù)讀RROracle、PostgreSQL 默認(rèn)讀已提交RC國內(nèi)大量團(tuán)隊又主動把 MySQL 改成 RC——這個選擇背后是鎖開銷、binlog 格式、undo 膨脹一整串連鎖反應(yīng)。本篇回答四個問題四種隔離級別在 InnoDB 里各靠什么手段達(dá)成而不是背 SQL 標(biāo)準(zhǔn)定義一行數(shù)據(jù)的版本鏈和 ReadView 如何精妙配合讓讀寫互不阻塞快照讀與當(dāng)前讀這兩套眼睛為什么在同一事務(wù)里會看到不同世界以及 RC 與 RR 怎么選、長事務(wù)為什么要命。核心原理第一四級隔離在 InnoDB 里的真實實現(xiàn)手段。臟讀讀未提交 RC 級別下的 U 狀態(tài)數(shù)據(jù)由只讀已提交版本杜絕不可重復(fù)讀兩次 SELECT 結(jié)果不同在 RR 下由 MVCC 快照杜絕幻讀范圍查詢多出不存在的幽靈行則是雙保險純 SELECT 看到的幻象由 MVCC 擋掉——新插入的行 trx_id 在快照之后版本鏈走到它時判不可見而 UPDATE/DELETE 撞到的幻象 MVCC 擋不住必須靠 next-key lock記錄鎖間隙鎖在寫入側(cè)禁止間隙插入。所以RR 能防幻讀的完整表述是讀路徑靠快照、寫路徑靠間隙鎖兩套機(jī)制拼起來才閉環(huán)。MySQL 的 RR 比 SQL 標(biāo)準(zhǔn)要求的更強(qiáng)標(biāo)準(zhǔn)只承諾用鎖定讀防幻讀。第二MVCC 版本鏈 ReadView沒有更玄。每行記錄頭里藏著兩個隱藏列DB_TRX_ID最后修改這行的事務(wù) id和DB_ROLL_PTR指向 undo log 里上一版本的指針同一行的歷次修改由此串成一條從新到舊的版本鏈。事務(wù)第一次一致性讀時生成一張 ReadView四個字段活躍事務(wù) id 列表m_ids、其中最小值min_trx_id、下一個將分配的事務(wù) idmax_trx_id即 up_limit_id、自己的creator_trx_id??梢娦耘卸ㄋ牟阶甙姹?trx_id 等于自己→可見小于 min_trx_id→快照建立前早已提交可見大于等于 max_trx_id→快照之后才出現(xiàn)不可見否則查 m_ids在名單里當(dāng)時未提交不可見不在則可見。讀某行時從鏈頭往下走第一個可見版本就是返回值。RC 與 RR 的機(jī)制差異只有一條RC 給每條 SELECT重建 ReadViewRR 整個事務(wù)只用第一張——僅此一改兩類隔離的全部行為差異都能推導(dǎo)出來。第三快照讀與當(dāng)前讀是兩套眼睛。普通 SELECT 是快照讀走版本鏈不加任何鎖這是讀寫互不阻塞的來源而 UPDATE、DELETE、SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE是當(dāng)前讀必須讀最新版本并對它加鎖——否則改寫就會丟失別人已提交的增量。這就產(chǎn)生了 RR 的經(jīng)典裂縫快照讀看到 100你基于它算出 150 回寫UPDATE 落筆時行上已是別人提交的 130——你的 50 被吞。RR 保證的是你讀到的不變不是你寫的依據(jù)為真。樂觀鎖WHERE version?后檢查影響行數(shù)與悲觀鎖先 FOR UPDATE 再算都是把讀依據(jù)切到當(dāng)前讀這一側(cè)的補(bǔ)救。第四undo、purge 與長事務(wù)的經(jīng)濟(jì)賬。版本鏈物理上躺在 undo log 里事務(wù)提交不等于舊版本立刻消失只要還有一個 ReadView 可能引用它purge 線程就不能清理。一個忘了提交、或跑了幾小時的大查詢會把整個數(shù)據(jù)庫的清理水位釘死undo 表空間暴漲、版本鏈越拖越長——之后所有行的快照讀都要多走 N 跳鏈表表現(xiàn)為莫名全局變慢。RR 比 RC 更容易積累這種代價因為它的快照從第一條一致性讀起就凍結(jié)。第一次代碼實驗及輸出下面是純 Python 的確定性 MVCC 模擬內(nèi)存模型非真 mysqld行是一個從新到舊的版本鏈?zhǔn)聞?wù)啟動時領(lǐng)取單調(diào)遞增 id 并生成 ReadView最小活躍 id、活躍名單、id 上限visible()復(fù)刻 InnoDB 的四步判定read()沿鏈找第一個可見版本RC 每次讀重建快照、RR 只建一次。場景T100(RR) 與 T101(RC) 先開局隨后三個事務(wù)先后把余額改成 120、150、200。# MVCC 模擬: 行 undo 版本鏈(新-舊), 事務(wù) 一張 ReadView(快照)# visible() 即 InnoDB readview-sees(trx_id) 的判定順序NEXT[100]# 事務(wù) id 分配器(單調(diào)遞增)ACTIVEset()# 已 START 未提交的事務(wù) idclassVersion:def__init__(self,trx_id,value,prevNone):self.trx_id,self.value,self.prevtrx_id,value,prevclassRow:def__init__(self,trx_id,value):self.headVersion(trx_id,value)defupdate(self,trx,value):self.headVersion(trx.id,value,self.head)# 舊值壓進(jìn)版本鏈defchain(self):out,v[],self.headwhilev:out.append((trx%d,val%d)%(v.trx_id,v.value))vv.prevreturn - .join(out)classTrx:def__init__(self,iso):self.id,self.isoNEXT[0],iso NEXT[0]1ACTIVE.add(self.id)self.viewself.snapshot()# RR: 開啟時只建一次defsnapshot(self):lomin(ACTIVE)return(lo,set(ACTIVE),NEXT[0])# 最小活躍 id / 活躍名單 / 上限defvisible(self,trx_id):lo,mids,limitself.viewiftrx_idself.id:returnTrue# 自己改的必可見(當(dāng)前讀依據(jù))iftrx_idlo:returnTrue# 快照前早已提交iftrx_idlimit:returnFalse# 快照后才出現(xiàn)的事務(wù)returntrx_idnotinmids# 快照時仍活躍 - 不可見defread(self,row):ifself.isoRC:self.viewself.snapshot()# RC: 每條 SELECT 重建快照vrow.headwhilevandnotself.visible(v.trx_id):vv.prev# 順版本鏈找第一個可見版本returnv.valueifvelseNonedefcommit(self):ACTIVE.discard(self.id)rowRow(1,100)# trx_id1 為已提交歷史t_rr,t_rcTrx(RR),Trx(RC)t_aTrx(RR);row.update(t_a,120);t_a.commit()t_bTrx(RR);row.update(t_b,150);t_b.commit()print(兩次已提交更新后版本鏈:,row.chain())t_cTrx(RR);row.update(t_c,200)# 未提交的第三個版本print(T%d(RR) 讀 - %s | T%d(RC) 讀 - %s (T%d 改動未提交)%(t_rr.id,t_rr.read(row),t_rc.id,t_rc.read(row),t_c.id))t_c.commit()print(T%d(RR) 讀 - %s | T%d(RC) 讀 - %s (T%d 提交后)%(t_rr.id,t_rr.read(row),t_rc.id,t_rc.read(row),t_c.id))print(再讀一次: T%d(RR) - %s | T%d(RC) - %s%(t_rr.id,t_rr.read(row),t_rc.id,t_rc.read(row)))print(此刻版本鏈:,row.chain())運(yùn)行輸出兩次已提交更新后版本鏈: (trx103,val150) - (trx102,val120) - (trx1,val100) T100(RR) 讀 - 100 | T101(RC) 讀 - 150 (T104 改動未提交) T100(RR) 讀 - 100 | T101(RC) 讀 - 200 (T104 提交后) 再讀一次: T100(RR) - 100 | T101(RC) - 200 此刻版本鏈: (trx104,val200) - (trx103,val150) - (trx102,val120) - (trx1,val100)三行對照把兩個隔離級別的差異全部演完。RR 恒讀 100它的 ReadView 里 max_trx_id102102/103/104 三個版本全都出生于快照之后沿鏈下走只能退到 trx1 的起點(diǎn)版本——可重復(fù)讀不是鎖住了行而是你永遠(yuǎn)在走自己的時間線。RC 的第一讀落在 150 而非 200因為重建快照時 T104 仍在活躍名單里未提交修改不可見這就是讀已提交四個字T104 提交后下一讀立刻跟上 200。另一個細(xì)節(jié)藏在版本鏈里四次修改、四個版本全在——真實 InnoDB 此刻若無早快照引用舊版本purge 已可回收而 T100 這個從開局沒再動過的事務(wù)會把 120、150 兩個版本一直釘在 undo 里。把 T100 換成跑三小時的報表查詢就是生產(chǎn) undo 暴漲的標(biāo)準(zhǔn)劇本。工程化改進(jìn)第一步把隔離級別當(dāng)成部署配置顯式鎖定。transaction_isolation在 my.cnf 定全局值應(yīng)用連接參數(shù)再聲明一次保證測試、預(yù)發(fā)、生產(chǎn)一致——RC 與 RR 的行為差異足以讓同一份代碼兩邊結(jié)果不同這類問題必須靠配置對齊消滅而不是靠人記。選 RC 的團(tuán)隊高并發(fā) OLTP、互聯(lián)網(wǎng)寫多讀少拿走的是間隙鎖大幅減少、鎖等待變短、undo 鏈更短必須同時接受主從復(fù)制必須用binlog_formatROWstatement 格式在 RC 下有序性不保證從庫回放會漂數(shù)據(jù)以及下文所有并發(fā)寫保護(hù)要自己建。第二步寫路徑三選一杜絕裸讀-改-寫。增量類計數(shù)、余額一律原子 UPDATESET bal bal ?右側(cè)在當(dāng)前讀行鎖保護(hù)下求值天然正確條件類用樂觀鎖行帶version列UPDATE ... WHERE id? AND version?應(yīng)用必須檢查 affected rows為 0 即沖突重試必須先讀后算的復(fù)雜判斷扣庫存前驗證一組狀態(tài)用SELECT ... FOR UPDATE把讀依據(jù)釘在當(dāng)前讀上事務(wù)內(nèi)完成校驗與寫入。三條路殊途同歸讓判斷和寫共用同一雙眼睛。第三步把長事務(wù)當(dāng)故障對待。監(jiān)控information_schema.INNODB_TRX中trx_started超過閾值OLTP 建議 60 秒的事務(wù)并告警trx_mysql_thread_id直接給出 kill 目標(biāo)報表與分析查詢走只讀從庫或加MAX_EXECUTION_TIME連接池回收連接時必須回滾懸掛事務(wù)框架層的借出未提交是隱形元兇。RR 下尤其要盯因為一個凍結(jié)的快照就是全庫 purge 的剎車片。第四步給快照與當(dāng)前不一致預(yù)留解釋位。代碼評審時看到先 SELECT 判斷、后 INSERT/UPDATE的模式就問一句要不要 FOR UPDATE監(jiān)控上遇到 1062 主鍵沖突或 affected rows 與預(yù)期不符先懷疑快照讀與當(dāng)前讀的裂縫別當(dāng)成隨機(jī)故障。把這條認(rèn)知寫進(jìn)團(tuán)隊排障手冊能省掉無數(shù)復(fù)現(xiàn)不了的懸案。第二次代碼實驗及輸出下面回放開頭那個玄學(xué)場景兩個事務(wù)給同一賬戶分別 50、30期望終態(tài) 180。三種寫法——應(yīng)用層先讀后回寫RR 默認(rèn)行為、SELECT ... FOR UPDATE串行化帶鎖等待時間線、單條原子 UPDATE——對比各自終態(tài)把隔離級別防什么、不防什么變成可運(yùn)行結(jié)論。# 場景回放: 事務(wù) A、B 給同一賬戶分別加 50 / 30, 期望終態(tài) 180# 三種寫法的結(jié)局完全不同 —— RR 隔離級別本身救不了讀-改-寫deflost_update_demo():bal100# 當(dāng)前已提交唯一值a_snap,b_snapbal,bal# A/B 先各自快照讀, 都讀到 100a_new,b_newa_snap50,b_snap30bala_new# A: UPDATE account SET bal150balb_new# B: UPDATE SET bal130, 當(dāng)前讀直接壓最新值print([先讀后回寫] 終態(tài) %d, 應(yīng)為 180 - A 的 50 被吞掉%bal)deffor_update_demo():bal,log100,[]log.append(A: SELECT ... FOR UPDATE 拿到行鎖, 當(dāng)前讀 bal%d%bal)log.append(B: SELECT ... FOR UPDATE 撞鎖, 進(jìn)入 row lock wait)a_newbal50log.append(A: UPDATE bal%d, COMMIT 放鎖%a_new)bala_new# A 提交才輪到 B 拿鎖log.append(B: 獲鎖后當(dāng)前讀看到 %d, 而非自己的舊快照%bal)bal30log.append(B: UPDATE bal%d, COMMIT%bal)forlineinlog:print( line)print( [FOR UPDATE] 終態(tài) %d, 正確%bal)defatomic_update_demo():bal100bal50# UPDATE account SET balbal50bal30# SET 右側(cè)是當(dāng)前讀, 在行鎖保護(hù)下求值print([原子 UPDATE] 終態(tài) %d, 正確且無需顯式加鎖%bal)lost_update_demo()for_update_demo()atomic_update_demo()運(yùn)行輸出[先讀后回寫] 終態(tài) 130, 應(yīng)為 180 - A 的 50 被吞掉 A: SELECT ... FOR UPDATE 拿到行鎖, 當(dāng)前讀 bal100 B: SELECT ... FOR UPDATE 撞鎖, 進(jìn)入 row lock wait A: UPDATE bal150, COMMIT 放鎖 B: 獲鎖后當(dāng)前讀看到 150, 而非自己的舊快照 B: UPDATE bal180, COMMIT [FOR UPDATE] 終態(tài) 180, 正確 [原子 UPDATE] 終態(tài) 180, 正確且無需顯式加鎖第一行就是丟失更新lost update的全部機(jī)制A、B 的快照讀各自合法地看到 100兩次 UPDATE 又各自合法地覆蓋最新值——沒有任何一步違反 RR 的承諾MVCC 從不阻止基于過期依據(jù)的寫入SQL 標(biāo)準(zhǔn)的四級隔離在 ANSI/ISO:SQL92 定義里本來也不要求防它是 InnoDB 用間隙鎖把 RR 加強(qiáng)到防讀出來的幻象而已。FOR UPDATE 時間線的修復(fù)點(diǎn)要看準(zhǔn)它不是讓 B讀到新值這么簡單而是把 B 的讀搬進(jìn)了鎖隊列使判斷與寫入之間不可能插入第三者——代價是并發(fā)度降為串行。原子 UPDATE 最優(yōu)把讀改寫壓進(jìn)一條語句行鎖只在語句級持有一瞬。生產(chǎn)里按這個優(yōu)先級排序能原子就不加鎖必須加鎖就 FOR UPDATE只有跨語句復(fù)雜校驗才考慮樂觀鎖加重試。常見陷阱其一以為 RR 防丟失更新。上面實驗第一行就是證據(jù)可重復(fù)讀防的是讀側(cè)異常寫側(cè)沖突要靠當(dāng)前讀。其二以為BEGIN之后快照立刻凍結(jié)。RR 的 ReadView 生成于事務(wù)內(nèi)第一條一致性 SELECT 執(zhí)行時不是 BEGIN——BEGIN; DO OTHER STUFF(3s); SELECT與前一種寫法的可見集合可能不同靠事務(wù)起始點(diǎn)推理結(jié)果會錯。其三長事務(wù)拖垮 purge一個忘記提交的事務(wù)讓 undo 只增不減、版本鏈越讀越長全庫點(diǎn)查悄悄變慢排障第一步查 INNODB_TRX而不是加索引。其四RC statement 格式 binlog非確定性更新LIMIT無 ORDER BY、NOW()主從回放結(jié)果不同數(shù)據(jù)靜默漂移改 RC 必須同時鎖 ROW 格式。其五把SELECT 沒有這行但 INSERT 報 1062當(dāng) bug快照讀按自己時間線看不見、當(dāng)前讀按最新版本撞主鍵——正確姿勢是 INSERT 前先想清楚以哪邊為準(zhǔn)INSERT ... ON DUPLICATE KEY UPDATE或先 FOR UPDATE 探測間隙。落地清單my.cnf 顯式設(shè)定transaction_isolation應(yīng)用連接參數(shù)二次聲明三環(huán)境一致RC 部署必配binlog_formatROW寫路徑優(yōu)先級原子 UPDATE SELECT ... FOR UPDATE 樂觀鎖(version 列 affected rows 檢查)禁止裸讀改寫監(jiān)控information_schema.INNODB_TRX事務(wù)超 60 秒告警并可定位 kill連接池回收時強(qiáng)制回滾懸掛事務(wù)報表/大查詢走從庫或設(shè)MAX_EXECUTION_TIME不給主庫留凍結(jié)快照的機(jī)會代碼評審專項攔截先 SELECT 判斷后 UPDATE兩段式排障手冊收錄 1062 與快照裂縫的解釋隔離讓讀寫互不阻塞代價是寫與寫之間仍要排隊——一旦排隊形成環(huán)就是鎖等待圖上的死鎖兩個事務(wù)互相持有對方想要的鎖誰來都不放行。為什么UPDATE范圍條件會鎖住不存在的行為什么加了索引反而死鎖變少SHOW ENGINE INNODB STATUS里那段 LATEST DETECTED DEADLOCK 怎么讀下一篇《MySQL 內(nèi)核實戰(zhàn)4行鎖、間隙鎖與死鎖定位》把 InnoDB 的鎖模型與現(xiàn)場取證講透。參考來源MySQL 8.0 Reference ManualSET TRANSACTION 與事務(wù)隔離級別https://dev.mysql.com/doc/refman/8.0/en/set-transaction.htmlMySQL 8.0 Reference ManualInnoDB Multi-Versioning版本鏈與 ReadViewhttps://dev.mysql.com/doc/refman/8.0/en/innodb-multi-versioning.htmlMySQL 8.0 Reference ManualConsistent Nonlocking Reads快照讀與當(dāng)前讀https://dev.mysql.com/doc/refman/8.0/en/innodb-consistent-read.htmlMySQL 8.0 Reference ManualLocks Set by Different SQL Statements in InnoDBhttps://dev.mysql.com/doc/refman/8.0/en/innodb-locking-reads.htmlWikipediaIsolation (database systems)https://en.wikipedia.org/wiki/Isolation_(database_systems) 覺得有用就點(diǎn)個贊 收藏方便回頭查閱有疑問直接在評論區(qū)留言我看到都會回。 本文屬于《MySQL 內(nèi)核實戰(zhàn)》系列持續(xù)更新關(guān)注不迷路。 文章里的代碼都能直接跑。想要可直接 clone 的完整工程 配套部署腳本 / 踩坑清單評論一聲或發(fā)郵件到cj2664qq.com我免費(fèi)發(fā)你。如果你正好在做類似系統(tǒng)、或有工程化難題想找人做也歡迎郵件聊一句——我按實際情況評估能落地的就接單或出方案。評論和郵件都能直接找到我不用跳別的平臺。