从用户体验出发:分页设计的完整实战指南

前言:在高并发、大数据量的系统中,"分页"看似简单,却常常成为性能瓶颈的源头。

  • 为什么查第 1 页只要 10ms,查第 10000 页却要 2s?
  • 为什么每次分页都要执行一次 COUNT(*)?这个操作真的必要吗?
  • 用户到底需要"跳到第 100 页",还是只需要"加载更多"?

本文将从「用户体验 → 数据库原理 → 方案选型 → 框架实践(MyBatis / MyBatis‑Plus / PageHelper / 原生 SQL / Keyset)→ 性能优化」五个层面,系统性地解答这些问题,并给出「可立即落地」的高性能分页实践。


1. 用户体验驱动:先明确你要支持哪种交互

在讨论任何技术方案之前,先问自己:用户到底怎么用?

1.1 跳页

  • 用户:

    • "我要看第 N 页""直接跳到最后一页看最新一条数据之前的历史"。
    • 常见于:运营后台、报表系统、管理控制台。
  • 技术要求:

    • 精确的总条数 / 总页数。
    • 排序稳定,否则用户翻页会看到重复或漏掉记录。

    结论:这类场景适合「页号分页」。

1.2 下拉加载 / 无限滚动

  • 用户:

    • "刷一刷""加载更多""往下滑就行",不关心第几页。
    • 常见于:Feed 流、日志流、资讯流。
  • 技术要求:

    • 性能稳定。
    • 顺滑体验,不能频繁"回跳"或出现大量重复记录。
    • 排序要稳定不抖动。

    结论:这类场景适合「游标 / Keyset 分页」。

1.3 时间窗口 / 区间浏览

  • 用户:

    • "看最近 7 天的数据""看 ID 在 [10000, 20000] 的记录"。
    • 常见于:报表分析、审计日志、归档浏览。
  • 技术要求:

    • 强依赖范围(时间/ID)的过滤;分页只是将窗口内数据切块。
    • 更关注某个时间段内的数据完整性。

    结论:这类场景适合「窗口/区间分页(按时间或 ID 范围)」。

体验决定技术模型:

  • 管理后台 → 页号分页
  • 大数据流 → Keyset
  • 报表/归档 → 时间窗口/区间分页

2. 数据库底层原则:让分页走在"对"的路上

无论选哪种模型,有几个底层原则几乎是"铁律"。

2.1 稳定排序且可索引

  • 排序字段必须有索引:

    • 否则分页必然伴随 filesort + 回表,越往后越慢。
  • 排序要"稳定":

    • 仅按非唯一列排序(如 ORDER BY created_at DESC)会导致同一时间多条记录无确定先后,翻页时容易重复/丢失。
    • 推荐复合排序键:
      ORDER BY created_at DESC, id DESC
      对应复合索引:

      (created_at, id)\]。

  • LIMIT offset, size 的执行本质:

    • 数据库需要"扫描 offset + size 行,然后丢弃前 offset 行",offset 越大,扫描成本越高。
  • 深分页查询复杂筛选时,容易触发:

    • filesort;
    • 大量回表;
    • Buffer Pool 热数据被冲刷。

    Keyset 的核心价值之一,就是避免这种大偏移扫描。

参考:一些云厂商的深分页优化实践中,常通过改造为 Keyset / Scroll 模式来规避深分页性能问题。

2.3 COUNT(* ) 的成本权衡

COUNT(*) 在分页返回 totaltotalPages 上非常常见,但要记住:

  • COUNT 的目的:

    • 不是"查数据",而是"告诉前端总共有多少条/多少页"。
  • 问题:

    • 在大表 + 复杂 WHERE + 多表 JOIN 的场景,精确 COUNT 是一笔不小的开销。

    应对策略:

  • 分离并简化 countQuery

    • 例如只对主表/主键做 count,避免多表 join。
  • 使用 "hasNext" 代替精确总页数:

    • 查询 size + 1 条记录:

      • 若条数 > size,则 hasNext = true,返回前 size 条;
      • 前端只展示"加载更多",而不显示"总页数/第几页"。

2.4 避免函数/表达式排序

  • ORDER BY DATE(create_time)ORDER BY func(column)

    • 常导致索引失效。
    • 正确做法:预先将需要排序的值写入一个字段并建索引,或改变业务需求。

3. 方案选型一览:怎么选、为什么选

