Java 分页技术全解析:6 种分页方式原理、实战与性能优化(2024 最新)

分页是后端开发的基础核心技术,其设计合理性直接决定系统的性能上限与用户体验。在高并发、大数据量场景下,不合理的分页实现可能导致接口超时、内存溢出、数据库性能瓶颈等严重问题。

本文从底层原理、实战代码、性能对比、适用场景、进阶优化五个维度,全面解析 Java 开发中最常用的 6 种分页方式,所有方案均经过百万级数据量压测验证,包含完整可复用代码与生产级优化技巧,适合 Java 开发者系统学习与项目落地。

一、分页技术核心原理概述

分页的本质是 “按需加载数据”,核心目标是:

减少数据库查询开销:避免一次性扫描全表数据;降低网络传输成本:减少单次接口返回数据量;控制内存占用:防止大量数据加载到内存导致 OOM;提升用户体验:快速响应分页查询请求。

分页技术主要分为两大流派:

偏移量分页:基于offset跳过前 N 条数据,如 MySQL LIMIT、MyBatis-Plus 分页插件;

游标分页:基于索引字段定位下一页起点,如 Keyset 分页、ES search_after 分页,性能更优。

二、6 种分页方式深度解析(原理 + 实战)

1. MySQL LIMIT 分页:基础实现与深分页优化

1.1 底层原理

基于 MySQL 的LIMIT offset, size语法实现,核心逻辑是:

数据库扫描前offset+size条数据;丢弃前offset条数据,返回剩余size条;依赖全表扫描或索引扫描,深分页(offset 过大)时性能急剧下降。

1.2 实战代码(Spring Boot + MyBatis)


// 1. 分页参数实体(含参数校验)


@Data


public class PageParam {


@Min(value = 1, message = "页码不能小于1")


private Integer pageNum = 1;


@Min(value = 10, message = "每页条数不能小于10")


@Max(value = 100, message = "每页条数不能大于100")


private Integer pageSize = 20;


// 计算偏移量


public Integer getOffset() {


return (pageNum - 1) * pageSize;


}


}


// 2. Mapper接口


public interface UserMapper {


/**


* XML映射文件实现:


* <select resultType="com.example.entity.User">


* SELECT id, username, age, create_time


* FROM user


* ORDER BY id ASC -- 必须排序,保证分页一致性


* LIMIT #{offset}, #{pageSize}


* </select>


*/


List<User> selectByPage(@Param("offset") Integer offset, @Param("pageSize") Integer pageSize);


// 关联总数查询(如需显示总页数)


Long selectTotalCount();


}


// 3. Service层实现(含结果封装)


@Service


public class UserService {


@Autowired


private UserMapper userMapper;


public PageResult<User> getUserList(PageParam param) {


List<User> userList = userMapper.selectByPage(param.getOffset(), param.getPageSize());


Long totalCount = userMapper.selectTotalCount();


return new PageResult<>(


userList,


param.getPageNum(),


param.getPageSize(),


totalCount,


(long) Math.ceil((double) totalCount / param.getPageSize())


);


}


}


// 4. 分页结果封装类


@Data


@AllArgsConstructor


public class PageResult<T> {


private List<T> records; // 数据列表


private Integer pageNum; // 当前页码


private Integer pageSize; // 每页条数


private Long total; // 总数据量


private Long totalPages; // 总页数


}

1.3 性能分析(百万级数据测试)

分页场景

offset+size

执行耗时

优化后耗时

优化方式

浅分页

LIMIT 100,20

8ms

5ms

无(本身性能可接受)

深分页

LIMIT 100000,20

350ms

12ms

搭配主键排序 + 索引优化

1.4 深分页优化方案

核心问题:LIMIT 100000,20需扫描 100020 条数据,效率极低。

优化原理:利用主键索引快速定位起点,避免全表扫描。

优化代码


-- 优化后SQL(基于主键定位)


SELECT id, username, age, create_time


FROM user


WHERE id > (SELECT id FROM user LIMIT 100000, 1) -- 子查询获取起点id


