分页是后端开发的基础核心技术,其设计合理性直接决定系统的性能上限与用户体验。在高并发、大数据量场景下,不合理的分页实现可能导致接口超时、内存溢出、数据库性能瓶颈等严重问题。
本文从底层原理、实战代码、性能对比、适用场景、进阶优化五个维度,全面解析 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培训##技术文档编写#


