如果你已經能用 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(全表掃描)→index→range→ref→const。看到ALL就是紅燈。 - key:實際用到的索引。是
NULL表示索引完全沒生效。 - rows:預估要掃描的列數。這個數字從 50 萬降到 20,就是你這次優化的成績單。
- Extra:出現
Using filesort代表排序沒走索引;出現Using index反而是好事(覆蓋索引)。
四、步驟 3:建立複合索引,並理解「最左前綴」
上面那句查詢同時用到 user_id、status 篩選,再用 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,還是加快取解決的?留言分享你的踩坑經驗,我們一起把資料庫調教得更聽話。