MyBatis分页陷阱:从RowBounds内存泄漏到高效分页实战
凌晨三点,手机突然响起刺耳的报警声。打开监控系统一看,某核心服务的堆内存曲线像坐了火箭一样直线上升,最终触发了OOM崩溃。经过彻夜排查,罪魁祸首竟是项目中一段看似无害的MyBatis分页代码——new RowBounds(0, 10)。这次事故让我深刻认识到,在数据量爆炸的时代,分页查询远不是简单的limit参数就能解决的问题。
1. 线上OOM事故现场还原
那是一个普通的业务迭代日,我们上线了一个新的用户列表查询功能。初期测试时一切正常,直到三个月后的某个营销活动日,系统突然崩溃。查看错误日志时,发现了这样的关键信息:
java.lang.OutOfMemoryError: Java heap space at java.util.Arrays.copyOf(Arrays.java:3332) at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124) at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.handleRowValuesForSimpleResultMap(DefaultResultSetHandler.java:354)事故特征分析:
- 发生时间:业务高峰期(上午10:00-11:00)
- 影响范围:所有依赖用户列表查询的接口
- 数据规模:用户表记录数从上线时的1万条增长到120万条
- 关键代码片段:
public List<User> getUsers(RowBounds rowBounds) { return userMapper.selectAllUsers(rowBounds); }注意:这种"先全量查询再内存分页"的模式,在数据量超过10万条时就可能成为定时炸弹
2. RowBounds工作原理深度解析
打开MyBatis源码,在DefaultResultSetHandler类中找到了问题的根源。RowBounds实现的是典型的逻辑分页机制:
// 简化后的核心逻辑 private void handleRowValues(ResultSet rs, RowBounds rowBounds) throws SQLException { skipRows(rs, rowBounds.getOffset()); // 先跳过offset条记录 int count = 0; while (count < rowBounds.getLimit() && rs.next()) { // 处理单行数据 count++; } }物理分页 vs 逻辑分页对比:
| 特性 | 物理分页 | 逻辑分页(RowBounds) |
|---|---|---|
| 执行位置 | 数据库层面 | 应用内存层面 |
| SQL生成 | 自动添加LIMIT子句 | 原样执行完整查询 |
| 内存消耗 | 只加载分页数据 | 加载全部结果集 |
| 性能表现 | 稳定高效 | 随数据量线性下降 |
| 适用场景 | 大数据量 | 小数据量或静态数据 |
RowBounds的三大致命缺陷:
- 全量加载:即使只需要10条数据,也会先查询百万级结果集
- 连接占用:大结果集传输期间会长时间占用数据库连接
- 序列化开销:所有数据都要经历完整的JDBC反序列化过程
3. 生产环境分页方案选型指南
经过这次教训,我们梳理出不同场景下的分页最佳实践:
3.1 基础分页:SQL LIMIT方案
<select id="selectByPage" resultType="User"> SELECT * FROM users ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select>适用场景:
- 数据量在百万级以下
- 不需要跳转到很远的页码(如直接跳转到第1000页)
3.2 高性能分页:游标分页
-- 第一页 SELECT * FROM users WHERE create_time > '2023-01-01' ORDER BY create_time, id LIMIT 10; -- 后续页 SELECT * FROM users WHERE create_time > '2023-01-15 14:30:00' OR (create_time = '2023-01-15 14:30:00' AND id > 1024) ORDER BY create_time, id LIMIT 10;优势对比:
- 避免了传统分页的
OFFSET性能陷阱 - 适合无限滚动加载场景
- 对数据库压力稳定可控
3.3 海量数据分页:Elasticsearch方案
当单表数据超过千万级时,我们采用了以下架构:
应用服务 → Elasticsearch集群 → 数据库 (分页查询) (全量同步)实施要点:
- 使用
search_after参数实现深度分页 - 设置合理的分片数和副本数
- 定期执行
forcemerge优化查询性能
4. MyBatis分页插件实战技巧
虽然不推荐使用RowBounds,但MyBatis生态中确实存在更智能的分页解决方案:
4.1 PageHelper正确配置
# application.yml pagehelper: helperDialect: mysql reasonable: true supportMethodsArguments: true params: count=countSql关键代码示例:
PageHelper.startPage(1, 10); // 第1页,每页10条 List<User> users = userMapper.selectAll(); PageInfo<User> pageInfo = new PageInfo<>(users);4.2 自定义拦截器实现
如果需要更精细的控制,可以自定义分页拦截器:
@Intercepts(@Signature(type= Executor.class, method="query", args={MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})) public class CustomPageInterceptor implements Interceptor { // 实现分页逻辑改写 }拦截器核心职责:
- 检测是否需要分页
- 改写原始SQL添加分页参数
- 执行count查询获取总数
- 返回包装后的分页结果
5. 分页性能优化全攻略
5.1 数据库层面优化
索引设计原则:
- 分页查询字段必须建立联合索引
- ORDER BY子句中的字段顺序决定索引有效性
- 避免在分页字段上使用函数操作
查询优化技巧:
-- 反例(无法使用索引) SELECT * FROM users ORDER BY DATE(create_time) DESC LIMIT 100,10; -- 正例 SELECT * FROM users WHERE create_time >= '2023-01-01' ORDER BY create_time DESC LIMIT 100,10;5.2 应用层缓存策略
采用两级缓存架构提升分页性能:
- 本地缓存:Guava Cache存储热点分页数据
CacheBuilder.newBuilder() .maximumSize(1000) .expireAfterWrite(5, TimeUnit.MINUTES) .build(); - 分布式缓存:Redis存储分页元数据
# Redis分页数据结构示例 HMSET page:users:1 total 1000 pages 100 items 10 data "[...]"
5.3 前端协作优化
通过API设计减少不必要的数据传输:
// 良好设计的分页响应 { "data": [...], "pagination": { "current_page": 1, "per_page": 10, "total": 1000, "has_more": true } }重要约定:
- 默认每页不超过50条记录
- 禁止无限制的
pageSize=0查询 - 对深度分页请求进行限流
那次OOM事故后,我们花了两个月时间重构了整个分页体系。现在回想起来,最大的收获不是技术方案本身,而是明白了在软件开发中,看似简单的功能往往隐藏着最危险的陷阱。特别是在处理数据访问层时,永远要对"网上抄来的代码"保持警惕,因为生产环境从不会对任何人的疏忽手下留情。