在業務開發中,「分組取前 N 筆」是極高頻需求:如每個用戶最新 5 筆訂單、每個分類點擊量前 10 的文章、每位銷售業績前 3 的客戶。MySQL 8.0 引入視窗函數(ROW_NUMBER()、RANK()、DENSE_RANK())後,這類問題迎來了最優雅的解法。本文將從傳統寫法演進到現代最佳實踐,並對比效能差異。
CREATE TABLE `orders` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用戶ID',
`amount` DECIMAL(10,2) NOT NULL COMMENT '訂單金額',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_created` (`user_id`, `created_at` DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
需求:查詢每位用戶最新下單的 3 筆訂單(按 created_at 降序)。
WITH ranked_orders AS (
SELECT
id, user_id, amount, created_at,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM orders
)
SELECT id, user_id, amount, created_at
FROM ranked_orders
WHERE rn <= 3
ORDER BY user_id, created_at DESC;
PARTITION BY user_id:以用戶分組ORDER BY created_at DESC:組內按時間倒序ROW_NUMBER():同時間不並列,嚴格 1,2,3…;若需並列名次用 RANK() 或 DENSE_RANK()WHERE rn <= 3,配合索引 (user_id, created_at DESC) 只掃描必要行SELECT id, user_id, amount, created_at
FROM (
SELECT
id, user_id, amount, created_at,
@rn := IF(@prev_user = user_id, @rn + 1, 1) AS rn,
@prev_user := user_id
FROM orders
CROSS JOIN (SELECT @rn := 0, @prev_user := 0) var_init
ORDER BY user_id, created_at DESC
) t
WHERE rn <= 3;
缺點:依賴特定執行順序,MySQL 8.0+ 已棄用用戶變數此用法,易產生非確定性結果,不建議新專案使用。
SELECT o1.*
FROM orders o1
WHERE (
SELECT COUNT(*)
FROM orders o2
WHERE o2.user_id = o1.user_id
AND o2.created_at > o1.created_at
) < 3
ORDER BY o1.user_id, o1.created_at DESC;
效能陷阱:相關子查詢對外層每行都要掃描內層,複雜度 O(N²),資料量超過 1 萬行明顯變慢,不適合生產環境大表。
(SELECT * FROM orders WHERE user_id = 1 ORDER BY created_at DESC LIMIT 3)
UNION ALL
(SELECT * FROM orders WHERE user_id = 2 ORDER BY created_at DESC LIMIT 3)
UNION ALL
...;
僅適用於「已知且固定的少數分組」,動態分組不可用。
| 方法 | 執行時間 | 掃描行數 | 適用版本 |
|---|---|---|---|
| 視窗函數 ROW_NUMBER() | ~45 ms | ~30,000 | 8.0+ |
| 用戶變數模擬 | ~120 ms | ~1,000,000 | 5.7 |
| 關聯子查詢 | ~8.2 s | ~1,000,000,000 | 全版本 |
SELECT o1.*
FROM orders o1
JOIN (
SELECT user_id, MAX(created_at) AS max_dt
FROM orders
GROUP BY user_id
) o2 ON o1.user_id = o2.user_id AND o1.created_at = o2.max_dt;
WITH ranked AS (
SELECT *,
DENSE_RANK() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS dr
FROM orders
)
SELECT * FROM ranked WHERE dr <= 3;
無論哪種寫法,複合索引 (分組欄位, 排序欄位 DESC) 都是核心:
ALTER TABLE orders ADD INDEX idx_user_created (user_id, created_at DESC);
MySQL 8.0 支援降序索引,優化器可直接按索引順序讀取,避免 Filesort。
WHERE ROW_NUMBER()... <= 3,必須用 CTE 或子查詢包裹GROUP BY + GROUP_CONCAT 取前 N 筆 ID 再 IN 查詢,受限於 group_concat_max_len(預設 1024),大分組會截斷,不推薦WHERE rn BETWEEN 4 AND 6MySQL 8.0+ 請直接用 ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...),語法標準、效能最優、可維護性最強。舊版本若無法升級,可暫時用用戶變數方案,但務必加上測試用例防止非確定性結果。索引 (group_col, sort_col DESC) 是所有方案的效能基石。