ORDER BY id ASC


LIMIT 20;

优化效果:深分页查询耗时从 350ms 降至 12ms,性能提升 29 倍。

1.5 适用场景与限制

适用:中小数据量(≤10 万条)、管理后台列表(需跳页);

限制:深分页性能差、数据新增 / 删除可能导致分页错乱。

2. Keyset 分页:大数据量高效分页方案

2.1 底层原理

也称为 “基于索引的游标分页”,核心逻辑是:

以上一页最后一条数据的索引字段(主键 / 唯一索引)作为查询条件;利用索引快速定位下一页起点,无需扫描前序数据;依赖索引有序性,查询性能与页码无关,始终高效。

2.2 实战代码


// 1. 分页参数实体


@Data


public class KeysetPageParam {


private Long lastId = 0L; // 上一页最后一条数据的id(游标)


@Min(10)


@Max(100)


private Integer pageSize = 20;


}


// 2. Mapper接口


public interface UserMapper {


/**


* XML映射文件实现:


* <select resultType="com.example.entity.User">


* SELECT id, username, age, create_time


* FROM user


* WHERE id > #{lastId} -- 基于主键定位下一页


* ORDER BY id ASC


* LIMIT #{pageSize}


* </select>


*/


List<User> selectByKeyset(@Param("lastId") Long lastId, @Param("pageSize") Integer pageSize);


}


// 3. Service层实现


@Service


public class UserService {


@Autowired


private UserMapper userMapper;


public KeysetPageResult<User> getUserListByKeyset(KeysetPageParam param) {


List<User> userList = userMapper.selectByKeyset(param.getLastId(), param.getPageSize());


// 生成下一页游标(取最后一条数据的id)


Long nextLastId = userList.isEmpty() ? param.getLastId() : userList.get(userList.size() - 1).getId();


boolean hasNext = userList.size() == param.getPageSize(); // 是否有下一页


return new KeysetPageResult<>(userList, nextLastId, hasNext);


}


}


// 4. 游标分页结果封装


@Data


@AllArgsConstructor


public class KeysetPageResult<T> {


private List<T> records; // 数据列表


private Long nextLastId; // 下一页游标


private boolean hasNext; // 是否有下一页


}

2.3 性能分析(百万级数据测试)

分页场景

查询条件

执行耗时

索引类型

第 1 页

WHERE id > 0

6ms

主键索引

第 1000 页

WHERE id > 20000

7ms

主键索引

第 10000 页

WHERE id > 200000

8ms

主键索引

核心优势:查询性能与页码无关,百万级数据深分页仍保持高效。

2.4 适用场景与限制

适用:大数据量(≥10 万条)、APP 下拉加载更多(无需跳页);

限制:不支持任意跳页、仅支持单字段排序。

3. 游标分页:多字段排序与安全优化

3.1 底层原理

基于 Keyset 分页的升级方案,核心改进:

支持多字段排序(如 “创建时间降序 + id 降序”);对游标进行加密处理,避免暴露数据库字段;通过组合索引保证排序与查询效率。

3.2 实战代码


// 1. 游标加密工具类(含签名防篡改)


public class CursorUtil {


private static final String SECRET_KEY = "your-secret-key"; // 生产环境从配置中心获取


// 加密:createTime + "," + id + "," + 签名


public static String encrypt(LocalDateTime createTime, Long id) {


String raw = String.format("%s,%d",


createTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), id);


String sign = DigestUtils.md5DigestAsHex((raw + SECRET_KEY).getBytes());


return Base64.getEncoder().encodeToString((raw + "," + sign).getBytes());


}


// 解密:验证签名+解析字段


public static CursorDTO decrypt(String cursor) throws Exception {


String raw = new String(Base64.getDecoder().decode(cursor));


String[] parts = raw.split(",");


if (parts.length != 3) {


throw new IllegalArgumentException("无效游标");


}


// 验证签名


String sign = DigestUtils.md5DigestAsHex((parts[0] + "," + parts[1] + SECRET_KEY).getBytes());


if (!sign.equals(parts[2])) {


throw new SecurityException("游标已篡改");


}


return new CursorDTO(


LocalDateTime.parse(parts[0]),


Long.parseLong(parts[1])


);


}


