1. 深度分页性能问题详解
1.1 问题根源分析
传统分页的工作原理
-- 传统 LIMIT 分页查询
SELECT * FROM orders ORDER BY create_time DESC LIMIT 1000000, 20;
MySQL 执行过程:
- 扫描并排序前 1,000,020 条记录
- 跳过前 1,000,000 条记录(OFFSET 部分)
- 仅返回最后的 20 条记录
性能问题:
- 时间复杂度:O(n + m),其中 n 是 OFFSET 值,m 是 LIMIT 值
- 内存消耗:需要在内存中维护大量临时数据
- I/O 压力:即使有索引,也需要读取大量索引页
- CPU 消耗:排序和跳过操作消耗大量 CPU 资源
性能对比数据
| 分页方式 | 第1页 | 第1000页 | 第10000页 | 第50000页 |
|---|---|---|---|---|
| OFFSET 分页 | 10ms | 200ms | 2000ms | 10000ms+ |
| 游标分页 | 10ms | 12ms | 15ms | 18ms |
1.2 MyBatis-Plus 中的深度分页问题
// MyBatis-Plus 传统分页示例
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
/**
* 使用 MyBatis-Plus 的 IPage 进行分页
* 当 pageNum 很大时(如 50000),性能会急剧下降
*/
public IPage<Order> getOrdersByPage(int pageNum, int pageSize) {
// 构建分页对象
Page<Order> page = new Page<>(pageNum, pageSize);
// 设置排序
page.addOrder(OrderItem.desc("create_time"));
// 执行分页查询 - 深度分页时性能极差
return orderMapper.selectPage(page, null);
}
}
生成的 SQL:
-- 当 pageNum=50000, pageSize=20 时
SELECT * FROM orders ORDER BY create_time DESC LIMIT 1000000, 20;
2. 游标分页解决方案
2.1 游标分页原理
核心思想:记住位置,而不是计算位置
-- 游标分页:使用上一页最后一条记录的排序字段值作为起点
SELECT * FROM orders
WHERE create_time < '2023-12-01 10:30:00' -- 上一页最后一条记录的时间
ORDER BY create_time DESC
LIMIT 20;
优势:
- 时间复杂度:O(log n + m),利用索引快速定位
- 性能稳定:与数据总量无关,只与 LIMIT 值相关
- 资源消耗低:不需要跳过大量记录
2.2 游标字段选择标准
选择标准(按优先级排序)
- 唯一性:字段值必须唯一,避免重复数据
- 有序性:字段具有天然的排序特性
- 索引支持:字段必须有索引(最好是复合索引)
- 稳定性:字段值在业务生命周期内不会改变
- 数据类型:优先选择数值型或时间型字段
推荐的游标字段类型
| 字段类型 | 推荐程度 | 说明 |
|---|---|---|
| 自增主键 ID | ⭐⭐⭐⭐⭐ | 最佳选择,天然唯一、有序、有索引 |
| 创建时间 | ⭐⭐⭐⭐ | 良好选择,但需处理并发创建的相同时间问题 |
| 复合字段 | ⭐⭐⭐⭐ | 如 (create_time, id),解决时间重复问题 |
| UUID | ⭐⭐ | 不推荐,无序且占用空间大 |
| 业务字段 | ⭐ | 一般不推荐,可能重复或变更 |
2.3 最佳游标字段选择策略
策略 1:单字段游标(推荐)
/**
* 最佳实践:使用自增主键作为游标
* 优点:天然唯一、有序、有主键索引
*/
public class OrderCursorPagination {
/**
* 基于 ID 的游标分页
* @param lastId 上一页最后一条记录的 ID
* @param pageSize 每页大小
* @return 订单列表
*/
public List<Order> getOrdersByIdCursor(Long lastId, int pageSize) {
// SQL: SELECT * FROM orders WHERE id > #{lastId} ORDER BY id LIMIT #{pageSize}
return orderMapper.selectByCursor(lastId, pageSize);
}
}
策略 2:复合字段游标(处理时间重复)
/**
* 复合游标:解决创建时间可能重复的问题
* 使用 (create_time, id) 作为复合游标
*/
public class OrderCompositeCursorPagination {
/**
* 复合游标分页参数
*/
public static class Cursor {
private LocalDateTime lastCreateTime;
private Long lastId;
// 构造函数、getter、setter
public Cursor(LocalDateTime lastCreateTime, Long lastId) {
this.lastCreateTime = lastCreateTime;
this.lastId = lastId;
}
public LocalDateTime getLastCreateTime() { return lastCreateTime; }
public Long getLastId() { return lastId; }
}
/**
* 基于复合游标的分页查询
* @param cursor 上一页的游标信息
* @param pageSize 每页大小
* @return 订单列表
*/
public List<Order> getOrdersByCompositeCursor(Cursor cursor, int pageSize) {
return orderMapper.selectByCompositeCursor(cursor, pageSize);
}
}
3. 完整实现示例
3.1 数据库表结构
-- 订单表结构
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '订单ID,自增主键',
order_no VARCHAR(50) NOT NULL UNIQUE COMMENT '订单编号',
user_id BIGINT NOT NULL COMMENT '用户ID',
amount DECIMAL(10, 2) NOT NULL COMMENT '订单金额',
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '订单状态',
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
-- 索引优化
INDEX idx_user_id (user_id),
INDEX idx_create_time (create_time),
INDEX idx_status_create_time (status, create_time) -- 复合索引
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
3.2 MyBatis Mapper 实现
单字段游标分页
<!-- OrderMapper.xml -->
<mapper namespace="com.example.mapper.OrderMapper">
<!-- 基于 ID 的游标分页查询 -->
<select id="selectByIdCursor" resultType="com.example.entity.Order">
SELECT
id,
order_no,
user_id,
amount,
status,
create_time,
update_time
FROM orders
WHERE id > #{lastId}
ORDER BY id ASC
LIMIT #{pageSize}
</select>
<!-- 基于创建时间的游标分页查询(降序) -->
<select id="selectByCreateTimeCursor" resultType="com.example.entity.Order">
SELECT
id,
order_no,
user_id,
amount,
status,
create_time,
update_time
FROM orders
WHERE create_time <![CDATA[<]]> #{lastCreateTime}
ORDER BY create_time DESC
LIMIT #{pageSize}
</select>
<!-- 复合游标分页查询 -->
<select id="selectByCompositeCursor" resultType="com.example.entity.Order">
SELECT
id,
order_no,
user_id,
amount,
status,
create_time,
update_time
FROM orders
WHERE
create_time <![CDATA[<]]> #{cursor.lastCreateTime}
OR (create_time = #{cursor.lastCreateTime} AND id > #{cursor.lastId})
ORDER BY create_time DESC, id ASC
LIMIT #{pageSize}
</select>
<!-- 获取总记录数(可选,用于显示总页数) -->
<select id="countAll" resultType="java.lang.Long">
SELECT COUNT(*) FROM orders
</select>
</mapper>
MyBatis-Plus Mapper 实现
// OrderMapper.java
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
/**
* 基于 ID 的游标分页查询
* @param lastId 上一页最后一条记录的 ID
* @param pageSize 每页大小
* @return 订单列表
*/
@Select("SELECT * FROM orders WHERE id > #{lastId} ORDER BY id ASC LIMIT #{pageSize}")
List<Order> selectByIdCursor(@Param("lastId") Long lastId, @Param("pageSize") int pageSize);
/**
* 基于创建时间的游标分页查询
* @param lastCreateTime 上一页最后一条记录的创建时间
* @param pageSize 每页大小
* @return 订单列表
*/
@Select("SELECT * FROM orders WHERE create_time < #{lastCreateTime} ORDER BY create_time DESC LIMIT #{pageSize}")
List<Order> selectByCreateTimeCursor(@Param("lastCreateTime") LocalDateTime lastCreateTime,
@Param("pageSize") int pageSize);
/**
* 复合游标分页查询
* @param cursor 游标参数对象
* @param pageSize 每页大小
* @return 订单列表
*/
List<Order> selectByCompositeCursor(@Param("cursor") OrderCompositeCursorPagination.Cursor cursor,
@Param("pageSize") int pageSize);
}
3.3 Service 层实现
/**
* 订单服务类 - 游标分页实现
*/
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
/**
* 基于 ID 的游标分页查询
* 适用于按创建顺序分页的场景
*
* @param lastId 上一页最后一条记录的 ID,首次查询传 null 或 0
* @param pageSize 每页大小,建议限制最大值(如 100)
* @return 分页结果包装对象
*/
public CursorPageResult<Order> getOrdersByIdCursor(Long lastId, int pageSize) {
// 参数校验
if (pageSize <= 0 || pageSize > 100) {
pageSize = 20; // 默认每页20条
}
// 首次查询或 lastId 为 null 时,从最小 ID 开始
if (lastId == null || lastId <= 0) {
lastId = 0L;
}
// 执行游标分页查询
List<Order> orders = orderMapper.selectByIdCursor(lastId, pageSize);
// 构建分页结果
boolean hasMore = orders.size() == pageSize;
Long nextCursor = hasMore ? orders.get(orders.size() - 1).getId() : null;
return new CursorPageResult<>(orders, nextCursor, hasMore);
}
/**
* 基于创建时间的游标分页查询(降序)
* 适用于按时间倒序显示最新数据的场景
*
* @param lastCreateTime 上一页最后一条记录的创建时间,首次查询传 null
* @param pageSize 每页大小
* @return 分页结果包装对象
*/
public CursorPageResult<Order> getOrdersByCreateTimeCursor(LocalDateTime lastCreateTime, int pageSize) {
// 参数校验
if (pageSize <= 0 || pageSize > 100) {
pageSize = 20;
}
// 首次查询时,不设置时间条件
if (lastCreateTime == null) {
// 查询最新的 pageSize 条记录
List<Order> orders = orderMapper.selectLatestOrders(pageSize);
boolean hasMore = orders.size() == pageSize;
LocalDateTime nextCursor = hasMore ? orders.get(orders.size() - 1).getCreateTime() : null;
return new CursorPageResult<>(orders, nextCursor, hasMore);
}
// 执行时间游标查询
List<Order> orders = orderMapper.selectByCreateTimeCursor(lastCreateTime, pageSize);
boolean hasMore = orders.size() == pageSize;
LocalDateTime nextCursor = hasMore ? orders.get(orders.size() - 1).getCreateTime() : null;
return new CursorPageResult<>(orders, nextCursor, hasMore);
}
/**
* 复合游标分页查询
* 解决创建时间重复的问题,确保分页的准确性
*
* @param cursor 复合游标对象,包含最后的时间和 ID
* @param pageSize 每页大小
* @return 分页结果包装对象
*/
public CursorPageResult<Order> getOrdersByCompositeCursor(
OrderCompositeCursorPagination.Cursor cursor, int pageSize) {
if (pageSize <= 0 || pageSize > 100) {
pageSize = 20;
}
// 首次查询
if (cursor == null) {
List<Order> orders = orderMapper.selectLatestOrders(pageSize);
boolean hasMore = orders.size() == pageSize;
OrderCompositeCursorPagination.Cursor nextCursor = hasMore ?
new OrderCompositeCursorPagination.Cursor(
orders.get(orders.size() - 1).getCreateTime(),
orders.get(orders.size() - 1).getId()
) : null;
return new CursorPageResult<>(orders, nextCursor, hasMore);
}
// 执行复合游标查询
List<Order> orders = orderMapper.selectByCompositeCursor(cursor, pageSize);
boolean hasMore = orders.size() == pageSize;
OrderCompositeCursorPagination.Cursor nextCursor = hasMore ?
new OrderCompositeCursorPagination.Cursor(
orders.get(orders.size() - 1).getCreateTime(),
orders.get(orders.size() - 1).getId()
) : null;
return new CursorPageResult<>(orders, nextCursor, hasMore);
}
/**
* 获取最新订单(首次查询使用)
*/
public List<Order> selectLatestOrders(int pageSize) {
return orderMapper.selectLatestOrders(pageSize);
}
}
3.4 分页结果包装类
/**
* 游标分页结果包装类
* 通用的分页结果结构
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CursorPageResult<T> {
/**
* 当前页的数据列表
*/
private List<T> data;
/**
* 下一页的游标值
* 可以是 ID、时间戳或其他类型的游标
*/
private Object nextCursor;
/**
* 是否还有更多数据
* true 表示还有下一页,false 表示已是最后一页
*/
private boolean hasMore;
/**
* 构造函数
*/
public CursorPageResult(List<T> data, Object nextCursor, boolean hasMore) {
this.data = data;
this.nextCursor = nextCursor;
this.hasMore = hasMore;
}
/**
* 判断是否为第一页
*/
public boolean isFirstPage() {
return nextCursor == null && !data.isEmpty();
}
/**
* 获取数据总数(可选,需要额外查询)
*/
public Long getTotalCount() {
// 如果需要总数,可以在这里添加查询逻辑
// 但通常游标分页不需要精确总数
return null;
}
}
3.5 Controller 层实现
/**
* 订单控制器 - 游标分页 API
*/
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Autowired
private OrderService orderService;
/**
* 基于 ID 的游标分页查询 API
*
* 请求参数:
* - lastId: 上一页最后一条记录的 ID(可选,首次查询不传)
* - pageSize: 每页大小(可选,默认20,最大100)
*
* 响应示例:
* {
* "data": [...],
* "nextCursor": 123456,
* "hasMore": true
* }
*/
@GetMapping("/cursor/id")
public ResponseEntity<CursorPageResult<Order>> getOrdersByIdCursor(
@RequestParam(required = false) Long lastId,
@RequestParam(defaultValue = "20") Integer pageSize) {
CursorPageResult<Order> result = orderService.getOrdersByIdCursor(lastId, pageSize);
return ResponseEntity.ok(result);
}
/**
* 基于创建时间的游标分页查询 API
*
* 请求参数:
* - lastCreateTime: 上一页最后一条记录的创建时间(ISO 8601 格式,可选)
* - pageSize: 每页大小(可选,默认20,最大100)
*/
@GetMapping("/cursor/time")
public ResponseEntity<CursorPageResult<Order>> getOrdersByCreateTimeCursor(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime lastCreateTime,
@RequestParam(defaultValue = "20") Integer pageSize) {
CursorPageResult<Order> result = orderService.getOrdersByCreateTimeCursor(lastCreateTime, pageSize);
return ResponseEntity.ok(result);
}
/**
* 复合游标分页查询 API
*
* 请求参数通过 JSON Body 传递:
* {
* "lastCreateTime": "2023-12-01T10:30:00",
* "lastId": 123456
* }
*/
@PostMapping("/cursor/composite")
public ResponseEntity<CursorPageResult<Order>> getOrdersByCompositeCursor(
@RequestBody(required = false) CompositeCursorRequest request,
@RequestParam(defaultValue = "20") Integer pageSize) {
OrderCompositeCursorPagination.Cursor cursor = null;
if (request != null) {
cursor = new OrderCompositeCursorPagination.Cursor(
request.getLastCreateTime(),
request.getLastId()
);
}
CursorPageResult<Order> result = orderService.getOrdersByCompositeCursor(cursor, pageSize);
return ResponseEntity.ok(result);
}
}
/**
* 复合游标请求参数类
*/
@Data
public class CompositeCursorRequest {
private LocalDateTime lastCreateTime;
private Long lastId;
}
4. 前端使用示例
4.1 JavaScript 前端实现
/**
* 游标分页前端实现示例
*/
class OrderPagination {
constructor() {
this.currentPageData = [];
this.nextCursor = null;
this.hasMore = true;
this.pageSize = 20;
}
/**
* 加载下一页数据
*/
async loadNextPage() {
if (!this.hasMore) {
console.log('没有更多数据了');
return;
}
try {
let url = '/api/orders/cursor/id';
let params = new URLSearchParams();
params.append('pageSize', this.pageSize);
// 如果不是第一页,添加游标参数
if (this.nextCursor !== null) {
params.append('lastId', this.nextCursor);
}
const response = await fetch(`${url}?${params}`);
const result = await response.json();
// 更新分页状态
this.currentPageData = result.data;
this.nextCursor = result.nextCursor;
this.hasMore = result.hasMore;
// 渲染数据到页面
this.renderOrders(result.data);
} catch (error) {
console.error('加载订单失败:', error);
}
}
/**
* 渲染订单数据到页面
*/
renderOrders(orders) {
const container = document.getElementById('orders-container');
orders.forEach(order => {
const orderElement = document.createElement('div');
orderElement.innerHTML = `
<div class="order-item">
<span>订单号: ${order.orderNo}</span>
<span>金额: ¥${order.amount}</span>
<span>时间: ${order.createTime}</span>
</div>
`;
container.appendChild(orderElement);
});
}
/**
* "加载更多"按钮点击处理
*/
onLoadMoreClick() {
this.loadNextPage();
}
}
// 初始化分页
const orderPagination = new OrderPagination();
// 首次加载
orderPagination.loadNextPage();
// 绑定"加载更多"按钮
document.getElementById('load-more-btn').addEventListener('click', () => {
orderPagination.onLoadMoreClick();
});
4.2 API 调用示例
# 首次查询(获取第一页)
GET /api/orders/cursor/id?pageSize=20
# 响应
{
"data": [
{"id": 1, "orderNo": "ORD001", "amount": 99.99, "createTime": "2023-12-01T10:00:00"},
{"id": 2, "orderNo": "ORD002", "amount": 199.99, "createTime": "2023-12-01T10:01:00"},
...
{"id": 20, "orderNo": "ORD020", "amount": 299.99, "createTime": "2023-12-01T10:19:00"}
],
"nextCursor": 20,
"hasMore": true
}
# 第二次查询(获取第二页)
GET /api/orders/cursor/id?lastId=20&pageSize=20
# 响应
{
"data": [
{"id": 21, "orderNo": "ORD021", "amount": 149.99, "createTime": "2023-12-01T10:20:00"},
...
{"id": 40, "orderNo": "ORD040", "amount": 89.99, "createTime": "2023-12-01T10:39:00"}
],
"nextCursor": 40,
"hasMore": true
}
5. 最佳实践总结
5.1 游标字段选择优先级
-
首选:自增主键 ID
- 天然唯一、有序
- 主键索引性能最佳
- 实现最简单
-
次选:复合字段 (create_time, id)
- 解决时间重复问题
- 需要创建复合索引
- 适用于按时间排序的业务场景
-
避免:纯时间字段
- 可能存在并发创建的相同时间
- 需要额外处理重复问题
5.2 性能优化建议
-
索引优化
-- 单字段游标 ALTER TABLE orders ADD INDEX idx_id (id); -- 复合游标 ALTER TABLE orders ADD INDEX idx_create_time_id (create_time, id); -
限制页面大小
// 限制最大页面大小,防止恶意请求 if (pageSize > 100) { pageSize = 100; } -
业务层面限制
// 对于不需要深度分页的业务,可以限制最大页数 if (cursor != null && cursorValue < MIN_ALLOWED_CURSOR) { throw new IllegalArgumentException("不支持深度分页查询"); }
5.3 适用场景
- 社交动态流:微博、朋友圈等按时间倒序展示
- 订单列表:电商平台的订单查询
- 日志系统:系统日志的分页查询
- 消息列表:聊天消息、通知消息等
5.4 不适用场景
- 需要精确跳转到指定页码:如传统的页码导航
- 需要显示总记录数:游标分页通常不计算总数
- 随机访问:无法直接跳转到第 N 页
通过使用游标分页,可以彻底解决深度分页的性能问题,为用户提供流畅的分页体验,同时大大降低数据库的负载压力。
深度分页的本质问题是 “跳过大量数据” 的操作成本过高,解决方案的核心思想是 “记住位置,而不是计算位置”。

1129

被折叠的 条评论
为什么被折叠?



