資料庫效能與維運 ch04 排序、分組與深分頁:翻到第五千頁把資料庫拖垮
下一章→
CH 04 看不見的成本

排序、分組與深分頁:翻到第五千頁把資料庫拖垮

filesort 兩種算法sort_buffer 與磁碟合併暫存表何時產生用索引免掉排序LIMIT 100000,10 為什麼慢延遲關聯與 keyset paginationBuffer Pool 被沖走

前三章處理的都是「怎麼把資料找出來」。這一章處理的是找出來之後的事—— 排序、分組、跳過前面幾萬列。這些動作在 SQL 裡都只是一個短短的子句,在執行時卻可能比查詢本身貴上幾十倍。

而它們有一個共同的性質,讓它們特別難被發現:

它們的成本不會出現在你要的結果裡。一頁只回傳 10 筆資料的 API,背後可能排序了 80 萬列、寫了 300MB 的暫存檔案。從回應大小、從結果內容,你完全看不出來。

這一章要拆的是:

  • Using filesort:它到底做了什麼?什麼時候在記憶體排完,什麼時候會落到磁碟?
  • Using temporary:暫存表什麼時候產生,什麼時候會從記憶體變成磁碟上的表。
  • 用索引免掉排序:什麼樣的索引可以讓 ORDER BY 完全免費——以及 MySQL 8.0 的一個行為變更(GROUP BY 不再隱式排序)。
  • 深分頁:LIMIT 100000, 10 為什麼慢,而且慢在一個很多人想不到的地方。
  • 兩種解法:延遲關聯(改動最小)與 keyset pagination(治本,但要付出功能上的代價)。
  • 一個真實場景:營運同事把後台列表翻到第 5000 頁,整個站跟著慢下來——包括那些跟這張表無關的查詢。

這一章也是這一軌第二條主線「代價守恆」最集中的一章:每一個讓查詢變快的手段,都會在別的地方付錢——索引付寫入、快取付一致性、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 列
排序、分組、深分頁這三件事的共同點:它們的成本藏在「被丟掉的資料」裡。而被丟掉的東西,不會出現在回應大小、不會出現在結果內容、也不會出現在 ORM 的日誌裡。

三個要在 EXPLAIN 裡找的字

  • Using filesort:需要額外排序(ch01 提過,這章拆開講)。
  • Using temporary:需要建暫存表。
  • 大的 rows 配小的 LIMIT:這個組合本身就是訊號——它要看很多列,才能回答一個很小的問題。

先建立一個判斷框架

這一章所有的優化,本質上都在做同一件事之一:

1
讓資料庫不必排序——用索引提供現成的順序。
2
讓要排序的東西變小——排 rowid 而不是整列、先過濾再排序。
3
讓要跳過的東西消失——把「跳過 10 萬列」換成「從某個位置開始讀」。

三種手段對應三個層次的改動成本:改索引、改 SQL、改產品行為。而它們的效果剛好也是這個順序的反過來——改動越大,效果越徹底。

Using filesort 這個名字造成很多誤會。它不代表一定會寫檔案,它只代表「無法從索引直接取得順序,必須自己排一次」。

兩種排序方式

  • 單路排序(single-pass):把 SELECT 需要的所有欄位都放進 sort buffer,排完直接輸出。
  • 雙路排序(two-pass):只把排序欄位 + 主鍵放進 buffer,排完之後再依序回表取完整資料。

取捨很直接:單路只讀一次資料但佔記憶體多;雙路省記憶體但要回表第二次(隨機 I/O)。MySQL 會依「每列有多寬」自行選擇——所以 SELECT * 會把這個決策推向雙路,多付一次回表成本。這是「不要 SELECT *」這句老生常談,在排序場景下的具體理由。

sort_buffer_size 與磁碟合併

SELECT @@sort_buffer_size;   -- 預設 256KB(每個連線每次排序各一份)

裝不下的時候,MySQL 走外部歸併排序:把資料切成一塊塊,每塊排好寫成暫存檔案,最後多路合併。

這時候一句 SELECT 會產生實際的磁碟寫入。而它同樣是「測試環境不會發生」的那類問題——測試資料量小,一次就排完了。

怎麼確認它落磁碟了

SHOW STATUS LIKE 'Sort_merge_passes';
-- 這個數字持續增加 = 有排序在走磁碟合併

SHOW STATUS LIKE 'Sort_rows';   -- 排過的總列數,跟回傳列數對照就知道浪費多少

ORDER BY ... LIMIT 的優化

