从用户体验出发:高性能的分页设计优化指南

120 阅读4分钟

前言:分页的本质是“顺序+边界+成本”

  • 深分页慢,本质是 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;

执行过程:

  1. 数据库必须先按 created_at DESC 排序 全部匹配的记录;
  2. 然后跳过前 199,980 行;
  3. 最后取接下来的 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 是高并发场景的长期最优解。

延伸阅读: