分頁查詢是 Web 應用的核心需求。MySQL 原生支援 LIMIT offset, row_count,但深分頁(大 Offset)會導致效能急劇下降。本文介紹 Java 中實現 MySQL 分頁的三種主流方案:傳統 Offset/Limit、子查詢優化、以及高效能的 Keyset 分頁(游標分頁),並提供 Spring Data JPA / MyBatis 實作範例。
| 方案 | 原理 | 優點 | 缺點 | 適用場景 |
|---|---|---|---|---|
| Offset/Limit | LIMIT offset, size | 語法簡單、支援隨機跳頁 | 深分頁掃描大量行、效能 O(N) | 頁數少、資料量小、後台管理 |
| 子查詢優化 | 先查主鍵 ID 再回表 | 減少回表行數 | 仍需掃描 Offset 行索引 | 中等分頁、需保留跳頁 |
| Keyset 分頁 | WHERE id > last_id ORDER BY id LIMIT size | 極快、穩定 O(1)、無深分頁問題 | 不支援隨機跳頁、需唯一遞增排序鍵 | 無限滾動、API 分頁、大資料集 |
<select id="selectPage" resultType="User">
SELECT * FROM t_user
WHERE status = 1
ORDER BY create_time DESC
LIMIT #{offset}, #{pageSize}
</select>
Pageable pageable = PageRequest.of(page, size, Sort.by("createTime").descending());
Page page = userRepository.findByStatus(1, pageable);
利用覆蓋索引先取 ID,再回表查完整欄位:
<select id="selectPageOptimized" resultType="User">
SELECT * FROM t_user
WHERE id IN (
SELECT id FROM t_user
WHERE status = 1
ORDER BY create_time DESC
LIMIT #{offset}, #{pageSize}
)
ORDER BY create_time DESC
</select>
前提:(status, create_time, id) 建立聯覆蓋索引。
核心思想:記住上一頁最後一條記錄的排序鍵值,下一頁直接從該值之後查詢。
// 第一頁
{ "pageSize": 20, "lastId": null, "lastCreateTime": null }
// 後續頁
{ "pageSize": 20, "lastId": 10050, "lastCreateTime": "2023-10-01 12:00:00" }
<select id="selectByKeyset" resultType="User">
SELECT * FROM t_user
WHERE status = 1
<if test="lastId != null and lastCreateTime != null">
AND (create_time < #{lastCreateTime}
OR (create_time = #{lastCreateTime} AND id < #{lastId}))
</if>
ORDER BY create_time DESC, id DESC
LIMIT #{pageSize}
</select>
@Query("SELECT u FROM User u WHERE u.status = 1 " +
"AND (?1 IS NULL OR u.createTime < ?1 OR (u.createTime = ?1 AND u.id < ?2)) " +
"ORDER BY u.createTime DESC, u.id DESC")
Page findByKeyset(LocalDateTime lastCreateTime, Long lastId, Pageable pageable);
// 呼叫時 pageable 僅設 pageSize,page=0
Pageable pageable = PageRequest.of(0, 20);
Page page = repo.findByKeyset(lastCreateTime, lastId, pageable);
-- 測試表:100 萬行,索引 (status, create_time, id)
-- 第 50000 頁,每頁 20 筆
-- Offset/Limit:約 12.3 秒(全表掃描 100 萬行)
SELECT * FROM t_user WHERE status=1 ORDER BY create_time DESC LIMIT 1000000, 20;
-- Keyset:約 0.003 秒(索引定位直接取 20 筆)
SELECT * FROM t_user WHERE status=1
AND (create_time < '2023-01-01' OR (create_time='2023-01-01' AND id < 50000))
ORDER BY create_time DESC, id DESC LIMIT 20;