作为一名长期使用 MyBatis-Plus 的开发人员,我发现很多团队虽然每天都在用它的分页功能,但对底层实现原理却一知半解。让我们先拆解这个"黑盒子"。
MyBatis-Plus 的分页本质是通过拦截器实现的。当你在 Spring Boot 中配置了 PaginationInnerInterceptor 后,它会自动拦截所有 Mapper 方法的执行。具体工作流程如下:
这个过程中最精妙的部分是 SQL 解析和改写。插件会分析你的原始 SQL,智能判断是否需要分页以及如何拼接分页参数。比如对于包含 GROUP BY 的复杂查询,它会生成更精确的 COUNT 查询。
Page<T> 对象承载了分页的所有元信息,其完整生命周期如下:
java复制// 初始化阶段
Page<User> page = new Page<>(1, 10); // 当前页=1,页大小=10
page.setSearchCount(true); // 是否执行COUNT查询(默认true)
// 执行阶段
IPage<User> result = userMapper.selectPage(page, queryWrapper);
// 结果阶段
result.getRecords(); // 当前页数据列表
result.getTotal(); // 总记录数
result.getPages(); // 总页数
result.getCurrent(); // 当前页码
result.getSize(); // 每页大小
关键经验:在需要高性能的场景,可以通过 setSearchCount(false) 禁用 COUNT 查询,特别是在已知数据量或不需要展示总页数时。
MyBatis-Plus 的分页插件支持多种数据库方言,通过 DbType 指定:
java复制new PaginationInnerInterceptor(DbType.MYSQL) // MySQL
new PaginationInnerInterceptor(DbType.ORACLE) // Oracle
new PaginationInnerInterceptor(DbType.POSTGRE_SQL) // PostgreSQL
不同数据库的分页语法差异由插件自动处理。例如:
LIMIT offset, sizeLIMIT size OFFSET offset不同技术体系对页码起点的定义存在差异:
| 技术体系 | 起始页码 | 典型代表 |
|---|---|---|
| 数据库层面 | 0 | MySQL LIMIT 子句 |
| Java持久层框架 | 1 | MyBatis-Plus、Spring Data JPA |
| 前端分页组件 | 1 | Element UI、Ant Design |
| REST API规范 | 0/1 | 无强制标准 |
在 MyBatis-Plus 中,无论你传入的页码是多少,内部都会统一转换:
java复制// 核心计算公式
offset = (current - 1) * size
// 示例
new Page<>(1, 10) → limit 0, 10
new Page<>(2, 10) → limit 10, 10
这种设计实现了业务页码与技术实现的解耦,开发者始终用自然数表示页码,而底层使用从0开始的偏移量。
基于多个项目的经验,我总结出以下最佳实践:
java复制// 参数校验示例
public PageResult<User> queryByPage(int page, int size) {
if (page < 1) {
throw new BusinessException("页码必须大于0");
}
if (size > MAX_PAGE_SIZE) {
throw new BusinessException("每页条数超过最大值");
}
// ...
}
当处理大数据量时,传统的 LIMIT offset, size 分页会出现严重性能问题。以查询第100万页为例:
sql复制SELECT * FROM large_table LIMIT 9999990, 10
这个查询需要:
我曾在生产环境遇到过这类查询导致数据库CPU飙升至100%的案例。
游标分页(Cursor-based Pagination)是解决深分页问题的银弹。其核心思想是:
sql复制-- 第一页
SELECT * FROM table ORDER BY id DESC LIMIT 10;
-- 后续页(假设上一页最后一条记录的id=100)
SELECT * FROM table WHERE id < 100 ORDER BY id DESC LIMIT 10;
在 MyBatis-Plus 中的实现:
java复制QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.lt("id", lastId) // 游标条件
.orderByDesc("id") // 必须与游标字段一致
.last("LIMIT 10"); // 页大小
List<User> users = userMapper.selectList(wrapper);
我在测试环境(1000万数据量表)做了对比测试:
| 分页方式 | 页码 | 平均耗时 | 数据库负载 |
|---|---|---|---|
| 传统分页 | 第1页 | 50ms | 低 |
| 传统分页 | 第50万页 | 4200ms | 高 |
| 游标分页 | 任意页 | 60-80ms | 低 |
实测结论:当页码超过1万时,游标分页性能优势呈指数级增长
根据我的安全审计经验,分页接口主要面临三类威胁:
java复制// 最大页大小限制
private static final int MAX_PAGE_SIZE = 100;
public void validatePageParams(int page, int size) {
if (page < 1) {
throw new IllegalArgumentException("页码必须大于0");
}
if (size < 1 || size > MAX_PAGE_SIZE) {
throw new IllegalArgumentException("页大小超出限制");
}
// 深度分页保护
if (page > 1000) {
throw new IllegalArgumentException("超出最大查询深度");
}
}
使用Redis实现滑动窗口限流:
java复制// 基于IP的限流:每分钟最多60次分页请求
public boolean tryAcquire(String ip) {
String key = "page:limit:" + ip;
long now = System.currentTimeMillis();
Long count = redisTemplate.opsForZSet().count(key, now - 60000, now);
if (count != null && count >= 60) {
return false;
}
redisTemplate.opsForZSet().add(key, now, now);
redisTemplate.expire(key, 1, TimeUnit.MINUTES);
return true;
}
对于敏感数据接口,可以增加结果指纹校验:
java复制// 生成分页结果指纹
String generateFingerprint(List<Data> dataList) {
StringJoiner sj = new StringJoiner("|");
dataList.forEach(data -> sj.add(data.getId() + ":" + data.getVersion()));
return DigestUtils.md5DigestAsHex(sj.toString().getBytes());
}
// 在返回结果中包含指纹
pageResult.setFingerprint(generateFingerprint(dataList));
这样客户端在请求下一页时需要携带上一页的指纹,可以有效防止数据篡改和乱序爬取。
在大型系统中,我推荐采用分层分页策略:
对于相对静态的分页数据,可以采用二级缓存:
java复制public PageResult<User> getUsersWithCache(int page, int size) {
String cacheKey = String.format("users:page:%d:size:%d", page, size);
// 尝试从缓存获取
PageResult<User> cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached;
}
// 查询数据库
PageResult<User> result = userService.queryByPage(page, size);
// 设置缓存(过期时间5分钟)
redisTemplate.opsForValue().set(cacheKey, result, 5, TimeUnit.MINUTES);
return result;
}
在分布式系统中,分页会面临数据一致性问题。我的解决方案是:
java复制// 使用时间戳作为游标
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.lt("create_time", lastCreateTime)
.orderByDesc("create_time")
.last("LIMIT 10");
对于需要 join 多表的分页查询,我推荐两种方案:
方案一:先分页再关联
sql复制-- 先在主表分页
SELECT * FROM main_table LIMIT 0, 10;
-- 再关联查询明细
SELECT * FROM detail_table
WHERE main_id IN (1,2,3...10);
方案二:使用冗余字段
将关联表的必要字段冗余到主表,避免 join 操作。
当需要导出大量数据时,传统分页会导致内存溢出。我的解决方案是:
java复制// 使用流式查询
try (Cursor<User> cursor = userMapper.selectCursor(queryWrapper)) {
cursor.forEach(user -> {
// 处理每条记录
exportToFile(user);
});
}
这种方案不会一次性加载所有数据,而是逐条从数据库读取处理。
对于移动端常见的无限滚动加载,建议:
javascript复制// 前端游标分页示例
async function loadMore(lastId) {
const res = await fetch(`/api/items?lastId=${lastId}&size=10`);
// 更新lastId并渲染数据
}
在生产环境中,我建议监控以下分页相关指标:
当发现分页性能下降时,我的排查步骤通常是:
sql复制-- 检查分页查询的执行计划
EXPLAIN SELECT * FROM large_table LIMIT 1000000, 10;
在项目上线前,建议进行分页接口的专项压测:
使用JMeter可以这样配置测试计划:
不同版本的 MyBatis-Plus 分页行为有所差异:
| 版本范围 | 重要变化 |
|---|---|
| 3.0.x - 3.4.x | 基本功能稳定 |
| 3.5.0+ | 新增分页优化器功能 |
| 最新版 | 增强对多数据库的支持 |
从旧版升级时需要注意:
建议在测试环境充分验证后再进行生产环境升级。
随着分布式数据库的普及,分页面临新的挑战:
新型硬件如PMEM、GPU数据库等,可能改变传统分页的实现方式:
云服务商开始提供专门的分页服务:
这些服务通常内置了最佳实践,简化了开发者的工作。