據(jù)庫MVCC詳解:從版本鏈到ReadView,一文講透并發(fā)控制)
這個問題的背后其實是數(shù)據(jù)庫并發(fā)控制里最精巧的一套機制在起作用——MVCCMulti-Version Concurrency Control多版本并發(fā)控制。我剛接觸數(shù)據(jù)庫那會兒對“SELECT不阻塞UPDATE”這句話將信將疑直到自己動手做了幾組實驗、翻了源碼和官方文檔才真正把這條鏈路理清楚。這篇文章就把我這些年對MVCC的理解一次性講透包括核心原理、可見性判斷邏輯、實際場景推演還有生產(chǎn)環(huán)境里容易踩的坑。1. 為什么會有MVCC先看看沒有它會發(fā)生什么1.1 一個看似簡單的問題先回憶一下你日常寫SQL的場景一個業(yè)務(wù)系統(tǒng)里事務(wù)A在統(tǒng)計報表執(zhí)行一條比較耗時的SELECT與此同時事務(wù)B在更新同一張表里的某條訂單記錄。在沒有特殊機制的情況下這兩條語句會互不相讓嗎會的只要在傳統(tǒng)的加鎖并發(fā)模型里這個問題就繞不開。數(shù)據(jù)庫為了保證數(shù)據(jù)一致性和事務(wù)隔離性最簡單粗暴的辦法就是加鎖讀的時候加讀鎖寫的時候加寫鎖讀鎖和寫鎖互斥。結(jié)果是SELECT要等UPDATE提交之后才能讀到數(shù)據(jù)UPDATE也要等SELECT釋放讀鎖之后才能動手。對于高并發(fā)業(yè)務(wù)來說這種串行化等待幾乎致命。那有沒有辦法讓SELECT和UPDATE各干各的有方向大致分成兩種一種是進一步細分鎖粒度把鎖精確到行、甚至到索引項但鎖沖突還是存在另一種就是今天的主角——讓每一條數(shù)據(jù)記錄同時保留多個歷史版本讀的人各自讀取符合自己事務(wù)視角的版本寫的人繼續(xù)更新最新版本。讀寫之間不僅不需要互相等鎖還都能提交成功。這個思路就是MVCC。1.2 MVCC的設(shè)計哲學(xué)空間換時間MVCC的核心思想概括成一句話就是“以空間換時間”。傳統(tǒng)鎖機制把并發(fā)控制的重心放在“阻塞誰”上MVCC則把重心放在“給誰看哪個版本”上。每條記錄被更新時舊版本不會被立刻刪除而是以某種方式保留在系統(tǒng)里。新版本記錄和舊版本記錄之間通過指針形成一條版本鏈。當(dāng)某個事務(wù)執(zhí)行普通SELECT時數(shù)據(jù)庫會依據(jù)該事務(wù)啟動時生成的一份額外信息ReadView即一致性視圖沿著版本鏈找到對當(dāng)前事務(wù)可見的那個版本。這個版本可能是最新數(shù)據(jù)也可能是一兩輪更新之前的數(shù)據(jù)。這就實現(xiàn)了所謂的一致性快照讀一個事務(wù)在同一個快照視角下多次讀取同一條記錄結(jié)果始終一致。而UPDATE這類寫操作則直接操作最新版本并為它生成一個新版本掛到鏈上。用生活里的場景類比MVCC就像是圖書館里的“預(yù)約借閱機制”當(dāng)一個讀者在閱讀某本書的最新修訂稿時另一個讀者完全可以在隔壁看到這本書的舊版本或草稿版。新舊版本并存各取所需互不干擾。2. 深入MVCC核心機制隱藏列、版本鏈與ReadView2.1 一行數(shù)據(jù)比你想的多存了三個字段很多人以為數(shù)據(jù)庫表里的一行數(shù)據(jù)就只有你建表時定義的字段。實際上以InnoDB存儲引擎為例每一行還額外維護著幾個隱藏的字段MVCC能運轉(zhuǎn)全靠它們。DB_TRX_ID表示最近一次插入或更新這行數(shù)據(jù)的事務(wù)ID。DB_ROLL_PTR回滾指針指向該行的上一個版本在undo log中的位置版本鏈通過它串聯(lián)起來。DB_ROW_ID隱式自增ID當(dāng)表沒有主鍵時InnoDB會自動生成聚簇索引這個字段就作為聚簇索引的鍵值。對于MVCC來說最關(guān)鍵的是前兩個字段。DB_TRX_ID相當(dāng)于這行數(shù)據(jù)的“最近修改者標(biāo)簽”ReadView在判斷可見性時核心就是拿當(dāng)前行記錄的DB_TRX_ID和ReadView里記錄的事務(wù)ID范圍做比較。DB_ROLL_PTR則負責(zé)把分散在undo log里的歷史版本串成一條完整的鏈保證回溯時能沿著鏈表逐級往上找。2.2 版本鏈?zhǔn)窃趺葱纬傻呐e個例子假設(shè)有一條用戶記錄id1name張三。初始插入事務(wù)ID為10那么DB_TRX_ID10回滾指針指向空。接著事務(wù)20執(zhí)行了UPDATE將name改成李四數(shù)據(jù)庫會執(zhí)行兩個動作首先把當(dāng)前行舊內(nèi)容拷貝到undo log生成一個版本記錄然后在原行上更新name字段并將DB_TRX_ID改成20DB_ROLL_PTR指向剛才生成的undo記錄中。于是版本鏈就變成了兩層新版本DB_TRX_ID20name李四的DB_ROLL_PTR指向舊版本。舊版本DB_TRX_ID10name張三存儲在undo log中回滾指針為空。如果再有一次事務(wù)30執(zhí)行UPDATE把name改成王五版本鏈又會多一層。每次寫操作都在鏈頭追加新版本舊版本依次向后移動。查詢時從鏈頭沿著DB_ROLL_PTR一路往下找直到找到對當(dāng)前事務(wù)可見的版本為止。這里有個容易被忽略的細節(jié)undo log中的舊版本不僅要保留被修改前的字段值還會保留被修改前對應(yīng)的DB_TRX_ID和DB_ROLL_PTR。因為在ReadView判斷可見性時需要逐層讀取這些信息缺一不可。2.3 ReadView每個事務(wù)的“時間快照”有了版本鏈還需要一個判斷依據(jù)——到底哪些版本對當(dāng)前事務(wù)可見。這個判斷依據(jù)就是ReadView。ReadView的生成時機與隔離級別相關(guān)這點在后面會詳細展開。先看ReadView里最重要的四個字段creator_trx_id創(chuàng)建這個ReadView的事務(wù)ID。m_ids在創(chuàng)建ReadView時當(dāng)前系統(tǒng)中所有未提交的讀寫事務(wù)ID列表。min_trx_idm_ids中最小的事務(wù)ID。max_trx_id創(chuàng)建ReadView時系統(tǒng)即將分配給下一個事務(wù)的ID可以理解為當(dāng)前系統(tǒng)中最大的事務(wù)ID上限。判斷某個版本是否可見的規(guī)則其實可以歸納成幾條如果被訪問版本的DB_TRX_ID等于creator_trx_id說明這個版本就是當(dāng)前事務(wù)自己修改的肯定可見。如果DB_TRX_ID小于min_trx_id說明該版本早在當(dāng)前事務(wù)啟動前就已經(jīng)提交可見。如果DB_TRX_ID大于等于max_trx_id說明該版本是在當(dāng)前事務(wù)啟動之后才被創(chuàng)建不可見。如果DB_TRX_ID落在min_trx_id和max_trx_id之間還要再判斷它是否還在m_ids列表中如果在列表中說明事務(wù)尚未提交不可見如果不在列表中說明事務(wù)已經(jīng)提交可見。這四條規(guī)則就是MVCC可見性判斷的核心整個MVCC的“魔法”都濃縮在這幾步判斷里。我在剛學(xué)習(xí)的時候為了把這幾條規(guī)則徹底記牢直接把規(guī)則抄在便簽紙上貼在顯示器旁邊做實驗時對照著看很快就通了。2.4 RR和RC下ReadView生成時機的差異同一個MVCC機制在不同隔離級別下的表現(xiàn)會有明顯差異最典型的就是可重復(fù)讀RR和讀已提交RC兩種隔離級別。在可重復(fù)讀RR級別下事務(wù)第一次執(zhí)行普通SELECT時會生成一個ReadView并一直使用到事務(wù)結(jié)束。后續(xù)所有普通SELECT都復(fù)用這同一個ReadView所以整個事務(wù)期間看到的數(shù)據(jù)都是一致的就像事務(wù)開始時拍了張“照片”之后一直看這張照片。在讀已提交RC級別下事務(wù)中每執(zhí)行一次普通SELECT都會生成一個新的ReadView。這樣不同時間點的SELECT能看到不同的已提交數(shù)據(jù)所以RC級別下同一個事務(wù)中兩次SELECT可能讀到不同的結(jié)果也就是不可重復(fù)讀。兩條規(guī)則放在一起就能理解為什么RR能解決一部分“換讀”問題以及為什么在RC下MVCC的語義會有所放寬。這也是面試中經(jīng)常被追問的細節(jié)把這塊弄明白比背十遍八股文都管用。3. 快照讀與當(dāng)前讀SELECT為什么不需要等鎖3.1 快照讀的完整執(zhí)行流程普通SELECT語句在InnoDB里被稱為一致性非鎖定讀Consistent Nonlocking Read也叫快照讀。它不加任何鎖直接生成或復(fù)用ReadView然后沿著版本鏈查找對當(dāng)前事務(wù)可見的版本。執(zhí)行流程大致如下事務(wù)開始根據(jù)隔離級別確定ReadView的生成策略。定位到目標(biāo)記錄讀取行上的DB_TRX_ID。將DB_TRX_ID與ReadView中的min_trx_id、max_trx_id、m_ids進行比較。如果可見直接返回該版本的數(shù)據(jù)。如果不可見通過DB_ROLL_PTR跳轉(zhuǎn)到鏈表中的下一個舊版本重復(fù)步驟3的判斷。直到找到可見版本或者遍歷完整個版本鏈。因為整個過程不涉及鎖所以快照讀永遠不會被其他事務(wù)的鎖阻塞也不需要等待其他事務(wù)提交。這就是“SELECT從來不阻塞UPDATE”的第一層含義SELECT本身不拿鎖UPDATE自然不用等它。3.2 當(dāng)前讀UPDATE為什么必須加鎖與快照讀相對的是當(dāng)前讀Current Read也叫鎖定讀。UPDATE、DELETE、INSERT以及SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE 都走當(dāng)前讀。當(dāng)前讀讀取的是記錄的最新版本而不是歷史版本并且在讀取時需要獲取對應(yīng)的行鎖或間隙鎖。為什么寫操作必須用當(dāng)前讀原因很簡單寫操作要修改數(shù)據(jù)如果基于舊版本去更新那后提交的事務(wù)就會把先提交的修改覆蓋掉導(dǎo)致丟失更新。所以UPDATE必須先拿到最新版本并加鎖確保在修改期間沒有其他事務(wù)能同時寫同一條記錄。以UPDATE為例執(zhí)行過程大致是執(zhí)行器定位到目標(biāo)行。加X鎖排他鎖防止其他事務(wù)并發(fā)修改同一行。讀取最新版本的數(shù)據(jù)。生成undo log復(fù)制舊版本信息。修改行內(nèi)容更新DB_TRX_ID調(diào)整DB_ROLL_PTR。如果當(dāng)前事務(wù)尚未提交其他事務(wù)的當(dāng)前讀操作會被阻塞但快照讀不受影響。這里有個關(guān)鍵認知MVCC并沒有完全取代鎖。它解決的是讀寫之間的互斥而寫寫之間的沖突仍然要靠鎖來解決。兩個事務(wù)同時UPDATE同一條記錄時后發(fā)起的UPDATE必須等待先獲取鎖的事務(wù)釋放鎖否則就會一直阻塞。3.3 用一個時間線推演SELECT和UPDATE的完整并發(fā)過程為了更直觀我們用一張表格推演一遍。事務(wù)A執(zhí)行SELECT事務(wù)B執(zhí)行UPDATE隔離級別采用可重復(fù)讀RR。時刻事務(wù)ASELECT事務(wù)BUPDATEt1開啟事務(wù)生成ReadView開啟事務(wù)t2SELECT id1讀取DB_TRX_ID10的舊版本返回結(jié)果暫時未操作t3SELECT id1再次讀取仍返回DB_TRX_ID10的版本UPDATE id1獲取X鎖新增DB_TRX_ID30的新版本t4SELECT id1繼續(xù)讀取舊版本不受t3 UPDATE影響COMMIT在這個推演里事務(wù)A在t3時刻第二次SELECT時數(shù)據(jù)已經(jīng)變了但它因為使用的是事務(wù)開始時生成的ReadView讀到的依然是舊版本。整個過程里事務(wù)A從未被阻塞事務(wù)B的UPDATE也從未因為SELECT的存在而停下來。這正是MVCC在RR隔離級別下最典型的應(yīng)用場景。那如果事務(wù)A執(zhí)行的是SELECT ... FOR UPDATE呢抱歉當(dāng)前讀才會被阻塞。事務(wù)B一旦持有X鎖事務(wù)A的FOR UPDATE會進入鎖等待狀態(tài)一直到事務(wù)B提交或回滾。4. 不同數(shù)據(jù)庫的MVCC實現(xiàn)差異4.1 InnoDB的undo log方案前面講的主要是InnoDB的實現(xiàn)。InnoDB把舊版本數(shù)據(jù)統(tǒng)一存放在系統(tǒng)表空間或獨立undo表空間中行記錄中只保留一個回滾指針通過它指向undo log中的舊版本。優(yōu)點是主表記錄體積小、結(jié)構(gòu)固定缺點是版本鏈過長時回溯歷史版本需要頻繁訪問undo log產(chǎn)生額外的隨機IO。在InnoDB中版本鏈的長度主要由并發(fā)更新次數(shù)決定。一個被大量更新過的熱記錄版本鏈可能非常長遇到長事務(wù)時讀操作就需要沿著版本鏈逐級查找性能會有所下降。4.2 PostgreSQL的堆內(nèi)多版本方案PostgreSQL采用了另一種思路舊版本直接留在原始數(shù)據(jù)頁堆表中新的更新會在同一數(shù)據(jù)頁上追加一條新的元組tuple。相關(guān)事務(wù)ID信息xmin、xmax直接寫進元組頭。然后通過xmax標(biāo)記舊元組是否失效當(dāng)清理進程VACUUM運行時再把這些死亡元組從頁面上物理清除。這種方案的優(yōu)點是讀取舊版本時不需要訪問額外的undo區(qū)域局部性更好缺點是同一數(shù)據(jù)頁上可能堆積大量死亡元組造成表膨脹bloat而且清理不及時的話會顯著影響查詢性能。4.3 其他數(shù)據(jù)庫的取舍Oracle同樣使用undo表空間方案與InnoDB類似但Oracle的undo管理更成熟支持自動調(diào)優(yōu)和保留期設(shè)置。SQL Server默認使用基于鎖的隔離機制但提供了READ_COMMITTED_SNAPSHOT選項和快照隔離級別開啟后也走行版本控制路線在tempdb中保存版本數(shù)據(jù)。這些差異說明MVCC沒有一個放之四海而皆準(zhǔn)的樣板核心思想一致工程實現(xiàn)上各有取舍。4.4 實現(xiàn)方案對比對比維度InnoDBundo log方案PostgreSQL堆內(nèi)多版本方案舊版本存儲位置獨立undo log區(qū)域原數(shù)據(jù)頁內(nèi)的新增元組行記錄額外信息DB_TRX_ID、DB_ROLL_PTRxmin、xmax等元組頭字段歷史版本回溯代價可能跨頁隨機訪問IO成本較高同頁內(nèi)查找局部性好但頁內(nèi)易堆積死亡元組空間回收機制purge線程異步清理undo記錄VACUUM進程回收死亡元組典型膨脹風(fēng)險undo表空間膨脹表和索引膨脹5. 生產(chǎn)環(huán)境中的MVCC問題排查與調(diào)優(yōu)5.1 版本膨脹undo失控與表膨脹MVCC在帶來巨大并發(fā)收益的同時也埋了一個隱患——舊版本不會立即消失。如果清理不及時就會出問題。對InnoDB來說undo log不能被purge線程及時回收。一旦purge線程落后undo表空間會持續(xù)膨脹磁盤占用暴漲數(shù)據(jù)庫整體性能下滑。常見場景有超長事務(wù)長時間不提交、高頻UPDATE海量數(shù)據(jù)后事務(wù)不結(jié)束等。對PostgreSQL來說死亡元組的堆積會造成表和索引膨脹。這個更直觀表占用的磁盤空間是實際數(shù)據(jù)的數(shù)倍但vacuum一直跟不上查詢還要掃描大量死亡元組性能自然變差。5.2 長事務(wù)是版本膨脹的源頭版本膨脹最核心的根源就是長事務(wù)。某個事務(wù)一直不提交就會導(dǎo)致另一個關(guān)鍵問題被它創(chuàng)建的舊版本對數(shù)據(jù)庫來說“未來可能還需要可見”因此purge線程不敢清理這些版本鏈上的任何內(nèi)容。只要這個長事務(wù)存在所有它改動過的記錄以及與之相關(guān)的版本鏈只能一直保留。我見過一個具體案例某業(yè)務(wù)系統(tǒng)里一個后臺統(tǒng)計作業(yè)每天運行兩個多小時期間不做任何提交。結(jié)果那段時間內(nèi)所有被更新的業(yè)務(wù)數(shù)據(jù)undo log膨脹到平時幾十倍。更麻煩的是長事務(wù)還會導(dǎo)致其他事務(wù)的version鏈路無法被回收形成連鎖反應(yīng)。5.3 當(dāng)前讀帶來的阻塞問題MVCC能解決的只是普通SELECT和寫操作之間的阻塞關(guān)系。一旦業(yè)務(wù)SQL里出現(xiàn)了SELECT ... FOR UPDATE或者UPDATE本身MVCC就管不著了。兩個業(yè)務(wù)同時更新同一行照樣會出現(xiàn)鎖等待鎖等待超時后會直接報錯返回。不少同學(xué)在這個地方踩坑以為有了MVCC就可以在所有場景下不用考慮鎖競爭。事實上FOR UPDATE場景下只要事務(wù)A一直持有X鎖不提交事務(wù)B的當(dāng)前讀就會一直阻塞直到鎖等待超時。排查這類問題時要看的是鎖等待狀態(tài)和事務(wù)持有鎖的情況而不是MVCC層面的內(nèi)容。5.4 監(jiān)控視圖與排查命令實際工作中排查MVCC相關(guān)問題主要看幾個監(jiān)控指標(biāo)和系統(tǒng)視圖。InnoDB場景下可以查看information_schema里的 INNODB_TRX、INNODB_LOCKS、INNODB_LOCK_WAITS 表以及通過 SHOW ENGINE INNODB STATUS 查看事務(wù)和鎖信息。重點關(guān)注是否存在運行時間特別長的事務(wù)。undo log的增長速率。有沒有大量purge線程跟不上或鎖等待事件。PostgreSQL場景下查詢 pg_stat_activity 可以看活躍事務(wù)查 pg_stat_user_tables 可以看n_dead_tup死亡元組數(shù)量。如果n_dead_tup持續(xù)增長且比活元組數(shù)量還多就該手動觸發(fā)VACUUM ANALYZE或者檢查autovacuum配置了。6. 實戰(zhàn)心得與避坑建議踩過幾次坑之后我總結(jié)了幾條MVCC相關(guān)的經(jīng)驗分享出來供大家參考。第一正確理解“SELECT不阻塞UPDATE”的適用范圍。普通SELECT走快照讀時確實不會被UPDATE阻塞但開發(fā)人員在實際編寫系統(tǒng)時要清楚FOR UPDATE和UPDATE本身仍存在鎖等待。別把“讀不阻塞寫”理解成“所有讀都不阻塞寫”。第二隔離級別的選擇會直接影響MVCC行為。RR級別下ReadView只生成一次同一個事務(wù)內(nèi)多次SELECT結(jié)果一致。如果業(yè)務(wù)不需要這種強一致可以考慮用RC級別來降低快照保持時間也能間接減輕一些版本保留的壓力。第三盡量避免在事務(wù)中做大范圍更新也盡量避免長事務(wù)。事務(wù)里UPDATE行數(shù)越多、事務(wù)運行時間越長undo log、版本鏈和各類鎖的占用就越大可能直接拖垮整個實例。我個人的習(xí)慣是把大事務(wù)拆成小批次提交每一步都盡快結(jié)束事務(wù)。第四監(jiān)控一定要落地。數(shù)據(jù)庫沒有出現(xiàn)問題時你可能感受不到MVCC的代價一旦版本開始膨脹往往已經(jīng)是事故現(xiàn)場。提前配好undo增長監(jiān)控、鎖等待監(jiān)控和長事務(wù)監(jiān)控真的能省很多半夜救火的痛苦。MVCC是數(shù)據(jù)庫世界里少有的“讀寫雙贏”機制它讓讀操作幾乎無條件地讓路于寫操作又通過版本隔離保障了事務(wù)的一致性視圖。但理解它、用好它不能只停留在“SELECT不阻塞UPDATE”這一句話上。我希望這篇文章能幫你把MVCC的工作原理、可見性判斷規(guī)則和實際運維中的關(guān)鍵點串起來下次再有人問起這個問題時你能講得比他期待的更透。