3.1 页号分页(Offset)

  • 优点:

    • 用户容易理解;支持跳页;生态成熟(框架支持丰富)。
  • 缺点:

    • 深分页(大 offset)性能差;
    • 需要更谨慎地处理 COUNT 和排序稳定性。

    适合:管理后台、报表、需要跳页的业务。

3.2 Keyset(游标分页)

  • 优点:

    • 性能稳定:不依赖 offset,翻到"后面几页"也不会明显变慢。
    • 抖动更少:以稳定排序键推进,数据新增/删除对已翻页影响小。
  • 缺点:

    • 不支持任意跳页(你不能直接跳第 100 页,因为协议是"从当前游标继续向后看")。

    适合:日志流、Feed 流、时间序列等"只往后看"的场景。

GraphQL Relay、Twitter timeline、很多云存储/列表 API 都采用类似 Keyset 的游标分页。

3.3 窗口/区间分页

  • 优点:

    • 与业务语义贴近("按时间段查看数据");
    • 利用范围索引效率高。
  • 缺点:

    • UI 层的"第几页"含义不那么强。

    适合:报表、审计日志、归档数据分析。

趋势:越来越多系统放弃"精确跳页 + 深分页",转向「Keyset + 加载更多」,以换取性能与一致性。


4. 原生 SQL 的页号分页与 Keyset 实践

4.1 页号分页

以 MyBatis Mapper XML 为例。

Mapper XML(数据 + 简化版 count):

xml 复制代码
<select id="listItems" resultType="Item">
  SELECT id, title, created_at
  FROM item
  WHERE user_id = #{userId}
  ORDER BY created_at DESC, id DESC
  LIMIT #{offset}, #{size}
</select>

<select id="countItems" resultType="long">
  SELECT COUNT(*)
  FROM item
  WHERE user_id = #{userId}
</select>

Service 计算 offset 并做入参约束:

java 复制代码
int size = Math.min(reqSize, 100); // 限制最大页大小,防止变相全量
int page = Math.max(reqPage, 1);
int offset = (page - 1) * size;

List<Item> list = mapper.listItems(userId, offset, size);
long total = mapper.countItems(userId);
int totalPages = (int) Math.ceil(total * 1.0 / size);

注意事项:

  • 排序字段 created_atid 必须有索引;
  • 尽量使用复合索引 (user_id, created_at, id)
  • 复杂条件时可拆分 countItems 为更简单的统计语句。

4.2 Keyset 分页

仍以 MyBatis XML 为例。

Mapper XML(Keyset 查询):

