1. 从“内存爆炸”到“丝滑导出”:为什么传统分页导出扛不住了?
大家好,我是老码,一个在后台开发领域摸爬滚打了十多年的老兵。今天想和大家聊聊一个几乎所有后台系统都会遇到的“老大难”问题:大数据量的Excel导出。特别是当数据量上了十万、百万级别的时候,那个导出按钮一点,服务器可能就直接“躺平”给你看了。
我印象特别深,几年前接手一个老项目,里面有个“导出全市用户数据”的功能。当时用的是最经典的分页查询导出方案:前端点导出,后端吭哧吭哧地按每页5000条去数据库里捞数据,捞出来一批,往Excel里写一批,循环往复。数据量小的时候还行,直到有一次运营同学要导出一份近80万条的历史订单记录。好家伙,点了导出之后,页面就转圈圈,转了快十分钟,浏览器直接崩溃了。登录服务器一看,内存使用率飙到了98%,差点触发告警。这还不是最糟的,因为这个导出任务长时间占用数据库连接和大量内存,导致其他正常的业务接口响应也变慢了,整个系统体验卡顿。
这就是典型的传统分页导出遇到的瓶颈。它的工作原理很简单,我画个图大家就明白了:用户请求 -> 计算总条数 -> 分页查询(limit offset, size)-> 数据装入List -> 写入Excel -> 循环直到结束。这个过程有几个致命的“坑”:
- 内存杀手:每一次分页查询,MyBatis都会把查询到的几千条数据完整地映射成Java对象,全部加载到JVM堆内存中的一个
List里。如果你有80万条数据,每页5000条,那就要在内存里来来回回组装160个巨大的List。虽然每个List在写入Excel后理论上可以被GC回收,但在高并发导出时,瞬间产生的内存压力是巨大的,极易引发Full GC甚至OOM(内存溢出)。 - 数据库的沉重负担:
LIMIT offset, size这种深度分页查询,对数据库(尤其是MySQL)是非常不友好的。offset值越大,数据库需要扫描和丢弃的行就越多。比如LIMIT 100000, 5000,数据库实际上需要先扫描100500行,然后扔掉前10万行,只返回最后的5000行。这个“扫描-丢弃”的过程非常消耗CPU和IO资源。 - 响应迟钝,用户体验差:用户点了导出,后端需要等所有数据都查完、处理完,才能生成完整的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流式查询?
开启流式查询非常简单,主要就是配置两个参数:resultSetType 和 fetchSize。有两种方式,注解式和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:这就是我们上面说的“魔法值”,告诉驱动启用流式模式。@ResultType或resultType:必须指定。因为返回的是Cursor<T>,框架需要知道把每一行数据映射成什么Java对象。
一个必须注意的“坑”:事务 流式查询有一个非常重要的前提:必须在一个数据库事务中进行。因为流式查询需要保持与数据库的连接和结果集的状态,直到所有数据读取完毕。如果方法执行完,MyBatis自动关闭了连接,你的Cursor就变成“无源之水”,再遍历就会报错 Cursor is already closed。
解决方法很简单,在调用了流式查询的Service方法上,加上 @Transactional 注解即可。这样,在整个方法执行期间,数据库连接会一直保持打


396

被折叠的 条评论
为什么被折叠?



