MySQL 深分页如何进行性能优化?
作者:一名八年经验的 Java 开发工程师
引言:深分页,真的只是"翻页慢"那么简单吗?
在面试中,你是否遇到过这样的问题?
"你了解 MySQL 深分页的性能问题吗?如何优化?"
又或者在真实项目中,当你翻到第 1000 页的数据时,接口突然变得异常缓慢,甚至超时崩溃。你打开慢 SQL 日志,看到那条熟悉的 LIMIT 100000, 20
,心里默默叹了口气:"啊,又是深分页惹的祸。"
作为一名有 8 年开发经验的 Java 工程师,我在多个后台系统和数据中心项目中都遇到过深分页带来的性能瓶颈。起初我们只是加索引、调 SQL,但最终发现:分页的本质,其实是数据访问策略的设计问题。
这篇文章将带你深入理解:
- 为什么深分页会拖垮数据库?
- 如何结合实际业务场景进行优化?
- 有哪些可落地的代码实践?
- 如何用一个高性能的游标分页方案替代传统分页?
一、背景介绍
在日常业务开发中,分页查询是非常常见的需求。例如在管理后台系统中,展示订单列表、用户列表、日志记录等都需要分页加载。
通常我们会使用类似下面的 SQL:
sql
SELECT * FROM orders ORDER BY create_time DESC LIMIT 100000, 20;
上述查询语句的含义是:跳过 100000 条记录,取第 100001 到 100020 条数据。这种分页方式我们称为"深分页" 。
二、深分页的性能问题
MySQL 在执行 LIMIT offset, size
的时候,会先扫描 offset + size 条记录,然后抛弃前 offset 条,只返回 size 条。
也就是说,LIMIT 100000, 20
实际上扫描了 100020 行,仅返回 20 行。若表数据量和 offset 很大,性能将急剧下降,甚至拖垮数据库。
三、业务场景分析
假设你在做一个订单系统,运营人员需要查看历史订单数据,而这些订单数据量非常庞大(千万级),他们经常会翻到第 1000 页查看订单(每页 20 条)。
你发现系统在翻页到后面时接口响应非常慢,排查后定位到是 MySQL 查询耗时严重。
这时候我们就需要 优化深分页的查询方式。
四、优化思路
✅ 方法一:使用覆盖索引
vbnet
SELECT id FROM orders ORDER BY id LIMIT 100000, 20;
SELECT * FROM orders WHERE id IN (...);
通过先查主键,再回表查详细数据,减少回表成本。
✅ 方法二:记录上一次的游标(推荐)
使用**"基于游标的分页"**,也叫作"Keyset Pagination"或"Seek 方法"。
思路是:不要使用 LIMIT offset
,而是通过上一次查询的最后一条数据的主键或排序字段作为游标,下次查询直接从该游标之后开始。
sql
SELECT * FROM orders WHERE id > ? ORDER BY id ASC LIMIT 20;
优势:
- 避免跳过大量数据,性能优越
- 更适合实时数据流或按时间排序的场景
五、实战代码:Java 工具类实现游标分页
我们封装一个通用的分页工具类,支持游标分页,适用于 Spring + MyBatis/MyBatis-Plus 项目。
📦 1. 分页请求参数类
arduino
public class CursorPageRequest {
/**
* 游标字段,如 id、时间戳等
*/
private Long cursor;
/**
* 每页数量
*/
private int pageSize = 20;
public CursorPageRequest() {}
public CursorPageRequest(Long cursor, int pageSize) {
this.cursor = cursor;
this.pageSize = pageSize;
}
public Long getCursor() {
return cursor;
}
public void setCursor(Long cursor) {
this.cursor = cursor;
}
public int getPageSize() {
return pageSize;
}
public void setPageSize(int pageSize) {
this.pageSize = pageSize;
}
}
📦 2. 通用分页返回类
kotlin
import java.util.List;
public class CursorPageResponse<T> {
private List<T> data;
private Long nextCursor;
private boolean hasMore;
public CursorPageResponse(List<T> data, Long nextCursor, boolean hasMore) {
this.data = data;
this.nextCursor = nextCursor;
this.hasMore = hasMore;
}
public List<T> getData() {
return data;
}
public Long getNextCursor() {
return nextCursor;
}
public boolean isHasMore() {
return hasMore;
}
}
📦 3. MyBatis 示例 Mapper 接口(以订单为例)
java
复制
less
@Mapper
public interface OrderMapper {
/**
* 查询游标分页数据
* @param cursor 上次最后一条记录的 ID
* @param pageSize 每页数量
*/
@Select("SELECT * FROM orders " +
"WHERE (:cursor IS NULL OR id > #{cursor}) " +
"ORDER BY id ASC LIMIT #{pageSize}")
List<OrderDO> listByCursor(@Param("cursor") Long cursor, @Param("pageSize") Integer pageSize);
}
📦 4. Service 层封装分页逻辑
ini
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
public CursorPageResponse<OrderDO> listOrdersByCursor(CursorPageRequest request) {
List<OrderDO> list = orderMapper.listByCursor(request.getCursor(), request.getPageSize());
Long nextCursor = null;
boolean hasMore = false;
if (!list.isEmpty()) {
// 获取最后一条记录的 ID 作为下次游标
nextCursor = list.get(list.size() - 1).getId();
hasMore = list.size() == request.getPageSize();
}
return new CursorPageResponse<>(list, nextCursor, hasMore);
}
}
六、接口调用示例
前端每次请求:
ini
GET /orders?cursor=10021&pageSize=20
返回:
json
{
"data": [...],
"nextCursor": 10041,
"hasMore": true
}
前端将 nextCursor
作为下次请求的 cursor
参数实现"加载更多"效果。
七、总结
分页方式 | 优点 | 缺点 |
---|---|---|
LIMIT offset, size |
简单通用 | 深分页性能差 |
游标分页(Keyset) | 性能优秀,避免跳过大量数据 | 不支持跳页,只支持顺序加载 |
建议 :在数据量大、分页页码深的业务场景中,优先使用 游标分页,尤其是管理后台、数据分析系统、接口服务等。
八、建议与扩展
- 游标字段最好是唯一递增的,比如主键 ID、时间戳等
- 多字段组合游标可以支持更复杂的排序场景
- 可以将分页封装为 AOP 或 BaseService 通用组件
如果你有兴趣,我也可以进一步封装这个分页工具为一个独立的 Spring Boot Starter,欢迎点赞收藏交流 👏