從 MySQL 開始:索引與查詢優化實戰指南

如果你已經能用 PHP、Java 或 Python 把一個功能寫出來,卻常常遇到「本機跑得飛快、上線後列表頁轉圈圈」的窘境,那麼問題八成不在程式語言,而在資料庫。這篇文章不談艱深的儲存引擎原理,而是帶你用五個步驟,把一條慢查詢從 2 秒壓到 20 毫秒,並且理解「為什麼快」。

一、先建立心智模型:索引就是書的目錄

想像你手上有一本 500 頁、沒有目錄的書,要找「均線」這三個字,只能從第 1 頁翻到第 500 頁 —— 這就是資料庫的全表掃描(Full Table Scan)。而索引就是書後面的索引頁,寫著「均線 → 第 213 頁」,你翻兩下就到了。

  • 沒有索引:資料 100 萬筆,就要看 100 萬筆。
  • 有索引:MySQL 用 B+Tree 結構,100 萬筆大約只要 3~4 次磁碟定位。

重點:索引不是「加了就快」的魔法,它是一份需要額外維護的排序副本。加得對,查詢快百倍;加得亂,寫入變慢、空間翻倍,還可能完全用不上。

二、步驟 1:造出可以重現問題的資料

優化的前提是問題能被重現。先建一張訂單表,並塞入足夠多的資料:

CREATE TABLE orders (
  id        INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id   INT UNSIGNED NOT NULL,
  status    TINYINT NOT NULL DEFAULT 0,
  amount    DECIMAL(10,2) NOT NULL,
  created_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 用遞迴 CTE 快速灌 50 萬筆測試資料(MySQL 8.0)
INSERT INTO orders (user_id, status, amount, created_at)
WITH RECURSIVE seq(n) AS (
  SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 500000
)
SELECT FLOOR(RAND() * 10000),
       FLOOR(RAND() * 3),
       ROUND(RAND() * 5000, 2),
       NOW() - INTERVAL FLOOR(RAND() * 365) DAY
FROM seq;

如果你用的是 MySQL 5.7(很多虛擬主機仍是這個版本),沒有遞迴 CTE,可以改用一段簡單的迴圈腳本或多次 INSERT ... SELECT 自我倍增。

三、步驟 2:用 EXPLAIN 讀懂執行計畫

不要憑感覺猜,直接問 MySQL 打算怎麼查:

EXPLAIN SELECT id, amount, created_at
FROM orders
WHERE user_id = 8888 AND status = 1
ORDER BY created_at DESC
LIMIT 20;

輸出裡只要先盯住四個欄位:

  • type:連接類型。由壞到好大致是 ALL(全表掃描)→ indexrangerefconst。看到 ALL 就是紅燈。
  • key:實際用到的索引。是 NULL 表示索引完全沒生效。
  • rows:預估要掃描的列數。這個數字從 50 萬降到 20,就是你這次優化的成績單。
  • Extra:出現 Using filesort 代表排序沒走索引;出現 Using index 反而是好事(覆蓋索引)。

四、步驟 3:建立複合索引,並理解「最左前綴」

上面那句查詢同時用到 user_idstatus 篩選,再用 created_at 排序。很多人會分別建三個單欄索引,但 MySQL 通常只會挑一個用。正確做法是建一個複合索引

ALTER TABLE orders
  ADD INDEX idx_user_status_time (user_id, status, created_at);

複合索引的欄位順序就像電話簿「先按姓、再按名」排序,這叫最左前綴原則

  • user_id → 用得上索引。
  • user_id + status → 用得上。
  • 只查 status(跳過最左的 user_id)→ 用不上

排序欄位放在最後一欄,還能順便把 Using filesort 消掉,因為索引本身已經是排好序的。

五、步驟 4:避開五個讓索引失效的寫法

索引建好了卻沒生效,九成是踩到下面這幾個坑:

-- 1) 在欄位上套函式:索引失效
SELECT * FROM orders WHERE DATE(created_at) = '2026-08-17';
-- 改成範圍查詢
SELECT * FROM orders
WHERE created_at >= '2026-08-17 00:00:00'
  AND created_at <  '2026-08-18 00:00:00';

-- 2) 左側模糊比對:索引失效
SELECT * FROM users WHERE name LIKE '%龍';   -- 用不上
SELECT * FROM users WHERE name LIKE '偉龍%'; -- 用得上

-- 3) 型別隱式轉換:phone 是 VARCHAR 卻傳數字,索引失效
SELECT * FROM users WHERE phone = 13800000000;   -- 錯
SELECT * FROM users WHERE phone = '13800000000'; -- 對

另外兩個常見問題:對索引欄位做運算(如 WHERE amount * 2 > 100)以及OR 串接不同欄位(可用 UNION ALL 改寫)。

特別注意:索引失效不會報錯,只會默默變慢。所以每次改完 SQL,養成再跑一次 EXPLAIN 的習慣。

六、步驟 5:覆蓋索引與深分頁優化

如果查詢要的欄位全部都在索引裡,MySQL 連原始資料列都不用回頭讀,這叫覆蓋索引Extra 會顯示 Using index。所以「SELECT * 改成只選需要的欄位」不只是規範問題,而是實打實的效能差距。

另一個經典陷阱是深分頁。LIMIT 100000, 20 會先掃過前 10 萬筆再丟掉,越翻越慢。改用「記住上一頁最後一個 id」的游標寫法:

-- 慢:偏移量越大越慢
SELECT * FROM orders ORDER BY id LIMIT 100000, 20;

-- 快:用上一頁最後的 id 當書籤
SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 20;

七、收尾檢查清單

  • 慢查詢有沒有開日誌?slow_query_log = ON,門檻設 1 秒,先讓問題自己浮出來。
  • 單表索引控制在 5 個以內,別為每個欄位都建索引 —— 寫入成本會反噬。
  • 區分度太低的欄位(例如只有 0/1 的 is_deleted)單獨建索引意義不大,放進複合索引才有價值。
  • 上線前用接近真實量級的資料測,1000 筆測試資料看不出任何問題。

優化資料庫的樂趣,在於它是少數「投入一小時、效能提升十倍」還能被量化的工作。從 EXPLAIN 開始,你會發現很多所謂的「系統卡」,其實只是少了一個索引。

互動話題:你遇過最誇張的慢查詢是幾秒?最後是靠加索引、改寫 SQL,還是加快取解決的?留言分享你的踩坑經驗,我們一起把資料庫調教得更聽話。