TaoToken 流式查询实战:MyBatis Cursor 分页大数据查询
发布时间:2026/10/10 22:19:53 锦皓数字建站

1. 从一次 OOM 说起MyBatis Cursor 流式查询到底解决什么问题线上有个对账任务要从一张两千万行的流水表里捞出符合条件的数据逐条做状态比对再回写。最早用的是常规分页LIMIT offset, size一页一页翻。前几页很快翻到几十万页之后单次查询耗时从几十毫秒涨到几秒数据库 CPU 直接拉满。后来改成记录上次最大 id下次只查大于这个 id 的记录也就是常说的游标翻页效率确实稳了但代码里多了一堆进度记录逻辑中途失败还得考虑断点续跑维护成本不低。再后来换成 MyBatis 的Cursor也就是流式查询情况才真正好转。它做的事情很朴素查询成功后不一次性把结果集塞进内存而是返回一个迭代器应用每次从迭代器里取一条。数据库端保持结果集游标打开逐批把数据推给客户端。对两千万行的场景来说内存占用从整表加载降到一批几百行响应也从等全部查完变成边查边处理。流式查询适合谁我总结下来是三类场景一是数据量大到不能一次性加载进 JVM 的导出、对账、批处理任务二是需要边读边写、且对实时性有要求的同步作业三是 SQL 本身比较复杂、没法简单用 id 翻页优化的查询。如果你只是查个几万行的列表接口用普通分页就够了没必要上 Cursor反而增加事务和连接管理的复杂度。这篇就围绕 MyBatis Cursor 在分页大数据场景下的落地来讲从 ResultHandler 和 Cursor 的选型对比到 fetchSize、事务边界的设置再到用 EXPLAIN 和内存监控验证流式效果给出可以直接复制的配置和代码。中间会用到 TaoToken 的模型对话能力来辅助排查一些报错和生成配置片段这部分放在第二节讲。2. 前置准备用 TaoToken 辅助生成 MyBatis Cursor 配置与排错在动手改代码之前我习惯先把环境里的依赖版本、数据库类型、连接池类型这些信息理清楚因为 Cursor 的行为跟这几样东西强相关。比如 MySQL 需要useCursorFetchtruePostgreSQL 默认就支持服务端游标Druid 连接池在事务结束后会清理连接这些细节如果搞错流式查询要么不生效要么报一堆莫名其妙的错。TaoToken 在这里的用法是把报错信息、依赖版本、连接池配置贴给模型让它帮我判断问题出在哪或者直接生成一段符合当前版本的配置。它的模型对话入口在 https://taotoken.net/api 走的是标准 API 协议我用 curl 就能调不需要额外装客户端。先拿一个 API Key。登录官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 进控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 在 API Keys 页面创建一个。创建完记得复制保存页面刷新后就看不到完整 Key 了。拿到 Key 之后先验证一下模型对话能不能通。我用的是curl你也可以用 Postmancurl https://taotoken.net/api/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer sk-你的Key \ -d { model: claude-sonnet-4-20250514, messages: [ {role: user, content: MyBatis Cursor 流式查询在 MySQL 下需要设置什么参数才能生效} ] }返回里如果有choices[0].message.content说明链路通了。这一步很关键因为后面排查 Cursor 报错时我会把异常堆栈直接贴进去问比翻文档快。如果你更习惯在编辑器里用TaoToken 也支持接入 Claude Code 这类编码工具。接入方式是在工具里配置 Base URL 为https://taotoken.net/apiKey 填刚才创建的Model ID 填你用的模型名。这三件套配好之后写 Mapper、调 SQL、看报错都能在编辑器里完成。需要提醒的是TaoToken 在这里的角色是辅助排查和生成配置不是替代你的数据库客户端或 MyBatis 本身。Cursor 能不能流式最终取决于数据库驱动、连接池、事务这三者的配合模型只能帮你更快定位问题。3. 可复制配置MyBatis Cursor 分页查询的完整落地这一节给的是可以直接抄进项目的配置和代码。我按依赖配置 → 数据源配置 → Mapper 定义 → Service 调用的顺序来每一步都标清楚路径和参数含义。3.1 依赖与全局配置先确认pom.xml里有 MyBatis 或 MyBatis-Plus 的依赖。我用的是 MyBatis-Plus 3.5.xdependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency然后在application.yml里开启游标抓取。注意这个配置在 MyBatis-Plus 下是放在mybatis-plus.configuration.settings下mybatis-plus: configuration: settings: use-cursor-fetch: true default-fetch-size: 500 default-statement-timeout: 300use-cursor-fetch: true是让 MySQL 驱动使用服务端游标的关键。default-fetch-size: 500表示每次从数据库取 500 行到客户端这个值后面会讲怎么调。default-statement-timeout设 300 秒是因为流式查询可能跑很久默认超时容易中途断掉。如果你用的是原生 MyBatis配置放在mybatis-config.xmlconfiguration settings setting nameuseCursorFetch valuetrue/ setting namedefaultFetchSize value500/ /settings /configuration3.2 数据源连接参数MySQL 的 JDBC URL 必须带上useCursorFetchtrue否则服务端游标不生效fetchSize会被忽略驱动还是会一次性把结果拉回来spring: datasource: url: jdbc:mysql://127.0.0.1:3306/your_db?useCursorFetchtrueuseServerPrepStmtstruerewriteBatchedStatementstrue username: your_user password: your_pass driver-class-name: com.mysql.cj.jdbc.DriveruseServerPrepStmtstrue配合useCursorFetchtrue才能让 MySQL 走服务端游标。这两个参数缺一不可我踩过这个坑只加useCursorFetch没加useServerPrepStmts结果fetchSize设了 500 但内存还是爆因为驱动走了客户端游标把全部结果缓存了。3.3 Mapper 定义Mapper 接口的返回类型写成CursorT不要写ListTpublic interface CardMapper { CursorCardUpdateVO getCardCursorByBatchIdOrCardNum( Param(batchId) String batchId, Param(startingNumber) String startingNumber, Param(endingNumber) String endingNumber, Param(cardRange) Integer cardRange ); }XML 里设置resultSetTypeFORWARD_ONLY和fetchSizeselect idgetCardCursorByBatchIdOrCardNum resultTypecom.example.vo.CardUpdateVO resultSetTypeFORWARD_ONLY fetchSize500 SELECT M.BU_ID as buId, M.MEMBER_ID as memberId, C.CARD_ID as cardId, C.CARD_NUM as cardNum, C.STATUS_CD as statusCd FROM CUSTOMER_LOY_CARD C JOIN CUSTOMER_LOY_MEMBER M ON C.MEMBER_ID M.MEMBER_ID where if testcardRange 1 and startingNumber ! null and endingNumber ! null AND C.VISIBLE_CARD BETWEEN #{startingNumber} AND #{endingNumber} /if if testcardRange 2 and batchId ! null and batchId ! AND C.BATCH_ID #{batchId} /if /where /selectFORWARD_ONLY表示结果集只能向前遍历这是流式查询的默认也是推荐模式。fetchSize500是单次网络往返取的行数不是总行数。3.4 Service 调用与事务边界Cursor 必须在事务内使用因为事务没结束前连接不会归还游标才能保持打开。用try-with-resources保证游标一定关闭Transactional(rollbackFor Exception.class) public void syncCardStatus(String buId, CardQueryReq req) { int batchSize 1000; ListCardUpdateVO buffer new ArrayList(batchSize); try (CursorCardUpdateVO cursor cardMapper.getCardCursorByBatchIdOrCardNum( req.getBatchId(), req.getStartingNumber(), req.getEndingNumber(), req.getCardRange())) { for (CardUpdateVO vo : cursor) { buffer.add(vo); if (buffer.size() batchSize) { batchUpdate(buffer); buffer.clear(); } } if (!buffer.isEmpty()) { batchUpdate(buffer); } } catch (Exception e) { log.error(Cursor 处理失败, e); throw e; } }这里有几个点要注意。第一Transactional是必须的没有事务的话连接会在查询返回后立即关闭遍历时就会报Statement closed。第二try-with-resources保证即使中途抛异常游标也会关闭连接能正常归还。第三batchUpdate是在同一个事务里的如果它失败整个事务回滚游标也会关闭。如果你不想让整个方法都在一个长事务里可以把读和写拆开读用 Cursor 流式取取到一批就提交一次写。但这样就不能用Transactional包住整个方法了需要手动管理事务边界。我一般用TransactionTemplateAutowired private TransactionTemplate transactionTemplate; public void syncCardStatus(String buId, CardQueryReq req) { transactionTemplate.execute(status - { try (CursorCardUpdateVO cursor cardMapper.getCardCursorByBatchIdOrCardNum(...)) { // 遍历处理 } return null; }); }3.5 分页与流式的取舍有人会问既然 Cursor 是流式的那还需要分页吗答案是看场景。Cursor 本身不解决深度分页问题它解决的是内存占用问题。如果你的查询条件能走索引Cursor 会顺着索引一路读下去效率稳定。如果查询条件本身很烂比如LIKE %xxx%或者多表 JOIN 没有合适索引那 Cursor 也只是把一次低效查询变成一次低效查询 流式传输总耗时不会变好。所以我的做法是先用EXPLAIN确认查询能走索引再上 Cursor。如果EXPLAIN显示typeALL全表扫先加索引别急着改流式。4. 验证请求与成功结果用 EXPLAIN 和内存监控确认流式生效配置写完不代表流式就生效了。我见过太多以为开了流式其实还是全量加载的情况。这一节给两个验证动作一个看 SQL 执行计划一个看 JVM 内存。4.1 用 EXPLAIN 确认索引和扫描行数在数据库客户端里对同一条 SQL 跑EXPLAINEXPLAIN SELECT M.BU_ID, M.MEMBER_ID, C.CARD_ID, C.CARD_NUM, C.STATUS_CD FROM CUSTOMER_LOY_CARD C JOIN CUSTOMER_LOY_MEMBER M ON C.MEMBER_ID M.MEMBER_ID WHERE C.BATCH_ID B20240101;重点看三列type、key、rows。type最好是ref或rangekey显示实际用的索引rows是预估扫描行数。如果typeALL说明全表扫先加索引。如果key是NULL也是没走索引。流式查询下rows的预估值不影响内存但影响总耗时。因为 Cursor 是边读边传扫描行数越多总时间越长。所以EXPLAIN的作用是确认这条 SQL 本身是高效的而不是流式让它变高效。4.2 用 JVM 内存监控确认没有全量加载最直接的验证方式是看堆内存。在方法执行前后打印内存Runtime rt Runtime.getRuntime(); long before rt.totalMemory() - rt.freeMemory(); // 执行 Cursor 遍历 long after rt.totalMemory() - rt.freeMemory(); log.info(内存增量: {} MB, (after - before) / 1024 / 1024);如果流式生效内存增量应该稳定在fetchSize * 单行大小 * 常数的量级比如 500 行、每行 1KB增量大概几 MB。如果增量随着总行数线性增长比如两千万行涨到几个 GB说明流式没生效驱动还是把结果全缓存了。更精确的方式是用jvisualvm或arthas看堆直方图。我一般用 arthas 的dashboard命令观察old gen的增长曲线。流式查询下老年代应该基本平稳只有少量临时对象非流式下老年代会阶梯式上涨直到 GC 触发。4.3 用 TaoToken 模型对话验证配置如果你不确定自己的配置对不对可以把application.yml和 Mapper XML 贴给 TaoToken 的模型对话问它这段配置在 MySQL 8.0 MyBatis-Plus 3.5.5 下能否让 Cursor 流式生效。模型会逐项检查useCursorFetch、useServerPrepStmts、fetchSize、resultSetType这几个关键点比人肉翻文档快。调用方式还是走 APIcurl https://taotoken.net/api/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer sk-你的Key \ -d { model: claude-sonnet-4-20250514, messages: [ {role: user, content: 帮我检查这段 MyBatis Cursor 配置\n[贴配置]\n数据库是 MySQL 8.0连接池是 Druid问流式查询能否生效有哪些坑} ] }返回里会指出具体哪一项缺失或冲突。我实测下来它对useServerPrepStmts和useCursorFetch的配合关系判断得比较准能省不少排查时间。5. 常见报错排查401、local proxy failed、reading choices、OAuth这一节列几个我在用 Cursor TaoToken 过程中真实遇到的报错以及对应的排查动作。每个报错都给出触发场景和解决方式。5.1 401 Unauthorized调用 TaoToken API 时返回 401通常是 Key 没带对或者格式不对。检查两点一是Authorization头是不是Bearer sk-xxx格式Bearer和 Key 之间有一个空格二是 Key 有没有复制完整控制台里创建后只显示一次如果没保存就得重新创建。# 错误写法 -H Authorization: sk-xxx # 正确写法 -H Authorization: Bearer sk-xxx如果确认格式没问题还是 401去控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 看 Key 的状态是不是被禁用或者额度用完了。5.2 local proxy failed这个报错一般出现在编辑器插件或本地工具里提示本地代理失败。原因是工具配置的 Base URL 不对或者本地网络环境有干扰。检查工具里的 Base URL 是不是https://taotoken.net/api注意不要带多余的路径也不要带 UTM 参数。如果工具支持自定义 Header确认Content-Type: application/json有加上。还有一种情况是工具本身走了系统代理而系统代理配置有问题。这时候把工具的代理设置改成直连或不使用代理再试。5.3 reading choices 报错返回体里choices字段读取失败通常是响应结构跟预期不一致。可能的原因一是模型名写错了服务端返回了错误信息而不是正常的choices数组二是请求体 JSON 格式有问题比如多了逗号或者引号没转义。排查方式是把完整响应打印出来看curl -s https://taotoken.net/api/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer sk-你的Key \ -d {model:claude-sonnet-4-20250514,messages:[{role:user,content:test}]} | jq .如果返回里有error字段按错误信息处理。如果没有choices检查模型名是不是当前支持的。5.4 OAuth 相关报错如果你用的是 Claude Code 这类需要 OAuth 的工具接入 TaoToken 时可能会遇到 OAuth 报错。这类工具通常支持两种模式OAuth 登录和 API Key。接入 TaoToken 时选 API Key 模式Base URL 填https://taotoken.net/apiKey 填控制台创建的 KeyModel ID 填模型名。三件套配齐后OAuth 相关的报错就不会出现了。如果工具强制走 OAuth看它有没有自定义 API 端点的选项有的话切过去。没有的话换用支持 API Key 的客户端比如 Cline 或直接 curl。5.5 Cursor 遍历时报 Statement closed这个不是 TaoToken 的报错是 MyBatis Cursor 本身的。原因前面讲过没有事务或者事务提前结束了。检查方法上有没有Transactional以及try-with-resources的范围是不是包住了整个遍历过程。如果用了连接池还要确认连接池没有在事务结束前回收连接。Druid 连接池在 1.2.10 之前会打印No operations allowed after statement closed这是它在事务结束后清理连接导致的不影响数据但日志很烦。升级到 1.2.10 以上这个日志会降到 debug 级别。6. 长期编码与 Agent 场景用 Coding Plan 把 Cursor 调优做成常态流式查询的调优不是一次性的。随着数据量增长、表结构变化、索引调整fetchSize的最优值会变事务边界可能需要重新划分连接池参数也要跟着调。如果每次都要手动改配置、跑验证、看内存效率很低。我的做法是把这套流程固化下来用 TaoToken 的 Coding Plan 做长期的编码辅助把 Cursor 相关的配置模板、排查清单、验证脚本都沉淀进去。Coding Plan 入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 适合需要长期做编码和 Agent 任务的场景。具体怎么用比如我写了一个脚本每次改完 Cursor 配置后自动跑三件事一是对核心 SQL 跑EXPLAIN二是启动一个小的压测任务观察内存三是把结果贴给模型判断是否达标。这个脚本的骨架就是用 Coding Plan 生成的后面每次调整只需要改参数。再比如团队里新人接手这块代码时经常搞不清useCursorFetch和useServerPrepStmts的关系。我把常见问题和排查步骤整理成一个文档用模型对话生成初稿再人工校对。这样新人遇到报错时先查文档查不到再问模型最后才找人省了很多重复沟通。需要强调的是Coding Plan 和模型对话解决的是效率和知识沉淀问题不改变 Cursor 本身的运行机制。流式查询能不能生效最终还是看数据库驱动、连接池、事务这三者的配合。工具只是帮你更快定位和验证。如果你也在做大数据量的批处理任务建议先把EXPLAIN和内存监控这两个动作跑通再考虑上 Cursor。顺序反了的话很容易把SQL 本身慢误判成流式没生效白白折腾配置。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。