前三章處理的都是「怎麼把資料找出來」。這一章處理的是找出來之後的事—— 排序、分組、跳過前面幾萬列。這些動作在 SQL 裡都只是一個短短的子句,在執行時卻可能比查詢本身貴上幾十倍。
而它們有一個共同的性質,讓它們特別難被發現:
它們的成本不會出現在你要的結果裡。一頁只回傳 10 筆資料的 API,背後可能排序了 80 萬列、寫了 300MB 的暫存檔案。從回應大小、從結果內容,你完全看不出來。
這一章要拆的是:
Using filesort:它到底做了什麼?什麼時候在記憶體排完,什麼時候會落到磁碟?Using temporary:暫存表什麼時候產生,什麼時候會從記憶體變成磁碟上的表。ORDER BY 完全免費——以及 MySQL 8.0 的一個行為變更(GROUP BY 不再隱式排序)。LIMIT 100000, 10 為什麼慢,而且慢在一個很多人想不到的地方。這一章也是這一軌第二條主線「代價守恆」最集中的一章:每一個讓查詢變快的手段,都會在別的地方付錢——索引付寫入、快取付一致性、keyset pagination 付的是「不能跳頁」這個產品功能。
一支後台列表 API,回傳 20 筆資料、8KB 的 JSON,跑了 6 秒。從結果完全看不出問題——資料量這麼小,能慢到哪裡去?
因為排序的成本與回傳的資料量無關,它與「必須先看過多少列」有關。
SELECT * FROM orders WHERE status = 1
ORDER BY amount DESC LIMIT 20;
-- 符合 status = 1 的有 80 萬列
-- amount 上沒有索引,所以要「全部拿出來排一次」才知道前 20 名是誰
-- 回傳 20 列,實際處理 800,000 列Using filesort:需要額外排序(ch01 提過,這章拆開講)。Using temporary:需要建暫存表。rows 配小的 LIMIT:這個組合本身就是訊號——它要看很多列,才能回答一個很小的問題。這一章所有的優化,本質上都在做同一件事之一:
三種手段對應三個層次的改動成本:改索引、改 SQL、改產品行為。而它們的效果剛好也是這個順序的反過來——改動越大,效果越徹底。
Using filesort 這個名字造成很多誤會。它不代表一定會寫檔案,它只代表「無法從索引直接取得順序,必須自己排一次」。
SELECT 需要的所有欄位都放進 sort buffer,排完直接輸出。取捨很直接:單路只讀一次資料但佔記憶體多;雙路省記憶體但要回表第二次(隨機 I/O)。MySQL 會依「每列有多寬」自行選擇——所以 SELECT * 會把這個決策推向雙路,多付一次回表成本。這是「不要 SELECT *」這句老生常談,在排序場景下的具體理由。
SELECT @@sort_buffer_size; -- 預設 256KB(每個連線每次排序各一份)裝不下的時候,MySQL 走外部歸併排序:把資料切成一塊塊,每塊排好寫成暫存檔案,最後多路合併。
SELECT 會產生實際的磁碟寫入。而它同樣是「測試環境不會發生」的那類問題——測試資料量小,一次就排完了。SHOW STATUS LIKE 'Sort_merge_passes';
-- 這個數字持續增加 = 有排序在走磁碟合併
SHOW STATUS LIKE 'Sort_rows'; -- 排過的總列數,跟回傳列數對照就知道浪費多少MySQL 對「排序後只要前 N 列」有一個專門優化:用大小為 N 的優先佇列(堆),只保留當前最好的 N 列,其餘直接丟掉。這樣即使有 80 萬列,記憶體也只需要裝 20 列。
LIMIT 幾乎不會落磁碟。最好的排序是不排序。B+Tree 的葉節點本來就是有序的,只要索引的順序剛好就是你要的順序,資料庫直接照著讀就好——Extra 裡的 Using filesort 會消失。
KEY idx_m_s_c (merchant_id, status, created_at)
-- ✅ 免排序:等值條件鎖定前兩欄,第三欄在索引裡天生有序
WHERE merchant_id = 42 AND status = 1 ORDER BY created_at DESC
-- ❌ 要排序:中間的 status 沒有被等值鎖定,created_at 在索引裡不是全域有序的
WHERE merchant_id = 42 ORDER BY created_at DESC
-- ❌ 要排序:排序欄位不在同一個索引裡
WHERE merchant_id = 42 AND status = 1 ORDER BY amount DESC規則可以濃縮成一句:ORDER BY 的欄位,必須緊接在「被等值條件鎖定的前綴」之後。這與 ch01 的最左前綴是同一條規則的兩面——那裡看的是能不能定位,這裡看的是能不能提供順序。
ORDER BY created_at DESC, id ASC -- 一升一降MySQL 8.0 之前這種寫法一定要 filesort。8.0 引進降序索引之後可以這樣建:
KEY idx_c_i (created_at DESC, id ASC)注意 8.0 之前的語法允許你寫 DESC 但會默默忽略——所以看到舊 schema 裡有 DESC 不代表它真的是降序索引,要用 SHOW CREATE TABLE 在 8.0 上確認。
GROUP BY 會隱式排序,8.0 移除了這個行為。升級之後,那些「沒寫 ORDER BY 但一直是排好的」查詢,會開始回傳不保證順序的結果——而它不會報錯。這是升級時最容易被忽略的相容性問題之一,處理方式是把依賴的順序明確寫成 ORDER BY。反過來說,這個變更也是好事:5.7 那個隱式排序是白付的成本。8.0 之後如果你真的不需要順序,可以少排一次。
Extra 出現 Using index for group-by 是最好的情況:直接在索引上跳著讀,連掃描都省了。條件比較苛刻——單表、GROUP BY 的欄位是索引前綴、聚合函數限於 MIN/MAX 這類。
-- idx_m_c (merchant_id, created_at)
SELECT merchant_id, MIN(created_at) FROM orders GROUP BY merchant_id;
-- Extra: Using index for group-by
-- 每個 merchant 只讀第一筆,不用掃完它所有訂單Using temporary 比 Using filesort 更貴,因為它要先建一張表、把中間結果寫進去、可能再排一次。
GROUP BY 的欄位無法用索引完成分組GROUP BY 的欄位與 ORDER BY 的欄位不同DISTINCT 搭配 ORDER BYUNION(去重需要)——UNION ALL 不需要,這是兩者最重要的差別FROM (SELECT ...))在某些情況下被物化MySQL 8.0 的內部暫存表預設用 TempTable 引擎,先放記憶體:
SELECT @@internal_tmp_mem_storage_engine; -- TempTable
SELECT @@temptable_max_ram; -- 預設 1GB(全實例共用)
SELECT @@tmp_table_size, @@max_heap_table_size;超過上限就轉成磁碟上的暫存表,成本瞬間跳一個數量級。監控這個:
SHOW STATUS LIKE 'Created_tmp%';
-- Created_tmp_tables 建了幾張暫存表
-- Created_tmp_disk_tables 其中幾張跑到磁碟上 ← 這個比例要盯BLOB/TEXT 欄位。所以一句「順手把備註欄位也撈出來」的 SELECT,可能讓原本在記憶體裡完成的 GROUP BY 變成磁碟操作。這是 SELECT * 第二個具體的代價。回扣 ch01 說過的:Using temporary; Using filesort 同時出現,而且 rows 很大——先建表、再寫入、再整批排序,三件貴事一次做完。後台的統計報表頁十之八九慢在這裡。
GROUP BY 與 ORDER BY 對齊(或在 8.0 明確加上 ORDER BY NULL 表示不需要排序),常常就能消掉暫存表。UNION ALL 就不要用 UNION——如果你確定兩邊不會重複,去重那一步是白花的。先破除一個常見的誤解:LIMIT 100000, 10 不是資料庫「跳」到第 100,001 列。它是老老實實把前面 100,000 列全部讀出來、然後丟掉。
SELECT * FROM orders WHERE status = 1
ORDER BY created_at DESC LIMIT 100000, 10;idx_created_at 索引,從最新往回讀。SELECT * 不是覆蓋索引)。status = 1,通過的才算一列。核心觀念:先在索引上完成分頁(不回表),拿到主鍵之後再回表一次。
-- 改寫前:跳過的 10 萬列,每一列都回表
SELECT * FROM orders WHERE status = 1
ORDER BY created_at DESC LIMIT 100000, 10;
-- 改寫後:內層只碰索引(覆蓋索引),只有最後 10 列回表
SELECT o.* FROM orders o
JOIN (
SELECT id FROM orders
WHERE status = 1
ORDER BY created_at DESC
LIMIT 100000, 10
) AS t ON t.id = o.id;成立的前提是內層查詢必須能被索引覆蓋——需要一個 (status, created_at) 之類的索引,讓 WHERE、ORDER BY、以及取出 id(InnoDB 二級索引天生帶主鍵)都在索引裡完成。
治本的做法:不要用「跳過幾列」定位,改用「從哪個值之後開始」定位。
-- 第一頁
SELECT * FROM orders WHERE status = 1
ORDER BY created_at DESC, id DESC LIMIT 20;
-- 下一頁:帶著上一頁最後一筆的值回來
SELECT * FROM orders
WHERE status = 1
AND (created_at, id) < ('2026-08-13 10:22:31', 88214)
ORDER BY created_at DESC, id DESC LIMIT 20;id)。只用 created_at 的話,同一秒內的多筆資料會在翻頁時重複出現或被跳過。OFFSET 分頁會讓某些資料重複出現或消失;keyset 不會。週二下午 3 點,監控告警:整個服務的 p99 從 80 毫秒衝到 4 秒。異常的不只訂單相關的 API——連會員登入、商品查詢這些完全不同的表,也一起變慢。
先抓當下在跑什麼(回扣 ch01 的救火三件套):
SHOW PROCESSLIST;
-- 一句跑了 200 秒的 SELECT,來自後台匯出功能
EXPLAIN FOR CONNECTION 4471;
-- key=idx_created type=range rows=8420000 Extra=Using where-- 那句 SQL 長這樣(每頁 100 筆,第 5000 頁)
SELECT * FROM orders WHERE created_at >= '2026-01-01'
ORDER BY created_at DESC LIMIT 499900, 100;營運同事在後台一頁一頁往後翻找一筆舊訂單。單看這句 SQL:掃 50 萬列、回表 50 萬次、丟掉 499,900 列——很慢,但為什麼會影響到會員登入?
KILL 4471; 中止那句查詢。(created_at, id) 覆蓋索引——回表從 50 萬次降到 100 次。MAX_EXECUTION_TIME 保險;匯出功能改走非同步(下載檔案)而不是即時翻頁;長時間報表查詢考慮走唯讀複本,與線上流量隔離。深分頁的根因通常不在 SQL,在介面設計允許了一個很貴的問題。所以這一節談的是 Java 這邊怎麼寫。
// 這行產生的就是 LIMIT ?, ?,頁碼越大越慢
Page<Order> page = orderRepo.findByStatus(1, PageRequest.of(4999, 100));而且 Page(相對於 Slice)還會多發一句 COUNT(*) 來算總頁數——那句 count 在大表上常常比查詢本身更貴,因為它必須掃過所有符合條件的列。
// 不需要總筆數時改用 Slice,省掉那句 COUNT
Slice<Order> slice = orderRepo.findByStatus(1, PageRequest.of(0, 100));public record OrderPage(List<OrderDto> items, String nextCursor) {}
// cursor 就是「上一頁最後一筆的排序鍵」,通常編碼成不透明字串
@Query("""
SELECT o FROM Order o
WHERE o.status = :status
AND (o.createdAt < :ts OR (o.createdAt = :ts AND o.id < :id))
ORDER BY o.createdAt DESC, o.id DESC
""")
List<Order> findNextPage(int status, LocalDateTime ts, long id, Pageable limit);幾個實務要點:
id 當 tie-break。不要矯枉過正,OFFSET 在這些情況完全沒問題:
COUNT(*) 沒有捷徑,即使走覆蓋索引也要掃完整個索引。EXPLAIN 的 rows 估算,或維護一張計數表)、或只在第一頁算一次並快取。把這一章收成一個可以照著跑的流程。
EXPLAIN 有沒有 Using filesort/Using temporary?rows 與實際回傳列數差幾個數量級?ORDER BY 的欄位有沒有緊接在等值前綴之後?沒有的話,補一個複合索引就結束了——這是成本最低的解法。SELECT *(避免雙路排序與 BLOB 落磁碟)、確認 LIMIT 夠小以觸發優先佇列優化。OFFSET 很大就先改延遲關聯(改動小、當天可上),再排程改成 keyset。SHOW STATUS LIKE 'Sort_merge_passes'; -- 排序落磁碟的次數
SHOW STATUS LIKE 'Sort_rows'; -- 排過的總列數
SHOW STATUS LIKE 'Created_tmp_disk_tables'; -- 落磁碟的暫存表
SHOW STATUS LIKE 'Innodb_buffer_pool_reads';-- 沒命中快取、真的去讀磁碟的次數前三個持續成長代表這一章的問題正在發生;第四個突然跳高,就是上面那個「Buffer Pool 被沖走」的訊號。
ORDER BY 的欄位緊接在等值前綴之後,成本就歸零。下一章要處理的是另一種「一句 SQL 影響所有人」的機制,而且它比 Buffer Pool 更難診斷:鎖。
第二條主線在這一章特別密集,把帳一次算清楚。
created_at 這類每列都不同的欄位,索引不會小。sort_buffer_size/join_buffer_size → 每連線每次操作各一份。這是最容易釀成事故的調參:改成 32MB、200 個連線同時排序就是 6.4GB,而那是從 Buffer Pool 搶來的,結果是「為了讓排序不落磁碟,害得所有查詢都不命中快取」。JOIN FETCH 時會改成在記憶體裡分頁——把符合條件的資料全部撈進 JVM 再切。這時候 LIMIT 根本不會出現在 SQL 裡,而你會在應用日誌看到 OOM 而不是慢查詢(ch08 主講)。| Extra | 在做什麼 | 什麼時候會落磁碟 | 第一順位處理 |
|---|---|---|---|
| Using filesort | 索引提供不了順序,自己排一次 | 超過 sort_buffer_size(預設 256KB)→ 外部歸併 | 調索引欄位順序讓 ORDER BY 走索引 |
| Using temporary | 建暫存表存中間結果 | 超過 temptable_max_ram;或結果含 BLOB/TEXT 必落磁碟 | 對齊 GROUP BY 與 ORDER BY;UNION 改 UNION ALL |
| Using index for group-by | 直接在索引上跳著讀完成分組 | 不會 | 這是目標狀態,不用處理 |
| Using temporary; Using filesort | 先建表寫入、再整批排序 | 兩者都可能 | 配大 rows=後台報表最典型的慢法,優先處理 |
| 做法 | 第 5000 頁的成本 | 能跳頁嗎 | 什麼時候用 |
|---|---|---|---|
| OFFSET 分頁(LIMIT n, m) | 隨頁碼線性成長:掃 50 萬列、回表 50 萬次 | 可以 | 小表;或頁數有上限(例如最多 20 頁) |
| 延遲關聯(deferred join) | 仍線性成長,但單價降約十倍(只讀索引不回表) | 可以 | 改動最小、當天可上的止血手段 |
| keyset pagination(cursor) | 恆定:一次索引定位 + 讀 20 列 | 不能 | 治本;但要產品同意拿掉跳頁 |
| 變數 | 意義 | 異常訊號 | 對應章節手段 |
|---|---|---|---|
| Sort_merge_passes | 排序走磁碟歸併的次數 | 持續增加 | 用索引免排序;減少 SELECT 的欄位 |
| Sort_rows | 排過的總列數 | 遠大於實際回傳列數 | 先過濾再排序;檢查是否深分頁 |
| Created_tmp_disk_tables | 落到磁碟的暫存表數 | 占 Created_tmp_tables 比例高 | 檢查 BLOB/TEXT 欄位;對齊 GROUP BY/ORDER BY |
| Innodb_buffer_pool_reads | 沒命中快取、真的讀磁碟的次數 | 突然跳高 | 找是誰在大量回表沖快取(深分頁/全表掃) |
點擊卡片翻面查看答案,共 13 張。