原理詳解:從回表優(yōu)化到EXPLAIN實(shí)戰(zhàn))
前兩天一個準(zhǔn)備去中國郵政面試Java崗的朋友回來跟我復(fù)盤說面試官盯著MySQL追著問聚簇索引和二級索引的區(qū)別、回表是什么、聯(lián)合索引最左前綴最后落到一句——“你知道ICP嗎索引條件下推講講原理和應(yīng)用場景?!彼?dāng)時(shí)有點(diǎn)懵名字聽過EXPLAIN里見過Using index condition但真要講清楚“條件下推到底推給了誰、推下去之后發(fā)生了什么”就講不利索了。這其實(shí)是很多人的通病會用EXPLAIN但沒把Server層和存儲引擎層的分工想透遇到“索引條件下推”這種偏底層的優(yōu)化就露餡。這篇就把ICP徹底拆開。先說清楚它解決什么問題再一步步還原一次查詢在“沒有ICP”和“有ICP”兩種狀態(tài)下分別怎么干活然后給一組可以自己復(fù)現(xiàn)的實(shí)驗(yàn)最后把面試追問方向、實(shí)戰(zhàn)里的坑和排查套路一起講完。不管你是準(zhǔn)備面試的Java開發(fā)還是平時(shí)被慢查詢折騰得夠嗆的后端這篇都能直接拿來用。1. 面試官到底在考什么把背景先對齊1.1 MySQL執(zhí)行一次查詢誰在干活要理解ICP第一步得先在腦子里建一張MySQL的“執(zhí)行地圖”。一條SELECT語句進(jìn)來要經(jīng)過連接器建立連接、權(quán)限校驗(yàn)、分析器詞法語法解析、優(yōu)化器決定訪問路徑、選擇索引、執(zhí)行器調(diào)用存儲引擎接口最后才輪到存儲引擎——也就是InnoDB——去真正讀數(shù)據(jù)。這里最關(guān)鍵的分工是Server層負(fù)責(zé)“怎么查、查完再過濾”存儲引擎層負(fù)責(zé)“按什么方式把數(shù)據(jù)找出來”。在沒有ICP的年代取數(shù)和過濾這兩件事的邊界非常機(jī)械存儲引擎負(fù)責(zé)把索引定位到的記錄對應(yīng)的完整行撈出來交給Server層Server層再拿著每一行逐條去套WHERE條件。問題就出在這個“先撈上來、再判斷”的流程上。如果一條二級索引能定位出1萬條記錄但真正滿足完整WHERE條件的只有800條那9200次回表和后續(xù)的逐行判斷都是純浪費(fèi)。ICP要干的就是把這種浪費(fèi)壓縮到最低。1.2 從“回表”說起回表這個詞面試幾乎必考。InnoDB的表是聚簇索引結(jié)構(gòu)主鍵索引的葉子節(jié)點(diǎn)直接存整行數(shù)據(jù)而二級索引的葉子節(jié)點(diǎn)只存“索引列 主鍵值”。你用二級索引查數(shù)據(jù)時(shí)得先在二級索引里找到主鍵值再拿著主鍵回聚簇索引取整行這個過程就叫回表官方也叫書簽查找?;乇硎怯姓鎸?shí)代價(jià)的它是隨機(jī)IO為主的操作命中的行越多回表次數(shù)越多慢查詢的概率越大。很多業(yè)務(wù)系統(tǒng)的慢SQL根子不在“沒建索引”而在“建了索引但回表次數(shù)太多”。ICP正是針對“二級索引 回表”這個組合做的優(yōu)化。它能在回表發(fā)生之前就把一部分WHERE條件先消化掉。換句話說ICP讓“過濾”這件事提前到了存儲引擎遍歷二級索引的時(shí)候。1.3 面試官問ICP實(shí)際在問三層?xùn)|西中國郵政這類業(yè)務(wù)系統(tǒng)大量訂單、物流、賬單查詢單表幾千萬行非常正常查詢性能直接決定線上穩(wěn)不穩(wěn)。面試官問ICP表面是考一個優(yōu)化名詞實(shí)際在考察三層能力第一層知不知道回表原理能不能畫出二級索引和聚簇索引的結(jié)構(gòu)差異。第二層知不知道Server層和存儲引擎層的邊界懂不懂“下推”這個動作意味著職責(zé)轉(zhuǎn)移。第三層能不能結(jié)合實(shí)際場景說清楚ICP的收益、限制以及和覆蓋索引、MRR這些優(yōu)化的取舍。所以別把ICP當(dāng)孤立名詞背。你如果能從“回表次數(shù)”這個指標(biāo)切入把收益量化出來再把邊界條件講明白這道題基本就穩(wěn)了。2. ICP原理拆解一次查詢的前后對比2.1 沒有ICP時(shí)一次查詢的完整流程假設(shè)有張員工表二級索引建在(last_name, age)上查詢是SELECT * FROM employees WHERE last_name 王 AND age 20;聯(lián)合索引是last_name在前、age在后所以last_name王能用到索引的等值定位age 20是索引內(nèi)第二列的范圍條件同樣能參與索引掃描。MySQL 5.6之前這條SQL的執(zhí)行流程是這樣的Server層通過優(yōu)化器確定訪問路徑走idx_last_age索引定位到所有l(wèi)ast_name王的索引記錄。InnoDB存儲引擎按這個范圍逐條掃描二級索引拿到每條索引記錄里的主鍵值。對每一條索引記錄存儲引擎都要拿著主鍵回聚簇索引把完整行讀出來。完整行返回給Server層Server層再判斷age 20是否成立成立則進(jìn)結(jié)果集不成立就丟棄。這個流程里age 20雖然涉及的是索引列但存儲引擎完全“看不見”它只會機(jī)械地把所有l(wèi)ast_name王的行都撈一遍。假設(shè)表里有8萬條姓王的員工其中8千條年齡小于等于20那就意味著要回表8萬次、向Server層傳8萬行最后只留下8千行。7萬多次回表和接近8萬行的傳輸全部白費(fèi)。2.2 有ICP時(shí)流程發(fā)生了哪些變化MySQL 5.6引入ICP之后同樣的查詢變成這樣Server層在生成執(zhí)行計(jì)劃時(shí)發(fā)現(xiàn)age 20這個條件只涉及索引列age在idx_last_age里于是把這個條件下推給存儲引擎。InnoDB掃描二級索引記錄時(shí)每掃到一條先做兩個判斷l(xiāng)ast_name是否等于 王并且age是否小于等于20。只有兩個條件都滿足的索引記錄才被允許回表取完整行。最后返回給Server層的是已經(jīng)過了一輪預(yù)篩選的數(shù)據(jù)數(shù)量大幅減少。前后的數(shù)據(jù)流對比非常直觀過濾動作從“Server層拿到完整行之后”提前到了“存儲引擎遍歷索引記錄時(shí)”?;乇泶螖?shù)從8萬次降到8千次Server層需要處理的行數(shù)也跟著降了一個量級。在數(shù)據(jù)量大、篩選率高的場景下這就是數(shù)量級的差別。對比項(xiàng)無ICP有ICP索引掃描范圍所有l(wèi)ast_name王的索引記錄同樣范圍回表次數(shù)約8萬次約8千次傳給Server層的行數(shù)約8萬行約8千行過濾發(fā)生位置Server層回表之后存儲引擎層回表之前2.3 為什么能在二級索引上直接判斷條件這里有個關(guān)鍵點(diǎn)二級索引的葉子節(jié)點(diǎn)里不光有索引列還帶著主鍵值。也就是說存儲引擎在掃描二級索引時(shí)手上已經(jīng)握有這條索引記錄的全部索引列值last_name、age以及主鍵id。正因?yàn)樗饕涗洷旧頂y帶了這些信息age 20這種只依賴索引列的條件就不需要回表看完整行才能判斷。存儲引擎在索引掃描過程中直接看一眼age字段的值就行了。所以ICP能成立底層靠的就是二級索引的存儲結(jié)構(gòu)本身。如果條件里混入了非索引列比如再加一個city 上海而city不在idx_last_age里那這個條件就下推不了。引擎只能先回表拿到完整行再判斷city。這也解釋了為什么ICP不是萬能的——它能推下去的條件必須是在索引上就能算出答案的條件。2.4 ICP生效的硬性條件根據(jù)官方文檔和實(shí)際驗(yàn)證ICP要生效得同時(shí)滿足這些條件訪問方法為range、ref、eq_ref或index中的一種也就是查詢確實(shí)走了索引掃描而不是全表掃描。表引擎必須是InnoDB或MyISAM實(shí)際生產(chǎn)里基本就是InnoDB。被下推的條件必須只涉及當(dāng)前表的索引列不能摻雜其他表的列。MySQL 5.6及以上版本且優(yōu)化器開關(guān)index_condition_pushdown為on默認(rèn)就是on。條件匹配引擎支持的操作類型。等值、范圍、BETWEEN、LIKE前綴匹配這些通常都可以。2.5 哪些場景ICP幫不上忙聚簇索引回表場景如果查詢走的是主鍵索引索引記錄本身就是完整的行根本不存在回表這個動作ICP自然沒有用武之地。條件含非索引列比如索引是(name, age)條件里還帶address xxxaddress不在索引里這個條件只能在回表后判斷。條件引用其他表的列多表關(guān)聯(lián)時(shí)涉及另一張表字段的條件不能下推給當(dāng)前表的存儲引擎。條件難以在索引層判斷對索引列使用函數(shù)如SUBSTR(name,1,1)王、類型不匹配導(dǎo)致隱式轉(zhuǎn)換、某些NOT條件和OR組合都可能破壞下推甚至直接讓整個索引失效。理解這些限制比背定義重要得多。面試時(shí)能主動說出“哪個條件下推不了”反而更能體現(xiàn)深度。3. 動手驗(yàn)證ICP用EXPLAIN看真相3.1 準(zhǔn)備實(shí)驗(yàn)環(huán)境與造數(shù)據(jù)理論講完做一個能自己復(fù)現(xiàn)的實(shí)驗(yàn)。我這里用的是MySQL 8.05.6之后都支持先建一張表CREATE TABLE employees ( id INT NOT NULL AUTO_INCREMENT, emp_no VARCHAR(20), last_name VARCHAR(50), age INT, city VARCHAR(50), PRIMARY KEY (id), KEY idx_last_age (last_name, age) ) ENGINEInnoDB;造點(diǎn)數(shù)據(jù)用存儲過程插10萬行重點(diǎn)是讓last_name王的數(shù)據(jù)足夠多對比效果才明顯DROP PROCEDURE IF EXISTS init_data; DELIMITER $$ CREATE PROCEDURE init_data() BEGIN DECLARE i INT DEFAULT 1; WHILE i 100000 DO INSERT INTO employees (emp_no, last_name, age, city) VALUES ( CONCAT(EMP, LPAD(i, 6, 0)), IF(i % 100 80, 王, 李), 18 (i % 30), IF(i % 2 0, 上海, 北京) ); SET i i 1; END WHILE; END$$ DELIMITER ; CALL init_data();這個造數(shù)方式故意讓姓王的比例占到80%也就是大約8萬行年齡分布在18到47歲。這樣便于看到ICP的篩選收益。實(shí)際業(yè)務(wù)里篩選率可能沒這么夸張但實(shí)驗(yàn)效果一目了然。3.2 對比實(shí)驗(yàn)開關(guān)ICP前后先保持默認(rèn)開關(guān)執(zhí)行查詢并看執(zhí)行計(jì)劃EXPLAIN SELECT * FROM employees WHERE last_name 王 AND age 20;在MySQL 8.0上Extra列會顯示Using index condition代表ICP生效。然后再把優(yōu)化器開關(guān)關(guān)掉模擬5.6之前的行為SET optimizer_switch index_condition_pushdownoff; EXPLAIN SELECT * FROM employees WHERE last_name 王 AND age 20; SET optimizer_switch index_condition_pushdownon;注意SET是會話級的不會影響其他連接但測完記得恢復(fù)。關(guān)掉ICP后執(zhí)行計(jì)劃里key仍然是idx_last_age但Extra列從Using index condition變成了Using where。這個變化就是核心證據(jù)同一個索引、同一個條件ICP開與關(guān)只影響過濾發(fā)生的層次不影響訪問路徑的選擇。很多人在面試?yán)镏v不清的“下推”用這兩條EXPLAIN一對比就非常直觀。3.3 結(jié)果解讀與rows列除了Extra列還可以看rows列。ICP開啟時(shí)優(yōu)化器估算的rows通常會小一些關(guān)閉ICP后rows估算會變大。rows雖然是估算值但趨勢能說明問題ICP讓優(yōu)化器認(rèn)為“需要回表的行數(shù)”大大減少。再看實(shí)際效果。我保持SELECT *讓回表必然發(fā)生在10萬行、姓王8萬行的數(shù)據(jù)上分別跑ICP開啟age 20在索引層過濾實(shí)際回表的行大約8千行。ICP關(guān)閉先回表取回所有8萬行姓王的記錄再在Server層過濾年齡回表次數(shù)直接多出約10倍。服務(wù)端狀態(tài)變量也能看出差異。運(yùn)行查詢后對比Handler_read_rnd、Handler_read_secondary等值ICP開啟時(shí)回表相關(guān)的讀取量明顯下降。如果你手頭環(huán)境方便可以用FLUSH STATUS配合SHOW STATUS LIKE Handler_read%實(shí)測。正是因?yàn)榛乇泶螖?shù)和Server層接收行數(shù)同時(shí)下降ICP的效果才這么明顯。如果你的SQL必須回表比如SELECT *ICP的價(jià)值最大如果你查詢的字段全在索引里那連回表都不需要直接走覆蓋索引那是另一個故事了。3.4 別把Using index condition和Using index搞混這里必須說一個絕大多數(shù)初級開發(fā)都會踩的誤區(qū)Extra列里出現(xiàn)Using index和Using index condition是兩種完全不同的優(yōu)化。Using index表示當(dāng)前查詢用到的所有字段都從索引里取得不需要回表這叫覆蓋索引。名字里的“index”側(cè)重“索引覆蓋”。Using index condition表示查詢需要回表取完整行但部分過濾條件被下推到了存儲引擎在索引掃描階段提前篩掉了不滿足條件的記錄?!癱ondition”是重點(diǎn)代表“條件下推”。Using where表示條件都在Server層完成過濾ICP沒參與或沒法參與。面試時(shí)能把這個區(qū)分講清楚會比單純背“Using index condition代表ICP”高一個檔次。不少文章把Using index condition說成“索引覆蓋”這是完全錯誤的要小心辨別。4. 實(shí)戰(zhàn)中的經(jīng)驗(yàn)與坑位4.1 典型受益場景聯(lián)合索引的第二列范圍過濾ICP最典型的受益場景就是聯(lián)合索引里第一列等值、第二列范圍過濾。比如索引(last_name, age)查詢WHERE last_name王 AND age BETWEEN 25 AND 35。沒有ICP時(shí)age的過濾發(fā)生在Server層存儲引擎要把所有姓王的記錄都回表有ICP時(shí)age在索引掃描時(shí)就過濾掉了。所以建索引時(shí)第二列、第三列不是擺設(shè)。只要查詢條件能落在索引列上哪怕不是最左前綴的等值部分ICP也能幫你省回表。這個認(rèn)知直接影響索引設(shè)計(jì)選擇度高的列放前面篩選率高的范圍條件放后面配合ICP可以大幅降低回表壓力。4.2 另一個受益場景LIKE前綴匹配后的再過濾第二個常見場景是模糊查詢。比如索引建在(name, age)上查詢WHERE name LIKE 張% AND age 20。age不在索引里但這不影響name LIKE 張%走索引的前綴掃描同時(shí)如果LIKE后面還有可下推的索引列條件比如name LIKE %三只要name還在索引上MySQL也可能把這個后綴條件下推在索引記錄層就過濾掉一批。實(shí)戰(zhàn)建議遇到前綴模糊查詢盡量讓能被索引判斷的條件和索引列對齊。比如“姓名以張開頭年齡小于某值”這種組合只要age在索引里ICP通常能幫你省掉一大片回表。這個場景在會員檢索、商品篩選里很常見。4.3 坑點(diǎn)函數(shù)和隱式轉(zhuǎn)換讓ICP失效這里展開說一個最容易踩的坑。索引列上套了函數(shù)比如SELECT * FROM employees WHERE LEFT(last_name, 1) 王 AND age 20;LEFT(last_name, 1)對索引列做了函數(shù)運(yùn)算MySQL沒法用正常的B樹結(jié)構(gòu)定位這個條件基本就跟索引告別了自然也沒有ICP可言。另一個高發(fā)場景是隱式類型轉(zhuǎn)換索引列是varchar傳入數(shù)字或者索引列是int傳入字符串都可能讓優(yōu)化器放棄用這個條件和索引做匹配。要避免這類問題第一原則是讓索引列“裸奔”——不要在索引列上套函數(shù)、不要做類型轉(zhuǎn)換、不要做加減乘除運(yùn)算。字段設(shè)計(jì)時(shí)也要注意類型統(tǒng)一應(yīng)用層傳參保持類型一致。4.4 與覆蓋索引的取舍什么時(shí)候別指望ICPICP雖然好但它只是減少了回表次數(shù)并沒有消滅回表。如果你的查詢里回表是最大瓶頸比起依賴ICP更徹底的做法是建立覆蓋索引——讓查詢的所有字段都在索引里把回表整個取消。比如固定查詢SELECT last_name, age FROM employees WHERE last_name王 AND age 20如果建了覆蓋(last_name, age)的索引Extra會顯示Using index回表次數(shù)直接歸零比ICP更極致。但覆蓋索引是有代價(jià)的索引要存儲更多字段寫放大更大索引體積更大插入更新更慢。所以取舍原則是查詢字段固定且量少、性能要求高優(yōu)先覆蓋索引查詢字段多而雜比如SELECT *只能靠ICP盡量減少回表。面試中能把“ICP是減量覆蓋索引是清零”這個對比說出來絕對加分。5. 面試延伸ICP與MRR、覆蓋索引的分工5.1 MRR是ICP的鄰居別混為一談MRRMulti-Range Read多范圍讀取也是MySQL 5.6加入的優(yōu)化但它解決的是另一個問題。二級索引回表時(shí)命中的主鍵順序通常是雜亂的回表就變成了大量隨機(jī)IO。MRR的做法是先把要回表的主鍵收集起來并排序再統(tǒng)一批量回表盡量把隨機(jī)IO變成順序IO。ICP和MRR經(jīng)常被放在一起問但切入點(diǎn)完全不同ICP是“減少回表次數(shù)”MRR是“優(yōu)化回表方式”。而且MRR開啟后需要暫存主鍵再排序會有額外的內(nèi)存或磁盤開銷?;卮饡r(shí)用一句話總結(jié)“ICP讓引擎少回表MRR讓引擎回表更順”面試官聽到這種精準(zhǔn)對比通常會認(rèn)可。5.2 從一條SQL看三種優(yōu)化的分工拿實(shí)驗(yàn)里的SQL來總結(jié)SELECT * FROM employees WHERE last_name 王 AND age 20;如果沒有索引全表掃描一切優(yōu)化無從談起。有了聯(lián)合索引idx_last_ageMySQL按最左前綴定位last_name王。ICP介入把a(bǔ)ge 20下推到存儲引擎減少回表次數(shù)。如果需求字段少且固定可以改造成覆蓋索引徹底免回表。如果回表不可避免、命中的主鍵又分散MRR可以在回表階段幫你排序聚攏。這幾層優(yōu)化不是互斥的可以同時(shí)作用于一條SQL的不同階段。面試官問“這幾個優(yōu)化你分得清嗎”其實(shí)就是在考察你是否理解它們各自作用在哪一層。優(yōu)化手段解決什么問題作用位置關(guān)鍵標(biāo)識ICP減少回表次數(shù)二級索引掃描階段Extra: Using index condition覆蓋索引徹底取消回表索引設(shè)計(jì)階段Extra: Using indexMRR優(yōu)化回表IO順序回表階段Using MRR可能關(guān)聯(lián)5.3 一條可直接參考的完整回答話術(shù)如果面試官當(dāng)場讓你講ICP可以參考這個框架控制在兩分鐘左右先給定義“索引條件下推是MySQL 5.6引入的優(yōu)化能把WHERE中涉及索引列的部分條件下推到存儲引擎層在掃描二級索引記錄時(shí)提前過濾?!痹僦v場景和收益“比如聯(lián)合索引(last_name, age)查詢last_name王 AND age20。沒有ICP引擎得把所有姓王的記錄都回表取完整行再交給Server層過濾有了ICP引擎在二級索引上直接判斷age20只對滿足條件的記錄回表回表次數(shù)可能從幾萬降到幾千?!痹僦v前提“ICP主要作用于二級索引回表場景條件得只涉及索引列涉及非索引列、其他表列的條件沒法下推。用EXPLAIN驗(yàn)證時(shí)Extra列顯示Using index condition?!弊詈笱a(bǔ)一句深度“它和覆蓋索引不一樣覆蓋索引是徹底免回表ICP是減少回表和MRR也不一樣一個減次數(shù)一個優(yōu)化回表順序?!边@個遞進(jìn)式的回答有原理、有量化、有驗(yàn)證、有對比基本可以拿滿分。6. 常見問題與排查技巧實(shí)錄6.1 問題一EXPLAIN里看不到Using index condition怎么辦先檢查查詢是否真的走了索引。如果type是ALL那是全表掃描ICP無從談起。再檢查條件里是否混入了非索引列是否對索引列做了函數(shù)或類型轉(zhuǎn)換。還要確認(rèn)優(yōu)化器開關(guān)沒被全局改過SHOW VARIABLES LIKE optimizer_switch;通常index_condition_pushdownon是默認(rèn)值。如果確實(shí)被關(guān)掉了可以在會話級臨時(shí)打開再驗(yàn)證效果。還有一個經(jīng)常被忽略的點(diǎn)如果查詢條件本身命中的行極少、回表次數(shù)本來就很小優(yōu)化器可能覺得“下推不下推收益不大”但索引生效時(shí)通常還是會顯示。6.2 問題二MySQL版本不同ICP行為有差異嗎ICP從5.6引入5.7和8.0延續(xù)基本原理一致。但每個版本對“什么條件下推”的支持細(xì)節(jié)有細(xì)微差異個別函數(shù)和操作符在版本間的行為可能變化。實(shí)踐時(shí)最穩(wěn)妥的辦法是以當(dāng)前版本的EXPLAIN輸出為準(zhǔn)不要拿老版本的結(jié)論硬套。8.0還可以用EXPLAIN FORMATtree結(jié)合傳統(tǒng)格式看filter條件的展示更直觀。6.3 問題三分區(qū)表能用ICP嗎InnoDB分區(qū)表在MySQL 5.6以后同樣可以用ICP。分區(qū)裁剪和ICP是兩個不同維度的優(yōu)化一個決定哪些分區(qū)可以不讀一個決定分區(qū)內(nèi)回表前怎么過濾兩者可以疊加。但分區(qū)表本身會帶來不少維護(hù)成本業(yè)務(wù)上要謹(jǐn)慎使用不要為了優(yōu)化而強(qiáng)行分區(qū)。6.4 慢查詢排查時(shí)怎么判斷是不是該依賴ICP我自己排查慢查詢的套路是這樣的分享給你第一步先看EXPLAIN的type和key確認(rèn)訪問路徑合理。 第二步看Extra出現(xiàn)Using index condition說明ICP已經(jīng)在幫你省回表如果大量回表并且是Using where說明條件沒被下推可能是有非索引列參與過濾。 第三步用狀態(tài)變量或者在會話里對比關(guān)掉ICP前后的執(zhí)行耗時(shí)把收益量化出來。 第四步如果回表確實(shí)是瓶頸再考慮兩個方向要么調(diào)整索引結(jié)構(gòu)讓更多條件下推要么改造成覆蓋索引徹底免回表。這套流程我平時(shí)排查慢查詢就是這么用的十次里有九次能定位到問題。核心思想是別只看一個點(diǎn)要把訪問路徑、過濾層次、回表量串起來看。我個人這些年排查慢查詢最大的體會是像ICP這種優(yōu)化背概念是不值錢的真正值錢的是你能不能在EXPLAIN里認(rèn)出它、在業(yè)務(wù)SQL里預(yù)判它、在索引設(shè)計(jì)里利用它。面試被問到時(shí)與其背得滾瓜爛熟不如拿一條真實(shí)SQL一步步講清楚“哪個條件下推了、哪次回表被省掉了、驗(yàn)證的Extra列長什么樣”。最后再分享一個小技巧平時(shí)給自己留一個造數(shù)環(huán)境把今天的實(shí)驗(yàn)自己跑一遍。開關(guān)一次index_condition_pushdown看執(zhí)行計(jì)劃的變化比看十篇原理文章都管用。下次不管是面試還是實(shí)際排查心里都有底。