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分页 | 12ms | 480ms | 4200ms |
| 优化后的游标分页 | 15ms | 18ms | 22ms |
当翻到第1000页时,传统方式需要4秒多,而优化方案仍保持在毫秒级。这种差异在用户频繁翻页时会直接导致体验恶化。
2. 高性能分页的替代方案实践
2.1 游标分页(Cursor Pagination)实现
游标分页的核心思想是:记住最后一条记录的位置,下次查询从这个位置开始。以订单查询为例:
// 使用MyBatis-Plus实现 public Page<Order> 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方式 Page<Order> page = orderService.lambdaQuery() .gt(Order::getId, lastId) .orderByAsc(Order::getId) .page(Page.of(1, size, false)); // 反模式:传统分页(性能随页码增加下降) Page<Order> page = orderService.page(Page.of(current, size), Wrappers.<Order>query().orderByDesc("create_time"));3.2 JPA分页查询优化
// 使用Spring Data JPA实现游标分页 public interface OrderRepository extends JpaRepository<Order, Long> { List<Order> findByIdGreaterThanOrderByIdAsc(Long lastId, Pageable pageable); } // 模糊查询分页优化(避免全表扫描) @Query("SELECT o FROM Order o WHERE o.orderNo LIKE %:keyword% AND o.id > :lastId ORDER BY o.id") List<Order> 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.id
4.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条) Set<String> 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_score=None, page_size=20): if last_score: # 游标分页 items = redis.zrevrangebyscore( sort_key, last_score, 0, start=0, num=page_size, withscores=True) else: # 第一页 items = redis.zrevrange( sort_key, 0, page_size-1, withscores=True) return items这种方案虽然增加了存储开销,但能保证O(log(N))的查询复杂度,适合高并发场景。