1. 为什么create_time降序分页会成为性能瓶颈前几天排查一个线上慢查询时发现一个有趣的现象某张百万级数据表的分页查询前端越往后翻页响应时间越长。查看实现代码果然是用了典型的ORDER BY create_time DESC LIMIT ?, ?写法。这种看似合理的常规操作在数据量增长后却暴露出严重的性能问题。1.1 时间戳排序的隐藏代价当使用create_time DESC进行分页时数据库引擎的工作流程是这样的需要扫描整个索引树假设create_time有索引对所有记录按时间排序跳过前N条记录后取M条对于第100页的查询假设每页20条实际执行的是SELECT * FROM orders ORDER BY create_time DESC LIMIT 2000, 20这意味着数据库要先排序并丢弃前2000条记录才能返回需要的20条。随着页码增大OFFSET值线性增长性能呈指数级下降。1.2 真实场景下的性能对比测试我们在测试环境用800万订单数据做了对比实验查询方式第1页耗时第100页耗时第1000页耗时传统OFFSET分页12ms480ms4200ms优化后的游标分页15ms18ms22ms当翻到第1000页时传统方式需要4秒多而优化方案仍保持在毫秒级。这种差异在用户频繁翻页时会直接导致体验恶化。2. 高性能分页的替代方案实践2.1 游标分页Cursor Pagination实现游标分页的核心思想是记住最后一条记录的位置下次查询从这个位置开始。以订单查询为例// 使用MyBatis-Plus实现 public PageOrder listOrders(Long lastId, int pageSize) { return new LambdaQueryChainWrapper(orderMapper) .gt(Order::getId, lastId) // 关键点用ID代替OFFSET .orderByAsc(Order::getId) .page(new Page(1, pageSize, false)); // 禁用count查询 }前端接收响应时需要保存最后一条记录的ID{ data: [...], next_cursor: 10245 // 下一页的起始ID }2.2 复合索引优化方案对于必须按时间排序的场景可以建立(status, create_time, id)这样的联合索引ALTER TABLE orders ADD INDEX idx_status_time_id (status, create_time, id);查询时使用覆盖索引SELECT id, create_time FROM orders WHERE status PAID ORDER BY create_time DESC, id DESC LIMIT 20;关键技巧始终在ORDER BY最后加上主键排序避免create_time相同时的分页漂移问题3. 不同框架下的最佳实践3.1 MyBatis-Plus分页优化// 好实践使用lastId方式 PageOrder page orderService.lambdaQuery() .gt(Order::getId, lastId) .orderByAsc(Order::getId) .page(Page.of(1, size, false)); // 反模式传统分页性能随页码增加下降 PageOrder page orderService.page(Page.of(current, size), Wrappers.Orderquery().orderByDesc(create_time));3.2 JPA分页查询优化// 使用Spring Data JPA实现游标分页 public interface OrderRepository extends JpaRepositoryOrder, Long { ListOrder findByIdGreaterThanOrderByIdAsc(Long lastId, Pageable pageable); } // 模糊查询分页优化避免全表扫描 Query(SELECT o FROM Order o WHERE o.orderNo LIKE %:keyword% AND o.id :lastId ORDER BY o.id) ListOrder searchAfter(Param(keyword) String keyword, Param(lastId) Long lastId, Pageable pageable);4. 生产环境中的避坑指南4.1 常见错误及解决方案分页漂移问题现象新增数据导致前后两页出现重复记录修复ORDER BY create_time DESC, id DESC双字段排序深分页超时现象LIMIT 100000, 20查询超时方案改用WHERE id last_id形式联合查询性能差优化先分页查出ID再通过JOIN获取详情SELECT t1.* FROM detail t1 JOIN (SELECT id FROM main ORDER BY id LIMIT 10000, 10) t2 ON t1.main_id t2.id4.2 监控与调优建议在MySQL慢查询日志中重点关注# my.cnf配置 slow_query_log 1 long_query_time 1 log_queries_not_using_indexes 1使用EXPLAIN检查执行计划确保出现Using index覆盖索引没有Using filesort额外排序分页查询的响应时间监控指标P99应保持在200ms以内翻页次数超过50次时性能不应明显下降5. 特殊场景处理方案5.1 跳页需求的技术实现对于必须支持随机跳页的业务场景如后台管理系统可以采用以下折中方案首次加载时缓存ID列表ZADD order_ids 1620000000 10001 1620000001 10002...跳页时通过ZRANGE获取ID范围// 获取第5页的ID范围每页20条 SetString ids redisTemplate.opsForZSet() .reverseRange(order_ids, 80, 99);通过IN查询获取数据SELECT * FROM orders WHERE id IN (10081,10082...10099);5.2 多维度排序解决方案当需要支持按时间/价格/销量等多种排序方式时为每种排序方式建立单独的有序集合使用Redis的ZINTERSTORE组合查询条件通过ZSCAN实现游标分页# 伪代码示例 def paginate(sort_key, last_scoreNone, page_size20): if last_score: # 游标分页 items redis.zrevrangebyscore( sort_key, last_score, 0, start0, numpage_size, withscoresTrue) else: # 第一页 items redis.zrevrange( sort_key, 0, page_size-1, withscoresTrue) return items这种方案虽然增加了存储开销但能保证O(log(N))的查询复杂度适合高并发场景。