MySQL 對「排序後只要前 N 列」有一個專門優化:用大小為 N 的優先佇列(堆),只保留當前最好的 N 列,其餘直接丟掉。這樣即使有 80 萬列,記憶體也只需要裝 20 列。

  • 好處:小 LIMIT 幾乎不會落磁碟。
  • 但它仍然要「看過」全部 80 萬列——CPU 成本與 I/O 成本都在,只是省下記憶體。
  • N 很大時(深分頁)這個優化就失效了——`LIMIT 100000, 10` 的堆要能裝 100,010 列。這正是下面深分頁那節的伏筆。
所以「有 LIMIT 就不用擔心排序」是錯的。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:MySQL 8.0 的行為變更

MySQL 5.7 的 GROUP BY 會隱式排序,8.0 移除了這個行為。升級之後,那些「沒寫 ORDER BY 但一直是排好的」查詢,會開始回傳不保證順序的結果——而它不會報錯。這是升級時最容易被忽略的相容性問題之一,處理方式是把依賴的順序明確寫成 ORDER BY。

反過來說,這個變更也是好事:5.7 那個隱式排序是白付的成本。8.0 之後如果你真的不需要順序,可以少排一次。

loose index scan:連分組都免掉

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 BY
  • UNION(去重需要)——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;

逐步推導成本

1
走 idx_created_at 索引,從最新往回讀。
2
每讀一筆索引項,回表取完整資料列(因為 SELECT * 不是覆蓋索引)。
3
回表之後才能判斷 status = 1,通過的才算一列。
4
累積到 100,010 列之後,把前面 100,000 列全部丟掉,回傳最後 10 列。
真正的成本是那 100,000 次「回表之後又被丟掉」的隨機 I/O。不是排序、不是掃描——是白做的回表。這就是為什麼深分頁的成本會隨著頁碼線性增長:第 1 頁 5 毫秒,第 5000 頁 8 秒。

解法一:延遲關聯(deferred join)

核心觀念:先在索引上完成分頁(不回表),拿到主鍵之後再回表一次。

-- 改寫前:跳過的 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 二級索引天生帶主鍵)都在索引裡完成。

  • 優點:只改 SQL,API 介面不變,頁碼功能保留。
  • 限制:成本仍隨頁碼線性成長,只是單價從「回表一次」降到「讀一個索引項」,大約快十倍。第 5000 頁還是會比第 1 頁慢。

解法二:keyset pagination(seek method)

治本的做法:不要用「跳過幾列」定位,改用「從哪個值之後開始」定位。

-- 第一頁
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;
  • 成本恆定:每一頁都是索引上的一次定位 + 讀 20 列。第 5000 頁與第 1 頁一樣快。
  • 要有唯一的 tie-break 欄位(上例的 id)。只用 created_at 的話,同一秒內的多筆資料會在翻頁時重複出現或被跳過。
  • 順便解決了另一個問題:翻頁期間有新資料寫入時,OFFSET 分頁會讓某些資料重複出現或消失;keyset 不會。
代價守恆:keyset pagination 買到的是恆定成本,付出的是「不能跳到第 N 頁」。這不是技術限制,是資訊本質——不掃過前面的資料,就不可能知道第 5000 頁的起點在哪。所以這個決定是產品決定,不是工程決定。

徵狀

週二下午 3 點,監控告警:整個服務的 p99 從 80 毫秒衝到 4 秒。異常的不只訂單相關的 API——連會員登入、商品查詢這些完全不同的表,也一起變慢。

  • 沒有部署、沒有流量尖峰(QPS 甚至比平常低)。
  • 資料庫 CPU 只有 40%,但磁碟 I/O 打滿。
  • 10 分鐘後自己恢復了,隔天下午 3 點又發生一次。

診斷

