1. 大结果集查询为什么会在 AI 辅助开发链路里翻车
先说结论:MyBatis 查询导致的内存溢出,绝大多数不是数据库扛不住,而是 JVM 堆被一次性加载的结果集撑爆了。这个问题在 AI 辅助开发链路里被放大的原因很直接——你让 AI 工具帮你生成 Mapper、写 SQL、跑批量任务时,它默认写出来的往往是selectList全量返回,本地跑小表没事,一上真实数据量就 OOM。
我先把场景拆清楚。假设你有一张order_record表,几百万行,某天要做一个「导出全部订单做离线分析」的需求。AI 助手给你生成的代码大概率长这样:
List<OrderRecord> list = orderMapper.selectAll(); for (OrderRecord o : list) { // 处理 }这段代码的问题在于:selectAll()返回的List会把所有行都实例化成 Java 对象,全部驻留在堆里。一行OrderRecord假设 500 字节,500 万行就是 2.5GB 的堆占用,还没算 MyBatis 内部的结果映射开销。默认 JVM 堆往往只有 1~2GB,直接java.lang.OutOfMemoryError: Java heap space。
更隐蔽的是,AI 工具链里经常有「多轮对话 + 上下文拼接」的环节。你让 AI 帮你分析查询结果,它可能把整个结果集序列化成 JSON 塞进 prompt,这时候内存压力是双份的:一份在 MyBatis 结果集,一份在序列化后的字符串。所以控制内存不只是数据库层的事,而是整条链路的事。
那为什么强调「TaoToken 场景」?因为当你用统一的 Key/API 通道接入多个 AI 工具(比如代码补全、SQL 生成、结果分析)时,这些工具共享同一套后端服务进程。一个查询把堆打满,整个服务连带 AI 调用一起挂掉,影响面比单机脚本大得多。所以配置策略要按「生产级共享服务」的标准来做,而不是「本地跑通就行」。
核心矛盾就一句话:一次性加载 vs 流式读取 vs 分页,三者的内存曲线完全不同。一次性加载是 O(n),流式读取和游标是 O(fetchSize),分页是 O(pageSize)。下面逐个拆。
先明确几个概念,避免后面混淆:
defaultFetchSize:控制 JDBC 每次从数据库网络往返取多少行。注意它不等于「内存里只留这么多行」,对 MySQL 默认驱动来说,结果集仍可能在客户端累积。- 分页查询(RowBounds / LIMIT):把大查询拆成多次小查询,每次只物化一页。
ResultHandler:逐行回调,MyBatis 不帮你攒 List,你自己决定每行怎么处理。Cursor<T>:真正的流式游标,配合useCursorFetch=true才能让 MySQL 服务端逐批下发。
这四种手段不是互斥的,实际项目里经常组合使用。接下来先讲接入前置,再给可复制配置。
2. TaoToken 统一 Key/API 通道接入 AI 工具的前置准备
这一节解决「怎么把 AI 工具接进来」的问题。因为后面的压测验证、SQL 生成、结果分析都要靠 AI 工具配合,通道不稳,排障就无从谈起。
TaoToken 在这里的角色是一个统一的模型调用入口:你用一套 Key,就能让不同的 AI 工具(代码助手、对话工具、Agent 框架)走同一个 API 通道。对做 MyBatis 内存优化这件事来说,它的价值在于——你可以让 AI 帮你生成压测脚本、分析 GC 日志、审查 Mapper 配置,而不用每个工具单独配一遍凭证。
先拿 Key。打开控制台页面:
https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite在 API Keys 页面创建一个新 Key,复制出来。这个 Key 就是后面所有工具共用的凭证。创建入口:
https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite拿到 Key 之后,不同工具接入方式不一样,但核心三件套永远是:Base URL + API Key + Model ID。Base URL 统一用:
https://taotoken.net/api注意这个地址不带任何查询参数,是纯 API 端点。Model ID 按你实际要用的模型填,比如做代码审查可以选偏推理的模型,做批量文本处理可以选偏快的模型。
如果你用的是 Claude Code 这类命令行编码工具,接入文档在这里:
https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewriteClaude Code 的接入方式是把 Base URL 和 Key 写进它的配置。具体来说,你需要设置环境变量或者配置文件里的ANTHROPIC_BASE_URL和ANTHROPIC_API_KEY(不同版本字段名可能略有差异,以文档为准)。配好之后,Claude Code 发出的请求就会走 TaoToken 通道。
如果你用的是 Cline 这类带 MCP 的编辑器插件,接入时同样填三件套。Cline 的 MCP 配置里,模型提供方选自定义/兼容 OpenAI 协议,Base URL 填https://taotoken.net/api,Key 填你创建的那串,Model ID 填你要用的模型。这里要提醒一句:MCP 工具不要直连生产数据库,让它读配置文件、读日志、读代码就行,数据库连接交给你的应用自己管。
如果你用的是 Codex 这类工具,它的auth.json里需要写全三件套。典型结构是这样:
{ "base_url": "https://taotoken.net/api", "api_key": "sk-你的Key", "model": "你的ModelID" }字段名以你所用版本的实际要求为准,但 Base URL、Key、Model ID 这三样一个都不能少。少一个就是 401 或者 model not found。
配好之后怎么验证通道是通的?最直接的办法是发一个最小请求。用 curl 测:
curl https://taotoken.net/api/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk-你的Key" \ -d '{ "model": "你的ModelID", "messages": [{"role": "user", "content": "回复ok"}] }'返回里有choices字段就说明通道通了。如果返回 401,检查 Key 有没有复制全、有没有多余空格;如果返回 model 相关错误,检查 Model ID 拼写。
通道通了之后,你就可以让 AI 工具帮你做后面的事:生成压测代码、审查 MyBatis 配置、分析 OOM 堆转储。这一步是整个链路的地基,地基不稳,后面所有优化都白搭。
顺便说一句,如果你要长期跑编码类 Agent 任务,可以考虑 Coding Plan,额度模型更适合持续调用:
https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite3. 可复制的 MyBatis 配置片段与流式查询代码
这一节是全文的技术核心,直接给能抄的配置。我按「全局配置 → 数据源 → Mapper → 调用代码」的顺序来,每一段都说明它解决什么问题。
3.1 mybatis-config.xml 全局设置
先看全局配置文件。defaultFetchSize和defaultStatementTimeout是两个关键项:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd"> <configuration> <settings> <!-- 每次网络往返取 500 行,避免一次性拉全量 --> <setting name="defaultFetchSize" value="500"/> <!-- 单条语句超时 30 秒,防止慢查询拖垮连接池 --> <setting name="defaultStatementTimeout" value="30"/> <!-- 开启驼峰映射,减少手写 resultMap --> <setting name="mapUnderscoreToCamelCase" value="true"/> <!-- 延迟加载,避免关联对象一次性全查出来 --> <setting name="lazyLoadingEnabled" value="true"/> <setting name="aggressiveLazyLoading" value="false"/> </settings> </configuration>defaultFetchSize=500的含义是:JDBC 每次从数据库取 500 行到客户端。但要注意,对 MySQL 默认驱动,这个值只影响网络往返批次,客户端仍可能把整个结果集攒起来。所以它必须配合下面的useCursorFetch=true才真正生效为流式。
3.2 数据源连接串(MySQL 关键参数)
这是最容易被忽略、但决定流式是否真正生效的一环。以 HikariCP 为例:
spring: datasource: url: jdbc:mysql://127.0.0.1:3306/demo?useCursorFetch=true&useServerPrepStmts=true&rewriteBatchedStatements=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: app_user password: your_password hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000 max-lifetime: 1800000三个参数的作用:
useCursorFetch=true:让 MySQL 服务端使用游标,逐批下发数据,而不是一次性把全部结果塞进客户端内存。这是流式查询的开关。useServerPrepStmts=true:启用服务端预处理语句,配合游标使用。rewriteBatchedStatements=true:批量写入时合并语句,和查询内存无关,但批量场景常用。
如果只设defaultFetchSize不设useCursorFetch=true,MySQL 驱动默认会把整个结果集加载到内存,你的 fetchSize 形同虚设。这是很多人踩的坑。
3.3 Mapper 接口:三种返回类型对照
同一个查询,返回类型不同,内存行为完全不同:
public interface OrderRecordMapper { // 危险:全量 List,OOM 高发区 List<OrderRecord> selectAllAsList(); // 分页:每次只取一页 List<OrderRecord> selectByPage(@Param("offset") int offset, @Param("limit") int limit); // 流式游标:逐行处理,堆占用恒定 @Select("SELECT id, order_no, amount, create_time FROM order_record WHERE status = #{status}") Cursor<OrderRecord> selectByStatusCursor(@Param("status") int status); }对应的 XML(如果用注解就不需要):
<select id="selectByPage" resultType="com.example.entity.OrderRecord"> SELECT id, order_no, amount, create_time FROM order_record WHERE status = #{status} ORDER BY id LIMIT #{offset}, #{limit} </select>注意分页 SQL 一定要带ORDER BY,否则 LIMIT 的翻页结果不稳定,可能漏行或重复。
3.4 调用代码:游标 + try-with-resources
游标必须显式关闭,否则连接泄漏比 OOM 还难查:
@Service public class OrderExportService { @Autowired private OrderRecordMapper orderRecordMapper; public void exportToFile(int status, Path output) throws IOException { try (Cursor<OrderRecord> cursor = orderRecordMapper.selectByStatusCursor(status); BufferedWriter writer = Files.newBufferedWriter(output)) { for (OrderRecord record : cursor) { writer.write(record.getOrderNo() + "," + record.getAmount()); writer.newLine(); } } } }try-with-resources保证游标和文件流都会关闭。游标迭代过程中,MyBatis 会按 fetchSize 从服务端拉数据,堆里同时存在的对象数量大致等于 fetchSize,而不是总行数。
3.5 ResultHandler 方案(适合自定义处理)
如果你不想用 Cursor,ResultHandler是另一种逐行方案:
public void handleAll(int status) { orderRecordMapper.selectByStatusWithHandler(status, new ResultHandler<OrderRecord>() { @Override public void handleResult(ResultContext<? extends OrderRecord> context) { OrderRecord record = context.getResultObject(); // 逐行处理,比如写入队列或文件 process(record); } }); }Mapper 方法签名:
void selectByStatusWithHandler(@Param("status") int status, ResultHandler<OrderRecord> handler);ResultHandler的好处是处理逻辑内聚,坏处是不能像游标那样用 for-each,且要注意不要在 handler 里做阻塞操作,否则会拖长数据库连接占用时间。
3.6 分页方案(RowBounds 与 LIMIT 对比)
RowBounds是 MyBatis 的逻辑分页,它会把全部结果查出来再在内存里跳过 offset 行,大表上反而更危险:
// 不推荐:逻辑分页,内存里仍然全量 List<OrderRecord> list = sqlSession.selectList( "com.example.mapper.OrderRecordMapper.selectAll", null, new RowBounds(0, 1000));推荐用物理分页(LIMIT),或者用 PageHelper 这类插件生成 LIMIT 语句。物理分页每次只查一页,内存占用是 O(pageSize)。
四种方案对照:
| 方案 | 内存占用 | 适用场景 | 关键前提 |
|---|---|---|---|
| 全量 List | O(总行数) | 小表、确定数据量小 | 无 |
| defaultFetchSize | O(fetchSize) 起 | 通用调优 | MySQL 需配 useCursorFetch |
| 物理分页 LIMIT | O(pageSize) | 需要翻页展示 | 带 ORDER BY |
| Cursor 游标 | O(fetchSize) | 大批量导出/处理 | try-with-resources 关闭 |
| ResultHandler | O(fetchSize) | 自定义逐行处理 | 不在 handler 里阻塞 |
4. 压测验证:怎么确认内存真的降下来了
配置写完不代表生效,必须压测验证。这一节给可执行的验证动作。
4.1 造数据
先造一张有足够行数的表,500 万行起步才能看出差异:
CREATE TABLE order_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, amount DECIMAL(12,2) NOT NULL, status INT NOT NULL, create_time DATETIME NOT NULL, KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 用存储过程批量插入,这里示意插入 500 万行 DELIMITER $$ CREATE PROCEDURE gen_orders(IN total INT) BEGIN DECLARE i INT DEFAULT 0; WHILE i < total DO INSERT INTO order_record(order_no, amount, status, create_time) VALUES (CONCAT('NO', i), RAND()*1000, i % 5, NOW()); SET i = i + 1; END WHILE; END$$ DELIMITER ; CALL gen_orders(5000000);4.2 对比测试:全量 vs 游标
写一个简单的 JMH 或者直接 main 方法,分别跑两种查询,观察堆占用:
public class MemoryCompare { public static void main(String[] args) throws Exception { // 场景一:全量 List long before1 = usedHeap(); List<OrderRecord> all = mapper.selectAllAsList(); System.out.println("全量行数=" + all.size() + " 堆增量=" + (usedHeap() - before1) / 1024 / 1024 + "MB"); // 场景二:游标 long before2 = usedHeap(); int count = 0; try (Cursor<OrderRecord> cursor = mapper.selectByStatusCursor(1)) { for (OrderRecord r : cursor) { count++; } } System.out.println("游标行数=" + count + " 堆增量=" + (usedHeap() - before2) / 1024 / 1024 + "MB"); } static long usedHeap() { Runtime rt = Runtime.getRuntime(); return rt.totalMemory() - rt.freeMemory(); } }启动参数给一个受限堆,模拟生产环境:
java -Xms256m -Xmx512m -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/tmp/dump.hprof \ -cp target/classes com.example.MemoryCompare预期结果:全量 List 在 500 万行时直接抛OutOfMemoryError,并生成堆转储;游标方案堆增量稳定在几十 MB 以内,行数正常输出。
4.3 用 AI 工具辅助分析堆转储
OOM 之后拿到的dump.hprof可以用 MAT 分析,也可以让 AI 工具帮你读关键指标。把堆转储的摘要(不是整个文件)喂给模型,让它判断哪个对象占大头。这一步走 TaoToken 通道:
https://taotoken.net/api模型对话入口在这里,适合做这种分析类任务:
https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite4.4 观察 GC 日志
加 GC 日志参数,看 Full GC 频率:
java -Xms256m -Xmx512m \ -Xlog:gc*:file=/tmp/gc.log:time,uptime,level,tags \ -cp target/classes com.example.MemoryCompare全量方案会看到频繁 Full GC 甚至 GC overhead limit exceeded;游标方案 GC 平稳。这是最直观的对比。
4.5 验证 useCursorFetch 是否真的生效
有个简单办法:在 MySQL 端开 general log,看查询是否分批下发。或者用SHOW PROCESSLIST观察状态。更直接的是对比开启前后堆占用——如果开了useCursorFetch=true堆占用明显下降,说明服务端游标生效了。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth
这一节按真实报错来,每个都给定位思路。
5.1 401 Unauthorized
现象:调用 AI 接口返回 401,或者 MyBatis 应用启动时报认证失败。
排查顺序:
第一,检查 Key 是否复制完整。Key 通常有固定前缀,复制时容易漏掉尾部字符或带入空格。用echo -n "sk-xxx" | wc -c确认长度。
第二,检查请求头格式。必须是Authorization: Bearer sk-xxx,Bearer 和 Key 之间一个空格,不能多不能少。
第三,检查 Base URL 是否写错。正确是https://taotoken.net/api,不要多加/v1之外的路径,也不要带查询参数。
第四,如果用的是 Codex 的auth.json,确认base_url、api_key、model三个字段都填了。少一个字段可能报 401 或 model not found。
5.2 local proxy failed
现象:工具报local proxy failed或连接被拒绝。
这个报错通常出现在本地工具尝试通过某个本地端口转发请求时。定位思路:
第一,确认没有配置任何本地转发端口。Base URL 应该直接是https://taotoken.net/api,不要指向127.0.0.1:xxxx。
第二,检查工具的网络配置里有没有残留的 proxy 设置。有些工具会读环境变量,检查HTTP_PROXY、HTTPS_PROXY是否被设置成了无效值,如果有就清掉。
第三,确认本机 DNS 能解析taotoken.net,用nslookup taotoken.net或ping测一下。
第四,如果是容器环境,检查容器网络是否能出网。
5.3 reading choices 相关报错
现象:返回体解析时报reading 'choices'或cannot read property 'choices' of undefined。
这个错误的本质是:客户端期望返回 OpenAI 格式的choices数组,但实际返回的不是这个结构。可能原因:
第一,请求路径不对。Chat completions 的路径是/v1/chat/completions,如果路径写错,返回的可能是错误页或别的结构。
第二,Model ID 填错,服务端返回了错误信息而不是正常响应。检查返回体的原始内容,通常里面有error字段说明原因。
第三,请求体格式不对,比如messages不是数组,或者model字段缺失。用前面的 curl 命令先验证最小请求能通。
第四,流式和非流式混淆。如果请求里带了stream: true,返回的是 SSE 流,客户端按普通 JSON 解析就会失败。确认客户端支持流式解析。
5.4 OAuth 相关报错
现象:工具提示需要 OAuth 登录,或者 token 过期。
有些工具默认走 OAuth 流程,但接入自定义 API 通道时应该用 API Key 模式。定位思路:
第一,在工具设置里找「认证方式」,切换成 API Key,而不是 OAuth。
第二,如果工具强制 OAuth,检查是否有「自定义端点」选项,填上 Base URL 和 Key。
第三,Claude Code 这类工具,确认配置的是ANTHROPIC_API_KEY而不是 OAuth token。具体字段名以接入文档为准:
https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite5.5 MyBatis 侧的内存相关报错
除了 AI 通道的报错,MyBatis 本身也有几个典型内存问题:
java.lang.OutOfMemoryError: Java heap space:结果集太大,用游标或分页解决。
java.lang.OutOfMemoryError: GC overhead limit exceeded:GC 花太多时间回收很少内存,本质还是对象太多,同上。
Cursor未关闭导致连接池耗尽:报Connection is not available, request timed out。检查是否用了 try-with-resources。
ResultHandler里抛异常导致连接不释放:在 handler 里加 try-catch,确保异常不会中断迭代。
6. 把配置沉淀成团队规范:长期编码与 Agent 协作建议
前面五节把「怎么配、怎么验、怎么排」讲完了。最后一节聊怎么把这套东西固化下来,避免下次又踩坑。
第一,把 MyBatis 配置模板化。在团队的项目脚手架里预置mybatis-config.xml和带useCursorFetch=true的数据源模板,新项目直接继承。这样 AI 工具生成代码时,也会基于模板来,不会默认写出全量selectList。
第二,给 Mapper 定规矩。超过一定行数的表,禁止提供无分页的selectAll方法。可以在代码审查清单里加一条:所有返回List的查询方法,必须有分页参数或明确的数据量上限注释。
第三,压测纳入 CI。用一个轻量的集成测试,在受限堆(比如-Xmx256m)下跑一遍大结果集查询,确认不 OOM。这个测试可以本地跑,不需要连生产库。
第四,AI 工具的使用边界要清楚。让 AI 帮你生成 SQL、审查配置、分析 GC 日志都没问题,但不要让 AI 工具直连生产数据库执行查询。数据库连接始终由你的应用管理,AI 只处理文本和代码。
第五,长期跑编码类 Agent 任务的话,用 Coding Plan 更合适,额度模型对持续调用更友好:
https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite第六,Key 管理要规范。不同环境用不同 Key,生产 Key 不要写进代码仓库。定期轮换。创建和管理入口:
https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite最后说一个我实际踩过的坑:defaultFetchSize设了但没设useCursorFetch=true,压测时堆占用一点没降,查了半天才发现是 MySQL 驱动默认把结果集全缓存了。所以配置项之间是有依赖关系的,不能只看单个参数。把useCursorFetch=true和defaultFetchSize当成一对来配,再配合游标或分页,内存才能真正控住。