xml 复制代码
<select id="listItemsSeek" resultType="Item">
  SELECT id, title, created_at
  FROM item
  WHERE user_id = #{userId}
    AND (
      created_at < #{lastCreatedAt}
      OR (created_at = #{lastCreatedAt} AND id < #{lastId})
    )
  ORDER BY created_at DESC, id DESC
  LIMIT #{size}
</select>

Service 组装游标:

java 复制代码
List<Item> list = mapper.listItemsSeek(userId, lastCreatedAt, lastId, size);
// 返回下一页游标(取最后一条的 created_at 和 id)
Item tail = list.isEmpty() ? null : list.get(list.size() - 1);
NextPageToken next = tail == null
    ? null
    : new NextPageToken(tail.getCreatedAt(), tail.getId());

协议设计建议:

  • 不直接暴露 lastCreatedAt / lastId,可以将其编码为一个 nextPageToken(Base64 + 签名)。
  • 客户端只关心 nextPageToken,不需要理解内部细节。

索引建议:

  • (user_id, created_at, id) 复合索引;
  • 这样 SQL 可以利用索引做顺序扫描,而不是大范围扫表。

5. MyBatis‑Plus:快速实现页号分页

原理:

MyBatis‑Plus 的优势是:在"不牺牲 SQL 控制力"的前提下,帮你处理好常规分页工作。

5.1 分页插件配置

参考官方文档配置分页插件:

java 复制代码
@Configuration
public class MybatisPlusConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

5.2 基本分页使用

java 复制代码
Page<Item> page = new Page<>(pageNum, pageSize, true); // true:统计总数

LambdaQueryWrapper<Item> qw = Wrappers.lambdaQuery(Item.class)
    .eq(Item::getUserId, userId)
    .orderByDesc(Item::getCreatedAt)
    .orderByDesc(Item::getId);

Page<Item> result = itemMapper.selectPage(page, qw);

long total = result.getTotal();
List<Item> records = result.getRecords();

注意:

  • 一样要保证排序字段有索引;
  • pageSize 要做上限控制;
  • pageNum 建议从 1 开始,内部转换为 offset。

5.3 复杂 COUNT 的优化

对于复杂查询:

  • 可以关闭自动 count:new Page<>(pageNum, pageSize, false)

  • 手工执行更轻量的 count SQL:

    • 例如只在主表上 count,再在服务层组装 Page 或自定义分页响应;
  • 或者采用 "size+1 条 + hasNext" 策略,避免 COUNT。


6. PageHelper:拦截器式分页(MyBatis 生态)

原理:

PageHelper 通过拦截 MyBatis 的查询,在 SQL 末尾自动加上 limit/offset 以及 count 语句。

基本用法:

java 复制代码
PageHelper.startPage(pageNum, pageSize);
List<Item> list = itemMapper.listItems(userId); // 原 SQL 会被拦截并拼接 LIMIT/OFFSET
PageInfo<Item> pageInfo = new PageInfo<>(list);
long total = pageInfo.getTotal();
List<Item> records = pageInfo.getList();

建议:

  • 搭配稳定排序(建议在 SQL 里显式写 ORDER BY);
  • 配置合理的参数(reasonablepageSizeZero 等);
  • COUNT 昂贵时,可以使用 PageHelper 提供的"关闭 count + 自己统计"组合。

7. 批量查询附加数据:避免 N+1

分页列表常需展示关联数据,如:

  • 每条订单的用户昵称;
  • 每条短链的"今日点击数";
  • 每条帖子下的"点赞数"。

错误做法:循环查询(N+1)

java 复制代码
for (Order order : orders) {
    User user = userMapper.selectById(order.getUserId()); // 每条都查一次
}

问题:

  • 当前页 N 条就要 N 次查询;
  • 延迟和数据库负载线性增长,容易拖垮系统。

正确做法:批量查询 + 内存合并

java 复制代码
// 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。


8. COUNT 的替代:hasNext 体验

COUNT(*) 昂贵时,可以用"是否还有下一页"代替"总条数/总页数"。

简单模式:

java 复制代码
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 的开销。


9. 九个问题、最佳实践

给你一个可以直接对照的 checklist:

  • 明确交互:是要"跳页",还是"加载更多",还是"按时间段看"?
  • 排序是否稳定?是否包含唯一性字段?是否有对应索引?
  • 是否限制了 pageSize 的最大值(防止变相全量查询)?
  • 深分页场景是否考虑使用 Keyset 替代 Offset?
  • COUNT 是否会非常昂贵?是否可用简化 countQueryhasNext 方案?
  • 分页列表是否有"附加统计/关联信息"?是否通过批量查询避免 N+1?
  • 是否避免了表达式/函数排序(ORDER BY func(column))?
  • 分页 SQL 是否利用到合适的复合索引(如 (user_id, created_at, id))?
  • 是否对真实数据分布做过压测和慢查询分析?

10. 结语:

分页是体验与性能的交汇点,当你从用户体验出发,理解数据库的真实运行方式,再结合框架能力做选择时,"分页"就不再是一个隐蔽的性能雷区,而会成为你后端设计中一个可靠、可控的基础设施。

创作不易,希望多多支持!

延伸阅读:

相关推荐
v***85743 分钟前
SpringBoot Maven快速上手
spring boot·后端·maven
Overt0p44 分钟前
抽奖系统 (1)
java·spring boot·spring·java-ee·java-rabbitmq
c***69301 小时前
SpringCloudGateWay
java
星释1 小时前
Rust 练习册 99:让数字开口说话
开发语言·后端·rust
子豪-中国机器人1 小时前
C++自定义结构体学习方法:
java·开发语言
August_._1 小时前
【软件安装教程】Node.js 开发环境搭建详解:从安装包下载到全局配置,一篇搞定所有流程
java·vue.js·windows·后端·node.js·配置
7***99871 小时前
springboot的 nacos 配置获取不到导致启动失败及日志不输出问题
java·spring boot·后端
MOMO陌染1 小时前
Java 面向对象之类与对象:编程世界的实体化核心
java·后端
花开花富贵1 小时前
Java 构造方法
java