前言:分页的本质是“顺序+边界+成本”
- 深分页慢,本质是
OFFSET导致扫描过多数据。 - 排序不稳定,会出现重复/丢失。
COUNT(*)常昂贵,未必必要。- 交互决定技术:不是所有场景都需要跳页。
1. 先选交互,再定技术
- 跳页:运营后台、报表;需要总页数与任意页跳转。
- 加载更多:Feed/日志流;强调一致性与低延迟。
- 时间窗口:审计/归档;按区间拉取更稳。
结论:主场景不是精确跳页时,优先“加载更多 + Keyset”。
2. 数据库铁律:保证“可索引且稳定的顺序”
- 排序必须有索引:否则 filesort + 回表。
- 顺序要稳定:使用复合排序键
ORDER BY created_at DESC, id DESC
索引建议:(created_at, id)或结合过滤列的复合索引。 - 避免深分页:少用
LIMIT offset, size的大偏移。 - 避免函数排序:
ORDER BY DATE(create_time)会让索引失效。
3. 方案选型:
- Offset(跳页友好,深分页差)
SELECT * FROM orders ORDER BY created_at DESC, id DESC LIMIT 10 OFFSET 10000; - Keyset(高并发友好,加载更多)
SELECT * FROM orders WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 10; - 时间窗口(区间浏览/归档)
SELECT * FROM orders WHERE created_at BETWEEN ? AND ? ORDER BY created_at DESC, id DESC LIMIT 100;
倾向:高并发与海量数据,以 Keyset 为主,必要时辅以时间窗口。
3.4 框架实践
-
MyBatis 原生(推荐 Keyset 直接写 SQL)
<select id="listBySeek" resultType="Order"> SELECT id, user_id, created_at FROM orders WHERE status = #{status} AND (created_at, id) < (#{cursorCreatedAt}, #{cursorId}) ORDER BY created_at DESC, id DESC LIMIT #{size} </select> -
MyBatis‑Plus(分页插件 + 关闭 count)
Page<Order> page = new Page<>(pageNum, pageSize, false); // 关闭COUNT IPage<Order> result = orderMapper.selectPage(page, Wrappers.<Order>lambdaQuery() .eq(Order::getStatus, status) .orderByDesc(Order::getCreatedAt) .orderByDesc(Order::getId) );Keyset 场景:直接写原生 SQL 或
@Select,不要强求插件。 -
PageHelper(适合 Offset,Keyset 手写)
PageHelper.startPage(pageNum, pageSize, false); // 视场景关闭 COUNT List<Order> list = orderMapper.listByStatus(status); -
原生 SQL + 复合索引(高并发收敛方案)
- 顺序必须统一:
ORDER BY created_at DESC, id DESC - 索引:
(filter_columns..., created_at, id)
- 顺序必须统一:
4. 注意事项
4.1 避免深分页
SELECT * FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 199980;
执行过程:
- 数据库必须先按 created_at DESC 排序 全部匹配的记录;
- 然后跳过前 199,980 行;
- 最后取接下来的 20 行返回。
数据量大的情况下要尽量选择keyset分页。
4.2.复杂 COUNT 的优化
当 COUNT(*) 昂贵时,可以用“是否还有下一页”代替“总条数/总页数”。
简单模式:
int size = pageSize;
List<Item> list = mapper.listItemsWithLimit(userId, offset, size + 1);
boolean hasNext = list.size() > size;
List<Item> pageRecords = hasNext ? list.subList(0, size) : list;
返回给前端:
records: 当前页数据;hasNext: 是否还有下一页;- 可以不返回
total/totalPages。
体验上类似「加载更多」,对多数“向后浏览”的场景足够好,而且避免了每次 COUNT 的开销。
4.3 批量查询附加数据:避免 N+1
分页列表常需展示关联数据,如:
- 每条订单的用户昵称;
- 每条短链的“今日点击数”;
- 每条帖子下的“点赞数”。
错误做法:循环查询(N+1)
for (Order order : orders) {
User user = userMapper.selectById(order.getUserId()); // 每条都查一次
}
正确做法:批量查询 + 内存合并
// 1. 提取当前页所有 user_id
List<Long> userIds = orders.stream()
.map(Order::getUserId)
.distinct()
.toList();
// 2. 一次查出所有用户
Map<Long, User> userMap = userMapper.selectBatchIds(userIds).stream()
.collect(Collectors.toMap(User::getId, Function.identity()));
// 3. 内存组装 DTO
List<OrderDTO> dtos = orders.stream()
.map(o -> new OrderDTO(o, userMap.get(o.getUserId())))
.toList();
SQL 次数从 N+1 降为 2,性能直接“降了一个数量级”。
通用原则:分页列表需要附加统计或关联信息时,一定要批量查,禁止逐条查,避免 N+1。
5. 最佳实践
- 统一排序:
created_at DESC, id DESC - 建索引:
(filters..., created_at, id) - 主接口改 Keyset:返回
records + hasNext + nextCursor - 关闭不必要的
COUNT(*) - 批量查从表,避免 N+1
- 限制
pageSize与最大页数
结语
高性能分页的要点不多:稳定顺序、合适索引、避免深分页、谨慎 COUNT、批量化关联。以交互为锚点选技术,Keyset 是高并发场景的长期最优解。