
1. 大结果集查询为什么会在 AI 辅助开发链路里翻车先说结论MyBatis 查询导致的内存溢出绝大多数不是数据库扛不住而是 JVM 堆被一次性加载的结果集撑爆了。这个问题在 AI 辅助开发链路里被放大的原因很直接——你让 AI 工具帮你生成 Mapper、写 SQL、跑批量任务时它默认写出来的往往是selectList全量返回本地跑小表没事一上真实数据量就 OOM。我先把场景拆清楚。假设你有一张order_record表几百万行某天要做一个「导出全部订单做离线分析」的需求。AI 助手给你生成的代码大概率长这样ListOrderRecord 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你自己决定每行怎么处理。CursorT真正的流式游标配合useCursorFetchtrue才能让 MySQL 服务端逐批下发。这四种手段不是互斥的实际项目里经常组合使用。接下来先讲接入前置再给可复制配置。2. TaoToken 统一 Key/API 通道接入 AI 工具的前置准备这一节解决「怎么把 AI 工具接进来」的问题。因为后面的压测验证、SQL 生成、结果分析都要靠 AI 工具配合通道不稳排障就无从谈起。TaoToken 在这里的角色是一个统一的模型调用入口你用一套 Key就能让不同的 AI 工具代码助手、对话工具、Agent 框架走同一个 API 通道。对做 MyBatis 内存优化这件事来说它的价值在于——你可以让 AI 帮你生成压测脚本、分析 GC 日志、审查 Mapper 配置而不用每个工具单独配一遍凭证。先拿 Key。打开控制台页面https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite在 API Keys 页面创建一个新 Key复制出来。这个 Key 就是后面所有工具共用的凭证。创建入口https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite拿到 Key 之后不同工具接入方式不一样但核心三件套永远是Base URL API Key Model ID。Base URL 统一用https://taotoken.net/api注意这个地址不带任何查询参数是纯 API 端点。Model ID 按你实际要用的模型填比如做代码审查可以选偏推理的模型做批量文本处理可以选偏快的模型。如果你用的是 Claude Code 这类命令行编码工具接入文档在这里https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewriteClaude Code 的接入方式是把 Base URL 和 Key 写进它的配置。具体来说你需要设置环境变量或者配置文件里的ANTHROPIC_BASE_URL和ANTHROPIC_API_KEY不同版本字段名可能略有差异以文档为准。配好之后Claude Code 发出的请求就会走 TaoToken 通道。如果你用的是 Cline 这类带 MCP 的编辑器插件接入时同样填三件套。Cline 的 MCP 配置里模型提供方选自定义/兼容 OpenAI 协议Base URL 填https://taotoken.net/apiKey 填你创建的那串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_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite3. 可复制的 MyBatis 配置片段与流式查询代码这一节是全文的技术核心直接给能抄的配置。我按「全局配置 → 数据源 → Mapper → 调用代码」的顺序来每一段都说明它解决什么问题。3.1 mybatis-config.xml 全局设置先看全局配置文件。defaultFetchSize和defaultStatementTimeout是两个关键项?xml version1.0 encodingUTF-8? !DOCTYPE configuration PUBLIC -//mybatis.org//DTD Config 3.0//EN http://mybatis.org/dtd/mybatis-3-config.dtd configuration settings !-- 每次网络往返取 500 行避免一次性拉全量 -- setting namedefaultFetchSize value500/ !-- 单条语句超时 30 秒防止慢查询拖垮连接池 -- setting namedefaultStatementTimeout value30/ !-- 开启驼峰映射减少手写 resultMap -- setting namemapUnderscoreToCamelCase valuetrue/ !-- 延迟加载避免关联对象一次性全查出来 -- setting namelazyLoadingEnabled valuetrue/ setting nameaggressiveLazyLoading valuefalse/ /settings /configurationdefaultFetchSize500的含义是JDBC 每次从数据库取 500 行到客户端。但要注意对 MySQL 默认驱动这个值只影响网络往返批次客户端仍可能把整个结果集攒起来。所以它必须配合下面的useCursorFetchtrue才真正生效为流式。3.2 数据源连接串MySQL 关键参数这是最容易被忽略、但决定流式是否真正生效的一环。以 HikariCP 为例spring: datasource: url: jdbc:mysql://127.0.0.1:3306/demo?useCursorFetchtrueuseServerPrepStmtstruerewriteBatchedStatementstruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: app_user password: your_password hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000 max-lifetime: 1800000三个参数的作用useCursorFetchtrue让 MySQL 服务端使用游标逐批下发数据而不是一次性把全部结果塞进客户端内存。这是流式查询的开关。useServerPrepStmtstrue启用服务端预处理语句配合游标使用。rewriteBatchedStatementstrue批量写入时合并语句和查询内存无关但批量场景常用。如果只设defaultFetchSize不设useCursorFetchtrueMySQL 驱动默认会把整个结果集加载到内存你的 fetchSize 形同虚设。这是很多人踩的坑。3.3 Mapper 接口三种返回类型对照同一个查询返回类型不同内存行为完全不同public interface OrderRecordMapper { // 危险全量 ListOOM 高发区 ListOrderRecord selectAllAsList(); // 分页每次只取一页 ListOrderRecord selectByPage(Param(offset) int offset, Param(limit) int limit); // 流式游标逐行处理堆占用恒定 Select(SELECT id, order_no, amount, create_time FROM order_record WHERE status #{status}) CursorOrderRecord selectByStatusCursor(Param(status) int status); }对应的 XML如果用注解就不需要select idselectByPage resultTypecom.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 (CursorOrderRecord 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 方案适合自定义处理如果你不想用 CursorResultHandler是另一种逐行方案public void handleAll(int status) { orderRecordMapper.selectByStatusWithHandler(status, new ResultHandlerOrderRecord() { Override public void handleResult(ResultContext? extends OrderRecord context) { OrderRecord record context.getResultObject(); // 逐行处理比如写入队列或文件 process(record); } }); }Mapper 方法签名void selectByStatusWithHandler(Param(status) int status, ResultHandlerOrderRecord handler);ResultHandler的好处是处理逻辑内聚坏处是不能像游标那样用 for-each且要注意不要在 handler 里做阻塞操作否则会拖长数据库连接占用时间。3.6 分页方案RowBounds 与 LIMIT 对比RowBounds是 MyBatis 的逻辑分页它会把全部结果查出来再在内存里跳过 offset 行大表上反而更危险// 不推荐逻辑分页内存里仍然全量 ListOrderRecord list sqlSession.selectList( com.example.mapper.OrderRecordMapper.selectAll, null, new RowBounds(0, 1000));推荐用物理分页LIMIT或者用 PageHelper 这类插件生成 LIMIT 语句。物理分页每次只查一页内存占用是 O(pageSize)。四种方案对照方案内存占用适用场景关键前提全量 ListO(总行数)小表、确定数据量小无defaultFetchSizeO(fetchSize) 起通用调优MySQL 需配 useCursorFetch物理分页 LIMITO(pageSize)需要翻页展示带 ORDER BYCursor 游标O(fetchSize)大批量导出/处理try-with-resources 关闭ResultHandlerO(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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 用存储过程批量插入这里示意插入 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(); ListOrderRecord all mapper.selectAllAsList(); System.out.println(全量行数 all.size() 堆增量 (usedHeap() - before1) / 1024 / 1024 MB); // 场景二游标 long before2 usedHeap(); int count 0; try (CursorOrderRecord 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_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite4.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观察状态。更直接的是对比开启前后堆占用——如果开了useCursorFetchtrue堆占用明显下降说明服务端游标生效了。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-xxxBearer 和 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_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite5.5 MyBatis 侧的内存相关报错除了 AI 通道的报错MyBatis 本身也有几个典型内存问题java.lang.OutOfMemoryError: Java heap space结果集太大用游标或分页解决。java.lang.OutOfMemoryError: GC overhead limit exceededGC 花太多时间回收很少内存本质还是对象太多同上。Cursor未关闭导致连接池耗尽报Connection is not available, request timed out。检查是否用了 try-with-resources。ResultHandler里抛异常导致连接不释放在 handler 里加 try-catch确保异常不会中断迭代。6. 把配置沉淀成团队规范长期编码与 Agent 协作建议前面五节把「怎么配、怎么验、怎么排」讲完了。最后一节聊怎么把这套东西固化下来避免下次又踩坑。第一把 MyBatis 配置模板化。在团队的项目脚手架里预置mybatis-config.xml和带useCursorFetchtrue的数据源模板新项目直接继承。这样 AI 工具生成代码时也会基于模板来不会默认写出全量selectList。第二给 Mapper 定规矩。超过一定行数的表禁止提供无分页的selectAll方法。可以在代码审查清单里加一条所有返回List的查询方法必须有分页参数或明确的数据量上限注释。第三压测纳入 CI。用一个轻量的集成测试在受限堆比如-Xmx256m下跑一遍大结果集查询确认不 OOM。这个测试可以本地跑不需要连生产库。第四AI 工具的使用边界要清楚。让 AI 帮你生成 SQL、审查配置、分析 GC 日志都没问题但不要让 AI 工具直连生产数据库执行查询。数据库连接始终由你的应用管理AI 只处理文本和代码。第五长期跑编码类 Agent 任务的话用 Coding Plan 更合适额度模型对持续调用更友好https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite第六Key 管理要规范。不同环境用不同 Key生产 Key 不要写进代码仓库。定期轮换。创建和管理入口https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite最后说一个我实际踩过的坑defaultFetchSize设了但没设useCursorFetchtrue压测时堆占用一点没降查了半天才发现是 MySQL 驱动默认把结果集全缓存了。所以配置项之间是有依赖关系的不能只看单个参数。把useCursorFetchtrue和defaultFetchSize当成一对来配再配合游标或分页内存才能真正控住。