FEATURED · 精选文章

Java 大批量数据查询 OOM 问题解决,普通分页、游标分页、MyBatis 流式查询、分批导出对比与生产实践

发布时间 / 2026/9/2 7:40:39
来源 / 创域科博编辑部
栏目 / 资讯中心
Java 大批量数据查询 OOM 问题解决,普通分页、游标分页、MyBatis 流式查询、分批导出对比与生产实践 业务开发写接口很多人习惯直接查询把结果封装成ListEntity直接返回。在测试环境只有几十、几百条数据一切运行平稳。一旦到生产环境数据表数据膨胀到几万、几十万条一次性全部加载到 JVM 内存瞬间占用大量堆内存直接抛出OutOfMemoryError服务 OOM严重的时候会导致服务重启影响整条业务线。很多同学知道要做分页但分不清普通分页、游标分页、流式查询、分批导出分别适合什么场景什么时候该用哪一种。今天结合线上真实故障把不同方案的原理、Demo、优缺点、适用场景讲清楚同时整理一份生产编码检查清单。一、故障还原为什么直接返回大 List 会 OOM❌ 危险反面代码测试环境正常线上大数据直接 OOMRestController public class StudentController { Autowired private StudentMapper studentMapper; // 危险数据量一大全部对象加载进JVM堆触发OOM GetMapping(/student/list/all) public ResultListStudent queryAllStudent() { ListStudent studentList studentMapper.selectAll(); return Result.success(studentList); } }原理MyBatis 默认会把查询结果全部对象装入 List全部放在堆内存数据量越大内存占用越高超过堆上限直接 OOM。误区调大 JVM 堆内存不是根治方案业务数据持续增长迟早还是会崩。二、方案 1普通 Limit‑Offset 分页适合前端网页列表翻页总页数不会特别大缺点offset 值越大数据库需要扫描跳过越多行查询性能越来越差不适合大批量数据导出、全表遍历。Mapper XMLselect idselectStudentByPage resultTypecom.demo.entity.Student select id,name,phone,create_time from t_student order by id asc limit #{offset}, #{pageSize} /selectService ControllerService public class StudentService { Autowired private StudentMapper studentMapper; public PageResultStudent queryStudentPage(Integer pageNum, Integer pageSize) { // 生产必须强制限制最大pageSize防止传入超大值OOM int maxPageSize 500; pageSize Math.min(pageSize, maxPageSize); int offset (pageNum - 1) * pageSize; ListStudent list studentMapper.selectStudentByPage(offset, pageSize); Long total studentMapper.countAll(); return new PageResult(total, list); } } RestController public class StudentController { Autowired private StudentService studentService; GetMapping(/student/page) public ResultPageResultStudent page(RequestParam Integer pageNum, RequestParam Integer pageSize) { return Result.success(studentService.queryStudentPage(pageNum, pageSize)); } }⚠️生产必做强制限制pageSize上限不允许前端传 10000 这种超大值。三、方案 2游标分页Id 游标大数据顺序遍历适合后台任务同步、批量处理全表数据不能跳页只能顺序向后遍历原理使用id lastId替代 offset数据库可以利用索引性能不会随着数据量变大衰减。Mapper XMLselect idselectStudentByCursor resultTypecom.demo.entity.Student select id,name,phone,create_time from t_student where id #{lastId} order by id asc limit #{pageSize} /selectService 遍历 DemoService public class StudentSyncService { Autowired private StudentMapper studentMapper; /** * 游标分页遍历全表做业务处理 */ public void syncAllStudent() { long lastId 0L; int pageSize 1000; while (true) { ListStudent pageList studentMapper.selectStudentByCursor(lastId, pageSize); if (CollectionUtils.isEmpty(pageList)) { break; } // 处理当前批次数据 handleBatchData(pageList); // 更新游标为当前批次最大ID lastId pageList.stream() .mapToLong(Student::getId) .max() .getAsLong(); } } private void handleBatchData(ListStudent pageList) { // 业务逻辑同步、清洗、统计等 } }⚠️坑点如果存在 ID 断号、中间数据被删除不会报错但遍历效率会下降如果业务会物理删除数据可以改用create_time id联合游标。四、方案 3MyBatis 流式查询 ResultHandler适合服务内部大批量处理不会一次性把全部数据加载到 List数据一行一行从数据库读取。重要警告流式查询会持续占用数据库连接业务处理不能耗时过长否则耗尽连接池。Mapper 接口不返回 Listvoid 方法Mapper public interface StudentMapper { void streamQueryAllStudent(Param(resultHandler) ResultHandlerStudent resultHandler); }Mapper XMLselect idstreamQueryAllStudent resultTypecom.demo.entity.Student select id,name,phone,create_time from t_student order by id asc /selectService 调用示例Service public class StudentStreamService { Autowired private SqlSession sqlSession; Autowired private StudentMapper studentMapper; public void streamHandleStudent() { studentMapper.streamQueryAllStudent(context - { Student student context.getResultObject(); // 单条业务处理一条一条读取不全部存入内存 handleSingleStudent(student); }); sqlSession.commit(); } private void handleSingleStudent(Student student) { // 单条处理逻辑 } }注意事项不要在 handler 内部做远程 RPC、长时间 IO会占住数据库连接使用完毕及时释放 sqlSession避免连接泄露。五、方案 4异步分批导出文件前端大批量报表下载❌禁止同步 HTTP 接口返回几万行大 JSON网关超时、内存爆炸。✅正确做法异步后台任务分批查询写入 OSS 生成文件完成后返回下载链接给前端。伪代码示例Service public class ExportService { Autowired private StudentMapper studentMapper; Autowired private OssFileService ossFileService; Autowired private AsyncTaskExecutor asyncTaskExecutor; /** * 接收导出请求立刻返回任务ID */ public String submitExportTask() { String taskId UUID.randomUUID().toString(); asyncTaskExecutor.execute(() - { // 后台异步执行分批查询写入临时文件上传OSS File tempFile generateBigExcelFile(); String downloadUrl ossFileService.upload(tempFile); // 更新任务状态保存下载链接 updateExportTask(taskId, downloadUrl, TaskStatus.SUCCESS); }); return taskId; } private File generateBigExcelFile() { // 游标分页分批读取分批写Excel不要一次性加载全部数据 long lastId 0; int batchSize 1000; File excelFile createTempExcel(); while (true) { ListStudent batch studentMapper.selectStudentByCursor(lastId, batchSize); if (CollectionUtils.isEmpty(batch)) break; writeExcelBatch(excelFile, batch); lastId batch.stream().mapToLong(Student::getId).max().getAsLong(); } return excelFile; } }流程前端调用接口拿到 taskId轮询任务状态接口任务完成拿到 OSS 下载 url 进行下载。六、方案选型对照表业务场景推荐方案不推荐方案前端页面列表翻页页数有限limit offset 普通分页流式查询后台任务遍历全表做业务逻辑数据量大游标分页 / MyBatis 流式查询一次性 selectAll 装入 List用户导出几十万行报表下载异步任务分批生成文件返回下载链接同步接口返回大 List JSON七、各方案踩坑汇总offset‑limit 分页offset 越大数据库扫描行数越多性能衰减不适合超大页数遍历。游标分页ID 存在断号、物理删除会影响遍历效率业务频繁删数据建议用create_time id复合游标。MyBatis 流式查询业务处理不能慢会占用数据库连接池慢业务会把连接池耗尽。大批量导出同步接口返回超大 JSON网关会超时同时引发服务 OOM。所有分页接口必须限制最大 pageSize防止恶意传入超大 pageSize 打垮服务。八、生产编码 CheckList✅禁止无限制条件直接selectAll返回完整 List✅前端列表必须强制分页设置最大单页条数例如最多 500 条禁止前端传无限大 pageSize✅大数据后台遍历优先游标分页慎用大 offset✅流式查询业务逻辑不能耗时过长避免占用数据库连接✅大批量文件导出走异步 对象存储不走同步 http 接口返回完整数据✅压测关注大查询接口内存指标线上配置 OOM 告警。九、AI 的局限性虽然现在都是AI帮助写代码了但是核心的知识点我们还是要掌握在自己手里。AI 很容易生成直接返回全量 List 的 demodemo 在测试环境跑起来没问题但完全不会考虑线上数据膨胀后的 OOM 风险。AI 只实现功能缺少线上量级的风险考量。复制 AI 代码上生产一定要评估数据规模。内存溢出很多时候不是 JVM 参数调优可以解决根源在于查询逻辑。区分业务场景选择对应的分页 / 流式 / 异步导出方案不要把全部数据一次性加载进 JVM 内存。本文属于【Java 后端线上踩坑实录】系列持续更新 SpringBoot3 JDK17/JDK21 线上真实故障复盘全部附带可复现代码与生产配置。 如果本文帮你避开坑欢迎点赞收藏关注我不错过后续实战内容。你们线上有没有遇到过 List 过大造成 OOM 故障当时怎么解决的欢迎评论区交流。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