先抓當下在跑什麼(回扣 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 列——很慢,但為什麼會影響到會員登入?

根因:Buffer Pool 被沖走

1
這句查詢回表 50 萬次,把 50 萬列訂單資料載進了 Buffer Pool。
2
Buffer Pool 是固定大小的。載入新頁面就要淘汰舊頁面——而被淘汰的正是平常熱門的那些資料(會員、商品、熱門訂單索引)。
3
於是其他所有查詢原本命中記憶體的,現在全都要重新從磁碟讀 → 磁碟 I/O 打滿,整站變慢。
4
查詢結束後,熱資料慢慢被重新載回來,服務自己恢復——這解釋了「10 分鐘後自己好了」。
這是 ch01 說過的「EXPLAIN 看不到的東西」最典型的案例:一句 SQL 的執行計畫不會告訴你它會把別人的快取沖掉。單句慢是它自己的事,把 Buffer Pool 洗掉是所有人的事。(InnoDB 的 LRU 有 midpoint insertion 策略專門緩解這件事,但擋不住持續 50 萬次的回表。)

修法(依序)

1
立刻:KILL 4471; 中止那句查詢。
2
當天:改成延遲關聯,並補上 (created_at, id) 覆蓋索引——回表從 50 萬次降到 100 次。
3
這週:後台列表改成 keyset pagination;把「跳到第 N 頁」的輸入框拿掉,改成「下一頁」+ 條件篩選。這是產品決定,要跟營運講清楚為什麼。
4
治理:所有後台查詢加上 MAX_EXECUTION_TIME 保險;匯出功能改走非同步(下載檔案)而不是即時翻頁;長時間報表查詢考慮走唯讀複本,與線上流量隔離。

真正的教訓

「這只是後台功能,慢一點沒關係」是這個故障的真正起點。後台與前台共用同一個 Buffer Pool、同一組連線池、同一顆磁碟——沒有任何一句慢查詢真的只影響它自己。這條會在 ch07 完整展開。

深分頁的根因通常不在 SQL,在介面設計允許了一個很貴的問題。所以這一節談的是 Java 這邊怎麼寫。

Spring Data 的 Pageable 預設就是 OFFSET 分頁

// 這行產生的就是 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));

keyset pagination 的介面長相

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);

幾個實務要點:

  • cursor 要不透明(Base64 或簽章)。直接暴露時間戳與 id 會讓前端開始自己拼接,之後就改不動了。
  • 排序鍵必須唯一——所以一定要帶上 id 當 tie-break。
  • 不要同時支援兩種分頁。留著頁碼參數就一定有人用,深分頁的問題就還在。

什麼時候 OFFSET 分頁是可以的

不要矯枉過正,OFFSET 在這些情況完全沒問題:

  • 資料量本來就小(幾千列以內的設定檔、字典表)。
  • 頁數被限制住——例如搜尋結果最多只給 20 頁,超過請縮小條件。這是最務實的折衷:既保留跳頁功能,又讓成本有上界。

還有一個常被忽略的成本:COUNT(*)

  • 大表上的 COUNT(*) 沒有捷徑,即使走覆蓋索引也要掃完整個索引。
  • 常見折衷:不顯示總筆數(改成「還有更多」)、顯示近似值(用 EXPLAIN 的 rows 估算,或維護一張計數表)、或只在第一頁算一次並快取。
  • 「總共幾筆」這個需求的真實價值,往往遠低於它的成本——這是最值得回頭跟產品確認的一個欄位。

把這一章收成一個可以照著跑的流程。

1
先確認成本是不是在排序上:EXPLAIN 有沒有 Using filesort/Using temporary?rows 與實際回傳列數差幾個數量級?
2
能不能用索引免掉:ORDER BY 的欄位有沒有緊接在等值前綴之後?沒有的話,補一個複合索引就結束了——這是成本最低的解法。
3
免不掉的話,讓排序的東西變小:拿掉 SELECT *(避免雙路排序與 BLOB 落磁碟)、確認 LIMIT 夠小以觸發優先佇列優化。
4
是不是深分頁:OFFSET 很大就先改延遲關聯(改動小、當天可上),再排程改成 keyset。
5
檢查它有沒有波及別人:這句查詢會回表幾萬次嗎?會不會沖掉 Buffer Pool?要不要移到唯讀複本?

四個監控指標

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 被沖走」的訊號。

什麼時候這一章的優化不該做

  • 資料量小的表:幾千列的排序在記憶體裡是微秒等級。為它加索引,反而多付寫入成本。
  • 一天跑一次的報表:慢 30 秒沒有關係——但要確認它不會沖掉 Buffer Pool 影響線上,這才是重點。移到複本或離峰執行,比優化它更有效。
  • 還沒量測就先改成 keyset:它會犧牲跳頁功能。先確認真的有人在翻深頁(看存取日誌的 page 參數分佈),不要為了假想的問題砍掉真實的功能。

把這一章收成三句

① 排序、分組、深分頁的成本藏在「被丟掉的資料」裡,從結果大小完全看不出來。
② 最好的排序是不排序——讓 ORDER BY 的欄位緊接在等值前綴之後,成本就歸零。
③ 深分頁真正的代價不只是自己慢,而是把 Buffer Pool 沖掉、拖累完全無關的查詢。

