☰
MyBatis 流式查询配 TaoToken:大数据量导出场景的 Cursor 骨架与验证
2026/9/27 10:17:07 网站建设 项目流程

1. 百万级导出为什么一上分页就崩

做数据导出的人大概率都遇到过这个场景:运营要一份近一年的订单明细,表里躺着三百多万行,你按常规思路写了个limit offset, size的分页循环,本地跑十万行还挺快,一上生产就出问题。要么是翻到第几百页时offset越来越大,MySQL 每次都要扫描并丢弃前面几十万行,单页耗时从 200ms 涨到 8s;要么是导出任务把连接池占满,其他接口开始超时报警。

分页查询的本质是「查一批、返回、再查下一批」,每次查询都是一次独立的 SQL 往返,数据量越大,offset的代价越夸张。而流式查询的思路完全不同:它只发一次 SQL,数据库端把结果集准备好,客户端通过游标一条一条地拉,拉完一条处理一条,内存里始终只有当前这一行(或这一小批)。对百万级导出这种「读多、算轻、写文件」的任务来说,流式几乎是天然匹配的方案。

MyBatis 对这件事的支持就是Cursor加ResultHandler。Cursor实现了Iterable,你可以像遍历集合一样遍历它,但底层是懒加载的;ResultHandler则是每拿到一行就回调一次,适合边读边写。两者都能避免一次性把结果集塞进List,区别在于Cursor更贴近「迭代器」的写法,ResultHandler更贴近「回调」的写法。

不过流式查询有个绕不开的坑:它要求数据库连接在整个读取过程中保持打开。而 MyBatis 的 Mapper 方法默认执行完就归还连接,于是你经常会看到那个经典报错——java.lang.IllegalStateException: A Cursor is already closed.。这篇就围绕这个报错,把可复制的配置骨架、用 TaoToken 统一 Key 接入 AI 辅助生成与校验、以及怎么用日志和内存对比验证流式真的生效,一次讲清楚。

2. 用 TaoToken 统一 Key 接入 AI 辅助生成与校验

写流式查询的配置骨架时,最容易出错的不是 SQL,而是「连接什么时候关、事务边界在哪、Cursor 在哪个作用域里消费」。这些细节靠记忆容易漏,我习惯让 AI 帮我生成一版骨架再逐行核对。但多个模型、多个工具各配一套 Key 很烦,所以我用 TaoToken 把通道统一了。

TaoToken 是一个统一的模型调用入口,官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 地址是 https://taotoken.net/api 。它的价值在于:你只需要维护一个 Key,就能在对话、编码、Agent 等不同场景里切换模型,不用为每个工具单独申请和轮换凭证。对写这种「生成配置 + 校验配置」的活来说,一个 Key 走通全流程会省很多事。

具体怎么拿 Key:进控制台 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,在 API Keys 页面创建一个新 Key,复制出来存到环境变量里,别硬编码进代码。创建入口在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。

拿到 Key 之后,我一般分两步用:第一步,把「MyBatis 流式查询 + Spring 事务 + Cursor 消费」的需求描述给模型,让它生成骨架;第二步,把生成的代码再丢回去,让它专门检查「连接是否会在消费前关闭」「异常时 Cursor 是否释放」这两个点。第二步比第一步重要,因为 AI 生成的骨架经常在事务传播行为上想当然。

如果你只是想让模型帮你解释某段 Cursor 代码,可以直接用模型对话入口 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite 。如果你是要长期在 IDE 里做编码辅助、反复生成和校验这类配置,那更适合用 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite ,把额度用在持续编码上比单次对话划算。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有各语言 SDK 的调用示例。

注意:TaoToken 是模型调用的统一通道,不是数据库中间件,也不参与你的 SQL 执行。它只负责帮你生成和校验代码,别把它和 MyBatis 的运行链路混在一起理解。

3. 可复制的 Cursor + ResultHandler 配置骨架

下面这套骨架我按「Mapper 定义 → 事务边界 → 消费逻辑」三层拆开,你可以直接抄进项目改包名。核心原则只有一条:Cursor 的消费必须发生在数据库连接打开的作用域内。

3.1 Mapper 层:返回 Cursor 而不是 List

@Mapper public interface OrderMapper { @Select("select id, order_no, amount, created_at from t_order where created_at >= #{start}") Cursor<OrderRow> streamByCreatedAt(@Param("start") LocalDate start); @Select("select id, order_no, amount, created_at from t_order where created_at >= #{start}") void streamWithHandler(@Param("start") LocalDate start, ResultHandler<OrderRow> handler); }

Cursor<OrderRow>和ResultHandler<OrderRow>是两种消费方式。前者返回一个可迭代对象,后者没有返回值,靠回调把每行推给你。注意@Select里不要写limit,流式的意义就是一次拉全量、分批消费。

3.2 事务层:用 TransactionTemplate 包住消费过程

@Service public class OrderExportService { private final OrderMapper orderMapper; private final TransactionTemplate transactionTemplate; public OrderExportService(OrderMapper orderMapper, PlatformTransactionManager txManager) { this.orderMapper = orderMapper; this.transactionTemplate = new TransactionTemplate(txManager); // 流式读取是只读操作,设为只读事务减少开销 this.transactionTemplate.setReadOnly(true); } public long export(LocalDate start, Consumer<OrderRow> sink) { Long count = transactionTemplate.execute(status -> { long n = 0; try (Cursor<OrderRow> cursor = orderMapper.streamByCreatedAt(start)) { for (OrderRow row : cursor) { sink.accept(row); n++; } } return n; }); return count == null ? 0 : count; } }

这里有几个关键点。第一,transactionTemplate.execute保证了整个for循环都在同一个连接上,Cursor 不会提前关闭。第二,try-with-resources保证异常时 Cursor 被释放。第三,setReadOnly(true)对只读导出是合理的优化,但如果你在消费过程中还要写库,就得去掉。

3.3 消费层:分批写文件而不是攒 List

public void exportToFile(LocalDate start, Path target) throws IOException { try (BufferedWriter writer = Files.newBufferedWriter(target)) { AtomicLong batchCounter = new AtomicLong(); export(start, row -> { try { writer.write(row.toCsvLine()); writer.newLine(); long c = batchCounter.incrementAndGet(); if (c % 10000 == 0) { log.info("streamed {} rows", c); } } catch (IOException e) { throw new UncheckedIOException(e); } }); } }

每写一行就落盘,内存里不攒数据。batchCounter每满一万打一条日志,这就是后面验证流式是否生效的依据。

3.4 关键参数对照

配置项分页方案流式方案说明
SQL 形态limit offset, size循环单次全量查询流式只发一次 SQL
连接占用每页一次往返全程占用一个连接流式要控制并发数
内存峰值与 pageSize 相关与单行大小相关流式内存平稳
事务要求无必须有打开的事务否则 Cursor 提前关闭
适用场景交互式分页展示批量导出/ETL场景不同别硬套

提示:流式查询会长时间占用连接,导出任务建议用独立的数据源或限制并发,别和线上接口抢同一个连接池。

4. 验证请求:分批日志与内存占用对比

配置写完不代表流式真的生效了。我见过不少人以为返回Cursor就是流式,结果底层驱动还是把结果集全量缓冲了。验证要抓两个信号:日志是不是分批出现的,内存是不是平稳的。

4.1 用分批日志确认「边读边处理」

启动导出后观察日志。如果是流式,你会看到streamed 10000 rows、streamed 20000 rows这样一条条往外冒,而且第一条日志出现的时间应该远早于任务结束时间。如果是伪流式(结果集被全量缓冲),日志会在任务快结束时才集中刷出来。

2024-06-01 10:00:01.120 INFO streamed 10000 rows 2024-06-01 10:00:02.340 INFO streamed 20000 rows 2024-06-01 10:00:03.560 INFO streamed 30000 rows ... 2024-06-01 10:01:45.900 INFO export finished, total=3120450

日志间隔均匀、持续输出,说明数据是流进来的,不是最后一次性倒出来的。

4.2 用内存曲线确认「不攒数据」

在导出方法前后打印堆内存:

Runtime rt = Runtime.getRuntime(); long before = rt.totalMemory() - rt.freeMemory(); exportToFile(start, target); System.gc(); long after = rt.totalMemory() - rt.freeMemory(); log.info("heap delta: {} MB", (after - before) / 1024 / 1024);

流式方案下,这个 delta 通常在几十 MB 以内,且和总行数关系不大。分页方案如果 pageSize 设得大,或者有人图省事把结果攒进List再统一写,delta 会随数据量线性上涨,三百万行轻松吃掉几个 G。

4.3 用 JDBC 抓包确认 fetchSize

MySQL 驱动要真正流式,需要fetchSize配合。在连接串上加参数:

jdbc:mysql://host:3306/db?useCursorFetch=true&defaultFetchSize=1000

useCursorFetch=true让驱动使用服务端游标,defaultFetchSize=1000表示每次从服务端拉 1000 行到客户端。没有这个参数,驱动可能一次性把整个结果集读进内存,你的Cursor就名不副实了。验证方法是开general_log或看驱动日志,确认没有出现一次性大结果集读取。

4.4 三种方案实测对比

方案300 万行耗时堆内存峰值连接占用
分页 offset约 12 分钟约 1.2G短时多次
分页 + 游标优化约 4 分钟约 800M短时多次
Cursor 流式约 2 分钟约 60M全程一个

数据因机器和表结构而异,但趋势是稳定的:流式在内存上优势巨大,耗时也更短,代价是连接占用时间长。

5. 本篇常见错排查

5.1 A Cursor is already closed

这是最高频的报错。原因就一个:Cursor 在连接关闭后才被消费。常见触发写法是 Mapper 方法直接返回Cursor,然后在没有事务的 Service 里for循环。修复方式就是第 3 节里的TransactionTemplate包裹,或者用SqlSessionFactory.openSession()手动管理连接。

5.2 流式了但内存还是涨

检查连接串有没有useCursorFetch=true。另外确认消费逻辑里没有把行对象攒进集合,比如有人写List<OrderRow> all = new ArrayList<>(); cursor.forEach(all::add);,这就等于把流式又变回了全量加载。

5.3 导出任务把连接池占满

流式全程占一个连接,如果同时跑十个导出任务,就要十个连接。给导出任务单独配一个小连接池,或者用信号量限制并发数,别让它和业务接口共用。

5.4 ResultHandler 里抛异常导致连接泄漏

ResultHandler的回调里如果抛了未检查异常,事务会回滚,但如果你手动管理SqlSession而没在finally里关闭,连接就泄漏了。统一用try-with-resources或TransactionTemplate,让框架帮你兜底。

5.5 只读事务里做写操作

setReadOnly(true)之后如果在消费过程中写库,可能被数据库拒绝或行为异常。导出就是导出,别在同一个事务里混写操作。

6. 接入与排障的下一步

流式查询的骨架本身不复杂,难的是把连接、事务、消费三者的边界理清楚,再用日志和内存把「真的流式」验证出来。上面这套配置你可以直接落地,遇到报错时优先看第 5 节的排查清单。

如果你在生成或校验这套配置时需要模型帮忙,用 TaoToken 的 API Keys 创建入口 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 拿 Key,接入细节看文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。只是想让模型解释某段 Cursor 代码,走模型对话 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite 就够。要长期在 IDE 里反复生成和校验这类数据访问配置,Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 更适合把额度用在持续编码上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询