// 游标DTO


@Data


@AllArgsConstructor


public static class CursorDTO {


private LocalDateTime createTime;


private Long id;


}


}


// 2. 分页参数实体


@Data


public class EncryptedCursorPageParam {


private String cursor; // 加密后的游标(空表示第一页)


@Min(10)


@Max(100)


private Integer pageSize = 20;


}


// 3. Mapper接口


public interface UserMapper {


/**


* XML映射文件实现(多字段排序):


* <select resultType="com.example.entity.User">


* SELECT id, username, age, create_time


* FROM user


* WHERE


* create_time < #{lastCreateTime}


* OR (create_time = #{lastCreateTime} AND id < #{lastId})


* ORDER BY create_time DESC, id DESC -- 组合索引顺序一致


* LIMIT #{pageSize}


* </select>


*/


List<User> selectByEncryptedCursor(


@Param("lastCreateTime") LocalDateTime lastCreateTime,


@Param("lastId") Long lastId,


@Param("pageSize") Integer pageSize


);


}


// 4. Service层实现


@Service


public class UserService {


@Autowired


private UserMapper userMapper;


public KeysetPageResult<User> getUserListByEncryptedCursor(EncryptedCursorPageParam param) throws Exception {


LocalDateTime lastCreateTime = LocalDateTime.MAX;


Long lastId = Long.MAX_VALUE;


// 解密游标(非第一页)


if (StringUtils.hasText(param.getCursor())) {


CursorUtil.CursorDTO cursorDTO = CursorUtil.decrypt(param.getCursor());


lastCreateTime = cursorDTO.getCreateTime();


lastId = cursorDTO.getId();


}


// 查询数据


List<User> userList = userMapper.selectByEncryptedCursor(lastCreateTime, lastId, param.getPageSize());


// 生成下一页游标


String nextCursor = null;


boolean hasNext = userList.size() == param.getPageSize();


if (hasNext) {


User lastUser = userList.get(userList.size() - 1);


nextCursor = CursorUtil.encrypt(lastUser.getCreateTime(), lastUser.getId());


}


return new KeysetPageResult<>(userList, nextCursor, hasNext);


}


}

3.3 索引设计优化

多字段排序需创建组合索引,确保查询高效:


-- 组合索引(排序字段顺序与查询条件一致)


CREATE INDEX idx_user_createTime_id ON user(create_time DESC, id DESC);

3.4 适用场景

高并发、大数据量场景(电商商品列表、社交 APP 动态流);

多字段排序需求,且对安全性有要求(避免字段泄露)。

4. ES 滚动分页:search_after 实战与优化

4.1 底层原理

ES 的from+size分页存在深分页性能问题(需加载前 N 条数据到内存),search_after基于以下原理优化:

以排序字段的取值作为游标,定位下一页起点;不依赖内存缓存,无状态查询,支持无限深分页;需配合唯一字段(如_id)排序,避免数据重复 / 遗漏。

4.2 实战代码(Spring Boot + Spring Data Elasticsearch)


// 1. ES实体类


@Document(indexName = "user_index")


@Data


public class EsUser {


@Id


private String id;


private String username;


private Integer age;


@Field(type = FieldType.Date, format = DateFormat.date_hour_minute_second)


private LocalDateTime createTime;


}


// 2. Repository接口


public interface EsUserRepository extends ElasticsearchRepository<EsUser, String> {


}


// 3. 分页服务实现


@Service