下一章要處理的是另一種「一句 SQL 影響所有人」的機制,而且它比 Buffer Pool 更難診斷:鎖。

第二條主線在這一章特別密集,把帳一次算清楚。

五筆帳

  • 加複合索引免掉 filesort → 寫入時要多維護一棵 B+Tree、多佔空間。排序欄位通常是 created_at 這類每列都不同的欄位,索引不會小。
  • 調大 sort_buffer_size/join_buffer_size → 每連線每次操作各一份。這是最容易釀成事故的調參:改成 32MB、200 個連線同時排序就是 6.4GB,而那是從 Buffer Pool 搶來的,結果是「為了讓排序不落磁碟,害得所有查詢都不命中快取」。
  • 延遲關聯 → SQL 變複雜、需要一個能覆蓋的索引。成本仍隨頁碼線性成長,只是單價變低。
  • keyset pagination → 失去跳頁功能、cursor 要維護、前端要改。這是產品決定。
  • 移到唯讀複本 → 隔離了 Buffer Pool 汙染,但引入複本延遲:剛寫入的資料可能查不到。這是把效能問題換成一致性問題(ch07 會談怎麼判斷能不能接受)。

這一章的診斷看不到的東西

  • 排序期間持有的鎖。一個大排序在交易中執行時,它持有的鎖會一直到交易結束——排序花的每一秒,都是別人等鎖的一秒(ch05)。
  • 誰在呼叫它。一句深分頁 SQL 出現在慢查詢日誌裡,日誌不會告訴你它是使用者翻頁、爬蟲掃描、還是某個排程每五分鐘跑一次(ch07)。
  • ORM 有沒有偷偷加了東西。Hibernate 的分頁在有 JOIN FETCH 時會改成在記憶體裡分頁——把符合條件的資料全部撈進 JVM 再切。這時候 LIMIT 根本不會出現在 SQL 裡,而你會在應用日誌看到 OOM 而不是慢查詢(ch08 主講)。
最後這一點值得記住:這一章討論的所有優化,前提都是「SQL 真的長你以為的那樣」。而在 ORM 專案裡,這個前提經常不成立。
Extra 三個關鍵字:成本與處理方向
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沒命中快取、真的讀磁碟的次數突然跳高找是誰在大量回表沖快取(深分頁/全表掃)

練習題 點選選項查看解析

