从分页到流式:EasyExcel+MyBatis大数据导出性能跃迁实战

1. 从“内存爆炸”到“丝滑导出”:为什么传统分页导出扛不住了?

大家好,我是老码,一个在后台开发领域摸爬滚打了十多年的老兵。今天想和大家聊聊一个几乎所有后台系统都会遇到的“老大难”问题:大数据量的Excel导出。特别是当数据量上了十万、百万级别的时候,那个导出按钮一点,服务器可能就直接“躺平”给你看了。

我印象特别深,几年前接手一个老项目,里面有个“导出全市用户数据”的功能。当时用的是最经典的分页查询导出方案:前端点导出,后端吭哧吭哧地按每页5000条去数据库里捞数据,捞出来一批,往Excel里写一批,循环往复。数据量小的时候还行,直到有一次运营同学要导出一份近80万条的历史订单记录。好家伙,点了导出之后,页面就转圈圈,转了快十分钟,浏览器直接崩溃了。登录服务器一看,内存使用率飙到了98%,差点触发告警。这还不是最糟的,因为这个导出任务长时间占用数据库连接和大量内存,导致其他正常的业务接口响应也变慢了,整个系统体验卡顿。

这就是典型的传统分页导出遇到的瓶颈。它的工作原理很简单,我画个图大家就明白了:用户请求 -> 计算总条数 -> 分页查询(limit offset, size)-> 数据装入List -> 写入Excel -> 循环直到结束。这个过程有几个致命的“坑”:

  1. 内存杀手:每一次分页查询,MyBatis都会把查询到的几千条数据完整地映射成Java对象,全部加载到JVM堆内存中的一个List里。如果你有80万条数据,每页5000条,那就要在内存里来来回回组装160个巨大的List。虽然每个List在写入Excel后理论上可以被GC回收,但在高并发导出时,瞬间产生的内存压力是巨大的,极易引发Full GC甚至OOM(内存溢出)。
  2. 数据库的沉重负担LIMIT offset, size这种深度分页查询,对数据库(尤其是MySQL)是非常不友好的。offset值越大,数据库需要扫描和丢弃的行就越多。比如LIMIT 100000, 5000,数据库实际上需要先扫描100500行,然后扔掉前10万行,只返回最后的5000行。这个“扫描-丢弃”的过程非常消耗CPU和IO资源。
  3. 响应迟钝,用户体验差:用户点了导出,后端需要等所有数据都查完、处理完,才能生成完整的Excel文件并开始传输。在这之前,用户面对的是一个空白的等待页面,没有任何进度反馈。对于几十万的数据,这个等待时间可能是分钟级的,用户很可能以为系统卡死而反复点击,造成更严重的雪崩。

所以,当数据量上了规模,传统分页导出就像一辆载重小卡车非要拉几十吨的货,不仅自己跑不动,还把路(数据库和内存)给堵死了。我们必须换一种思路,从“批量搬运”改为“流水线作业”,这就是我们今天要讲的流式查询导出

2. 流式查询:像打开水龙头一样取数据

要解决上面那些问题,核心思路就一条:别一次性把所有数据都搬到内存里。能不能像打开水龙头一样,让数据一条一条地、或者一小批一小批地流过来,我这边边接收边处理边写入文件呢?当然可以,这就是流式查询(Streaming Query) 的精髓。

在MyBatis的语境里,流式查询通常和游标(Cursor) 这个概念绑定在一起。你可以把它想象成数据库给你的一根“吸管”。通过这根吸管,你可以慢慢地、持续地“吸”出数据,而不需要数据库把整桶水(全部结果集)先倒给你。

2.1 流式查询 vs. 游标查询:一字之差的微妙区别

这里有个非常关键且容易混淆的点:在MyBatis中,流式查询游标查询在代码写法上几乎一模一样,但它们底层与数据库的交互方式有本质区别,而这个区别就由一个参数决定:fetchSize

  • 游标查询:当设置 fetchSize = n (一个正整数,比如1000) 时,MyBatis/JDBC会告诉数据库:“我每次只要n条数据”。数据库服务器会使用服务器端游标,真的每次只准备n条数据通过网络发送给客户端。这需要数据库驱动的特殊支持(比如MySQL需要连接参数useCursorFetch=true)。
  • 流式查询:当设置 fetchSize = Integer.MIN_VALUE 时,这是一种特殊的信号。它告诉JDBC驱动:“我要用流式结果集”。此时,驱动会一次性执行完查询(数据库会把所有结果都准备好),但在传输时,它会利用TCP流的阻塞特性,控制网络数据包的发送节奏,实现客户端边读取、边传输的效果。它不一定依赖数据库的服务器端游标功能。

简单来说:

  • 游标查询是数据库“挤牙膏”,你喊一声它挤一点。
  • 流式查询是数据库“开闸放水”,但水龙头开关在你手里,你开一点,它流一点。

在实际使用中,特别是结合EasyExcel做导出,我们通常使用 fetchSize = Integer.MIN_VALUE流式查询方式。为什么呢?因为它兼容性更好(不强制要求数据库支持服务端游标),并且对于大数据量导出这种“只读一次、顺序向前”的场景,其性能表现已经足够优秀,能完美解决内存问题。

2.2 如何开启MyBatis流式查询?

开启流式查询非常简单,主要就是配置两个参数:resultSetTypefetchSize。有两种方式,注解式和XML式。

注解式配置(我最常用的方式):

public interface ExportMapper {
    @Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = Integer.MIN_VALUE)
    @ResultType(AreaCodeDto.class) // 明确指定返回类型,方便Cursor映射
    @Select("SELECT * FROM area_code WHERE parent_code = #{parentCode}")
    Cursor<AreaCodeDto> streamExport(@Param("parentCode") Long parentCode);
}

XML文件配置:

<select id="streamExport" resultType="com.example.dto.AreaCodeDto" 
        resultSetType="FORWARD_ONLY" fetchSize="-2147483648">
    SELECT * FROM area_code WHERE parent_code = #{parentCode}
</select>

参数解读:

  • resultSetType=FORWARD_ONLY:这是关键。它指定结果集是“只进”的,意味着你只能从第一条遍历到最后一条,不能回头。这正符合流式处理“一往无前”的特性,也是JDBC驱动能进行流式优化的前提。
  • fetchSize=Integer.MIN_VALUE:这就是我们上面说的“魔法值”,告诉驱动启用流式模式。
  • @ResultTyperesultType:必须指定。因为返回的是Cursor<T>,框架需要知道把每一行数据映射成什么Java对象。

一个必须注意的“坑”:事务 流式查询有一个非常重要的前提:必须在一个数据库事务中进行。因为流式查询需要保持与数据库的连接和结果集的状态,直到所有数据读取完毕。如果方法执行完,MyBatis自动关闭了连接,你的Cursor就变成“无源之水”,再遍历就会报错 Cursor is already closed

解决方法很简单,在调用了流式查询的Service方法上,加上 @Transactional 注解即可。这样,在整个方法执行期间,数据库连接会一直保持打

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值