分裂機(jī)制)
主鍵自增明明是順序插入為什么還會(huì)偶爾“卡”一下高并發(fā)往一張 InnoDB 表里INSERT主鍵是自增的。監(jiān)控上經(jīng)常能看到這樣一條曲線平時(shí) QPS 挺穩(wěn)但隔一段時(shí)間延遲就會(huì)猛地翹一下QPS 跟著掉一截過幾秒后自動(dòng)恢復(fù)再過一會(huì)兒又翹一次。曲線呈規(guī)律的鋸齒狀。很多人把這個(gè)現(xiàn)象粗略地歸結(jié)為「頁(yè)分裂Page Split」認(rèn)為是 B 樹把數(shù)據(jù)頁(yè)從中間劈開一半記錄被搬走從而導(dǎo)致了慢。但這只說對(duì)了一半。頁(yè)分裂確實(shí)發(fā)生了但在自增主鍵的場(chǎng)景下InnoDB 根本不會(huì)把頁(yè)「從中間劈開」。它會(huì)將新行直接放到右邊新開的頁(yè)上而左邊的老頁(yè)依然是滿的空間并沒有被對(duì)半撕裂。真正讓所有INSERT瞬間卡住的元兇是它們都擠在了同一片最右邊的葉子頁(yè)上。當(dāng)這頁(yè)快滿需要分裂時(shí)分配新頁(yè)、改前后鏈表、改父節(jié)點(diǎn)這段時(shí)間里所有后來(lái)的插入操作全都在這頁(yè)的門口排隊(duì)死等。分裂只是導(dǎo)火索對(duì)最右頁(yè)的Latch閂鎖排隊(duì)踩踏才是你在監(jiān)控上看到的那個(gè)“尖刺”。MySQL 8.0的InnoDB就是這么干的。我們扒開源碼從底層來(lái)看看這個(gè)過程。1. 先看一頁(yè)里有什么InnoDB默認(rèn)一頁(yè)是16KBinnodb_page_size。你可以把它想象成一張固定大小的帶網(wǎng)格的紙數(shù)據(jù)絕不是隨便往上堆的。紙的兩頭有兩個(gè)固定的「哨兵」infimum比頁(yè)內(nèi)所有真實(shí)記錄都小和supremum比所有真實(shí)記錄都大。用戶記錄從頁(yè)頭往頁(yè)尾長(zhǎng)而用于二分查找的頁(yè)目錄Page Directory則從頁(yè)尾往頁(yè)頭長(zhǎng)中間剩下一截空地。葉子頁(yè)還有指向「上一頁(yè)/下一頁(yè)」的指針FIL_PAGE_PREV、FIL_PAGE_NEXT串成了一個(gè)雙向鏈表。范圍掃描順著鏈表走就行不用每次都回根節(jié)點(diǎn)。當(dāng)插入一行數(shù)據(jù)時(shí)InnoDB會(huì)先在頁(yè)里找到合適的位置再看中間的空地夠不夠。如果頁(yè)內(nèi)因?yàn)橹暗膭h除留下了空洞雖然總字節(jié)數(shù)夠但連不成一整塊InnoDB會(huì)先在頁(yè)內(nèi)做一次整理reorganize把記錄擠緊湊了再插。如果整理完還是放不下才會(huì)觸發(fā)分裂。這條「先試試不行再分裂」的代碼入口叫btr_cur_optimistic_insert()樂觀插入放得下只改這一頁(yè)寫點(diǎn)Redo Log結(jié)束。日常絕大多數(shù)插入走的都是這條路。悲觀分裂放不下返回失敗。上層接著進(jìn)入btr_page_split_and_insert()。要申請(qǐng)新頁(yè)、搬運(yùn)數(shù)據(jù)、修改父節(jié)點(diǎn)甚至導(dǎo)致B樹長(zhǎng)高。你在監(jiān)控里看到的尖刺對(duì)應(yīng)的就是少數(shù)幾次悲觀分裂耗時(shí)疊加當(dāng)時(shí)所有并發(fā)插入都在這一頁(yè)排隊(duì)等待的綜合結(jié)果。2. 聚簇頁(yè)vs二級(jí)索引頁(yè)痛點(diǎn)截然不同兩棵樹的頁(yè)結(jié)構(gòu)長(zhǎng)得一樣但葉子里裝的“貨”不一樣導(dǎo)致它們的并發(fā)痛點(diǎn)完全不同。聚簇索引主鍵葉子節(jié)點(diǎn)存的是完整的整行數(shù)據(jù)附帶InnoDB隱藏列trx_id和roll_ptr。二級(jí)索引葉子節(jié)點(diǎn)只存索引列主鍵列。因?yàn)檠b的貨不同分裂時(shí)的代價(jià)差異巨大如果聚簇索引的一行非常寬比如2KB去掉頁(yè)頭信息一頁(yè)根本放不了幾條數(shù)據(jù)。沒插幾下就要分裂且一次搬走的字節(jié)量極大。行越寬一頁(yè)裝得越少監(jiān)控上的尖刺就越密集。二級(jí)索引的記錄通常很窄一頁(yè)能塞很多條分裂沒那么頻繁。但是主鍵是順序自增的二級(jí)索引列比如user_id往往是無(wú)序散列的。主鍵的痛點(diǎn)所有寫入都集中在最右頁(yè)排隊(duì)。二級(jí)索引的痛點(diǎn)插入點(diǎn)滿樹亂跳頁(yè)經(jīng)常被從中間切開索引更容易產(chǎn)生碎片變胖。延伸提醒主鍵如果發(fā)生修改整行要在聚簇索引里搬家所有二級(jí)索引里的主鍵副本也得跟著改。所以主鍵千萬(wàn)別用業(yè)務(wù)上會(huì)變的列。3. 自增插入真的不是對(duì)半切到底在哪切這取決于btr_page_split_and_insert()怎么選切點(diǎn)。InnoDB每個(gè)數(shù)據(jù)頁(yè)的頭部有個(gè)字段叫PAGE_LAST_INSERT專門記錄上一次數(shù)據(jù)插在了哪兒。如果本次插入的位置正好接在上一次的后面InnoDB就會(huì)判定“當(dāng)前是順序向右插入”從而走btr_page_get_split_rec_to_right()邏輯當(dāng)插到頁(yè)的最右端時(shí)**新記錄會(huì)自己去當(dāng)右邊新頁(yè)的第一條左邊的老頁(yè)原封不動(dòng)。**如果右邊還剩幾條舊記錄InnoDB會(huì)把它們搬到新頁(yè)并在當(dāng)前頁(yè)保留一條。源碼注釋解釋留這一條是為了讓后續(xù)的順序插入還能利用自適應(yīng)哈希AHI在當(dāng)前頁(yè)對(duì)齊位置。自增主鍵就是典型的這種模式左頁(yè)繼續(xù)保持滿載右頁(yè)剛打開后面的INSERT繼續(xù)往右頁(yè)填填滿再來(lái)一次。所以自增插入絕對(duì)不會(huì)讓索引變成一堆半空的碎片頁(yè)。相反如果主鍵是UUID或者是狀態(tài)值這種跳來(lái)跳去的數(shù)據(jù)InnoDB看不出順序就會(huì)走page_get_middle_rec()從中間切。這才是大家口中常說的「劈成兩半」。大約一半記錄被搬走產(chǎn)生更長(zhǎng)的Redo Log分裂后兩頁(yè)都只有半滿。如果持續(xù)這樣隨機(jī)插半滿的頁(yè)會(huì)越來(lái)越多不僅浪費(fèi)Buffer Pool范圍掃描也更吃虧。4. 一次悲觀分裂底層到底在忙什么悲觀分裂是一套組合拳都在btr_page_split_and_insert()里挨個(gè)執(zhí)行定切點(diǎn)往左、往右還是從中間切。要新頁(yè)調(diào)用btr_page_alloc()申請(qǐng)新頁(yè)并盡量要求物理上靠近當(dāng)前頁(yè)減少隨機(jī)IO。申請(qǐng)不到直接報(bào)錯(cuò)返回。掛鏈表調(diào)用btr_attach_half_pages()把新頁(yè)掛進(jìn)B樹修改前后頁(yè)的指針并在父節(jié)點(diǎn)加上指向新頁(yè)的記錄如果父頁(yè)也滿了分裂會(huì)向上傳導(dǎo)極端情況下根節(jié)點(diǎn)分裂樹高加一。釋放樹鎖如果新行放得下InnoDB會(huì)盡早釋放整棵索引樹的Latch。如果樹鎖抓太久別的插入連其他無(wú)關(guān)的頁(yè)都進(jìn)不去。搬數(shù)據(jù)最后才是把該搬的記錄搬過去把新行寫進(jìn)去。一個(gè)小細(xì)節(jié)預(yù)留的1/16空間在連續(xù)向一側(cè)插入且頁(yè)未壓縮時(shí)如果頁(yè)內(nèi)剩余空間小于「這一行的寬度一頁(yè)的1/16約1KB」樂觀插入會(huì)主動(dòng)放棄提前去分裂。 注釋里的理由很實(shí)在如果頁(yè)被連續(xù)插入徹底塞死以后要是發(fā)生UPDATE把變長(zhǎng)字段改大頁(yè)會(huì)碎得很厲害。所以最右頁(yè)「看著還有一點(diǎn)空就開始分裂」多半是觸發(fā)了這個(gè)1KB的保護(hù)線而不是空間算錯(cuò)了。5. 尖刺在監(jiān)控上到底長(zhǎng)什么樣假設(shè)有一張普通的訂單表CREATE TABLE orders ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, payload VARCHAR(512), PRIMARY KEY (id), KEY idx_user (user_id) ) ENGINEInnoDB;業(yè)務(wù)側(cè)有幾十個(gè)連接同時(shí)INSERT。因?yàn)閕d越來(lái)越大所有線程算出來(lái)的插入目標(biāo)全落在最右邊那片葉子上。修改這頁(yè)必須拿到排他X性質(zhì)的Page Latch。同一時(shí)刻只能有一個(gè)插入在改它其他人全在門口等。正常情況下如果空地夠樂觀插入極快排隊(duì)無(wú)感。 一旦空地不夠觸發(fā)悲觀分裂申請(qǐng)新頁(yè)、改鏈表、改父節(jié)點(diǎn)……操作耗時(shí)拉長(zhǎng)門口的隊(duì)伍瞬間變長(zhǎng)監(jiān)控上延遲直接翹起來(lái)QPS掉下去。分裂一結(jié)束新的最右頁(yè)誕生繼續(xù)飛速接客延遲瞬間掉回去。等這頁(yè)再次被填滿再來(lái)一輪。所以你看到的曲線是鋸齒而不是越跑越慢。如果payload字段很大一頁(yè)裝不了幾行這個(gè)鋸齒就會(huì)非常密集。避坑指南別把頁(yè)Latch和表級(jí)的AUTO-INC鎖混為一談。在MySQL 8.0中innodb_autoinc_lock_mode默認(rèn)是2普通INSERT獲取自增值幾乎沒有表級(jí)鎖等待。如果你在鎖監(jiān)控里沒看到AUTO-INC鎖只有大量等待同一個(gè)index page的semaphore那就是最右頁(yè)Latch被打滿了。6. 兩種常見的對(duì)比場(chǎng)景刪數(shù)據(jù)也會(huì)卡但卡的不在同一處當(dāng)你批量DELETE歷史數(shù)據(jù)時(shí)頁(yè)被掏空btr_compress()會(huì)嘗試把空頁(yè)和相鄰頁(yè)合并。這同樣需要拿Page Latch、改鏈表、改父節(jié)點(diǎn)。 白天高并發(fā)自增寫入卡在最右頁(yè)夜里跑定時(shí)任務(wù)刪老數(shù)據(jù)卡在被刪的舊數(shù)據(jù)段。兩件事經(jīng)常疊在一張表上遇到這種情況先排查慢在哪別一上來(lái)就喊「索引壞了重建吧」。換成UUID主鍵會(huì)怎樣UUID會(huì)讓插入點(diǎn)散落在滿樹的各個(gè)節(jié)點(diǎn)單頁(yè)上的并發(fā)爭(zhēng)搶立刻緩解最右頁(yè)的鋸齒尖刺會(huì)大幅減輕。 但代價(jià)轉(zhuǎn)移了頁(yè)經(jīng)常從中間切開產(chǎn)生大量半滿頁(yè)同樣的數(shù)據(jù)量占用更多的頁(yè)導(dǎo)致Buffer Pool命中率下降磁盤隨機(jī)IO和邏輯讀大幅上升。自增主鍵是用「所有寫擠在一頁(yè)」換取「樹更緊湊、范圍掃描更順」。沒有絕對(duì)的好壞取決于你的并發(fā)量和查詢模式是否吃索引的緊湊度。7. 線上碰到了該怎么看和處理排查路徑確認(rèn)尖刺是否伴隨大量INSERT且當(dāng)時(shí)沒有大規(guī)模DELETE排除頁(yè)合并的干擾。使用SHOW ENGINE INNODB STATUS查看semaphore段。如果有大量線程等同一個(gè)index page且行鎖和MDL鎖都很干凈基本就是最右頁(yè)Latch爭(zhēng)用。檢查單行數(shù)據(jù)的寬度行越寬分裂越頻繁以及是否有另一個(gè)非常熱的二級(jí)索引。處理手段保持自增瘦身表結(jié)構(gòu)主鍵繼續(xù)自增。把大JSON、大文本TEXT/BLOB拆分到旁路表讓主表葉子節(jié)點(diǎn)變窄。一頁(yè)能放的行數(shù)翻倍分裂頻率和搬運(yùn)代價(jià)就會(huì)減半。保持參數(shù)確認(rèn)innodb_autoinc_lock_mode2別亂改回1或0避免引入不必要的表級(jí)自增鎖。打散熱點(diǎn)如果寫入并發(fā)實(shí)在太高單頁(yè)Latch成了絕對(duì)瓶頸可以考慮按租戶、時(shí)間或者Hash分表。讓「1個(gè)最右頁(yè)」變成「N個(gè)表的最右頁(yè)」把并發(fā)攤開。別白費(fèi)力氣調(diào)innodb_fill_factor消除不了這個(gè)鋸齒。該參數(shù)主要影響重建索引DDL時(shí)的填充率無(wú)法阻止運(yùn)行時(shí)為了預(yù)留Update空間而觸發(fā)的1/16分裂機(jī)制??偨Y(jié)最右頁(yè)滿了會(huì)分裂新行去右邊左頁(yè)保持緊湊。所有并發(fā)寫入都在同一頁(yè)搶Latch分裂那一下排隊(duì)隊(duì)伍變長(zhǎng)監(jiān)控上就會(huì)出現(xiàn)一下一下的尖刺。理清了這個(gè)底層邏輯你就能精準(zhǔn)地決定是該“縮窄行寬”還是該“打散熱點(diǎn)”而不是盲目地去重建索引。