0 / 10
01 / 10
一支 API 只回傳 20 筆、8KB 的 JSON,卻跑了 6 秒。為什麼從結果看不出問題?
A 因為 JSON 序列化很慢
B 因為排序的成本與回傳量無關,而與「必須先看過多少列」有關——成本藏在被丟掉的資料裡
C 因為網路傳輸有延遲
D 因為連線池不足
解析
amount 上沒有索引時,要拿出全部 80 萬列排完才知道前 20 名是誰。排序、分組、深分頁三者的共同點就是成本藏在被丟掉的資料裡,不會出現在回應大小、結果內容或 ORM 日誌上。
02 / 10
關於 Using filesort,下列何者正確?
A 它代表一定會寫檔案到磁碟
B 它只代表無法從索引取得順序、必須自己排一次;資料量小時在 sort_buffer 內就排完了
C 它代表索引完全沒有生效
D 它只會出現在 ORDER BY,不會出現在 GROUP BY
解析
名字容易誤導。只有超過 sort_buffer_size(預設 256KB)時才會切塊寫暫存檔做外部歸併排序。要確認是否落磁碟,看 Sort_merge_passes 這個狀態變數。
03 / 10
為什麼 SELECT * 會讓排序更貴?
A 因為欄位多,網路傳輸慢
B 因為每列變寬會把排序推向雙路排序(排完還要回表取資料),且結果含 BLOB/TEXT 時暫存表必定落磁碟
C 因為 SELECT * 無法使用索引
D 因為 SELECT * 會鎖表
解析
單路排序把所有欄位放進 sort buffer(省 I/O 但佔記憶體),雙路只放排序鍵加主鍵(省記憶體但要回表第二次)。每列越寬越會被推向雙路。另外含 BLOB/TEXT 的內部暫存表一定落磁碟——這是「不要 SELECT *」在排序場景的兩個具體理由。
04 / 10
索引 (merchant_id, status, created_at),哪一句查詢的 ORDER BY 可以完全免掉排序?
A WHERE merchant_id = 42 ORDER BY created_at DESC
B WHERE merchant_id = 42 AND status = 1 ORDER BY created_at DESC
C WHERE merchant_id = 42 AND status = 1 ORDER BY amount DESC
D WHERE status = 1 ORDER BY created_at DESC
解析
規則是:ORDER BY 的欄位必須緊接在「被等值條件鎖定的前綴」之後。選項 A 的 status 沒被鎖定,created_at 在索引中不是全域有序;選項 C 的排序欄位不在索引裡;選項 D 沒鎖定第一個欄位。這與最左前綴是同一條規則的兩面。
05 / 10
MySQL 8.0 對 GROUP BY 有一個容易被忽略的行為變更,是什麼?
A GROUP BY 不再支援沒有聚合函數的欄位
B GROUP BY 不再隱式排序——升級後那些「沒寫 ORDER BY 卻一直是排好的」查詢會回傳不保證順序的結果,而且不報錯
C GROUP BY 一律需要建立暫存表
D GROUP BY 只能用在有索引的欄位上
解析
5.7 的 GROUP BY 會隱式排序,8.0 移除了。這是升級時最容易忽略的相容性問題,因為它安靜地改變結果順序。處理方式是把依賴的順序明確寫成 ORDER BY。反過來說,移除隱式排序也省掉了原本白付的成本。
06 / 10
LIMIT 100000, 10 真正的成本主要在哪裡?
A 排序 100,010 列
B 那 100,000 次「回表之後又被丟掉」的隨機 I/O
C 網路傳輸 100,010 列
D 建立暫存表
解析
資料庫不會「跳」到第 100,001 列,而是老實讀完前面的再丟掉。因為 SELECT * 不是覆蓋索引,每讀一筆索引項就要回表一次才能判斷條件與取資料——那 10 萬次白做的回表才是主要成本,也是為什麼成本隨頁碼線性成長。
07 / 10
延遲關聯(deferred join)的效果與限制是什麼?
A 讓成本變成恆定,第 5000 頁和第 1 頁一樣快
B 把單價從「回表一次」降到「讀一個索引項」,約快十倍,但成本仍隨頁碼線性成長
C 完全不需要索引配合
D 會改變查詢結果的順序
解析
內層只碰索引完成分頁(需要能覆蓋 WHERE 與 ORDER BY 的索引),只有最後 10 列回表。優點是只改 SQL、頁碼功能保留、當天可上;限制是第 5000 頁仍然比第 1 頁慢。要恆定成本必須改 keyset。
08 / 10
keyset pagination 用 (created_at, id) < (?, ?) 而不是只用 created_at,為什麼?
A 為了讓 SQL 比較短
B 因為排序鍵必須唯一——只用 created_at 時,同一時間的多筆資料在翻頁時會重複出現或被跳過
C 因為 created_at 不能建索引
D 因為 id 比較快
解析
需要一個唯一的 tie-break 欄位來確定「上一頁到底停在哪一筆」。這也順帶解決了 OFFSET 分頁的另一個問題:翻頁期間有新資料寫入時,OFFSET 會讓資料重複出現或消失,keyset 不會。
09 / 10
一句深分頁查詢為什麼會拖慢完全無關的 API(例如會員登入)?
A 因為它佔用了所有連線
B 因為它回表數十萬次,把大量資料載進固定大小的 Buffer Pool,淘汰掉其他查詢原本命中的熱資料,於是全部改成讀磁碟
C 因為它鎖住了整個資料庫
D 因為它讓 CPU 達到 100%
解析
Buffer Pool 是共用且固定大小的。這是「EXPLAIN 看不到的東西」最典型的案例——執行計畫不會告訴你這句 SQL 會沖掉別人的快取。也解釋了「查詢結束後服務自己恢復」:熱資料被慢慢載回來了。
10 / 10
Spring Data 的 Page 與 Slice,在效能上最重要的差別是什麼?
A Slice 不支援排序
B Page 會多發一句 COUNT(*) 算總頁數,而大表上的 count 常常比查詢本身更貴
C Page 使用 keyset pagination
D 兩者完全相同
解析
大表上的 COUNT(*) 沒有捷徑,即使走覆蓋索引也要掃完整個索引。不需要總筆數時改用 Slice 就能省掉它。實務上「總共幾筆」這個欄位的價值往往遠低於成本,很值得回頭跟產品確認。

點擊卡片翻面查看答案,共 13 張。

