分页查询
分页查询是后端接口的基础能力。Spring Boot 生态中主要有三种实现路径:Spring Data JPA(Pageable)、MyBatis-Plus 分页插件、MyBatis + PageHelper。本文统一介绍三者用法及通用实践。
一、Spring Data JPA 分页
Repository 层
public interface OrderRepository extends JpaRepository<Order, Long> {
// 直接在方法签名中接受 Pageable,Spring Data 自动生成分页 SQL
Page<Order> findByUserId(Long userId, Pageable pageable);
// 搭配 @Query
@Query("SELECT o FROM Order o WHERE o.status = :status AND o.userId = :userId")
Page<Order> findByUserIdAndStatus(@Param("userId") Long userId,
@Param("status") OrderStatus status,
Pageable pageable);
// 只需总数时用 Slice(不查 COUNT,性能更好)
Slice<Order> findByUserIdAndStatus(Long userId, OrderStatus status, Pageable pageable);
}Service 层
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepo;
public Page<OrderDto> listOrders(Long userId, OrderStatus status,
int page, int size, String sortField) {
Pageable pageable = PageRequest.of(
page,
size,
Sort.by(Sort.Direction.DESC, sortField) // 多字段排序:Sort.by(...).and(...)
);
return orderRepo.findByUserIdAndStatus(userId, status, pageable)
.map(OrderDto::from); // Page.map() 转换元素类型,保留分页元数据
}
}动态排序防注入
Sort 接受任意字符串,需对 sortField 做白名单校验:
private static final Set<String> ALLOWED_SORT_FIELDS =
Set.of("createdAt", "totalAmount", "status");
public Page<OrderDto> listOrders(Long userId, String sortField, String direction, int page, int size) {
if (!ALLOWED_SORT_FIELDS.contains(sortField)) {
sortField = "createdAt"; // 非法字段回退默认值
}
Sort.Direction dir = Sort.Direction.fromOptionalString(direction)
.orElse(Sort.Direction.DESC);
Pageable pageable = PageRequest.of(page, size, Sort.by(dir, sortField));
return orderRepo.findByUserId(userId, pageable).map(OrderDto::from);
}COUNT 查询优化
JPA 默认使用原查询的 FROM + WHERE 生成 COUNT SQL,复杂 JOIN 时可单独指定:
@Query(value = "SELECT o FROM Order o LEFT JOIN FETCH o.items WHERE o.userId = :userId",
countQuery = "SELECT COUNT(o) FROM Order o WHERE o.userId = :userId")
Page<Order> findWithItemsByUserId(@Param("userId") Long userId, Pageable pageable);二、MyBatis-Plus 分页
插件注册
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRESQL));
return interceptor;
}
}Mapper + Service
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
// 自定义 JOIN 查询也支持分页:第一个参数必须是 IPage
IPage<OrderVO> selectOrdersWithUser(IPage<OrderVO> page,
@Param("userId") Long userId,
@Param("status") String status);
}<!-- OrderMapper.xml -->
<select id="selectOrdersWithUser" resultType="OrderVO">
SELECT o.*, u.name AS user_name
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
<where>
<if test="userId != null">AND o.user_id = #{userId}</if>
<if test="status != null">AND o.status = #{status}</if>
</where>
ORDER BY o.created_at DESC
</select>@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderMapper orderMapper;
public IPage<OrderVO> pageOrders(Long userId, String status, int pageNum, int pageSize) {
Page<OrderVO> page = new Page<>(pageNum, pageSize);
return orderMapper.selectOrdersWithUser(page, userId, status);
}
// 简单单表分页:直接用 LambdaQueryWrapper
public IPage<Order> pageSimple(int pageNum, int pageSize) {
return orderMapper.selectPage(
new Page<>(pageNum, pageSize),
Wrappers.<Order>lambdaQuery()
.eq(Order::getStatus, "PENDING")
.orderByDesc(Order::getCreatedAt)
);
}
}三、MyBatis + PageHelper
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>2.1.0</version>
</dependency>pagehelper:
helper-dialect: postgresql
reasonable: true # 页码超出范围时自动修正为首/末页
support-methods-arguments: true
params: count=countSql@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderMapper orderMapper;
public PageInfo<Order> pageOrders(Long userId, int pageNum, int pageSize) {
// PageHelper 使用 ThreadLocal,必须紧接在 Mapper 调用前,中间不能有其他 Mapper 调用
PageHelper.startPage(pageNum, pageSize, "created_at DESC");
List<Order> list = orderMapper.findByUserId(userId);
return PageInfo.of(list); // 包含 total、pages、pageNum、pageSize 等
}
}
PageHelper.startPage对紧随其后的第一条 SQL 生效,务必保证中间不插入其他查询。
四、统一分页响应格式
不同框架的分页对象(Page、IPage、PageInfo)字段不一致,建议在 Controller 层统一转换:
// 统一分页响应体
@Getter
@AllArgsConstructor
public class PageResult<T> {
private final List<T> items;
private final long total; // 总记录数
private final int page; // 当前页(从 1 开始)
private final int size; // 每页条数
private final int totalPages;
/** 从 Spring Data Page 转换 */
public static <T> PageResult<T> of(Page<T> page) {
return new PageResult<>(
page.getContent(),
page.getTotalElements(),
page.getNumber() + 1, // Spring Data 从 0 开始,统一改为 1
page.getSize(),
page.getTotalPages()
);
}
/** 从 MyBatis-Plus IPage 转换 */
public static <T> PageResult<T> of(IPage<T> page) {
return new PageResult<>(
page.getRecords(),
page.getTotal(),
(int) page.getCurrent(),
(int) page.getSize(),
(int) page.getPages()
);
}
/** 从 PageHelper PageInfo 转换 */
public static <T> PageResult<T> of(PageInfo<T> info) {
return new PageResult<>(
info.getList(),
info.getTotal(),
info.getPageNum(),
info.getPageSize(),
info.getPages()
);
}
}@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@GetMapping
public PageResult<OrderDto> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) Long userId,
@RequestParam(required = false) String status) {
// 入参校验:防止 size 过大
size = Math.min(size, 200);
return PageResult.of(orderService.pageOrders(userId, status, page, size));
}
}响应示例:
{
"items": [...],
"total": 1024,
"page": 3,
"size": 20,
"totalPages": 52
}五、游标分页(Cursor-based Pagination)
传统 OFFSET 分页在数据量大时性能下降(OFFSET 10000 仍需扫描前 10000 行)。游标分页通过记录上一页最后一条记录的唯一标识来定位,复杂度恒为 O(log N):
// 游标分页接口
@GetMapping("/cursor")
public CursorResult<OrderDto> listByCursor(
@RequestParam(required = false) Long cursor, // 上一页最后一条 ID
@RequestParam(defaultValue = "20") int size) {
List<Order> orders = orderRepo.findByCursor(cursor, size + 1); // 多取 1 条判断是否有下一页
boolean hasNext = orders.size() > size;
List<OrderDto> items = orders.stream()
.limit(size)
.map(OrderDto::from)
.toList();
Long nextCursor = hasNext ? items.getLast().getId() : null;
return new CursorResult<>(items, nextCursor, hasNext);
}// Repository(JPA)
@Query("SELECT o FROM Order o WHERE (:cursor IS NULL OR o.id < :cursor) " +
"ORDER BY o.id DESC")
List<Order> findByCursor(@Param("cursor") Long cursor, Pageable pageable);
// 调用时传 Pageable(只要 size,不要 page)
public List<Order> findByCursor(Long cursor, int size) {
return orderRepo.findByCursor(cursor, PageRequest.of(0, size));
}@Getter
@AllArgsConstructor
public class CursorResult<T> {
private final List<T> items;
private final Long nextCursor; // null 表示没有下一页
private final boolean hasNext;
}| 对比维度 | OFFSET 分页 | 游标分页 |
|---|---|---|
| 实现复杂度 | 低 | 中 |
| 深翻页性能 | 差(全表扫描) | 好(索引定位) |
| 跳页支持 | 支持 | 不支持 |
| 数据一致性 | 插入/删除后页码漂移 | 稳定 |
| 适用场景 | 后台管理、数据量 < 10 万 | 信息流、移动端无限滚动 |
六、性能优化要点
禁止大 OFFSET
-- 慢:扫描并丢弃前 50000 行
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 50000;
-- 快:游标定位(走索引)
SELECT * FROM orders WHERE id < 50001 ORDER BY id DESC LIMIT 20;避免 COUNT(*) 全表扫描
// 对于用户翻页,可以缓存 total,避免每次都 COUNT
@Cacheable(value = "order-count", key = "#userId")
public long countByUser(Long userId) {
return orderRepo.countByUserId(userId);
}只查需要的字段
// 使用投影接口,避免 SELECT *
public interface OrderSummary {
Long getId();
String getOrderNo();
BigDecimal getTotalAmount();
String getStatus();
}
Page<OrderSummary> findProjectedByUserId(Long userId, Pageable pageable);索引覆盖
分页排序字段必须建索引,否则每次都全表排序:
-- 按 created_at DESC 分页的必备索引
CREATE INDEX idx_orders_user_created ON orders(user_id, created_at DESC);七、前端对接约定
| 参数 | 类型 | 说明 | 默认值 |
|---|---|---|---|
page | int | 页码,从 1 开始 | 1 |
size | int | 每页条数,最大 200 | 20 |
sort | string | 排序字段(白名单校验) | createdAt |
direction | string | asc / desc | desc |
cursor | long | 游标分页时使用 | — |
相关链接
- JPA与Hibernate — Spring Data JPA
Pageable与Page - MyBatis-Plus — MP 分页插件与
IPage - MyBatis — PageHelper 分页插件
- 统一响应格式 — 统一包装分页响应体
- 参数校验 — 分页参数校验(
@Min、@Max) - 缓存 — 缓存 COUNT 查询结果
- 性能调优 — 慢查询分析与索引优化
- 数据访问 — Spring Data 整体架构