分页查询

分页查询是后端接口的基础能力。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 生效,务必保证中间不插入其他查询。


四、统一分页响应格式

不同框架的分页对象(PageIPagePageInfo)字段不一致,建议在 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);

七、前端对接约定

参数类型说明默认值
pageint页码,从 1 开始1
sizeint每页条数,最大 20020
sortstring排序字段(白名单校验)createdAt
directionstringasc / descdesc
cursorlong游标分页时使用

相关链接