一、基础概念
数据库分页
概念定义
数据库分页是指将查询结果集分成多个小块(页),每次只返回其中一部分数据的技术。通常用于前端展示数据时,避免一次性加载大量数据导致性能问题。
核心要素
- 页码(Page Number):当前显示的页数
- 每页大小(Page Size):每页包含的记录数
- 总记录数(Total Count):满足条件的全部记录数量
- 总页数(Total Pages):总记录数/每页大小(向上取整)
实现方式
1. LIMIT-OFFSET(MySQL/PostgreSQL)
SELECT * FROM table_name
LIMIT pageSize OFFSET (pageNumber-1)*pageSize;
2. ROWNUM(Oracle)
SELECT * FROM (
SELECT a.*, ROWNUM rn FROM (
SELECT * FROM table_name ORDER BY column_name
) a WHERE ROWNUM <= pageNumber*pageSize
) WHERE rn > (pageNumber-1)*pageSize;
3. ROW_NUMBER()(SQL Server)
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER(ORDER BY column_name) AS row_num
FROM table_name
) AS temp WHERE row_num BETWEEN startIndex AND endIndex;
性能优化
- 避免大偏移量:OFFSET值越大性能越差
- 使用索引列排序:确保ORDER BY使用索引列
- 游标分页:记录最后一条记录的ID,下次查询时使用WHERE id > last_id
常见误区
- 未考虑排序一致性(分页数据可能因数据变化而重复/遗漏)
- 忽略总记录数查询的性能影响(大数据量表COUNT(*)可能很慢)
- 未处理空页情况(请求页码超出范围时应返回空集)
Java实现示例
// Spring Data JPA分页
Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by("id"));
Page<User> page = userRepository.findAll(pageable);
// MyBatis分页
<select id="selectByPage" resultType="User">
SELECT * FROM users LIMIT #{offset}, #{pageSize}
</select>
分页查询的必要性
数据量过大时的性能问题
当数据库表中数据量达到百万甚至千万级别时,一次性查询所有数据会导致:
- 内存溢出风险(OOM)
- 网络传输压力剧增
- 查询响应时间显著延长
用户体验优化
- 视觉友好性:人类认知能力限制,单页展示20-50条数据最符合阅读习惯
- 交互效率:避免长页面滚动,快速定位目标数据
- 系统资源节约:移动端设备尤其需要减少流量消耗
典型应用场景
- 电商平台商品列表(如淘宝搜索页)
- 社交平台动态流(如微信朋友圈)
- 后台管理系统数据报表
- 日志查询系统
技术实现价值
- 数据库层面:通过LIMIT/OFFSET或游标减少结果集处理量
- 缓存优化:分页结果更适合做缓存预热
- 并发控制:降低锁竞争概率,提高系统吞吐量
注意事项
- 深度分页性能问题(如查询第1000页)
- 数据一致性挑战(分页期间数据变更)
- 不同数据库分页语法差异(MySQL/Oracle/SQL Server)
常见分页场景分析
电商平台商品列表
- 场景特点:数据量大(百万级SKU),需要支持多维度排序(销量/价格/评分)
- 技术要点:
- 使用
LIMIT offset, size实现基础分页 - 深度分页时采用
WHERE id > last_id LIMIT size优化 - 示例代码:
-- 传统分页(页数较小时) SELECT * FROM products ORDER BY sales DESC LIMIT 0, 20; -- 深度分页优化 SELECT * FROM products WHERE id > 10000 ORDER BY id LIMIT 20;
- 使用
后台管理系统
- 场景特点:需要精确总数统计,常伴随复杂查询条件
- 技术要点:
- 必须执行
COUNT(*)查询获取总记录数 - 建议使用缓存减轻COUNT查询压力
- 示例代码:
// MyBatis示例 @Select("SELECT COUNT(*) FROM users WHERE status = #{status}") long countUsers(@Param("status") int status); @Select("SELECT * FROM users WHERE status = #{status} LIMIT #{offset}, #{pageSize}") List<User> getUsersByPage(@Param("status") int status, @Param("offset") int offset, @Param("pageSize") int pageSize);
- 必须执行
移动端瀑布流
- 场景特点:无明确页码概念,持续下拉加载
- 技术要点:
- 使用游标分页(cursor-based pagination)
- 每次请求返回下一页的游标标识
- 示例响应结构:
{ "items": [...], "next_cursor": "a1b2c3d4", "has_more": true }
实时数据监控
- 场景特点:数据持续增长,需要获取最新数据
- 技术要点:
- 采用时间戳分页(timestamp pagination)
- 按时间倒序分页,避免遗漏新数据
- 示例查询:
SELECT * FROM sensor_data WHERE create_time < '2023-08-20 15:00:00' ORDER BY create_time DESC LIMIT 100;
注意事项
- 深度分页(offset>10000)必须优化,避免全表扫描
- 排序字段建议使用有索引的列
- 分页大小应根据业务场景合理设置(通常10-100条)
二、SQL原生分页实现
MySQL的LIMIT分页
概念定义
LIMIT是MySQL中用于限制查询结果集的子句,常用于分页查询。基本语法为:
SELECT * FROM table_name LIMIT offset, row_count;
offset:起始行偏移量(从0开始计数)row_count:返回的最大记录数
使用场景
- 网页分页显示(如每页显示10条记录)
- 大数据集分批处理
- 限制查询结果数量提高性能
标准分页实现
-- 第一页(前10条)
SELECT * FROM users LIMIT 0, 10;
-- 第二页
SELECT * FROM users LIMIT 10, 10;
-- 通用分页公式(pageNum页码,pageSize页大小)
SELECT * FROM users LIMIT (pageNum-1)*pageSize, pageSize;
性能优化
- 避免大偏移量:当offset很大时性能急剧下降
-- 低效写法(偏移量10万)
SELECT * FROM large_table LIMIT 100000, 20;
-- 优化方案:使用索引覆盖+子查询
SELECT * FROM large_table WHERE id >=
(SELECT id FROM large_table ORDER BY id LIMIT 100000, 1)
LIMIT 20;
- 配合ORDER BY使用
-- 必须确保排序一致
SELECT * FROM products ORDER BY create_time DESC LIMIT 0, 10;
常见误区
- 页码计算错误:忘记offset从0开始
- 缺少ORDER BY:导致分页结果不一致
- 大偏移量问题:直接使用LIMIT 100000,20性能极差
特殊语法变体
-- 只限制数量(从第1条开始)
SELECT * FROM users LIMIT 10;
-- MySQL 8.0+的替代语法
SELECT * FROM users LIMIT 10 OFFSET 20;
与其他数据库区别
- Oracle使用ROWNUM
- SQL Server使用TOP/FETCH-OFFSET
- PostgreSQL语法与MySQL相似但性能优化方式不同
Oracle的ROWNUM分页
概念定义
ROWNUM是Oracle数据库中的一个伪列(pseudo-column),它会在查询结果返回时为每一行分配一个从1开始的序号。ROWNUM是在数据被检索出来之后、排序之前分配的,因此直接使用ROWNUM进行分页时需要注意排序问题。
基本分页实现
Oracle中常用的ROWNUM分页模式是"三层查询":
- 最内层查询获取排序后的完整数据
- 中间层查询应用ROWNUM
- 最外层查询筛选指定范围的行
SELECT * FROM (
SELECT a.*, ROWNUM rn FROM (
SELECT * FROM employees ORDER BY hire_date DESC
) a WHERE ROWNUM <= 20
) WHERE rn > 10;
使用场景
- 需要兼容老版本Oracle(12c以下)
- 简单分页需求
- 需要精确控制每页显示的数据
注意事项
- 排序问题:ROWNUM是在排序前分配的,所以必须先在子查询中完成排序
- 性能问题:大数据量表分页时,越往后翻页性能越低
- 12c及以上版本:建议使用更简单的
OFFSET-FETCH语法 - ROWNUM别名:中间层查询必须给ROWNUM设置别名供外层使用
性能优化
对于大数据量分页,可以考虑以下优化:
- 使用索引列排序
- 添加WHERE条件缩小数据集
- 使用物化视图预先排序
12c+的替代方案
Oracle 12c开始支持标准SQL分页语法:
SELECT * FROM employees
ORDER BY hire_date DESC
OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY;
常见错误
- 直接在ORDER BY前使用ROWNUM过滤:
-- 错误示例:会先限制20条再排序 SELECT * FROM employees WHERE ROWNUM <= 20 ORDER BY hire_date DESC; - 忘记给ROWNUM设置别名导致外层无法引用
TOP 分页
概念定义
TOP 是 SQL Server 中的关键字,用于限制查询结果返回的行数。在分页场景中,通常结合 ORDER BY 和子查询实现。
使用场景
- 简单分页需求
- 数据量较小的表
- SQL Server 2008 及更早版本(无 OFFSET-FETCH)
实现方式
-- 第一页(每页10条)
SELECT TOP 10 * FROM Products ORDER BY ProductID;
-- 第二页(使用子查询跳过前10条)
SELECT TOP 10 * FROM Products
WHERE ProductID NOT IN (
SELECT TOP 10 ProductID FROM Products ORDER BY ProductID
)
ORDER BY ProductID;
注意事项
- 性能随页码增加而下降(NOT IN 效率低)
- 需要确保排序字段唯一性
- 不适合大数据量分页
OFFSET-FETCH 分页
概念定义
SQL Server 2012 引入的新语法,通过 OFFSET 跳过指定行数,FETCH NEXT 获取后续行数,是 ANSI SQL 标准的一部分。
使用场景
- SQL Server 2012 及以上版本
- 大数据量分页
- 需要高性能分页
实现方式
-- 第一页(每页10条)
SELECT * FROM Products
ORDER BY ProductID
OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY;
-- 第二页
SELECT * FROM Products
ORDER BY ProductID
OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY;
优势
- 语法简洁直观
- 性能优于 TOP 分页(特别是深层分页)
- 支持参数化查询
注意事项
- 必须与 ORDER BY 子句一起使用
- OFFSET 值较大时仍需优化(建议配合索引)
- 总页数需要额外查询 COUNT(*)
性能对比
小数据量
- TOP 和 OFFSET-FETCH 性能相近
大数据量深层分页
- OFFSET-FETCH 明显优于 TOP
- 示例:获取第1000页(每页10条)时:
- TOP 需要先排除前9990条
- OFFSET 直接跳过9990条
最佳实践建议
- 始终为分页查询创建适当的索引
- 对于 SQL Server 2012+,优先使用 OFFSET-FETCH
- 考虑使用
WITH(NOLOCK)提示减少锁争用 - 实现时建议封装为存储过程:
CREATE PROCEDURE GetPagedProducts
@PageNumber INT,
@PageSize INT
AS
BEGIN
SELECT * FROM Products
ORDER BY ProductID
OFFSET (@PageNumber - 1) * @PageSize ROWS
FETCH NEXT @PageSize ROWS ONLY;
END
PostgreSQL的LIMIT-OFFSET分页
概念定义
LIMIT-OFFSET是PostgreSQL实现分页查询的标准语法:
LIMIT:指定返回的记录数量OFFSET:指定跳过的记录数量
典型语法结构:
SELECT * FROM table_name LIMIT page_size OFFSET (page_num - 1) * page_size;
使用场景
- 前端表格数据分页展示
- 移动端上拉加载更多
- 大数据量查询结果分批处理
- 报表数据分批导出
实现示例
-- 获取第2页数据,每页10条
SELECT id, name FROM users
ORDER BY create_time DESC
LIMIT 10 OFFSET 10;
-- 等价写法(OFFSET可放LIMIT后)
SELECT id, name FROM users
ORDER BY create_time DESC
LIMIT 10, 10;
性能特点
- 简单易用:语法直观,开发成本低
- 全量扫描:OFFSET越大性能越差(需扫描并跳过前N条)
- 结果不稳定:若数据有变更,分页可能出现重复或遗漏
优化方案
- 添加索引:确保ORDER BY字段有索引
- 游标分页:使用WHERE条件替代OFFSET
-- 基于最后一条记录的ID分页 SELECT * FROM users WHERE id > last_id ORDER BY id LIMIT 10; - 物化视图:对静态数据预先计算
注意事项
- 避免大偏移量(OFFSET > 10000)
- 必须配合ORDER BY使用
- 深分页考虑使用keyset pagination
- OFFSET值应做参数化处理防止SQL注入
三、ORM框架分页实现
Hibernate分页实现
概念定义
Hibernate分页是指通过Hibernate框架从数据库中分批获取数据的技术,避免一次性加载大量数据导致内存溢出或性能下降。Hibernate提供了多种分页实现方式,主要通过Query或Criteria接口的setFirstResult()和setMaxResults()方法实现。
核心方法
- setFirstResult(int startPosition)
设置查询的起始位置(从0开始)。 - setMaxResults(int maxResults)
设置每页返回的最大记录数。
使用场景
- Web应用中的表格数据分页展示。
- 大数据量查询时减少内存占用。
- 提升用户体验,避免长时间等待数据加载。
实现方式
1. HQL分页
Query<Product> query = session.createQuery("FROM Product", Product.class);
query.setFirstResult(0); // 从第1条记录开始
query.setMaxResults(10); // 每页10条
List<Product> products = query.getResultList();
2. Criteria分页
CriteriaBuilder builder = session.getCriteriaBuilder();
CriteriaQuery<Product> criteria = builder.createQuery(Product.class);
Root<Product> root = criteria.from(Product.class);
criteria.select(root);
Query<Product> query = session.createQuery(criteria);
query.setFirstResult(10); // 第2页(跳过前10条)
query.setMaxResults(10);
List<Product> products = query.getResultList();
3. 原生SQL分页
SQLQuery query = session.createSQLQuery("SELECT * FROM products");
query.addEntity(Product.class);
query.setFirstResult(20);
query.setMaxResults(10);
List<Product> products = query.list();
注意事项
- 性能优化
- 确保分页查询的字段有索引。
- 避免使用
select *,只查询必要字段。
- 数据库兼容性
- 不同数据库分页语法不同(如MySQL用
LIMIT,Oracle用ROWNUM),但Hibernate会自动适配。
- 不同数据库分页语法不同(如MySQL用
- 总记录数查询
分页通常需要知道总记录数,可通过额外查询实现:Query<Long> countQuery = session.createQuery("SELECT COUNT(*) FROM Product", Long.class); long totalRecords = countQuery.uniqueResult(); - 内存分页陷阱
避免先查询全部数据再内存分页(如List.subList()),这会导致性能问题。
完整分页工具类示例
public class PaginationHelper<T> {
public Page<T> getPage(EntityManager em, Class<T> entityClass, int pageNumber, int pageSize) {
CriteriaBuilder builder = em.getCriteriaBuilder();
// 查询总数
CriteriaQuery<Long> countQuery = builder.createQuery(Long.class);
countQuery.select(builder.count(countQuery.from(entityClass)));
Long total = em.createQuery(countQuery).getSingleResult();
// 查询分页数据
CriteriaQuery<T> criteria = builder.createQuery(entityClass);
Root<T> root = criteria.from(entityClass);
criteria.select(root);
List<T> result = em.createQuery(criteria)
.setFirstResult((pageNumber - 1) * pageSize)
.setMaxResults(pageSize)
.getResultList();
return new Page<>(result, total, pageNumber, pageSize);
}
}
// 使用示例
PaginationHelper<Product> helper = new PaginationHelper<>();
Page<Product> page = helper.getPage(entityManager, Product.class, 2, 10);
MyBatis分页实现
概念定义
MyBatis分页是指在数据库查询中,通过限制返回的数据量来实现分页显示的功能。MyBatis本身不直接提供分页功能,但可以通过多种方式实现分页查询。
使用场景
- Web应用中列表数据的分页展示
2.大数据量查询时的性能优化
3.移动端应用的分批数据加载
实现方式
1. 使用RowBounds实现内存分页
List<User> users = sqlSession.selectList("getUserList", null, new RowBounds(10, 5));
- 原理:先查询所有数据到内存,再进行分页
- 缺点:大数据量时性能差
2. 使用分页插件(推荐)
常用插件:
- PageHelper
- MyBatis-Plus分页
PageHelper示例:
// 设置分页参数
PageHelper.startPage(1, 10);
List<User> users = userMapper.selectAll();
// 获取分页信息
PageInfo<User> pageInfo = new PageInfo<>(users);
3. 手动编写SQL分页
MySQL:
SELECT * FROM user LIMIT #{offset}, #{pageSize}
Oracle:
SELECT * FROM (
SELECT a.*, ROWNUM rn FROM (
SELECT * FROM user ORDER BY id
) a WHERE ROWNUM <= #{end}
) WHERE rn >= #{start}
注意事项
- 排序问题:分页查询必须指定排序字段
- 性能优化:大数据量表应添加适当索引
- 插件冲突:避免多个分页插件同时使用
- 分布式环境:注意分页参数的线程安全问题
最佳实践
- 推荐使用PageHelper等成熟插件
- 对于超大数据量,考虑使用游标分页
- 前端应传递合理的分页参数
- 实现统一的分页返回对象
示例统一返回对象:
public class PageResult<T> {
private int pageNum;
private int pageSize;
private long total;
private List<T> data;
// getter/setter
}
JPA分页实现
概念定义
JPA(Java Persistence API)分页是通过标准API实现数据库查询结果的分段获取,核心利用Pageable接口及其实现类PageRequest控制页码、每页条数和排序规则。
核心类与方法
Pageable接口- 定义分页参数(页码、每页大小、排序)
PageRequest类- 实现
Pageable的不可变对象,通过of()静态方法创建
- 实现
Page接口- 封装分页结果,包含数据列表、总页数、总记录数等信息
使用场景
- Web应用中的表格数据展示
- 移动端下拉加载更多
- 大数据量查询时减少内存占用
基础实现方式
1. Repository接口自动分页
public interface UserRepository extends JpaRepository<User, Long> {
Page<User> findByNameContaining(String name, Pageable pageable);
}
// 调用示例
Page<User> page = userRepository.findByNameContaining("张",
PageRequest.of(0, 10, Sort.by("createTime").descending()));
2. QueryDSL/JPA Criteria手动分页
@PersistenceContext
private EntityManager em;
public Page<User> getUsers(int page, int size) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<User> query = cb.createQuery(User.class);
Root<User> root = query.from(User.class);
// 查询总数
CriteriaQuery<Long> countQuery = cb.createQuery(Long.class);
countQuery.select(cb.count(countQuery.from(User.class)));
Long total = em.createQuery(countQuery).getSingleResult();
// 分页查询
List<User> result = em.createQuery(query)
.setFirstResult(page * size)
.setMaxResults(size)
.getResultList();
return new PageImpl<>(result, PageRequest.of(page, size), total);
}
排序实现
// 单字段排序
PageRequest.of(0, 10, Sort.by("age").descending());
// 多字段排序
Sort sort = Sort.by("department").ascending()
.and(Sort.by("salary").descending());
PageRequest.of(0, 10, sort);
性能优化建议
- 避免
count查询
当不需要总页数时,使用Slice代替Page:Slice<User> slice = repository.findByNameContaining("张", pageable); - 覆盖
countQuery
复杂查询时自定义count语句:@Query(value = "SELECT u FROM User u WHERE u.active = true", countQuery = "SELECT COUNT(u.id) FROM User u WHERE u.active = true") Page<User> findActiveUsers(Pageable pageable);
常见问题
- N+1查询问题
关联查询时需使用@EntityGraph或JOIN FETCH - 内存分页误区
确保SQL层面分页(LIMIT/OFFSET),而非内存中过滤 - 页码从0开始
Spring Data默认第一页是page=0
完整示例
@GetMapping("/users")
public Page<User> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "name,asc") String[] sort) {
Sort sorting = Sort.by(sort[0]).with(
sort[1].equalsIgnoreCase("asc") ?
Sort.Direction.ASC : Sort.Direction.DESC);
return userRepository.findAll(
PageRequest.of(page, size, sorting));
}
Spring Data分页实现
概念定义
Spring Data分页是Spring Data JPA提供的一种标准化的分页查询方式,通过Pageable接口和Page/Slice接口实现数据的分页查询和返回。核心特点是:
- 标准化:统一的分页参数(页码、每页条数)和返回结构
- 与Repository集成:直接支持在Repository方法中使用分页
- 多数据库兼容:自动生成不同数据库的分页SQL
核心接口
Pageable:分页请求接口(页码、每页大小、排序)PageRequest:Pageable的实现类(of()静态方法创建)Page:包含分页数据和元数据(总页数、总条数等)Slice:轻量分页结果(只包含是否有下一页)
使用场景
- Web应用的分页列表接口
- 大数据量查询时的分批处理
- 需要排序的分页查询
基础用法示例
// Repository定义
public interface UserRepository extends JpaRepository<User, Long> {
Page<User> findByAgeGreaterThan(int age, Pageable pageable);
}
// 服务层使用
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public Page<User> getUsersByAge(int age, int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("name").ascending());
return userRepository.findByAgeGreaterThan(age, pageable);
}
}
高级特性
- 自定义分页查询:
@Query("SELECT u FROM User u WHERE u.name LIKE %?1%")
Page<User> findByNameContaining(String name, Pageable pageable);
- Slice使用(不查询总数):
Slice<User> findTop10ByStatus(String status, Pageable pageable);
- Native SQL分页:
@Query(value = "SELECT * FROM users WHERE age > ?1",
countQuery = "SELECT count(*) FROM users WHERE age > ?1",
nativeQuery = true)
Page<User> findUsersByAgeNative(int age, Pageable pageable);
注意事项
- 页码从0开始:与前端约定好页码起始值
- N+1查询问题:分页查询关联实体时注意优化
- 性能考虑:
- 避免在大表上使用
count(*) - 对于超大数据量考虑使用
Slice替代Page
- 避免在大表上使用
- 排序字段:确保排序字段有索引
前端交互
典型的分页响应结构:
{
"content": [...], // 当前页数据
"pageable": { ... }, // 分页信息
"totalPages": 10, // 总页数
"totalElements": 100, // 总记录数
"last": false, // 是否最后一页
"size": 10, // 每页大小
"number": 0, // 当前页码(0-based)
"sort": { ... } // 排序信息
}
性能优化建议
- 对
count查询添加特定条件索引 - 复杂分页考虑使用游标分页(基于ID范围)
- 使用
@EntityGraph解决分页时的关联查询问题
四、内存分页实现
Java集合分页实现
概念定义
Java集合分页是指将一个大的集合数据按照指定的页数和每页显示的数量进行分割,返回指定页的数据子集。常用于前端展示、数据导出等场景。
核心实现方式
-
subList方法
利用List接口的subList(int fromIndex, int toIndex)方法实现物理分页:public static <T> List<T> getPage(List<T> sourceList, int pageNum, int pageSize) { int fromIndex = (pageNum - 1) * pageSize; if (fromIndex >= sourceList.size()) { return Collections.emptyList(); } int toIndex = Math.min(fromIndex + pageSize, sourceList.size()); return sourceList.subList(fromIndex, toIndex); } -
Stream API(Java8+)
使用Stream的skip和limit操作:public static <T> List<T> getPage(Stream<T> stream, int pageNum, int pageSize) { return stream.skip((pageNum - 1) * pageSize) .limit(pageSize) .collect(Collectors.toList()); }
注意事项
-
线程安全问题
subList()返回的视图与原集合共享数据,原集合修改会导致ConcurrentModificationException- 需要线程安全时建议返回新集合:
new ArrayList<>(sourceList.subList(...))
-
性能考量
- 大数据集(如10万+条记录)建议使用数据库分页
- 频繁分页操作可考虑缓存分页结果
-
边界处理
- 需校验pageNum/pageSize的合法性(>0)
- 处理最后一页数据不足的情况
完整工具类示例
public class PaginationUtil {
public static <T> PageResult<T> paginate(List<T> data, int pageNum, int pageSize) {
// 参数校验
if (pageNum < 1 || pageSize < 1) {
throw new IllegalArgumentException("页码和页大小必须大于0");
}
int total = data.size();
int fromIndex = (pageNum - 1) * pageSize;
// 超出范围返回空
if (fromIndex >= total) {
return new PageResult<>(Collections.emptyList(), pageNum, pageSize, total);
}
int toIndex = Math.min(fromIndex + pageSize, total);
List<T> pageData = new ArrayList<>(data.subList(fromIndex, toIndex));
return new PageResult<>(pageData, pageNum, pageSize, total);
}
public static class PageResult<T> {
private List<T> data;
private int pageNum;
private int pageSize;
private int total;
// 构造方法/getter/setter省略
}
}
流式处理分页实现
概念定义
流式处理分页是一种在数据库查询中处理大量数据的分页技术,它通过逐批获取数据的方式,避免一次性加载所有数据到内存中。与传统分页(如 LIMIT offset, size)不同,流式分页通常基于游标(Cursor)或有序键值(如自增ID、时间戳)实现,适合大数据量场景。
核心特点
- 低内存占用:每次只加载当前页的数据。
- 高性能:避免
OFFSET的深度分页性能问题。 - 无状态或弱状态:通过游标或有序键记录分页位置。
实现方式
1. 基于游标(Cursor)的分页
- 原理:使用唯一有序字段(如
id、create_time)作为游标,记录上一页最后一条数据的位置。 - SQL示例:
-- 第一页(按id升序) SELECT * FROM orders ORDER BY id ASC LIMIT 10; -- 后续页(假设上一页最后一条记录的id为100) SELECT * FROM orders WHERE id > 100 ORDER BY id ASC LIMIT 10;
2. 基于时间范围的分页
- 适用场景:按时间排序的数据(如日志、交易记录)。
- SQL示例:
-- 第一页(按时间降序) SELECT * FROM logs ORDER BY create_time DESC LIMIT 10; -- 后续页(假设上一页最后一条记录的时间为'2023-10-01 12:00:00') SELECT * FROM logs WHERE create_time < '2023-10-01 12:00:00' ORDER BY create_time DESC LIMIT 10;
3. Java实现示例(MyBatis)
// 参数类
public class PageParam {
private Long lastId; // 游标(上一页最后一条记录的ID)
private LocalDateTime lastTime; // 或时间游标
private int pageSize;
}
// Mapper接口
@Mapper
public interface OrderMapper {
List<Order> selectByCursor(@Param("param") PageParam param);
}
// Mapper XML
<select id="selectByCursor" resultType="Order">
SELECT * FROM orders
WHERE id > #{param.lastId} <!-- 或 create_time < #{param.lastTime} -->
ORDER BY id ASC
LIMIT #{param.pageSize}
</select>
注意事项
- 排序字段必须唯一:避免分页时数据重复或遗漏(例如,同分页字段值相同时需附加第二排序字段)。
- 游标稳定性:确保游标字段不会被修改(如自增ID比可更新的字段更可靠)。
- 客户端处理:需保存上一页的游标值,供下一页请求使用。
与传统分页对比
| 特性 | 流式分页 | 传统分页(LIMIT OFFSET) |
|---|---|---|
| 性能 | 高(无OFFSET扫描) | 低(OFFSET越大越慢) |
| 内存消耗 | 低 | 高(大数据量时) |
| 适用场景 | 大数据量、无限滚动 | 小数据量、固定页码 |
适用场景
- 移动端无限滚动加载(如社交媒体动态)。
- 后台大数据量导出(分批处理)。
- 实时数据流处理(如监控日志)。
内存分页的概念
内存分页是指将大量数据加载到内存后,通过程序逻辑进行分页处理的技术。与数据库分页不同,它避免了频繁的数据库查询,适合数据量适中且变化不频繁的场景。
适用场景
- 数据已全部加载到内存(如缓存、静态数据)
- 单次查询数据量在万级到百万级
- 需要高频分页访问(如后台管理系统)
核心优化方案
数据结构选择
// 推荐使用ArrayList(随机访问效率O(1))
List<Data> fullList = new ArrayList<>(100000);
// 避免LinkedList(随机访问效率O(n))
分页算法优化
public List<Data> getPage(List<Data> source, int page, int size) {
int fromIndex = (page - 1) * size;
if(fromIndex >= source.size()) {
return Collections.emptyList();
}
int toIndex = Math.min(fromIndex + size, source.size());
return source.subList(fromIndex, toIndex);
}
并行处理(百万级以上数据)
// 使用并行流处理分页计算
List<Data> pageData = fullList.parallelStream()
.skip((page-1)*size)
.limit(size)
.collect(Collectors.toList());
常见误区
- 全量排序分页:避免在每次分页时重新排序,应预先排序
- 对象引用问题:
subList()返回的是视图,修改会影响原集合 - 内存泄漏:长期持有大集合引用会导致GC无法回收
高级优化技巧
- 分页缓存:对高频访问页进行缓存(如Guava Cache)
- 预取机制:提前加载相邻页数据
- 对象复用:对于只读场景,考虑返回不可变集合
性能对比示例
| 数据量 | 普通分页 | 优化分页 |
|---|---|---|
| 10万 | 15ms | 2ms |
| 100万 | 130ms | 8ms |
| 1000万 | OOM风险 | 50ms |
注:测试环境为JDK17,16GB内存
五、分页性能优化
分页查询SQL优化
概念定义
分页查询SQL优化是指通过改进SQL语句或数据库结构,提高分页查询性能的技术手段。主要解决大数据量分页时性能下降的问题。
常见优化方案
1. 使用主键/索引列分页
-- 传统方式(性能差)
SELECT * FROM table LIMIT 10000, 10;
-- 优化方式(利用索引)
SELECT * FROM table WHERE id > 10000 LIMIT 10;
2. 子查询优化
-- 先查询主键,再关联
SELECT * FROM table t1
JOIN (SELECT id FROM table LIMIT 10000, 10) t2
ON t1.id = t2.id;
3. 延迟关联
SELECT * FROM table t1
INNER JOIN (
SELECT id FROM table
WHERE condition
ORDER BY sort_column
LIMIT 10000, 10
) t2 ON t1.id = t2.id;
使用场景
- 数据量超过10万条的分页查询
- 需要频繁分页操作的系统
- 响应速度要求高的场景
注意事项
- 避免使用OFFSET:大数据量时OFFSET性能极差
- 确保排序字段有索引:无索引会导致全表扫描
- 合理设置页大小:过大影响性能,过小增加请求次数
- 考虑使用游标分页:适合无限滚动场景
高级优化技巧
- 预计算分页数据:对静态数据可提前计算
- 缓存热门分页:使用Redis缓存前几页数据
- 分区表:按时间或范围分区提高查询效率
索引优化策略
概念定义
索引优化策略是指通过合理设计和使用数据库索引,以提高查询性能、减少I/O操作和优化数据库整体性能的方法。索引类似于书籍的目录,可以快速定位数据,避免全表扫描。
常见索引类型
- B-Tree索引:最常用的索引类型,适用于等值查询和范围查询。
- 哈希索引:适用于等值查询,但不支持范围查询。
- 全文索引:用于文本内容的搜索。
- 复合索引:多个列组合的索引,遵循最左前缀原则。
使用场景
- 高频查询字段:如用户表的
user_id、订单表的order_id。 - 排序和分组字段:如
ORDER BY create_time或GROUP BY category。 - 多表连接字段:如外键
foreign_key。
优化策略
-
选择合适的列:
- 选择区分度高的列(如唯一键或高基数列)。
- 避免对频繁更新的列建索引。
-
复合索引设计:
- 遵循最左前缀原则,将高频查询列放在左侧。
- 示例:
INDEX idx_name_age (name, age),可以优化WHERE name='xx'或WHERE name='xx' AND age=20。
-
避免冗余索引:
- 如已存在
(A,B)索引,则单独的(A)索引是冗余的。
- 如已存在
-
覆盖索引:
- 索引包含查询所需的所有字段,避免回表。
- 示例:
SELECT id, name FROM users WHERE name='xx',若索引为(name, id),则无需查表。
-
索引失效场景:
- 使用
!=、NOT IN、IS NULL等操作符。 - 对索引列进行函数操作(如
WHERE YEAR(create_time)=2023)。 - 隐式类型转换(如字符串列用数字查询)。
- 使用
示例代码
-- 创建复合索引
CREATE INDEX idx_user_phone_name ON users(phone, name);
-- 覆盖索引查询(无需回表)
EXPLAIN SELECT phone, name FROM users WHERE phone = '13800138000';
-- 避免索引失效
-- 不推荐(函数操作)
SELECT * FROM users WHERE DATE(create_time) = '2023-01-01';
-- 推荐(范围查询)
SELECT * FROM users WHERE create_time BETWEEN '2023-01-01 00:00:00' AND '2023-01-01 23:59:59';
注意事项
- 权衡读写性能:索引会降低写入速度(需维护索引结构)。
- 定期维护:删除未使用的索引,重建碎片化索引(如
ANALYZE TABLE)。 - 监控工具:使用
EXPLAIN分析查询计划,检查索引使用情况。
分页缓存实现
概念定义
分页缓存是指将数据库查询结果的分页数据存储在缓存中,以减少重复查询数据库的开销。通过缓存分页结果,可以显著提高分页查询的性能,特别是在高并发场景下。
使用场景
- 高频访问的分页数据:如电商网站的商品列表、新闻网站的新闻列表等。
- 数据变化频率较低:如历史订单、归档数据等。
- 高并发场景:如秒杀活动、热门榜单等。
实现方式
1. 缓存整个分页结果
将每一页的查询结果完整缓存,通常以 page:页码 为键存储。
示例代码(Redis + Spring Boot):
public List<Product> getProductsByPage(int page, int size) {
String cacheKey = "products:page:" + page + ":size:" + size;
List<Product> products = redisTemplate.opsForValue().get(cacheKey);
if (products == null) {
products = productRepository.findByPage(PageRequest.of(page, size));
redisTemplate.opsForValue().set(cacheKey, products, 1, TimeUnit.HOURS);
}
return products;
}
2. 缓存ID列表 + 单条记录缓存
- 缓存分页的ID列表。
- 单独缓存每条记录的详细信息。
优势:
- 减少缓存空间占用。
- 单条记录更新时,只需清除单条缓存。
示例代码:
public List<Product> getProductsByPage(int page, int size) {
String idListKey = "products:ids:page:" + page + ":size:" + size;
List<Long> productIds = redisTemplate.opsForValue().get(idListKey);
if (productIds == null) {
Page<Product> productPage = productRepository.findAll(PageRequest.of(page, size));
productIds = productPage.getContent().stream().map(Product::getId).collect(Collectors.toList());
redisTemplate.opsForValue().set(idListKey, productIds, 1, TimeUnit.HOURS);
}
return productIds.stream()
.map(id -> getProductById(id)) // 从缓存或DB获取单条记录
.collect(Collectors.toList());
}
private Product getProductById(Long id) {
String cacheKey = "product:" + id;
Product product = redisTemplate.opsForValue().get(cacheKey);
if (product == null) {
product = productRepository.findById(id).orElse(null);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, product, 1, TimeUnit.HOURS);
}
}
return product;
}
常见误区与注意事项
-
缓存一致性:
- 数据更新时需清除或更新相关缓存。
- 可采用延时双删策略减少脏数据。
-
缓存键设计:
- 需包含分页参数(如页码、每页大小)。
- 若查询有过滤条件,需将条件加入缓存键。
-
缓存雪崩:
- 为不同分页设置随机的过期时间。
- 使用互斥锁防止大量缓存同时重建。
-
大结果集问题:
- 避免缓存过大的分页结果,可考虑只缓存前N页。
-
分页变化处理:
- 新增/删除数据时,需考虑分页数据的重新计算。
- 可采用版本号或时间戳标记数据变更。
避免深度分页的技术
1. 概念定义
深度分页指的是在数据库查询中,当用户需要访问数据集的较后部分(例如第1000页)时,传统分页方式(如LIMIT offset, size)会导致性能急剧下降的问题。避免深度分页的技术旨在优化这类场景的查询效率。
2. 常见问题
- 性能瓶颈:使用
LIMIT offset, size时,数据库需要扫描offset + size条记录,再丢弃前offset条,导致资源浪费。 - 内存压力:深度分页可能引发临时表或排序操作,占用大量内存。
3. 解决方案
3.1 基于游标的分页(Cursor-based Pagination)
- 原理:通过记录上一页最后一条数据的唯一标识(如ID、时间戳),下次查询时直接定位。
- 示例代码(MySQL):
-- 第一页 SELECT * FROM orders ORDER BY id DESC LIMIT 10; -- 后续页(假设上一页最后一条记录的id为100) SELECT * FROM orders WHERE id < 100 ORDER BY id DESC LIMIT 10; - 优点:避免扫描无关数据,性能稳定。
- 限制:要求排序字段唯一且连续,不支持随机跳页。
3.2 延迟关联(Deferred Join)
- 原理:先通过子查询快速定位主键,再关联原表获取完整数据。
- 示例代码:
SELECT t.* FROM table t JOIN (SELECT id FROM table ORDER BY create_time LIMIT 10000, 10) AS tmp ON t.id = tmp.id; - 适用场景:单表大数据量分页,减少回表开销。
3.3 使用覆盖索引
- 原理:通过索引直接获取所需列,避免回表操作。
- 示例:
-- 假设索引覆盖了id和name字段 SELECT id, name FROM users ORDER BY create_time LIMIT 100000, 10;
4. 其他优化策略
- 业务限制:禁止直接跳转到过深的页码(如只允许“上一页/下一页”导航)。
- 预加载缓存:对热门深度页码数据提前缓存(如Redis存储第N页结果)。
- 分区查询:按时间或ID范围拆分数据,缩小单次查询范围。
5. 注意事项
- 游标分页需确保排序字段无重复值,否则可能丢失数据。
- 分页参数需严格校验,防止SQL注入或超大
offset导致崩溃。 - 结合
EXPLAIN分析执行计划,确认索引命中情况。
六、前端分页交互
分页参数设计
概念定义
分页参数是用于控制数据分页查询的核心参数,通常包含以下两个关键参数:
- 页码(pageNum/pageNo):当前请求的页数(从1开始计数)
- 每页条数(pageSize):单页返回的数据量
常见参数组合
// 基础分页参数类示例
public class PageParam {
private Integer pageNum = 1; // 默认第一页
private Integer pageSize = 10; // 默认每页10条
// getters & setters
}
高级参数扩展
- 排序参数:
private String sortField; // 排序字段 private String sortOrder; // ASC/DESC - 游标分页(用于大数据量场景):
private Long lastId; // 上一页最后一条记录的ID private Integer limit; // 获取条数
设计原则
- 默认值设置:应为pageNum和pageSize设置合理默认值
- 参数校验:
// 防止恶意传参 if(pageSize > 100) { pageSize = 100; } - 前后端约定:保持参数命名一致性(如pageNum/pageNo)
注意事项
- 页码越界:当请求页数超过总页数时应返回空列表而非报错
- 性能考量:避免过大pageSize(建议不超过100条/页)
- 分布式环境:排序字段需确保唯一性避免分页数据重复
数据库分页实现示例
// MyBatis分页参数处理
public List<User> selectByPage(@Param("pageNum") int pageNum,
@Param("pageSize") int pageSize) {
int offset = (pageNum - 1) * pageSize;
return sqlSession.selectList("selectUsers",
RowBounds.DEFAULT, new PageBounds(offset, pageSize));
}
特殊场景处理
- 深度分页优化:
/* 低效写法 */ SELECT * FROM table LIMIT 100000, 20; /* 优化写法 */ SELECT * FROM table WHERE id > 100000 LIMIT 20; - 总数统计:是否需要返回totalCount应根据业务需求决定
分页数据格式规范
分页数据格式规范定义了API或应用中返回分页数据的统一结构,确保前后端交互的一致性。
常见分页格式
- 基础分页格式
{
"data": [], // 当前页数据列表
"pageNum": 1, // 当前页码
"pageSize": 10, // 每页数量
"total": 100 // 总记录数
}
- 扩展分页格式(包含分页元信息)
{
"success": true,
"code": 200,
"message": "success",
"data": {
"list": [], // 当前页数据
"current": 1, // 当前页
"size": 10, // 每页大小
"total": 100, // 总记录数
"pages": 10 // 总页数
}
}
关键字段说明
data/list:当前页的实际数据内容(必需)pageNum/current:当前页码(必需)pageSize/size:每页记录数(必需)total:总记录数(必需)pages:总页数(可选,可由total和pageSize计算得出)
最佳实践
- 字段命名一致性:整个项目应保持统一的命名风格(如全用pageNum或全用current)
- 默认值处理:
- 未传分页参数时建议设置默认值(如pageNum=1, pageSize=10)
- 分页限制:
- 应对最大pageSize进行限制(如不超过100条/页)
- 空数据返回:
- 无数据时应返回空数组而非null
示例代码(Spring Boot)
// 统一分页返回对象
@Data
public class PageResult<T> {
private Integer code;
private String msg;
private PageData<T> data;
@Data
public static class PageData<T> {
private List<T> list;
private long current;
private long size;
private long total;
private long pages;
}
}
// Controller使用示例
@GetMapping("/users")
public PageResult<User> getUsers(
@RequestParam(defaultValue = "1") int current,
@RequestParam(defaultValue = "10") int size) {
Page<User> page = userService.page(new Page<>(current, size));
PageResult.PageData<User> pageData = new PageResult.PageData<>();
pageData.setList(page.getRecords());
pageData.setCurrent(page.getCurrent());
pageData.setSize(page.getSize());
pageData.setTotal(page.getTotal());
pageData.setPages(page.getPages());
return PageResult.success(pageData);
}
前后端分页交互流程
概念定义
前后端分页交互流程是指前端与后端协同实现数据分页展示的过程。前端负责发送分页请求参数并渲染分页数据,后端负责处理分页逻辑并返回对应数据。
核心交互步骤
-
前端发起请求
- 发送当前页码(pageNum/page)
- 发送每页条数(pageSize/limit)
- 可选:排序字段、过滤条件等
-
后端处理请求
- 接收分页参数
- 执行分页查询(SQL的LIMIT/OFFSET或ORM分页)
- 计算总记录数(用于前端显示总页数)
-
返回标准分页响应
{ "code": 200, "data": { "list": [...], // 当前页数据 "total": 100, // 总记录数 "pageNum": 1, // 当前页码 "pageSize": 10 // 每页大小 } } -
前端渲染处理
- 展示数据列表
- 更新分页控件状态
- 处理分页操作事件(上一页/下一页/跳转)
常见实现方式
RESTful风格接口示例:
// 后端Controller
@GetMapping("/users")
public PageResult<User> getUsers(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize) {
PageInfo<User> pageInfo = userService.getByPage(pageNum, pageSize);
return new PageResult<>(pageInfo);
}
前端请求示例(axios):
axios.get('/api/users', {
params: {
pageNum: 2,
pageSize: 10
}
}).then(response => {
// 处理分页数据
});
注意事项
- 参数校验:后端需验证pageNum/pageSize的合法性
- 性能优化:大数据量时避免COUNT(*)全表扫描
- 缓存策略:高频访问的分页数据可考虑缓存
- 一致性:排序字段需建立索引,避免分页时数据重复/遗漏
扩展方案
- 游标分页:适用于无限滚动场景,基于最后记录ID分页
- 预加载:前端可提前加载下一页数据提升体验
分页组件实现原理
1. 概念定义
分页组件是一种将大量数据分割成多个页面展示的技术,通常用于Web应用或数据库查询中。核心原理是通过**计算偏移量(offset)和限制返回数量(limit)**来实现数据的分段获取。
2. 核心要素
- 当前页码(pageNum):用户请求的页码
- 每页条数(pageSize):单页显示的数据量
- 总记录数(total):数据总量
- 总页数(totalPage):
ceil(total/pageSize)
3. 数据库分页实现
MySQL示例
SELECT * FROM table_name
LIMIT (pageNum-1)*pageSize, pageSize;
Oracle示例
SELECT * FROM (
SELECT t.*, ROWNUM rn FROM (
SELECT * FROM table_name ORDER BY id
) t WHERE ROWNUM <= pageNum*pageSize
) WHERE rn > (pageNum-1)*pageSize
4. 前端分页类型
- 客户端分页:一次性加载所有数据,前端计算分页
- 服务端分页:每次请求只获取当前页数据(推荐大数据量场景)
5. 实现注意事项
-
性能优化:
- 大数据表必须配合索引使用
- 避免使用
SELECT COUNT(*)计算总数(可缓存或估算)
-
边界处理:
- 页码超出范围时返回最后一页
- 空数据集特殊处理
-
排序一致性:
- 分页查询必须包含
ORDER BY子句 - 避免使用非唯一字段排序导致分页数据重复
- 分页查询必须包含
6. 现代优化方案
- Keyset分页(游标分页):
SELECT * FROM table WHERE id > last_id ORDER BY id LIMIT pageSize - 延迟关联(大数据量优化):
SELECT * FROM table INNER JOIN ( SELECT id FROM table ORDER BY create_time LIMIT 100000, 10 ) AS t USING(id)
七、特殊分页场景
排序分页实现
概念定义
排序分页是指在数据库查询中,将结果集按照指定字段排序后,再按页码和每页大小进行分页返回的技术。它是分页功能的增强版,解决了简单分页可能导致数据顺序混乱的问题。
核心实现方式
1. SQL 实现(MySQL/Oracle)
-- MySQL/Oracle/PostgreSQL
SELECT * FROM table_name
ORDER BY sort_column [ASC|DESC]
LIMIT offset, page_size;
-- Oracle 12c+
SELECT * FROM table_name
ORDER BY sort_column [ASC|DESC]
OFFSET offset ROWS FETCH NEXT page_size ROWS ONLY;
2. JPA/Hibernate 实现
// Spring Data JPA
Page<User> users = userRepository.findAll(
PageRequest.of(pageNum - 1, pageSize, Sort.by("createTime").descending())
);
// Hibernate Criteria
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<User> query = cb.createQuery(User.class);
Root<User> root = query.from(User.class);
query.orderBy(cb.desc(root.get("createTime")));
List<User> result = entityManager.createQuery(query)
.setFirstResult(offset)
.setMaxResults(pageSize)
.getResultList();
3. MyBatis 实现
<select id="selectByPage" resultType="User">
SELECT * FROM user
ORDER BY ${sortField} ${sortOrder}
LIMIT #{offset}, #{pageSize}
</select>
性能优化要点
-
索引优化:确保排序字段有适当索引
CREATE INDEX idx_create_time ON user(create_time); -
避免大偏移量:使用"上一页最大值"替代传统偏移量
-- 传统方式(性能差) SELECT * FROM user ORDER BY id LIMIT 1000000, 10; -- 优化方式 SELECT * FROM user WHERE id > last_max_id ORDER BY id LIMIT 10; -
覆盖索引:只查询索引包含的字段
SELECT id, name FROM user ORDER BY create_time LIMIT 0, 10;
常见问题解决方案
-
多字段排序:
ORDER BY status DESC, create_time DESC -
NULL值处理:
ORDER BY ISNULL(column_name), column_name -
分页总数计算:
// Spring Data JPA Page<User> page = repository.findAll(pageable); long total = page.getTotalElements();
前端交互建议
-
请求参数规范:
{ "page": 1, "size": 10, "sort": "createTime,desc|name,asc" } -
响应结构示例:
{ "data": [], "pagination": { "total": 100, "current": 1, "pageSize": 10 } }
多表关联分页
概念定义
多表关联分页是指在数据库查询中,需要对多个关联表进行联合查询,并对查询结果进行分页处理的技术。通常涉及JOIN操作和分页逻辑的结合。
使用场景
- 需要展示来自多个关联表的数据列表
- 数据量较大需要分页显示
- 例如:订单列表需要关联用户表、商品表等
实现方式
1. 传统LIMIT分页(适合简单关联)
SELECT a.*, b.field1, b.field2
FROM table_a a
JOIN table_b b ON a.id = b.a_id
ORDER BY a.create_time DESC
LIMIT 10 OFFSET 20; -- 第3页,每页10条
2. 子查询优化(适合复杂关联)
SELECT a.*, b.field1, b.field2
FROM (
SELECT * FROM table_a
ORDER BY create_time DESC
LIMIT 10 OFFSET 20
) a
JOIN table_b b ON a.id = b.a_id;
3. MyBatis实现示例
@Select("SELECT o.*, u.username, p.product_name " +
"FROM orders o " +
"JOIN users u ON o.user_id = u.id " +
"JOIN products p ON o.product_id = p.id " +
"ORDER BY o.create_time DESC " +
"LIMIT #{pageSize} OFFSET #{offset}")
List<OrderDTO> getOrderListWithPage(
@Param("offset") int offset,
@Param("pageSize") int pageSize);
注意事项
- 性能问题:多表JOIN分页可能导致性能下降
- 排序字段:确保使用索引字段排序
- 分页偏移量:大偏移量(OFFSET)会导致性能问题
- 数据一致性:分页过程中数据变更可能导致重复或遗漏
优化方案
- 使用覆盖索引减少回表
- 对主表先分页再关联(子查询方式)
- 考虑使用游标分页(基于最后一条记录的ID)
- 大数据量考虑分区表或分库分表
分组统计分页
概念定义
分组统计分页是指在数据库查询中,先对数据进行分组(GROUP BY)和聚合统计(如COUNT、SUM等),再对分组后的结果进行分页展示的技术。它与普通分页的主要区别在于:普通分页是对原始数据分页,而分组统计分页是对分组聚合后的结果分页。
使用场景
- 需要展示分类汇总数据的场景(如商品按类别统计销量)
- 数据看板和报表系统
- 需要先分组再分页的统计分析需求
实现方式
MySQL实现
-- 先进行分组统计的子查询
SELECT * FROM (
SELECT category, COUNT(*) as count, SUM(price) as total
FROM products
GROUP BY category
) AS grouped_data
LIMIT 10 OFFSET 0; -- 第一页,每页10条
Java+MyBatis实现
@Select("SELECT * FROM (" +
" SELECT category, COUNT(*) as count, SUM(price) as total " +
" FROM products " +
" GROUP BY category" +
") AS grouped_data " +
"LIMIT #{limit} OFFSET #{offset}")
List<CategoryStat> getCategoryStatsWithPagination(
@Param("offset") int offset,
@Param("limit") int limit);
注意事项
- 性能问题:分组操作本身消耗较大,大数据量时应确保分组字段有索引
- 分页准确性:在分布式系统中,直接LIMIT分页可能出现数据不一致
- 统计结果:分页是基于分组后的结果,不是原始数据的分页
- 内存消耗:某些数据库需要先完成全部分组才能分页
优化方案
- 对分组字段建立复合索引
- 对大数据量考虑使用"游标分页"(where条件+limit)
- 使用物化视图预先计算分组结果
- 考虑使用专门的OLAP数据库处理复杂统计分析
常见误区
- 误以为分页参数作用于原始数据而非分组结果
- 在分组字段上使用不恰当的过滤条件
- 忽略分组后数据量的变化对分页效果的影响
- 在大数据量场景下使用OFFSET导致性能问题
动态条件分页
概念定义
动态条件分页是指在数据库查询中,根据用户输入或业务逻辑动态生成查询条件,并在此基础上实现分页功能。与固定条件分页不同,动态条件分页的查询条件在运行时才确定。
使用场景
- 电商平台商品筛选(价格区间、品牌、分类等)
- 后台管理系统数据查询(多条件组合搜索)
- 报表系统(动态时间范围查询)
实现方式
1. SQL拼接方式(基础实现)
public Page<User> queryUsers(Map<String, Object> params, int pageNum, int pageSize) {
StringBuilder sql = new StringBuilder("SELECT * FROM user WHERE 1=1");
// 动态拼接条件
if (params.containsKey("name")) {
sql.append(" AND name LIKE '%").append(params.get("name")).append("%'");
}
if (params.containsKey("age")) {
sql.append(" AND age = ").append(params.get("age"));
}
// 分页处理
sql.append(" LIMIT ").append((pageNum - 1) * pageSize).append(",").append(pageSize);
// 执行查询...
}
2. MyBatis动态SQL(推荐方式)
<select id="selectUsers" resultType="User">
SELECT * FROM user
<where>
<if test="name != null and name != ''">
AND name LIKE CONCAT('%', #{name}, '%')
</if>
<if test="age != null">
AND age = #{age}
</if>
</where>
LIMIT #{offset}, #{pageSize}
</select>
3. JPA Specification方式
public Page<User> findUsers(SearchCriteria criteria, Pageable pageable) {
return userRepository.findAll((root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (criteria.getName() != null) {
predicates.add(cb.like(root.get("name"), "%" + criteria.getName() + "%"));
}
if (criteria.getAge() != null) {
predicates.add(cb.equal(root.get("age"), criteria.getAge()));
}
return cb.and(predicates.toArray(new Predicate[0]));
}, pageable);
}
注意事项
- SQL注入风险:字符串拼接方式需特别注意防范
- 索引失效:动态条件可能导致索引失效,需优化查询条件顺序
- 参数校验:所有动态参数必须进行有效性验证
- 性能考虑:复杂动态查询可能影响性能,建议添加适当索引
性能优化建议
- 使用预编译语句(PreparedStatement)
- 为常用查询条件建立复合索引
- 对大数据量分页使用"延迟关联"优化
SELECT * FROM user INNER JOIN (
SELECT id FROM user
WHERE [conditions]
LIMIT 10000, 10
) AS tmp USING(id)
八、分布式分页挑战
分布式环境分页问题
概念定义
分布式环境分页是指在分布式系统或微服务架构中,对跨多个节点或服务的数据进行分页查询时遇到的挑战。由于数据分散存储在不同的节点上,传统的单机分页方式(如 LIMIT offset, size)可能无法直接适用,且容易导致性能问题或数据一致性问题。
核心挑战
- 数据分散性:数据可能分布在不同的数据库实例或服务中,无法直接合并排序。
- 性能问题:传统的
OFFSET分页在分布式环境下可能导致大量无效数据传输(如OFFSET 10000需跳过前 10000 条记录)。 - 一致性:分页期间若数据发生增删,可能导致重复或遗漏(如“跳页”问题)。
常见解决方案
1. 基于游标的分页(Cursor-based Pagination)
- 原理:通过唯一且有序的字段(如主键、时间戳)作为游标,记录上一页的最后一条数据的位置。
- 示例查询:
SELECT * FROM table WHERE id > last_cursor_id ORDER BY id LIMIT page_size; - 优点:避免
OFFSET的性能问题,适合大数据量。 - 缺点:不支持随机跳页(如直接跳转到第 5 页)。
2. 全局排序分页
- 原理:通过中间件(如 Elasticsearch)或分布式计算框架(如 Spark)对分散数据全局排序后分页。
- 适用场景:需要严格全局排序的复杂查询。
3. 分片合并分页
- 原理:从各分片查询数据后,在内存中合并排序再分页。
- 示例流程:
- 每个分片执行
LIMIT offset + size查询。 - 合并所有分片结果并排序。
- 截取目标分页数据(如
[offset, offset + size])。
- 每个分片执行
- 缺点:内存消耗大,
offset较大时性能下降。
4. 预计算分页
- 原理:定期将分页结果预计算并缓存(如使用 Redis 存储分页数据)。
- 适用场景:数据变化频率低、查询频繁的场景。
注意事项
- 游标选择:游标字段需唯一且有序(如自增 ID、创建时间)。
- 数据一致性:若允许数据修改,需考虑游标分页期间的增删影响(如使用逻辑删除或版本号)。
- 性能权衡:内存合并分页适合小规模数据,游标分页适合大规模数据。
示例代码(游标分页)
// 请求参数
public class PageRequest {
private Long cursorId; // 上一页最后一条记录的 ID
private Integer pageSize;
}
// 服务层实现
public List<Item> getItemsByCursor(PageRequest request) {
return itemRepository.findByIdGreaterThanOrderByIdAsc(
request.getCursorId(),
PageRequest.of(0, request.getPageSize())
);
}
跨库分页解决方案
概念定义
跨库分页是指在多个数据库或数据源中查询数据时,实现分页功能的技术方案。由于数据分散在不同的库中,传统的单库分页方式(如 LIMIT offset, size)无法直接使用,需要特殊处理。
使用场景
- 分库分表架构(如水平分片)
- 微服务架构下多个服务独立数据库
- 数据异构场景(如主库+搜索引擎)
常见方案
1. 全局排序法(性能较低)
-- 从所有分库查询全量数据后在内存排序
SELECT * FROM (
(SELECT * FROM db1.tbl ORDER BY create_time DESC)
UNION ALL
(SELECT * FROM db2.tbl ORDER BY create_time DESC)
) AS tmp
ORDER BY create_time DESC
LIMIT 100, 10;
2. 分片查询法(推荐方案)
// 伪代码:按分片并行查询
List<Result> queryShards(int pageNo, int pageSize) {
int totalShards = 4; // 分片数
int shardLimit = pageSize / totalShards + 1;
return parallelQuery(shard -> {
return db(shard).query("SELECT * FROM tbl ORDER BY id LIMIT ?,?",
(pageNo-1)*shardLimit, shardLimit);
});
}
3. 二次查询法(精准分页)
- 各分库查询满足条件的ID
- 内存排序后确定最终记录的ID范围
- 用IN查询获取完整数据
注意事项
- 排序字段必须一致:所有分片使用相同排序规则
- 避免深分页:建议使用"上一页/下一页"模式替代跳页
- 性能权衡:分片查询法可能返回冗余数据
- 一致性要求:实时性高的场景需考虑数据同步延迟
优化建议
- 使用ES等中间件做统一分页查询
- 对分页字段建立索引(如create_time)
- 采用游标分页(记录最后一条数据的排序值)
分页数据一致性保证
概念定义
分页数据一致性是指在分页查询过程中,确保不同页之间的数据不会出现重复或遗漏,且在数据发生变更时仍能保持逻辑上的连贯性。
常见问题场景
- 数据动态变更:当用户浏览分页数据时,底层数据被增删改。
- 排序字段重复值:当使用非唯一字段排序时,可能导致分页边界数据错乱。
- 深度分页性能:传统
LIMIT offset, size方式在偏移量过大时性能急剧下降。
核心解决方案
1. 游标分页(Cursor-based Pagination)
// 使用最后一条记录的ID作为游标(假设按ID升序)
public List<User> getUsers(Long lastId, int pageSize) {
return jdbcTemplate.query(
"SELECT * FROM users WHERE id > ? ORDER BY id ASC LIMIT ?",
new Object[]{lastId, pageSize},
(rs, rowNum) -> new User(rs.getLong("id"), rs.getString("name"))
);
}
优势:
- 不受新增数据影响
- 性能稳定(WHERE条件走索引)
限制:
- 只能单向翻页
- 需要连续且唯一的排序字段
2. 版本号/时间戳控制
-- 首次查询获取数据版本
SELECT * FROM products
WHERE category_id = 1
ORDER BY price DESC
LIMIT 10;
-- 后续查询携带版本条件
SELECT * FROM products
WHERE category_id = 1
AND update_time <= '2023-10-01 12:00:00' -- 上次查询时记录的时间戳
ORDER BY price DESC
LIMIT 10 OFFSET 10;
3. 业务层缓存
- 对查询条件+页码做结果缓存
- 适合数据变化频率低的场景
- 需设置合理的TTL
注意事项
-
排序字段选择:
- 优先使用唯一字段(如主键)
- 复合排序需包含唯一字段(
ORDER BY create_time DESC, id DESC)
-
深度分页优化:
-- 低效方式 SELECT * FROM large_table LIMIT 1000000, 10; -- 优化方式(假设id是主键) SELECT * FROM large_table WHERE id > (SELECT id FROM large_table ORDER BY id LIMIT 1000000, 1) LIMIT 10; -
前端配合:
- 禁止用户跳转到随机页码
- 采用"加载更多"模式替代传统分页器
特殊场景处理
删除数据时的处理方案:
// 查询时排除逻辑删除的记录
@Query("SELECT a FROM Article a WHERE a.deleted = false AND a.id < ?1 ORDER BY a.id DESC")
List<Article> findBeforeCursor(Long cursorId, Pageable pageable);
分布式ID在分页中的应用
概念定义
分布式ID是在分布式系统中生成的全局唯一标识符,常用于数据库主键。常见的实现方式包括:
- UUID
- 雪花算法(Snowflake)
- 数据库自增序列(如Redis Incr)
- 号段模式
在分页场景中,分布式ID可以作为高效的分页依据,避免传统LIMIT offset, size的性能问题。
使用场景
-
深分页优化:
当使用WHERE id > last_id ORDER BY id LIMIT size替代LIMIT offset, size时,避免扫描大量无效数据。 -
多数据源合并分页:
多个服务返回的数据按分布式ID排序后合并,保证全局有序。 -
滚动分页(无限加载):
前端传递最后一条记录的ID,后端基于此查询下一页。
实现示例
// 基于ID的游标分页(Spring Data JPA示例)
public Page<User> getUsers(Long lastId, int size) {
// 首次查询
if (lastId == null) {
return userRepository.findByOrderByIdAsc(PageRequest.of(0, size));
}
// 后续分页
return userRepository.findByIdGreaterThanOrderByIdAsc(lastId, PageRequest.of(0, size));
}
// 使用MyBatis的XML配置
<select id="selectAfterId" resultType="User">
SELECT * FROM user
WHERE id > #{lastId}
ORDER BY id ASC
LIMIT #{size}
</select>
注意事项
-
ID必须有序:
需确保分布式ID生成时基本有序(如雪花算法含时间戳),否则无法用于范围查询。 -
跳页问题:
游标分页不支持直接跳转到任意页码,适合无限滚动场景。 -
索引设计:
必须为ID字段建立索引,否则性能反而下降。 -
数据删除处理:
如果删除记录导致ID不连续,需配合created_at时间字段辅助排序。
性能对比
| 方案 | 100万数据深分页耗时 |
|---|---|
| LIMIT 900000,100 | 2.1秒 |
| WHERE id > X | 0.03秒 |
通过分布式ID实现的分页方式,在数据量较大时性能优势显著。
九、常见分页组件
PageHelper原理分析
基本概念
PageHelper是MyBatis的一个分页插件,通过拦截SQL语句并自动添加分页逻辑(如LIMIT子句),简化了数据库分页操作。
核心实现机制
-
拦截器模式
基于MyBatis的Interceptor接口,拦截以下方法:Executor#query():拦截SQL执行过程StatementHandler#prepare():拦截SQL预处理
-
分页SQL改写
根据方言(Dialect)自动改写原始SQL:-- 原始SQL SELECT * FROM users -- 改写后(MySQL示例) SELECT * FROM users LIMIT 10 OFFSET 20 -
ThreadLocal存储分页参数
通过PageHelper.startPage(pageNum, pageSize)将参数存入ThreadLocal,执行后自动清除。
关键源码解析
// 拦截逻辑核心代码(简化版)
public Object intercept(Invocation invocation) throws Throwable {
// 1. 获取ThreadLocal中的分页参数
Page page = getPageParam();
// 2. 改写SQL
if (page != null) {
String newSql = dialect.getLimitString(
originalSql,
page.getStartRow(),
page.getPageSize()
);
resetSql(newSql); // 替换原SQL
}
// 3. 执行查询
return invocation.proceed();
}
使用注意事项
-
调用顺序
必须在查询方法前调用startPage():// 正确示例 PageHelper.startPage(1, 10); userMapper.selectAll(); -
线程安全问题
- 分页参数通过
ThreadLocal存储,需避免异步场景下的参数泄漏 - 建议用
try-finally清理:try { PageHelper.startPage(1, 10); return userMapper.selectAll(); } finally { PageHelper.clearPage(); }
- 分页参数通过
-
特殊SQL处理
嵌套查询/存储过程等复杂SQL可能需要手动分页。
性能优化建议
- 对大数据表使用
count(1)替代count(*) - 合理设置
reasonable参数避免不合理页码 - 分页深度过大时考虑其他方案(如游标分页)
MyBatis-Plus分页实现
概念定义
MyBatis-Plus(简称MP)是MyBatis的增强工具,提供了内置的分页插件(PaginationInnerInterceptor),简化了传统分页查询的代码编写。通过配置拦截器,开发者可以快速实现物理分页功能。
核心组件
- 分页插件:通过拦截SQL语句,自动添加分页语法(如MySQL的
LIMIT)。 - 分页对象:
Page<T>类,封装当前页码、每页条数、总记录数等分页参数。 - 分页API:
selectPage()或selectMapsPage()方法,直接返回分页结果。
使用步骤
1. 配置分页插件
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); // 指定数据库类型
return interceptor;
}
}
2. 使用分页查询
// 创建分页对象(当前页=1,每页10条)
Page<User> page = new Page<>(1, 10);
// 执行分页查询
Page<User> result = userMapper.selectPage(page, null); // 第二个参数为Wrapper条件
// 获取分页数据
List<User> records = result.getRecords(); // 当前页数据
long total = result.getTotal(); // 总记录数
高级用法
自定义分页SQL(XML方式)
<!-- UserMapper.xml -->
<select id="selectUserPage" resultType="User">
SELECT * FROM user WHERE age > #{param1.age}
</select>
// Mapper接口
Page<User> selectUserPage(Page<User> page, @Param("param1") User user);
// 调用方式
Page<User> page = new Page<>(1, 10);
userMapper.selectUserPage(page, new User().setAge(18));
不查询总记录数(性能优化)
Page<User> page = new Page<>(1, 10, false); // 第三个参数设为false
注意事项
- 数据库方言:需正确配置
DbType(如DbType.MYSQL、DbType.ORACLE)。 - 一对多分页:关联查询时可能出现分页不准确,建议用子查询或内存分页。
- 性能问题:大数据量表避免频繁
COUNT,可通过缓存或异步统计优化。
示例代码(完整流程)
// 1. 查询分页数据
Page<User> page = new Page<>(1, 10);
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.ge(User::getAge, 18);
Page<User> result = userMapper.selectPage(page, wrapper);
// 2. 获取分页信息
System.out.println("总记录数:" + result.getTotal());
System.out.println("当前页数据:" + result.getRecords());
Spring Data Commons 分页
概念定义
Spring Data Commons 提供了一套标准的分页抽象,通过 Pageable 接口和 Page 类实现统一的分页查询。核心类包括:
Pageable:分页请求接口(页码、每页条数、排序规则)。PageRequest:Pageable的实现类(不可变对象)。Page:分页结果封装(数据列表、总页数、总条数等)。
使用场景
- Web 分页查询:接收前端分页参数(如
/users?page=0&size=10)。 - Repository 层分页:与 Spring Data JPA 等集成,自动转换分页 SQL。
- 服务层分页处理:统一分页响应格式(如
{ data: [], total: 100 })。
核心方法示例
// 1. 创建分页请求(页码从0开始)
Pageable pageable = PageRequest.of(0, 10, Sort.by("createTime").descending());
// 2. JPA 分页查询
Page<User> page = userRepository.findByName("Alice", pageable);
// 3. 获取分页数据
List<User> users = page.getContent();
long totalElements = page.getTotalElements();
注意事项
- 页码从0开始:与前端约定时需注意(可通过
PageRequest自定义偏移量)。 - N+1查询问题:关联查询时需优化 SQL(如
@EntityGraph注解)。 - 性能陷阱:
count(*)在大表时较慢,可考虑延迟计数或缓存。
自定义分页响应
public class PageResult<T> {
private List<T> data;
private long total;
// getter/setter
}
// 转换方法
public static <T> PageResult<T> toResult(Page<T> page) {
PageResult<T> result = new PageResult<>();
result.setData(page.getContent());
result.setTotal(page.getTotalElements());
return result;
}
与前端交互
建议通过 HTTP 参数传递分页信息:
GET /api/users?page=0&size=10&sort=name,asc
使用 @PageableDefault 设置默认值:
@GetMapping
public Page<User> listUsers(@PageableDefault(size = 5) Pageable pageable) {
return userService.findAll(pageable);
}
自定义分页组件开发
概念定义
自定义分页组件是指开发者根据业务需求,自行设计和实现的分页功能模块。与框架提供的默认分页功能相比,自定义分页组件可以更灵活地控制分页逻辑、样式和交互行为。
使用场景
- 需要特殊的分页样式(如瀑布流分页)
- 需要集成复杂的分页逻辑(如内存分页)
- 需要支持特殊的分页参数(如自定义每页条数)
- 需要优化分页性能(如延迟加载)
核心实现要素
分页参数
public class PageParam {
private int pageNum; // 当前页码
private int pageSize; // 每页记录数
private int totalCount; // 总记录数
// getters/setters
}
分页算法
public class Pagination {
public static PageParam calculate(PageParam param) {
int totalPages = (int) Math.ceil((double) param.getTotalCount() / param.getPageSize());
param.setTotalPages(totalPages);
return param;
}
}
前端交互
function handlePageChange(pageNum) {
fetch(`/api/data?page=${pageNum}&size=${pageSize}`)
.then(response => response.json())
.then(updateView);
}
实现方式
后端实现
- SQL分页:
-- MySQL
SELECT * FROM table LIMIT offset, pageSize
-- Oracle
SELECT * FROM (
SELECT a.*, ROWNUM rn FROM (
SELECT * FROM table ORDER BY id
) a WHERE ROWNUM <= endRow
) WHERE rn > startRow
- 内存分页:
public List<User> paginate(List<User> data, PageParam param) {
return data.stream()
.skip((param.getPageNum() - 1) * param.getPageSize())
.limit(param.getPageSize())
.collect(Collectors.toList());
}
前端实现
<template>
<div class="pagination">
<button @click="prevPage" :disabled="currentPage === 1">上一页</button>
<span v-for="page in pageRange" :key="page">
<button @click="goToPage(page)">{{ page }}</button>
</span>
<button @click="nextPage" :disabled="currentPage === totalPages">下一页</button>
</div>
</template>
注意事项
- 性能优化:大数据量时避免使用内存分页
- 边界处理:处理第一页和最后一页的特殊情况
- 并发安全:分页参数需要线程安全
- 用户体验:合理设置最大页码显示数量
高级功能实现
// 分页缓存实现
public PageResult<T> getPageWithCache(PageParam param) {
String cacheKey = buildCacheKey(param);
return cache.computeIfAbsent(cacheKey, k -> {
return queryFromDB(param);
});
}
测试要点
- 空数据集分页
- 单页数据分页
- 跨页数据连续性
- 并发分页请求
- 异常参数处理
十、分页最佳实践
分页大小选择策略
概念定义
分页大小选择策略是指在数据库分页查询中,确定每页显示记录数量的方法。合理的分页大小能平衡性能与用户体验。
常见策略
-
固定分页大小
- 每页固定显示相同数量记录(如10、20、50条)
- 优点:实现简单,缓存友好
- 缺点:无法适应不同场景需求
-
前端动态指定
- 由用户界面传递
pageSize参数 - 示例代码(Spring Boot):
@GetMapping("/users") public Page<User> getUsers( @RequestParam(defaultValue = "10") int pageSize, @RequestParam(defaultValue = "0") int pageNo) { return userRepository.findAll(PageRequest.of(pageNo, pageSize)); }
- 由用户界面传递
-
响应式分页
- 根据设备类型自动调整(移动端/PC端不同分页大小)
- 实现逻辑:
int pageSize = isMobile(request) ? 5 : 20;
-
性能优化策略
- 大数据表:使用较小分页(如20条)降低单次查询负载
- 高频访问数据:适当增大分页(如50条)减少查询次数
注意事项
-
最大值限制
- 需限制
pageSize上限防止恶意请求(如MAX_PAGE_SIZE=100) - 防御代码示例:
pageSize = Math.min(pageSize, MAX_PAGE_SIZE);
- 需限制
-
性能权衡
- 过小:增加查询次数(网络开销)
- 过大:增加单次查询时间和内存消耗
-
业务相关性
- 列表页:建议10-30条
- 报表导出:可允许更大分页(需配合流式处理)
最佳实践
- 默认值推荐10-20条记录
- 提供
pageSize参数但限制可选范围(如10/20/50三种选项) - 关键接口实施分页监控,根据实际性能动态调整策略
分页性能监控指标
1. 查询响应时间
- 定义:执行分页查询从开始到返回结果的总耗时。
- 重要性:直接影响用户体验,响应时间过长可能导致页面卡顿或超时。
- 监控方法:记录每次分页查询的起止时间,计算平均值和峰值。
2. 数据库 I/O 负载
- 定义:分页查询过程中磁盘读写操作的频率和数据量。
- 重要性:高 I/O 负载可能导致数据库性能下降,影响其他查询。
- 监控方法:通过数据库监控工具(如 MySQL 的
SHOW STATUS)观察读写次数和数据量。
3. 内存使用情况
- 定义:分页查询时数据库和应用程序的内存占用情况。
- 重要性:内存不足可能导致频繁 GC 或查询中断。
- 监控方法:监控 JVM 堆内存(如
-Xmx设置)和数据库缓存使用情况。
4. 网络传输量
- 定义:分页结果从数据库传输到应用服务器的数据量。
- 重要性:大数据量传输会增加网络延迟和带宽消耗。
- 监控方法:记录每次查询返回的数据包大小(如通过
ResultSet的行数和列数估算)。
5. 分页查询效率
- 定义:分页查询的优化程度,通常通过执行计划分析。
- 重要性:低效查询(如全表扫描)会显著降低性能。
- 监控方法:使用
EXPLAIN分析 SQL 执行计划,关注是否命中索引。
6. 并发查询数
- 定义:同一时间内执行的分页查询请求数量。
- 重要性:高并发可能导致数据库连接池耗尽或锁竞争。
- 监控方法:监控数据库连接池活跃连接数和请求队列长度。
7. 缓存命中率
- 定义:分页结果从缓存中直接获取的比例。
- 重要性:高缓存命中率可显著减少数据库压力。
- 监控方法:记录缓存查询次数与总查询次数的比值(如 Redis 的
INFO命令)。
示例代码(监控响应时间)
// 使用 Spring AOP 监控分页查询耗时
@Aspect
@Component
public class PageQueryMonitor {
@Around("execution(* com.example.repository.*.findByPage(..))")
public Object logQueryTime(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
Object result = joinPoint.proceed();
long duration = System.currentTimeMillis() - startTime;
Metrics.record("page.query.time", duration); // 上报到监控系统
return result;
}
}
注意事项
- 避免过度监控:选择关键指标,避免因监控本身引入性能开销。
- 阈值设置:根据业务场景设置合理告警阈值(如响应时间 > 500ms 触发告警)。
- 趋势分析:结合历史数据观察性能变化趋势,而非仅关注单次值。
分页异常处理方案
分页异常类型
- 页码越界:请求的页码超过总页数或小于1
- 每页条数过大:单页请求数据量超过系统限制
- 参数格式错误:分页参数非数字或包含非法字符
- 排序字段不存在:请求的排序字段在表中不存在
- 数据库查询异常:分页查询时发生的SQL异常
处理方案
参数校验
// Spring Boot示例
@GetMapping("/users")
public Page<User> getUsers(
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "10") @Max(100) int size) {
// 业务逻辑
}
统一异常处理
@RestControllerAdvice
public class PaginationExceptionHandler {
@ExceptionHandler(PageOutOfBoundsException.class)
public ResponseEntity<ErrorResponse> handlePageOutOfBounds(PageOutOfBoundsException ex) {
return ResponseEntity.badRequest()
.body(new ErrorResponse("PAGE_OUT_OF_BOUNDS", ex.getMessage()));
}
@ExceptionHandler(InvalidSortFieldException.class)
public ResponseEntity<ErrorResponse> handleInvalidSortField(InvalidSortFieldException ex) {
return ResponseEntity.badRequest()
.body(new ErrorResponse("INVALID_SORT_FIELD", "无效的排序字段: " + ex.getFieldName()));
}
}
业务层防护
public Page<User> findUsers(int page, int size) {
// 计算总页数
long total = userRepository.count();
int totalPages = (int) Math.ceil((double) total / size);
if (page > totalPages) {
throw new PageOutOfBoundsException("请求页码超过最大页数");
}
return userRepository.findAll(PageRequest.of(page - 1, size));
}
最佳实践
- 设置合理的默认值(如page=1,size=10)
- 限制最大每页条数(建议不超过100)
- 返回分页元数据(总条数、总页数等)
- 对排序字段进行白名单校验
- 前端配合处理分页边界情况
返回示例
{
"success": false,
"code": "INVALID_PAGE_PARAM",
"message": "页码必须大于0",
"data": null,
"pageInfo": {
"currentPage": 0,
"pageSize": 10,
"totalPages": 5
}
}
分页安全注意事项
1. SQL 注入风险
- 问题:拼接 SQL 语句时未处理用户输入的页码参数(如
pageNum、pageSize),可能导致 SQL 注入。 - 示例风险代码:
String sql = "SELECT * FROM users LIMIT " + pageSize + " OFFSET " + (pageNum * pageSize); - 解决方案:
- 使用预编译语句(PreparedStatement):
String sql = "SELECT * FROM users LIMIT ? OFFSET ?"; PreparedStatement ps = connection.prepareStatement(sql); ps.setInt(1, pageSize); ps.setInt(2, (pageNum - 1) * pageSize);
- 使用预编译语句(PreparedStatement):
2. 参数边界校验
- 问题:未校验
pageNum和pageSize的合法性(如负数、超大值),可能导致资源耗尽或逻辑错误。 - 解决方案:
- 强制限制分页参数范围:
int safePageSize = Math.min(pageSize, 100); // 限制每页最多100条 int safePageNum = Math.max(pageNum, 1); // 页码最小为1
- 强制限制分页参数范围:
3. 数据泄露风险
- 问题:未校验用户权限直接分页查询,可能通过遍历页码获取未授权数据。
- 解决方案:
- 在 SQL 中追加权限过滤条件(如
WHERE user_id = ?)。 - 服务层校验当前用户是否有权访问目标数据。
- 在 SQL 中追加权限过滤条件(如
4. 性能与拒绝服务(DoS)
- 问题:允许过大的
pageSize或深分页(如pageNum=100000)可能导致数据库负载过高。 - 解决方案:
- 限制最大分页深度(如禁止
offset > 10000)。 - 对深分页改用基于游标的分页(Cursor-based Pagination):
SELECT * FROM users WHERE id > ? ORDER BY id LIMIT ?
- 限制最大分页深度(如禁止
5. 排序字段安全
- 问题:允许前端直接传入排序字段名(如
ORDER BY ${field}),可能导致注入或索引失效。 - 解决方案:
- 白名单校验排序字段:
List<String> allowedFields = Arrays.asList("id", "create_time"); if (!allowedFields.contains(field)) { field = "id"; // 默认降级字段 }
- 白名单校验排序字段:
6. 统计总数时的风险
- 问题:直接执行
COUNT(*)可能在高并发场景下成为性能瓶颈。 - 替代方案:
- 对大表使用估算值(如 MySQL 的
EXPLAIN估算行数)。 - 缓存总数结果并设置过期时间。
- 对大表使用估算值(如 MySQL 的

被折叠的 条评论
为什么被折叠?