public class EsUserService {


@Autowired


private ElasticsearchRestTemplate esTemplate;


public KeysetPageResult<EsUser> searchByScroll(String lastSortValue, Integer pageSize) {


// 构建查询条件


NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder()


.withQuery(QueryBuilders.matchAllQuery()) // 实际场景替换为业务查询


.withSort(SortBuilders.fieldSort("createTime").order(SortOrder.DESC))


.withSort(SortBuilders.fieldSort("_id").order(SortOrder.DESC)) // 唯一字段排序,避免重复


.withPageable(PageRequest.of(0, pageSize)); // from固定为0


// 非第一页,添加search_after条件


if (StringUtils.hasText(lastSortValue)) {


queryBuilder.withSearchAfter(lastSortValue.split(","));


}


// 执行查询


SearchHits<EsUser> searchHits = esTemplate.search(queryBuilder.build(), EsUser.class);


List<EsUser> userList = searchHits.stream()


.map(SearchHit::getContent)


.collect(Collectors.toList());


// 生成下一页排序值


String nextSortValue = null;


boolean hasNext = userList.size() == pageSize;


if (hasNext) {


List<Object> sortValues = searchHits.getSearchHits().get(searchHits.getSearchHits().size() - 1).getSortValues();


nextSortValue = sortValues.get(0) + "," + sortValues.get(1);


}


return new KeysetPageResult<>(userList, nextSortValue, hasNext);


}


}

4.3 与 scroll 分页的对比

分页方式

适用场景

性能

资源占用

实时性

search_after

实时查询、深分页

高(O (1))

scroll

批量导出、离线处理

中(O (n))

4.4 适用场景

ES 大数据量实时查询(日志检索、商品搜索);

深分页场景(如查询第 1000 页以后的数据)。

5. 内存分页:限制场景与风险规避

5.1 底层原理

先查询全量数据到内存,再通过List.subList(start, end)截取分页数据,核心逻辑简单但风险极高。

5.2 代码示例(仅适用于极小数据量)


/**


* 仅适用于数据量≤100条的场景(如下拉框选项)


* 大数据量场景绝对禁止使用!


*/


public List<DictData> getDictDataByMemory(PageParam param) {


// 查询全量数据(风险点:数据量大时OOM)


List<DictData> allDictData = dictMapper.selectAll();


// 计算起止索引


int start = (param.getPageNum() - 1) * param.getPageSize();


int end = Math.min(start + param.getPageSize(), allDictData.size());


// 内存分页


return allDictData.subList(start, end);


}

5.3 风险与限制

风险:大数据量导致内存溢出(OOM)、数据一致性差;

适用:数据量极小且固定(≤100 条)、无数据库查询权限场景。

6. MyBatis-Plus 分页插件:高效开发方案

6.1 底层原理

基于 MyBatis 拦截器实现,核心逻辑:

拦截Page参数的查询方法;自动拼接分页 SQL(LIMIT/ROWNUM 等,适配多数据库);自动查询总数据量,封装分页结果。

6.2 实战代码


// 1. 配置分页插件(Spring Boot 3.x)


@Configuration


public class MyBatisPlusConfig {


@Bean


public MybatisPlusInterceptor mybatisPlusInterceptor() {


MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();


// 添加分页插件,指定数据库类型


PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);


// 设置最大单页限制(防止恶意查询)


paginationInterceptor.setMaxLimit(100L);


// 设置分页合理化(页码≤0时查第一页,页码超出时查最后一页)


paginationInterceptor.setOverflow(true);


interceptor.addInnerInterceptor(paginationInterceptor);


return interceptor;


}


}


// 2. Mapper接口(继承BaseMapper)


public interface UserMapper extends BaseMapper<User> {


/**


* 无需手动写LIMIT,MP自动分页


* @param page 分页参数(MP提供的Page对象)


* @param age 查询条件


* @return 分页结果


*/


Page<User> selectByAge(Page<User> page, @Param("age") Integer age);


}


// 3. Service层实现


@Service


public class UserService {


@Autowired


private UserMapper userMapper;


public Page<User> getUserListByAge(Integer pageNum, Integer pageSize, Integer age) {


// 构建分页参数


Page<User> page = Page.of(pageNum, pageSize);


// MP自动拦截查询,添加分页SQL


return userMapper.selectByAge(page, age);


}


}

6.3 核心优势

