戰(zhàn):子查詢(xún)物化優(yōu)化解決MySQL慢查詢(xún))
我們直接從項(xiàng)目標(biāo)題入手。這個(gè)標(biāo)題是《php方案 子查詢(xún)物化優(yōu)化》看起來(lái)像是后端開(kāi)發(fā)里一條冷門(mén)的性能優(yōu)化經(jīng)驗(yàn)但實(shí)際一拆開(kāi)里面全是干貨。我在過(guò)去幾年的PHP開(kāi)發(fā)里不止一次被慢查詢(xún)折騰到凌晨最后靠子查詢(xún)物化這個(gè)思路把接口響應(yīng)時(shí)間從幾秒壓到幾十毫秒。這篇就專(zhuān)門(mén)聊聊子查詢(xún)物化是什么PHP側(cè)該怎么判斷、怎么用、有哪些坑。適合正在和MySQL慢查詢(xún)較勁的后端開(kāi)發(fā)者、搞過(guò)SQL優(yōu)化的朋友以及準(zhǔn)備面試性能優(yōu)化話(huà)題的同學(xué)。1. 子查詢(xún)物化到底是什么先搞懂優(yōu)化器在做什么1.1 一次慢查詢(xún)事故還原先說(shuō)一個(gè)很典型的場(chǎng)景。某天線上一個(gè)訂單統(tǒng)計(jì)接口突然變慢排查時(shí)發(fā)現(xiàn)罪魁禍?zhǔn)资窍旅孢@類(lèi)查詢(xún)SELECT * FROM orders o WHERE o.user_id IN ( SELECT user_id FROM user_tags WHERE tag_type vip AND created_at 2023-01-01 );初看沒(méi)毛病邏輯很簡(jiǎn)單——找出所有帶“VIP”標(biāo)簽的用戶(hù)然后查這些用戶(hù)的訂單。但問(wèn)題是user_tags表雖然加了索引數(shù)據(jù)量一旦上了百萬(wàn)級(jí)這條SQL的執(zhí)行時(shí)間就開(kāi)始肉眼可見(jiàn)地飆升。用EXPLAIN看執(zhí)行計(jì)劃發(fā)現(xiàn)子查詢(xún)部分被反復(fù)執(zhí)行外層每一行匹配都重新跑一遍子查詢(xún)這就是典型的“相關(guān)子查詢(xún)”或者優(yōu)化器選擇不當(dāng)造成的性能災(zāi)難。這個(gè)過(guò)程的代價(jià)非常具體假設(shè)外層orders有 50 萬(wàn)行子查詢(xún)每次掃描 20 萬(wàn)行那么理論掃描行數(shù)就是 50 萬(wàn)乘以 20 萬(wàn)這是一個(gè)天文數(shù)字再快的磁盤(pán)也扛不住。實(shí)際執(zhí)行計(jì)劃往往表現(xiàn)為DEPENDENT SUBQUERY你一看就知道優(yōu)化器把子查詢(xún)當(dāng)成相關(guān)子查詢(xún)處理了。1.2 物化優(yōu)化的核心邏輯子查詢(xún)物化Subquery Materialization是 MySQL 5.6 及以后版本引入的優(yōu)化策略核心思路其實(shí)特別簡(jiǎn)單先把子查詢(xún)的結(jié)果集抓出來(lái)生成一張內(nèi)存中的臨時(shí)表臨時(shí)表上自動(dòng)建好索引然后再結(jié)合外層查詢(xún)?nèi)プ鲫P(guān)聯(lián)判斷。還是上面那條SQL物化優(yōu)化后等價(jià)于SELECT * FROM orders o INNER JOIN ( SELECT DISTINCT user_id FROM user_tags WHERE tag_type vip AND created_at 2023-01-01 ) AS tmp ON o.user_id tmp.user_id;SELECT DISTINCT user_id這部分就是物化的結(jié)果它只執(zhí)行一次。臨時(shí)表會(huì)根據(jù)user_id自動(dòng)建立索引隨后外層 50 萬(wàn)行訂單記錄逐一去臨時(shí)表里做索引查找每次都走索引單次查找基本是微秒級(jí)。兩種方式的消耗差距不用跑壓測(cè)都能估算出來(lái)。所以物化的本質(zhì)是用一次確定的中間結(jié)果集替代原本可能重復(fù)無(wú)數(shù)次的子查詢(xún)執(zhí)行。這個(gè)思路在很多數(shù)據(jù)庫(kù)優(yōu)化器里都有體現(xiàn)MySQL 的實(shí)現(xiàn)相對(duì)成熟PHP 開(kāi)發(fā)者的任務(wù)就是理解它、觸發(fā)它、必要時(shí)手動(dòng)模擬它。1.3 為什么物化能帶來(lái)數(shù)量級(jí)的提升關(guān)鍵在于“重復(fù)執(zhí)行”和“建索引”兩個(gè)詞。傳統(tǒng)相關(guān)子查詢(xún)的執(zhí)行路徑是外層每拿一行數(shù)據(jù)就去執(zhí)行一次子查詢(xún)。如果外層有 N 行子查詢(xún)本身成本是 M那總成本就是 N×M。物化之后子查詢(xún)只執(zhí)行一次假設(shè)結(jié)果是 R 行臨時(shí)表建索引成本約 R×log(R)外層關(guān)聯(lián)成本是 N×log(R)總體上從乘積關(guān)系降到了加法關(guān)系。舉個(gè)直觀的數(shù)字例子外層 10 萬(wàn)行子查詢(xún)每次掃描 5 萬(wàn)行。優(yōu)化前10萬(wàn) × 5萬(wàn) 50 億行掃描物化后子查詢(xún)執(zhí)行一次 5 萬(wàn)行 臨時(shí)表關(guān)聯(lián) 10 萬(wàn)次索引查找 ≈ 15 萬(wàn)行級(jí)操作差距是四個(gè)數(shù)量級(jí)。這也是為什么只把SQL邏輯“看懂了”遠(yuǎn)遠(yuǎn)不夠必須看清優(yōu)化器生成的是哪種執(zhí)行策略。注意子查詢(xún)物化并不是所有場(chǎng)景都自動(dòng)開(kāi)啟。MySQL 優(yōu)化器有自己的成本模型它會(huì)在物化和非物化之間做代價(jià)估算。很多情況下優(yōu)化器做出了錯(cuò)誤的決策或者因?yàn)閰?shù)設(shè)置、表結(jié)構(gòu)統(tǒng)計(jì)信息過(guò)期導(dǎo)致沒(méi)有走物化路徑。這時(shí)候就需要PHP開(kāi)發(fā)者介入通過(guò)SQL改寫(xiě)或者會(huì)話(huà)級(jí)參數(shù)調(diào)整來(lái)“手動(dòng)”觸發(fā)優(yōu)化。2. 哪些場(chǎng)景適合物化哪些場(chǎng)景反而得不償失2.1 典型適用場(chǎng)景非相關(guān)子查詢(xún) 結(jié)果集適中物化最適合的場(chǎng)景有三個(gè)明顯特征。第一子查詢(xún)和外層查詢(xún)之間沒(méi)有直接關(guān)聯(lián)條件也就是非相關(guān)子查詢(xún)。比如WHERE id IN (SELECT user_id FROM ...),子查詢(xún)不引用外層表的字段它的結(jié)果集是固定的天然適合先算出來(lái)再?gòu)?fù)用。第二子查詢(xún)結(jié)果集大小適中。什么算適中從實(shí)踐看幾千到幾十萬(wàn)行都是物化的舒適區(qū)。結(jié)果集太大臨時(shí)表會(huì)從內(nèi)存溢出到磁盤(pán)物化反而變成一次大表復(fù)制性能反而更差。第三外層查詢(xún)量級(jí)大子查詢(xún)結(jié)果集相對(duì)小。這樣物化后的索引關(guān)聯(lián)優(yōu)勢(shì)才能充分放大。反過(guò)來(lái)如果外層只有幾十行子查詢(xún)即使每次執(zhí)行也就幾千行的成本物化的收益就不明顯甚至因?yàn)榻ㄅR時(shí)表有額外開(kāi)銷(xiāo)比直接嵌套執(zhí)行還慢。典型例子SELECT * FROM products WHERE category_id IN ( SELECT category_id FROM categories WHERE status 1 );categories表通常只有幾百行products可能有百萬(wàn)行。物化后臨時(shí)表就幾百行建索引成本極低外層百萬(wàn)行索引關(guān)聯(lián)暢快無(wú)比。2.2 不適合的邊界情況大結(jié)果集與相關(guān)子查詢(xún)物化不是萬(wàn)能藥。最典型的反例是子查詢(xún)結(jié)果集極大比如子查詢(xún)返回幾百萬(wàn)行物化過(guò)程本身就要把這幾百萬(wàn)行先寫(xiě)入臨時(shí)表這個(gè)寫(xiě)入成本已經(jīng)很恐怖了再加上臨時(shí)表可能落到磁盤(pán)I/O開(kāi)銷(xiāo)直接吞掉所有收益。另一個(gè)反例是相關(guān)子查詢(xún)。相關(guān)子查詢(xún)的含義是子查詢(xún)里引用了外層表的字段SELECT * FROM orders o WHERE EXISTS ( SELECT 1 FROM user_tags t WHERE t.user_id o.user_id AND t.tag_type vip );這種子查詢(xún)的結(jié)果集依賴(lài)于外層每一行的o.user_id物化的語(yǔ)義天然沖突——你沒(méi)法先算一個(gè)固定的結(jié)果集。所以遇到這種查詢(xún)優(yōu)化器通常會(huì)走DEPENDENT SUBQUERY或者半連接的其他策略比如FirstMatch、Duplicate Weedout物化策略不適用。如果遇到這種情況PHP側(cè)真正有效的手段是人工改寫(xiě)把相關(guān)子查詢(xún)改寫(xiě)成 JOIN或者把子查詢(xún)拆成兩步先在PHP里查一次用戶(hù)ID集合再拼第二層IN查詢(xún)。2.3 PHP開(kāi)發(fā)者怎么判斷優(yōu)化器有沒(méi)有走物化判斷方法很直接就是看EXPLAIN的輸出。還是剛才那條訂單查詢(xún)?nèi)绻吡宋锘瘓?zhí)行計(jì)劃里子查詢(xún)那一行的select_type顯示為MATERIALIZED然后table顯示為subquery2外層表的select_type是SIMPLE并且Extra里能看到類(lèi)似Using where的標(biāo)記。我用一段偽代碼來(lái)說(shuō)明判斷流程// 偽代碼: 執(zhí)行EXPLAIN并解析關(guān)鍵列 $explainResult query( EXPLAIN SELECT * FROM orders o WHERE o.user_id IN (SELECT user_id FROM user_tags WHERE tag_type vip) ); foreach ($explainResult as $row) { if ($row[select_type] MATERIALIZED) { echo 子查詢(xún)走了物化\n; } if (strpos($row[table], subquery) ! false) { echo 外層在關(guān)聯(lián)物化臨時(shí)表: {$row[table]}\n; } }執(zhí)行計(jì)劃是優(yōu)化器意圖的直接體現(xiàn)。每次優(yōu)化之前先拉一份EXPLAIN確認(rèn)瓶頸是不是子查詢(xún)?cè)俅_認(rèn)優(yōu)化器當(dāng)前選擇的策略這才是對(duì)癥下藥的第一步。不看執(zhí)行計(jì)劃直接改SQL大概率是在瞎猜。3. PHP側(cè)的實(shí)操方案三板斧完成優(yōu)化落地3.1 第一板斧拉執(zhí)行計(jì)劃確認(rèn)當(dāng)前策略任何優(yōu)化動(dòng)作之前第一件事是在數(shù)據(jù)庫(kù)上直接執(zhí)行EXPLAIN確認(rèn)當(dāng)前優(yōu)化器的選擇。我一般會(huì)順手把EXPLAIN FORMATJSON也跑一遍因?yàn)镴SON格式里信息更全能看到materialized_from_subquery這樣的關(guān)鍵字。實(shí)際命令長(zhǎng)這樣EXPLAIN FORMATJSON SELECT * FROM orders o WHERE o.user_id IN ( SELECT user_id FROM user_tags WHERE tag_type vip AND created_at 2023-01-01 );輸出里如果query_block下出現(xiàn)了materialized_from_subquery: { using_temporary_table: true, dependent: false, cacheable: true }那就說(shuō)明物化確實(shí)生效了??吹絛ependent: false表示這是非相關(guān)子查詢(xún)cacheable: true表示結(jié)果可以被緩存復(fù)用這正是我們想要的。如果沒(méi)看到物化關(guān)鍵字而是看到DEPENDENT SUBQUERY那就說(shuō)明優(yōu)化器選擇了最差的路徑需要進(jìn)入第二板斧。3.2 第二板斧SQL改寫(xiě)把“相關(guān)”變成“不相關(guān)”遇到優(yōu)化器不開(kāi)竅的情況最穩(wěn)妥的PHP側(cè)方案就是人工改寫(xiě)SQL把邏輯從“相關(guān)子查詢(xún)”變成“非相關(guān)子查詢(xún)”或者“JOIN”。以我們線上最常遇到的場(chǎng)景為例原始寫(xiě)法是這樣的SELECT * FROM orders o WHERE EXISTS ( SELECT 1 FROM user_tags t WHERE t.user_id o.user_id AND t.tag_type vip );這種寫(xiě)法優(yōu)化器大概率走DEPENDENT SUBQUERY性能堪憂(yōu)。改寫(xiě)后的方案SELECT o.* FROM orders o INNER JOIN ( SELECT DISTINCT user_id FROM user_tags WHERE tag_type vip ) t ON o.user_id t.user_id;改寫(xiě)之后子查詢(xún)部分變成了派生表Derived TableMySQL 會(huì)對(duì)派生表做物化處理并且自動(dòng)根據(jù)user_id建索引。后續(xù)外層關(guān)聯(lián)走的路徑本質(zhì)就是物化臨時(shí)表上的索引查找。這個(gè)方案在 MySQL 5.7 和 8.0 上都表現(xiàn)穩(wěn)定。注意改寫(xiě)SQL不能只追求“看起來(lái)優(yōu)化了”必須在改寫(xiě)后立刻再跑一次EXPLAIN確認(rèn)執(zhí)行計(jì)劃。有時(shí)候派生表的物化策略會(huì)被derived_merge優(yōu)化覆蓋也就是說(shuō)優(yōu)化器會(huì)把派生表合并進(jìn)外層查詢(xún)這本身也是一種優(yōu)化但場(chǎng)景不同表現(xiàn)不同一定要實(shí)測(cè)確認(rèn)。3.3 第三板斧會(huì)話(huà)級(jí)參數(shù)調(diào)整給優(yōu)化器“遞刀子”上一節(jié)說(shuō)到物化需要臨時(shí)表來(lái)承載結(jié)果集。如果臨時(shí)表太大MySQL 會(huì)把臨時(shí)表從內(nèi)存轉(zhuǎn)到磁盤(pán)性能立刻跳水。這時(shí)候僅僅改寫(xiě)SQL是不夠的還要讓臨時(shí)表這個(gè)容器足夠大。跟臨時(shí)表相關(guān)的參數(shù)主要有兩個(gè)tmp_table_size和max_heap_table_size。MySQL 的內(nèi)存臨時(shí)表大小會(huì)取這兩個(gè)參數(shù)中的較小值。默認(rèn)情況下這個(gè)值往往只有 16MB 到 64MB一旦物化結(jié)果超過(guò)這個(gè)閾值臨時(shí)表就會(huì)落到磁盤(pán)。PHP側(cè)可以在建立數(shù)據(jù)庫(kù)連接后針對(duì)當(dāng)前會(huì)話(huà)執(zhí)行// 使用 PDO $pdo-exec(SET SESSION tmp_table_size 268435456); $pdo-exec(SET SESSION max_heap_table_size 268435456);這里把臨時(shí)表上限調(diào)到了 256MB。但這個(gè)值不能盲目調(diào)大得配合實(shí)際數(shù)據(jù)量。如果物化結(jié)果經(jīng)常超過(guò) 256MB說(shuō)明結(jié)果集本身太大物化方案本身就該重新評(píng)估。另外一個(gè)值得關(guān)注的參數(shù)是 MySQL 8.0 的internal_tmp_mem_storage_engine它控制內(nèi)存臨時(shí)表的存儲(chǔ)引擎通常設(shè)置為T(mén)empTable也比默認(rèn)的MEMORY存儲(chǔ)引擎更高效。SET SESSION internal_tmp_mem_storage_engine TempTable;參數(shù)調(diào)整的本質(zhì)是“告訴優(yōu)化器你可以放心大膽地物化臨時(shí)表空間足夠”。但調(diào)參之前必須清楚這畢竟只是讓優(yōu)化器有更多操作空間SQL本身的結(jié)構(gòu)才是決定性的。4. 常見(jiàn)問(wèn)題與排查技巧實(shí)錄4.1 物化臨時(shí)表超過(guò)內(nèi)存限制性能反而更差這是我在實(shí)際項(xiàng)目里踩過(guò)最深的一個(gè)坑。現(xiàn)象改寫(xiě)SQL后第一次執(zhí)行EXPLAIN確實(shí)看到MATERIALIZED但線上執(zhí)行時(shí)間反而從 2 秒漲到了 8 秒。排查后定位到問(wèn)題物化結(jié)果集有 80 多萬(wàn)行超過(guò)臨時(shí)表內(nèi)存閾值MySQL 把臨時(shí)表寫(xiě)到了磁盤(pán)等于在磁盤(pán)上先復(fù)制了 80 萬(wàn)行數(shù)據(jù)再回表關(guān)聯(lián)中間多了大量 I/O。解決辦法有兩種一是優(yōu)化子查詢(xún)本身通過(guò)加過(guò)濾條件、縮小取樣范圍把物化結(jié)果集降下來(lái)二是有意識(shí)地在PHP側(cè)做一個(gè)“分片物化”——把大IN查詢(xún)拆成多個(gè)批次每個(gè)批次控制在5萬(wàn)行以?xún)?nèi)分多次查詢(xún)?cè)俸喜⒔Y(jié)果。這里分享一個(gè)我用過(guò)的PHP分片邏輯// 偽代碼: 大IN集合分批查詢(xún) $userIdChunks array_chunk($vipUserIds, 50000); $result []; foreach ($userIdChunks as $chunk) { $placeholders implode(,, array_fill(0, count($chunk), ?)); $sql SELECT * FROM orders WHERE user_id IN ($placeholders); $stmt $pdo-prepare($sql); $stmt-execute(array_values($chunk)); $result array_merge($result, $stmt-fetchAll()); }分批之后每次物化的臨時(shí)表都控制在合理范圍整體執(zhí)行時(shí)間反而比一次性大IN更快。這個(gè)思路的本質(zhì)就是讓物化結(jié)果集大小始終處于最優(yōu)區(qū)間。4.2 物化臨時(shí)表上的索引失效MySQL 物化臨時(shí)表會(huì)自動(dòng)建索引這一點(diǎn)是很多資料里明確提到的。但在某些版本或特殊場(chǎng)景下物化臨時(shí)表的索引可能沒(méi)有覆蓋到你需要關(guān)聯(lián)的字段或者在臨時(shí)表上繼續(xù)做過(guò)濾時(shí)優(yōu)化器選擇了全表掃描。遇到這類(lèi)問(wèn)題時(shí)建議直接在EXPLAIN輸出中查看table列里subqueryN對(duì)應(yīng)行的key字段。如果key為空說(shuō)明物化臨時(shí)表上沒(méi)有可用索引外層關(guān)聯(lián)正在做全表掃描。這種情況比較少見(jiàn)但一旦遇上最有效的辦法還是SQL改寫(xiě)。比如在子查詢(xún)內(nèi)部先做DISTINCT和必要的過(guò)濾確保物化結(jié)果集的粒度是唯一的。如果內(nèi)部已經(jīng)DISTINCT了優(yōu)化器通常會(huì)建立唯一索引關(guān)聯(lián)效率會(huì)有明顯提升。4.3 實(shí)戰(zhàn)排查速查表我把這幾年排查子查詢(xún)性能問(wèn)題的經(jīng)驗(yàn)匯總成了一張表每次遇到類(lèi)似問(wèn)題按表里的順序逐項(xiàng)排查基本都能快速定位排查項(xiàng)判斷方法解決方案執(zhí)行計(jì)劃是否走物化EXPLAIN中select_typeMATERIALIZED未走物化時(shí)改寫(xiě)SQL、加索引物化臨時(shí)表是否落盤(pán)EXPLAIN中Extra出現(xiàn)Using temporary且狀態(tài)文件在磁盤(pán)調(diào)大tmp_table_size、減少物化結(jié)果集臨時(shí)表是否有索引EXPLAIN中table 行key列非空子查詢(xún)內(nèi)部加DISTINCT、確保關(guān)聯(lián)字段唯一子查詢(xún)是否相關(guān)子查詢(xún)是否引用外層字段改寫(xiě)為JOIN或非相關(guān)子查詢(xún)外層結(jié)果集是否被過(guò)度過(guò)濾實(shí)際返回行數(shù)和掃描行數(shù)差距極大優(yōu)化索引、考慮覆蓋索引這張表基本能解決80%以上的子查詢(xún)性能問(wèn)題。核心原則是每一步優(yōu)化都反復(fù)用EXPLAIN驗(yàn)證不要憑感覺(jué)下結(jié)論。5. 從一次真實(shí)壓測(cè)看物化優(yōu)化的收益最后講一個(gè)我參與過(guò)的壓測(cè)案例幫助大家直觀感受物化帶來(lái)的數(shù)據(jù)變化。某統(tǒng)計(jì)接口原本依賴(lài)一條三層嵌套子查詢(xún)最內(nèi)層是百萬(wàn)級(jí)訂單表中間層是用戶(hù)標(biāo)簽過(guò)濾外層再做聚合。優(yōu)化前接口P95響應(yīng)時(shí)間在4秒左右數(shù)據(jù)庫(kù)服務(wù)器CPU動(dòng)不動(dòng)飆到90%。第一步拉EXPLAIN確認(rèn)執(zhí)行計(jì)劃發(fā)現(xiàn)最內(nèi)層子查詢(xún)被當(dāng)作相關(guān)子查詢(xún)反復(fù)執(zhí)行掃描行數(shù)極其夸張。第二步將嵌套子查詢(xún)改寫(xiě)成兩級(jí)物化派生表第一級(jí)先物化“符合條件的用戶(hù)ID集合”第二級(jí)把訂單表和物化結(jié)果做INNER JOIN然后在外層做聚合。改寫(xiě)后P95響應(yīng)時(shí)間直接從4秒降到220毫秒數(shù)據(jù)庫(kù)CPU穩(wěn)定在20%左右。壓測(cè)300并發(fā)單接口吞吐量從原先的每秒40次提升到每秒350次。這個(gè)案例里子查詢(xún)物化帶來(lái)的不是百分之幾十的優(yōu)化而是數(shù)量級(jí)的吞吐改善。如果當(dāng)初停留在“給子查詢(xún)加索引”這種表面操作根本解決不了問(wèn)題——因?yàn)橄嚓P(guān)子查詢(xún)每執(zhí)行一次都是全量掃描加多少索引都沒(méi)用?,F(xiàn)在再回頭看這個(gè)項(xiàng)目的標(biāo)題“php方案 子查詢(xún)物化優(yōu)化”其實(shí)本質(zhì)就兩件事理解MySQL優(yōu)化器的物化策略然后在PHP側(cè)通過(guò)SQL改寫(xiě)和會(huì)話(huà)參數(shù)調(diào)整讓優(yōu)化器選擇正確的執(zhí)行計(jì)劃。這兩件事做扎實(shí)了大部分慢查詢(xún)問(wèn)題都能迎刃而解。在我的實(shí)操經(jīng)驗(yàn)里子查詢(xún)物化優(yōu)化最大的價(jià)值不是某一個(gè)SQL的提速而是提供了一種思維方式——先想清楚數(shù)據(jù)是怎么流動(dòng)的再?zèng)Q定怎么讓數(shù)據(jù)庫(kù)少干活。你拿到的執(zhí)行計(jì)劃就是數(shù)據(jù)庫(kù)在告訴你它打算怎么干活你能做的就是讓它干得更聰明。下次如果你在PHP項(xiàng)目里再遇到類(lèi)似的慢查詢(xún)先別急著加緩存用EXPLAIN拉一下執(zhí)行計(jì)劃看看有沒(méi)有MATERIALIZED這條路走通了很多性能問(wèn)題其實(shí)沒(méi)有想象中那么難。