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