实战避坑:用MyBatis Cursor+Spring Boot优雅导出50万Excel,内存只占20MB

后台管理系统中最令人头疼的需求之一,莫过于"导出全部数据"这个看似简单的功能按钮。当产品经理轻描淡写地说"这个报表需要支持导出全部50万条记录"时,作为开发者的你是否感到后背发凉?传统分页查询导出耗时过长,一次性加载又可能导致服务内存溢出。本文将揭秘如何通过MyBatis Cursor与Spring Boot的黄金组合,实现内存占用仅20MB的百万级数据Excel导出方案。

1. 为什么传统导出方案会"爆内存"?

在电商订单导出或用户数据备份场景中,开发者常采用两种典型方案:

// 方案一:全量加载到内存
List<User> allUsers = userMapper.selectAll();
writeToExcel(allUsers); // 瞬间内存暴涨

// 方案二:分页批次处理
for(int i=0; i<totalPage; i++){
    Page<User> page = userMapper.selectPage(new Page(i, 5000));
    writeToExcel(page.getRecords()); // 多次IO操作耗时剧增
}

这两种方式的内存表现对比如下:

方案 50万条数据内存占用 耗时 数据库压力
全量加载 800MB~1.2GB 中等 一次性高
传统分页 50MB~100MB 很长 持续波动
Cursor流式 20MB以下 较短 平稳

内存杀手 的根源在于JDBC默认行为:执行查询时会先将所有结果集缓存在内存中。即使使用MyBatis的分页插件,也只是在应用层分页,无法解决数据库驱动层面的内存占用问题。

2. MyBatis Cursor的流式救赎方案

2.1 核心配置揭秘

让MyBatis真正实现流式查询的关键配置:

public interface UserMapper {
    @Select("SELECT * FROM large_user_table")
    @Options(fetchSize = Integer.MIN_VALUE)
    Cursor<User> selectAllWithCursor();
}

这个 fetchSize = Integer.MIN_VALUE 的魔法参数会触发MySQL JDBC驱动的流式结果集模式。其工作原理是:

  1. 数据库保持连接打开状态
  2. 逐条传输结果而非批量加载
  3. 应用端通过迭代器逐条消费

重要提示:必须保持事务开启状态直到Cursor处理完毕,否则连接关闭会导致数据获取中断

2.2 Spring Boot中的完整实现

结合Spring事务管理的典型服务层代码:

@Service
@RequiredArgsConstructor
public class ExportService {
    private final UserMapper userMapper;
    
    @Transactional(readOnly = true)
    public void exportLargeData(HttpServletResponse response) throws IOException {
        try (Cursor<User> cursor = userMapper.selectAllWithCursor();
             SXSSFWorkbook workbook = new SXSSFWorkbook(100)) {
             
            Sheet sheet = workbook.createSheet("Users");
            int rowNum = 0;
            
            for (User user : cursor) {
                Row row = sheet.createRow(rowNum++);
                row.createCell(0).setCellValue(user.getId());
                row.createCell(1).setCellValue(user.getName());
                // 其他字段处理...
                
                if(rowNum % 100 == 0) {
                    sheet.flushRows(); // 定期刷新行到磁盘
                }
            }
            
            response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
            workbook.write(response.getOutputStream());
        }
    }
}

这里组合了三个关键技术:

  1. MyBatis Cursor流式获取数据
  2. Apache POI的SXSSFWorkbook实现Excel流式写入
  3. Spring事务确保数据库连接持续可用

3. 性能优化与避坑指南

3.1 连接池关键配置

使用流式查询时,连接池需要特殊调整:

# application.yml
spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      connection-timeout: 60000
      max-lifetime: 1800000

需要特别注意:

  • 增大超时时间 :大数据量导出可能耗时较长
  • 避免连接泄漏 :确保在finally块中关闭Cursor
  • 隔离级别 :建议使用READ_COMMITTED避免长事务问题

3.2 内存监控对比

使用JVisualVM监控两种方案的内存曲线:

![内存占用对比图]

  • 传统方式:内存呈阶梯式上升,最终触发GC
  • Cursor方案:内存稳定在20MB左右波动

3.3 常见问题排查清单

  1. 数据中断

    • 检查是否在事务外使用Cursor
    • 确认数据库连接没有超时断开
  2. 导出速度慢

    • 调整SXSSFWorkbook的windowSize参数(默认100)
    • 检查数据库索引是否合理
  3. 内存仍然过高

    • 确保没有在循环中累积数据集合
    • 验证fetchSize确实设置为Integer.MIN_VALUE

4. 进阶场景与替代方案

4.1 超大规模数据导出

当数据量超过千万级时,可以考虑:

  1. 分片并行导出:
// 按ID范围分片处理
ExecutorService executor = Executors.newFixedThreadPool(4);
List<Future<File>> futures = new ArrayList<>();

for(int i=0; i<4; i++){
    final int segment = i;
    futures.add(executor.submit(() -> 
        exportSegment(segment * 250000, (segment+1) * 250000)
    ));
}
// 合并结果文件...
  1. 直接数据库导出:
-- MySQL直接导出到文件
SELECT * INTO OUTFILE '/tmp/export.csv'
FIELDS TERMINATED BY ',' 
FROM large_table;

4.2 其他数据库适配

不同数据库的流式配置差异:

数据库 流式查询关键配置 注意事项
MySQL fetchSize=Integer.MIN_VALUE 需要保持事务
Oracle fetchSize=100~500 建议使用READ_ONLY结果集
PostgreSQL fetchSize=1000 自动支持流式
SQL Server useCursorFetch=true 需要特殊连接参数

5. 生产环境实战建议

在实际项目中落地该方案时,建议采用以下策略:

  1. 渐进式实施

    • 先在非核心功能试点
    • 对比日志分析性能提升效果
    • 逐步替换原有导出接口
  2. 监控指标

    • 记录每次导出的内存峰值
    • 监控数据库连接占用时长
    • 统计导出失败率
  3. 用户体验优化

    • 前端添加进度提示
    • 支持断点续传
    • 提供异步导出+邮件通知

最近在处理一个跨境电商平台的订单导出需求时,这套方案成功将原本频繁OOM的导出功能优化为稳定支持百万级数据导出。关键发现是必须同时控制好POI的rowAccessWindowSize和MyBatis的fetchSize,两者协同才能达到最佳内存平衡点。

更多推荐