QUESTION
為什麼排序、分組、深分頁的成本特別難被發現?
點擊翻面
ANSWER
它們的成本藏在「被丟掉的資料」裡,與回傳量無關。回傳 20 筆的 API 可能排序了 80 萬列——從回應大小、結果內容、ORM 日誌都看不出來。
點擊翻回
QUESTION
Using filesort 一定會寫磁碟嗎?怎麼確認?
點擊翻面
ANSWER
不一定。它只代表索引提供不了順序、必須自己排。超過 sort_buffer_size(預設 256KB)才會外部歸併寫暫存檔。用 SHOW STATUS LIKE 'Sort_merge_passes' 確認。
點擊翻回
QUESTION
單路排序與雙路排序的差別?什麼會把 MySQL 推向雙路?
點擊翻面
ANSWER
單路把 SELECT 的所有欄位放進 sort buffer(省 I/O、佔記憶體);雙路只放排序鍵+主鍵,排完再回表(省記憶體、多一次隨機 I/O)。每列越寬越會走雙路——這是不要 SELECT * 的具體理由。
點擊翻回
QUESTION
ORDER BY ... LIMIT N 的優先佇列優化做了什麼?它的極限在哪?
點擊翻面
ANSWER
用大小為 N 的堆只保留最好的 N 列,小 LIMIT 幾乎不落磁碟。但它仍要看過全部的列(CPU 與 I/O 成本還在),而且 N 很大時(深分頁)就失效了。
點擊翻回
QUESTION
什麼條件下 ORDER BY 可以完全免掉排序?
點擊翻面
ANSWER
ORDER BY 的欄位必須緊接在「被等值條件鎖定的索引前綴」之後。例如 idx(merchant_id, status, created_at) 配 WHERE merchant_id=? AND status=? ORDER BY created_at。
點擊翻回
QUESTION
MySQL 8.0 對 GROUP BY 的行為變更是什麼?為什麼危險?
點擊翻面
ANSWER
不再隱式排序(5.7 會)。升級後那些沒寫 ORDER BY 卻一直是排好的查詢會開始回傳不保證順序的結果,而且不報錯。要把依賴的順序明確寫出來。
點擊翻回
QUESTION
哪些情況會產生 Using temporary?
點擊翻面
ANSWER
GROUP BY 無法用索引完成、GROUP BY 與 ORDER BY 欄位不同、DISTINCT 配 ORDER BY、UNION(去重)、某些衍生表被物化。UNION ALL 不需要去重所以不會。
點擊翻回
QUESTION
什麼情況下內部暫存表一定會落到磁碟?
點擊翻面
ANSWER
結果集含有 BLOB/TEXT 欄位時。所以「順手把備註欄位也撈出來」可能讓原本在記憶體完成的 GROUP BY 變成磁碟操作。監控 Created_tmp_disk_tables。
點擊翻回
QUESTION
LIMIT 100000, 10 的成本主要來自哪裡?
點擊翻面
ANSWER
那 10 萬次「回表之後又被丟掉」的隨機 I/O。資料庫不會跳過,它是讀完前 10 萬列再丟掉——所以成本隨頁碼線性成長。
點擊翻回
QUESTION
延遲關聯怎麼寫?效果與限制?
點擊翻面
ANSWER
內層只用覆蓋索引完成 LIMIT 取出主鍵,外層再 JOIN 回表取資料。單價降約十倍、頁碼功能保留、只改 SQL;但成本仍隨頁碼線性成長,且需要能覆蓋 WHERE 與 ORDER BY 的索引。
點擊翻回
QUESTION
keyset pagination 的成本特性與代價是什麼?
點擊翻面
ANSWER
成本恆定(一次索引定位 + 讀 N 列),第 5000 頁與第 1 頁一樣快;代價是不能跳到第 N 頁。這不是技術限制而是資訊本質,所以它是產品決定。
點擊翻回
QUESTION
為什麼一句深分頁查詢會拖慢完全無關的 API?
點擊翻面
ANSWER
它回表數十萬次,把大量冷資料載進固定大小的 Buffer Pool,淘汰掉別的查詢原本命中的熱資料,於是全站改成讀磁碟。執行計畫完全看不到這件事。
點擊翻回
QUESTION
調大 sort_buffer_size 最容易釀成什麼事故?
點擊翻面
ANSWER
它是每連線每次排序各一份,不是共用。32MB × 200 個連線同時排序=6.4GB,而那是從 Buffer Pool 搶來的——為了讓排序不落磁碟,害得所有查詢都不命中快取。
點擊翻回