MyBatis RowBounds分页踩坑实录:一次线上OOM事故教会我的事

张开发
2026/4/21 2:17:10 15 分钟阅读

分享文章

MyBatis RowBounds分页踩坑实录:一次线上OOM事故教会我的事
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 ListUser 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 idselectByPage resultTypeUser 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: countcountSql关键代码示例PageHelper.startPage(1, 10); // 第1页每页10条 ListUser users userMapper.selectAll(); PageInfoUser pageInfo new PageInfo(users);4.2 自定义拦截器实现如果需要更精细的控制可以自定义分页拦截器Intercepts(Signature(type Executor.class, methodquery, 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条记录禁止无限制的pageSize0查询对深度分页请求进行限流那次OOM事故后我们花了两个月时间重构了整个分页体系。现在回想起来最大的收获不是技术方案本身而是明白了在软件开发中看似简单的功能往往隐藏着最危险的陷阱。特别是在处理数据访问层时永远要对网上抄来的代码保持警惕因为生产环境从不会对任何人的疏忽手下留情。

更多文章