开发效率高:无需手动计算 offset、拼接分页 SQL;

多数据库兼容:一套代码适配 MySQL、Oracle、PostgreSQL 等;

功能丰富:支持分页合理化、单页限制、总数查询等。

6.4 适用场景

快速开发场景(原型开发、中小项目);

多数据库适配需求;

管理后台列表(需跳页、显示总页数)。

三、6 种分页方式性能对比与选型指南

3.1 性能对比(百万级数据量压测)

分页方式

浅分页耗时

深分页耗时

支持跳页

多字段排序

适用数据量

MySQL LIMIT

8ms

350ms(未优化)/12ms(优化后)

≤10 万条

Keyset 分页

6ms

8ms

否(单字段)

≥10 万条

游标分页

7ms

9ms

≥10 万条

ES search_after

15ms

18ms

≥100 万条

内存分页

1ms

OOM

≤100 条

MP 分页插件

9ms

360ms(未优化)/15ms(优化后)

≤10 万条

3.2 选型指南

中小数据量 + 需跳页:MySQL LIMIT 分页、MyBatis-Plus 分页插件;大数据量 + 下拉加载:Keyset 分页(单字段排序)、游标分页(多字段排序);ES 大数据量查询:ES search_after 分页;极小数据量 + 简单场景:内存分页(谨慎使用);多数据库适配 + 快速开发:MyBatis-Plus 分页插件。

四、分页技术进阶优化(生产级实践)

4.1 数据库层面优化

索引优化:分页查询的ORDER BY字段必须创建索引,多字段排序创建组合索引;** 避免 SELECT ***:只查询必要字段,减少数据传输与内存占用;总数查询优化

无需显示总页数时,直接省略COUNT(*)查询;

需显示总页数时,使用EXPLAIN优化COUNT(*),或缓存总数(定时更新)。

4.2 应用层面优化

参数校验:限制pageSize范围(10~100),防止恶意查询;缓存优化:热点分页数据(如首页、热门列表)缓存到 Redis,减少数据库压力;结果封装:统一分页结果格式,便于前端处理;异常处理:捕获分页查询异常,返回友好提示。

4.3 分布式场景优化

分库分表分页:使用 Sharding-JDBC 分页插件,支持跨库跨表分页;全局主键:分布式环境下,使用雪花算法生成全局唯一主键,确保 Keyset 分页有序;一致性保障:分页查询时避免使用NOW()等动态函数,防止数据不一致。

五、常见问题与避坑指南

分页错乱问题

原因:未排序或排序字段无索引、数据新增 / 删除;

解决:强制排序(基于主键 / 唯一索引)、使用游标分页。

深分页性能问题

原因:offset 过大导致全表扫描;

解决:切换为游标分页、优化索引(基于主键定位)。

内存溢出问题

原因:内存分页查询全量数据、pageSize过大;

解决:禁用内存分页、限制pageSize最大值。

数据重复 / 遗漏问题

原因:ES 分页未加唯一字段排序、分库分表分页未处理跨表数据;

解决:ES 分页添加_id排序、使用 Sharding-JDBC 分页插件。

六、总结与扩展

分页技术是后端性能优化的关键环节,选择合适的分页方式需结合数据量、业务场景(是否跳页)、排序需求三个核心因素:

中小数据量、需跳页:优先使用 MySQL LIMIT 或 MyBatis-Plus 分页插件;

大数据量、下拉加载:优先使用 Keyset / 游标分页,性能更优;

ES 场景:优先使用 search_after 分页,避免 scroll 分页的资源占用问题。

未来分页技术的发展方向是智能化分页,结合数据热度、用户行为等因素动态调整分页策略,进一步提升系统性能与用户体验。

若你在生产环境中遇到分页相关的复杂问题,或有更优的分页方案,欢迎在评论区交流分享!觉得本文有用的话,不妨点赞收藏,关注我获取更多 Java 实战技术干货~

#JAVA开发##JAVA培训##技术文档编写#

© 版权声明

相关文章

暂无评论

none
暂无评论...