分页查询是业务系统中最基础却最容易被低估的技术模块。在电商平台工作时,我们曾因分页实现不当导致促销活动页加载延迟高达8秒——当用户翻到第50页时,数据库实际扫描了前49页的全部数据。合理的分页实现能将同样场景的响应时间控制在200ms内。
分页的核心矛盾在于:用户只需要看到当前页的20条数据,但数据库需要知道从哪里开始截取这20条。传统方案(如MySQL的LIMIT)在深度分页时会产生严重的性能问题。假设每页20条数据,当用户请求第1000页时,数据库需要先扫描19980条记录才能定位到第1000页的起始位置。
MySQL标准分页语法看似简单:
sql复制SELECT * FROM products
ORDER BY create_time DESC
LIMIT 200, 20; -- 第11页(200=(11-1)*20)
但在千万级数据表中,这个查询可能产生灾难性后果。我曾用EXPLAIN分析过一个真实案例:
关键发现:当offset值超过10000时,响应时间呈指数级增长。在500万数据的商品表中,第500页(offset 10000)查询耗时从第1页的5ms暴增到1200ms。
主流方案如PageHelper通过拦截器改写SQL。其核心流程:
但需要注意:
在社交feed流场景中,我们采用基于create_time的游标分页:
java复制public PageResult<Product> listProducts(Long lastId, int pageSize) {
List<Product> products = productMapper.selectAfterId(
lastId,
pageSize
);
Long nextLastId = products.isEmpty() ?
null : products.get(products.size()-1).getId();
return new PageResult(products, nextLastId);
}
配套的Mapper XML:
xml复制<select id="selectAfterId" resultMap="productMap">
SELECT * FROM products
WHERE id > #{lastId}
ORDER BY id ASC
LIMIT #{pageSize}
</select>
优势对比:
| 方案类型 | 第1页耗时 | 第100页耗时 | 内存消耗 |
|---|---|---|---|
| 传统LIMIT | 5ms | 450ms | 高 |
| 游标分页 | 3ms | 5ms | 低 |
对于复杂查询,我们创建专用索引:
sql复制ALTER TABLE orders
ADD INDEX idx_status_creator_time (status, creator_id, create_time);
查询改写为:
java复制public interface OrderMapper {
@Select("SELECT id FROM orders WHERE status=#{status} " +
"AND creator_id=#{userId} ORDER BY create_time DESC " +
"LIMIT #{offset}, #{pageSize}")
List<Long> listOrderIdsByPage(PageParam param);
@Select("<script>SELECT * FROM orders WHERE id IN " +
"<foreach item='id' collection='ids' open='(' separator=',' close=')'>" +
"#{id}</foreach></script>")
List<Order> batchGetOrders(@Param("ids") List<Long> ids);
}
这种"二次查询"方案虽然增加了一次数据库交互,但总体性能提升显著:
在订单-商品关联查询时,传统JOIN分页会导致:
我们的解决方案:
java复制// 先分页查询订单ID
Page<Long> orderIds = orderMapper.selectOrderIdsByPage(pageParam);
// 再批量查询关联数据
List<OrderDTO> orders = orderIds.stream()
.map(id -> {
Order order = orderMapper.selectById(id);
List<Product> products = productMapper.selectByOrderId(id);
return new OrderDTO(order, products);
})
.collect(Collectors.toList());
对于全文检索场景,ES分页有特殊限制:
Java实现示例:
java复制SearchRequest request = new SearchRequest("products");
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.matchQuery("name", "手机"));
sourceBuilder.size(20);
// 使用search_after参数
if (lastSortValues != null) {
sourceBuilder.searchAfter(lastSortValues);
}
request.source(sourceBuilder);
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 记录最后一条的sort值用于下一页
Object[] lastSort = response.getHits()
.getHits()[response.getHits().getHits().length - 1]
.getSortValues();
推荐统一响应结构:
java复制public class PageResult<T> {
private Integer pageNum;
private Integer pageSize;
private Integer totalPage;
private Long total;
private List<T> data;
private Object extra; // 扩展字段
public static <T> PageResult<T> success(PageInfo<T> pageInfo) {
PageResult<T> result = new PageResult<>();
result.setPageNum(pageInfo.getPageNum());
result.setPageSize(pageInfo.getPageSize());
result.setTotal(pageInfo.getTotal());
result.setTotalPage(pageInfo.getPages());
result.setData(pageInfo.getList());
return result;
}
}
Controller层统一处理:
java复制@GetMapping("/products")
public ResponseEntity<PageResult<Product>> listProducts(
@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "20") Integer pageSize) {
PageHelper.startPage(pageNum, pageSize);
List<Product> products = productService.listAll();
PageInfo<Product> pageInfo = new PageInfo<>(products);
return ResponseEntity.ok(PageResult.success(pageInfo));
}
我们通过APM工具发现的分页性能瓶颈TOP3:
优化检查清单:
在商品搜索服务中,通过以下优化将第100页查询从2100ms降到85ms: