1. 从一次线上 OOM 说起:Mybatis Cursor 到底解决什么问题
如果你在项目里写过SELECT * FROM log这种没有分页的查询,并且表里数据量到了百万级,大概率遇到过java.lang.OutOfMemoryError: Java heap space。这不是 JVM 参数调大一点就能根治的问题,因为 Mybatis 默认的查询行为是:一次性把 ResultSet 里的所有行映射成 Java 对象,全部塞进List再返回。数据量一大,堆内存直接被撑爆。
Mybatis 提供了一个叫Cursor的返回类型,它的官方注释写得很直白:适合处理通常不适合放进内存的数百万项查询。核心机制是惰性获取——它不会一次性把全部结果读进内存,而是持有一个数据库游标,你迭代一次,它才从 ResultSet 里取一行(或一批)。这样内存占用从「全量数据」降到「单行 + 游标开销」,OOM 风险自然大幅下降。
但这里有个容易被忽略的坑:Cursor 必须配合事务或手动管理 SqlSession 生命周期。在 SpringBoot 里,SqlSession 默认只在 Mapper 方法调用期间存活,方法一返回,SqlSession 关闭,绑定的 Cursor 也跟着失效,你会拿到Cursor is closed或者直接抛异常。所以「用 Cursor 避免 OOM」这件事,一半是写法问题,一半是生命周期管理问题。
这篇内容聚焦三件事:Cursor 的正确写法与事务边界、结合 TaoToken 统一 Key/API 通道的配置骨架(config.toml / settings.json 可复制片段)、以及一套可执行的验证动作,帮你在真实项目里确认内存表现,而不是「看起来没报错」就完事。
2. TaoToken 前置:统一 Key 与 API 通道的配置骨架
在讲 Cursor 配置之前,先说明为什么这里会引入 TaoToken。很多团队在本地开发、CI、以及多个 AI 编码工具之间切换时,Key 和 API 地址散落在各处,改一个环境要动好几个文件。TaoToken 的作用是提供一个统一的 API 通道和 Key 管理入口,让模型对话、编码计划、控制台、API Keys 这些能力走同一套地址,减少配置漂移。
官网入口:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
API 基地址(不带 UTM):https://taotoken.net/api
你需要提前准备好的几个 deep link,后面配置和验证会用到:
- 模型对话:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite
- Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite
- 控制台:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite
- API Keys:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite
- 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
- ClaudeCodeAnthropic:https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claude_code&utm_campaign=rewrite
注意:TaoToken 在这里的角色是统一 Key/API 通道,不是数据库连接池,也不替代 Mybatis 本身。Cursor 的内存控制仍然由 Mybatis + JDBC 驱动负责,TaoToken 负责的是你调用模型能力时的通道一致性。
配置骨架分两份文件:一份给命令行/服务端工具用的config.toml,一份给编辑器类工具用的settings.json。两份都只放通道和 Key 引用,不硬编码明文 Key。
2.1 config.toml 片段
# ~/.taotoken/config.toml # 统一 API 通道配置,Key 从环境变量读取,避免明文入库 [api] base_url = "https://taotoken.net/api" timeout_seconds = 60 max_retries = 3 [auth] # 不要把真实 Key 写进文件,用环境变量注入 api_key_env = "TAOTOKEN_API_KEY" [models] default = "claude-sonnet" fallback = "gpt-4o-mini" [logging] level = "info" # 记录请求耗时,便于排查通道问题 log_latency = true2.2 settings.json 片段
{ "taotoken": { "baseUrl": "https://taotoken.net/api", "apiKeyEnv": "TAOTOKEN_API_KEY", "timeout": 60000, "retry": { "maxAttempts": 3, "backoffMs": 500 }, "features": { "modelChat": true, "codingPlan": true } } }环境变量注入方式(Linux/macOS):
export TAOTOKEN_API_KEY="你的Key" # 验证是否生效 echo $TAOTOKEN_API_KEY | head -c 8Windows PowerShell:
$env:TAOTOKEN_API_KEY = "你的Key" Write-Output $env:TAOTOKEN_API_KEY.Substring(0,8)Key 的获取入口在 API Keys 页面:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite
3. 可复制配置:Mybatis Cursor 写法 + 事务边界 + 批量参数
这一节是全文技术核心。Cursor 能不能真正避免 OOM,取决于四个点:Mapper 返回值、事务注解、fetchSize、以及迭代时的消费方式。
3.1 Mapper 返回值改成 Cursor
import org.apache.ibatis.cursor.Cursor; import org.apache.ibatis.annotations.Select; public interface LogMapper { @Select("SELECT id, level, message, created_at FROM log ORDER BY id") Cursor<Log> streamAll(); }关键点:返回值必须是Cursor<T>,不能是List<T>。Mybatis 在MethodSignature解析时,会判断returnsCursor = Cursor.class.equals(this.returnType),只有命中这个分支,才会走executeForCursor,最终调用doQueryCursor,而不是doQuery。
3.2 事务边界:两种方式二选一
方式一,在调用方法上加@Transactional:
import org.springframework.transaction.annotation.Transactional; @Service public class LogStreamService { private final LogMapper logMapper; public LogStreamService(LogMapper logMapper) { this.logMapper = logMapper; } @Transactional public void processAll() { try (Cursor<Log> cursor = logMapper.streamAll()) { cursor.forEach(log -> { // 逐行处理,内存只保留当前行 handle(log); }); } catch (IOException e) { throw new RuntimeException("cursor 关闭异常", e); } } private void handle(Log log) { // 业务处理 } }方式二,手动创建 SqlSession:
try (SqlSession session = sqlSessionFactory.openSession()) { LogMapper mapper = session.getMapper(LogMapper.class); try (Cursor<Log> cursor = mapper.streamAll()) { Iterator<Log> it = cursor.iterator(); while (it.hasNext()) { handle(it.next()); } } }注意:
Cursor实现了Closeable,务必用 try-with-resources 或显式 close,否则游标泄漏会拖垮数据库连接。
3.3 fetchSize:控制每次从数据库取多少行
Cursor 的惰性获取依赖 JDBC 的 fetchSize。默认值在不同驱动下不一样,MySQL 默认是「全量拉取」,这会让 Cursor 失去意义。必须显式设置:
@Select("SELECT id, level, message, created_at FROM log ORDER BY id") @Options(fetchSize = 1000) Cursor<Log> streamAll();或者在 XML 里:
<select id="streamAll" resultType="com.example.Log" fetchSize="1000"> SELECT id, level, message, created_at FROM log ORDER BY id </select>MySQL 要真正启用流式,连接串需要加useCursorFetch=true:
spring.datasource.url=jdbc:mysql://localhost:3306/demo?useCursorFetch=true&defaultFetchSize=1000PostgreSQL 则需要在自动提交关闭的前提下才能流式,所以事务注解不能省。
3.4 参数对照表
| 参数 | 作用 | 推荐值 | 不设置的后果 |
|---|---|---|---|
| fetchSize | 每次从 ResultSet 取的行数 | 500–2000 | MySQL 默认全量拉取,Cursor 失效 |
| useCursorFetch | MySQL 启用游标读取 | true | 流式不生效 |
| @Transactional | 延长 SqlSession 生命周期 | 必须 | Cursor is closed |
| try-with-resources | 确保游标关闭 | 必须 | 连接泄漏 |
| resultOrdered | 嵌套 resultMap 时保证顺序 | true(如需要) | 嵌套映射错乱 |
4. 验证请求与成功结果:确认内存真的降下来了
配置写完不代表生效。你需要一套可执行的验证动作,确认三件事:Cursor 是否真的流式、内存是否真的平稳、通道是否真的通。
4.1 验证 Cursor 是否流式
在handle方法里打印当前行号和线程内存:
private void handle(Log log) { long used = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(); System.out.println("row=" + log.getId() + " usedMB=" + (used / 1024 / 1024)); }如果内存随行数增长而基本平稳(在几十 MB 内波动),说明流式生效。如果 usedMB 一路飙升到几百 MB 甚至 OOM,说明 fetchSize 没生效,或者返回类型被解析成了 List。
4.2 验证事务边界
故意去掉@Transactional,你会看到:
org.apache.ibatis.exceptions.PersistenceException: Error attempting to get column ... Cursor is closed看到这个报错,反过来证明事务边界是 Cursor 存活的关键。加上注解后报错消失,说明生命周期管理正确。
4.3 验证 TaoToken 通道
用 curl 验证通道连通性:
curl -s -o /dev/null -w "%{http_code}\n" \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ https://taotoken.net/api/models返回 200 说明 Key 和通道正常。返回 401 检查 Key,返回 404 检查 base_url 是否多了斜杠。
4.4 成功结果长什么样
一次正常的验证输出应该类似:
row=1 usedMB=45 row=1000 usedMB=47 row=100000 usedMB=48 row=1000000 usedMB=49 cursor closed, total rows=1000000内存从 45MB 到 49MB,百万行数据只涨了 4MB,这就是 Cursor 该有的表现。对比普通List查询,同样数据量通常会直接 OOM 或占用 1GB 以上堆内存。
5. 本篇常见错排查
5.1 Cursor is closed
最常见。原因是没有事务或 SqlSession 提前关闭。解决:加@Transactional,或手动 openSession 并保证在 Cursor 消费完之前不关闭。
5.2 内存还是涨,Cursor 没生效
检查三点:Mapper 返回值是不是Cursor<T>;fetchSize 有没有设;MySQL 连接串有没有useCursorFetch=true。三者缺一,流式就不成立。
5.3 嵌套 resultMap 报错
Cursor 注释里明确写了:如果 resultMap 里用了 collection,SQL 必须用resultOrdered="true"并按 id 排序。否则嵌套映射会错乱。
5.4 迭代过程中执行其他查询
在 Cursor 迭代未结束时,同一个 SqlSession 上执行其他查询,可能因为连接被占用而阻塞或报错。建议把 Cursor 消费逻辑独立出来,不要在迭代中混用同一连接做写操作。
5.5 TaoToken 返回 401/403
Key 没注入或过期。检查环境变量名是否和配置文件里的api_key_env一致。重新生成 Key 的入口:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite
5.6 排障速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| Cursor is closed | 无事务 | 加 @Transactional |
| 内存飙升 | fetchSize 未生效 | 检查连接串和 @Options |
| 401 | Key 无效 | 重新注入环境变量 |
| 嵌套映射错乱 | 未 resultOrdered | SQL 加排序和 resultOrdered |
| 迭代阻塞 | 同连接混用 | 拆分消费逻辑 |
6. 落地建议与通道入口
Cursor 避免 OOM 的本质不是「换个返回类型」,而是把「一次性全量加载」改成「按需拉取 + 生命周期可控」。事务边界、fetchSize、连接串参数三者必须同时到位,缺一个都会退化成普通查询。验证时不要只看「没报错」,要打印内存曲线,确认百万行数据下堆内存平稳。
如果你在接入过程中遇到通道或 Key 的问题,优先走 API Keys 和接入文档:
- API Keys:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite
- 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
需要验证模型输出是否符合预期,用模型对话页面:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite
长期做编码和 Agent 场景,走 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite
最后留一个实操技巧:把 Cursor 消费逻辑包在一个独立的@Transactional方法里,方法内只做读取和轻量处理,重活丢到队列异步做。这样事务持有时间短,游标不会长时间占用连接,数据库和 JVM 两边都轻松。