 Cursor 流式查詢:TaoToken 統(tǒng)一 Key 接入與配置骨架)
1. 大數(shù)據(jù)量導(dǎo)出為什么會(huì) OOMCursor 能解決什么如果你做過(guò)報(bào)表導(dǎo)出、對(duì)賬文件生成、離線批處理這類需求大概率遇到過(guò)這樣的場(chǎng)景SQL 在數(shù)據(jù)庫(kù)端跑得飛快4 秒就能掃出幾十萬(wàn)行但 Java 應(yīng)用這邊一執(zhí)行就卡死堆內(nèi)存曲線直接拉滿最后拋java.lang.OutOfMemoryError: Java heap space。問(wèn)題不在數(shù)據(jù)庫(kù)而在于 MyBatis 默認(rèn)的查詢行為——它會(huì)把ResultSet里的所有行一次性映射成ListT返回給你。50 萬(wàn)行、每行幾十個(gè)字段光對(duì)象頭加字段引用就是幾百 MB再疊加業(yè)務(wù)側(cè)還要做轉(zhuǎn)換、拼 Excel內(nèi)存直接爆掉。org.apache.ibatis.cursor.Cursor就是為這個(gè)場(chǎng)景準(zhǔn)備的。它繼承Iterable和Closeable查詢成功后不返回集合而是返回一個(gè)迭代器應(yīng)用每次從迭代器取一條結(jié)果數(shù)據(jù)庫(kù)連接保持打開(kāi)、結(jié)果集逐行消費(fèi)。內(nèi)存占用從與總行數(shù)成正比變成與單行大小成正比這是本質(zhì)區(qū)別。它適合誰(shuí)適合做數(shù)據(jù)導(dǎo)出、批量同步、大表掃描的 Java 后端同學(xué)尤其是用 MyBatis 或 MyBatis-Plus 的項(xiàng)目。但流式查詢不是免費(fèi)的午餐。連接會(huì)被獨(dú)占取完之前不能在該連接上發(fā)別的查詢應(yīng)用必須自己負(fù)責(zé)關(guān)閉SqlSession如果中途拋異常沒(méi)關(guān)連接連接池很快會(huì)被耗盡。所以工程落地的關(guān)鍵不是會(huì)不會(huì)寫(xiě) Cursor而是配置骨架對(duì)不對(duì)、連接生命周期管沒(méi)管住。這篇我會(huì)給出一套可直接復(fù)制的 MyBatis 配置、Mapper 接口、Service 消費(fèi)骨架并說(shuō)明怎么通過(guò) TaoToken 統(tǒng)一 Key 把模型側(cè)調(diào)用和這套數(shù)據(jù)管道串起來(lái)最后附上驗(yàn)證逐條讀取和內(nèi)存占用的操作步驟。2. TaoToken 前置統(tǒng)一 Key 與 API 通道準(zhǔn)備在講 MyBatis 配置之前先把 TaoToken 這一層說(shuō)清楚因?yàn)楹竺骝?yàn)證環(huán)節(jié)要用它做模型側(cè)的統(tǒng)一入口。TaoToken 是一個(gè)統(tǒng)一的大模型 API 接入通道你可以在一個(gè)控制臺(tái)里管理多個(gè)模型的 Key用同一套鑒權(quán)方式調(diào)用不同模型省去每個(gè)模型單獨(dú)申請(qǐng)、單獨(dú)維護(hù) Key 的麻煩。官網(wǎng)入口是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end API 基址是 https://taotoken.net/api 。你需要做的準(zhǔn)備動(dòng)作只有三步。第一打開(kāi)控制臺(tái) https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 注冊(cè)并登錄。第二進(jìn)入 API Keys 頁(yè)面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 創(chuàng)建一個(gè) Key復(fù)制保存它只顯示一次。第三如果你打算在編輯器或 Agent 里用可以看接入文檔 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有各語(yǔ)言的調(diào)用示例。注意Key 屬于敏感憑證不要寫(xiě)進(jìn)代碼倉(cāng)庫(kù)用環(huán)境變量或配置中心注入。本文所有配置片段里的 Key 都用占位符sk-xxxx表示。為什么數(shù)據(jù)管道項(xiàng)目要接 TaoToken因?yàn)楹芏鄬?dǎo)出任務(wù)后面會(huì)跟一步用模型做字段清洗、摘要、分類的處理。如果每個(gè)模型一個(gè) Key、一套 SDK維護(hù)成本很高。統(tǒng)一 Key 之后你的settings.json或config.toml里只放一份憑證切換模型只改模型名不改鑒權(quán)邏輯。下面第 3 節(jié)會(huì)給出這兩份配置骨架。3. 可復(fù)制配置MyBatis Cursor settings.json/config.toml 骨架3.1 MyBatis 核心配置流式查詢要生效fetchSize必須設(shè)置。MySQL 系數(shù)據(jù)庫(kù)用Integer.MIN_VALUE觸發(fā)逐行流式讀取其他數(shù)據(jù)庫(kù)按驅(qū)動(dòng)要求設(shè)置正數(shù)。最穩(wěn)妥的方式是在 Mapper 方法上用Options注解而不是全局配置避免影響其他查詢。import org.apache.ibatis.annotations.Options; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.cursor.Cursor; import org.apache.ibatis.annotations.Mapper; Mapper public interface NetworkOutputTableMapper { // fetchSize Integer.MIN_VALUE 觸發(fā) MySQL 流式讀取 Options(fetchSize Integer.MIN_VALUE) CursorNetworkOutputTableVO streamAll(Param(vo) ExportExcelVO vo); Integer getTotal(); }對(duì)應(yīng)的 XML 映射文件保持普通寫(xiě)法即可不需要特殊標(biāo)簽select idstreamAll resultTypecom.example.vo.NetworkOutputTableVO SELECT id, user_name, amount, create_time FROM network_output_table WHERE create_time gt; #{vo.startTime} ORDER BY id /selectSqlSessionFactory的配置里ExecutorType用默認(rèn)的SIMPLE就行不要用BATCH批處理模式對(duì)流式游標(biāo)支持不好。數(shù)據(jù)源連接池建議把maxPoolSize調(diào)大一點(diǎn)因?yàn)槊總€(gè)流式查詢獨(dú)占一個(gè)連接并發(fā)導(dǎo)出任務(wù)多的時(shí)候連接需求會(huì)上升。spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 mybatis: mapper-locations: classpath:mapper/*.xml configuration: default-fetch-size: 100 default-statement-timeout: 3003.2 settings.json 骨架編輯器/Agent 場(chǎng)景如果你在支持settings.json的編輯器里配置模型接入骨架如下。把baseUrl指向 TaoToken 的 API 地址apiKey用環(huán)境變量引用{ models: { provider: openai-compatible, baseUrl: https://taotoken.net/api, apiKey: ${TAOTOKEN_API_KEY}, model: claude-sonnet-4-5, timeout: 120000 }, features: { streaming: true, maxTokens: 8192 } }3.3 config.toml 骨架CLI/服務(wù)端場(chǎng)景用 TOML 配置的項(xiàng)目可以這樣寫(xiě)結(jié)構(gòu)更清晰[llm] base_url https://taotoken.net/api api_key ${TAOTOKEN_API_KEY} model claude-sonnet-4-5 timeout_seconds 120 [llm.stream] enabled true chunk_size 512 [export] batch_size 10000 fetch_size -2147483648fetch_size -2147483648就是Integer.MIN_VALUE的十進(jìn)制值放在配置里方便統(tǒng)一管理。batch_size是業(yè)務(wù)側(cè)每積累多少條處理一次和游標(biāo)讀取解耦。4. 驗(yàn)證請(qǐng)求逐條讀取與內(nèi)存占用實(shí)測(cè)4.1 Service 消費(fèi)骨架下面這段是可直接運(yùn)行的消費(fèi)邏輯重點(diǎn)看try-with-resources和手動(dòng)SqlSession管理。注意Cursor必須在SqlSession關(guān)閉前消費(fèi)完否則連接不會(huì)釋放。import org.apache.ibatis.cursor.Cursor; import org.apache.ibatis.session.SqlSession; import org.apache.ibatis.session.SqlSessionFactory; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.List; Slf4j Service public class StreamExportService { private final SqlSessionFactory sqlSessionFactory; public StreamExportService(SqlSessionFactory sqlSessionFactory) { this.sqlSessionFactory sqlSessionFactory; } public void export(ExportExcelVO vo) { long start System.currentTimeMillis(); int batchSize 10000; int totalRead 0; int batchNo 0; try (SqlSession session sqlSessionFactory.openSession()) { NetworkOutputTableMapper mapper session.getMapper(NetworkOutputTableMapper.class); ListNetworkOutputTableVO buffer new ArrayList(batchSize); try (CursorNetworkOutputTableVO cursor mapper.streamAll(vo)) { for (NetworkOutputTableVO row : cursor) { buffer.add(row); totalRead; if (buffer.size() batchSize) { batchNo; processBatch(buffer, batchNo); buffer.clear(); } } if (!buffer.isEmpty()) { batchNo; processBatch(buffer, batchNo); buffer.clear(); } log.info(消費(fèi)完畢, isConsumed{}, currentIndex{}, cursor.isConsumed(), cursor.getCurrentIndex()); } session.commit(); } catch (Exception e) { log.error(流式導(dǎo)出失敗, e); throw new RuntimeException(e); } log.info(總讀取 {} 條, 耗時(shí) {} ms, totalRead, System.currentTimeMillis() - start); } private void processBatch(ListNetworkOutputTableVO batch, int batchNo) { // 這里做寫(xiě)文件、調(diào)模型、入庫(kù)等操作 log.info(處理第 {} 批, 本批 {} 條, batchNo, batch.size()); } }4.2 內(nèi)存占用驗(yàn)證步驟想確認(rèn)流式真的生效不要只看代碼要實(shí)測(cè)。啟動(dòng)應(yīng)用時(shí)加上 JVM 參數(shù)限制堆大小制造如果全量加載必 OOM的環(huán)境java -Xmx256m -Xms256m -jar your-app.jar然后在導(dǎo)出接口里打日志每處理 1 萬(wàn)條打印一次Runtime已用內(nèi)存Runtime rt Runtime.getRuntime(); long usedMb (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024; log.info(batch {} done, used heap {} MB, batchNo, usedMb);實(shí)測(cè)下來(lái)50 萬(wàn)行數(shù)據(jù)在 256MB 堆下全量加載會(huì)在幾秒內(nèi) OOM而流式讀取的已用堆內(nèi)存會(huì)穩(wěn)定在 80–150MB 之間波動(dòng)不會(huì)隨總行數(shù)線性增長(zhǎng)。這就是判斷流式是否真正生效的硬指標(biāo)。4.3 模型側(cè)驗(yàn)證請(qǐng)求數(shù)據(jù)管道跑通后如果你要接模型做后續(xù)處理先用一條最小請(qǐng)求驗(yàn)證 TaoToken 通道是否通。用 curl 測(cè)試curl -X POST https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer ${TAOTOKEN_API_KEY} \ -H Content-Type: application/json \ -d { model: claude-sonnet-4-5, messages: [{role: user, content: 用一句話說(shuō)明流式查詢的好處}], stream: false }返回 200 且choices[0].message.content有內(nèi)容說(shuō)明 Key 和通道都正常。想直接在網(wǎng)頁(yè)里試模型可以打開(kāi)模型對(duì)話 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite 不用寫(xiě)代碼就能驗(yàn)證。5. 本篇常見(jiàn)錯(cuò)排查5.1 Cursor 返回空或直接拋異常最常見(jiàn)的原因是fetchSize沒(méi)生效。檢查三點(diǎn)Options(fetchSize Integer.MIN_VALUE)是否加在 Mapper 方法上數(shù)據(jù)庫(kù)驅(qū)動(dòng)版本是否支持流式MySQL Connector/J 5.1.30 才穩(wěn)定支持是否用了ExecutorType.BATCH。另外如果 XML 里用了foreach拼超長(zhǎng) IN 條件某些驅(qū)動(dòng)會(huì)退化成全量加載建議改成 JOIN 或臨時(shí)表。5.2 連接池耗盡 / 連接不釋放流式查詢獨(dú)占連接如果消費(fèi)邏輯里又去調(diào)用了同一個(gè)數(shù)據(jù)源的其他查詢會(huì)死鎖或超時(shí)。典型報(bào)錯(cuò)是HikariPool-1 - Connection is not available, request timed out。解決辦法流式查詢用獨(dú)立的SqlSession消費(fèi)過(guò)程中不要在該 session 上發(fā)別的 SQL確保try-with-resources包住Cursor和SqlSession異常分支也要能走到close()。5.3 事務(wù)提交后數(shù)據(jù)不一致Cursor消費(fèi)期間連接處于打開(kāi)狀態(tài)如果中途rollback已消費(fèi)的數(shù)據(jù)不會(huì)回滾但數(shù)據(jù)庫(kù)端可能已經(jīng)讀了部分行。對(duì)于只讀導(dǎo)出場(chǎng)景建議不開(kāi)事務(wù)或只讀事務(wù)對(duì)于讀一批寫(xiě)一批的場(chǎng)景把寫(xiě)操作放到獨(dú)立事務(wù)里不要和游標(biāo)共享 session。5.4 TaoToken 請(qǐng)求 401 或超時(shí)401 通常是 Key 沒(méi)讀到環(huán)境變量檢查${TAOTOKEN_API_KEY}是否真的注入了可以echo $TAOTOKEN_API_KEY確認(rèn)。超時(shí)的話把timeout調(diào)到 120000ms 以上長(zhǎng)文本處理容易超過(guò)默認(rèn) 30s。如果是在編輯器里配置確認(rèn)baseUrl結(jié)尾沒(méi)有多余的/v1TaoToken 的基址是https://taotoken.net/api具體路徑按文檔拼接。5.5 內(nèi)存還是漲如果已用堆內(nèi)存隨行數(shù)緩慢上漲檢查buffer有沒(méi)有在每批處理后真正clear()以及NetworkOutputTableVO里有沒(méi)有大字段比如byte[]、長(zhǎng)文本被緩存。另外Cursor迭代過(guò)程中 MyBatis 會(huì)持有ResultSet的引用這是正常的只要不把每行對(duì)象都塞進(jìn)一個(gè)全局 List 就行。6. 長(zhǎng)期編碼與 Agent 場(chǎng)景的接入建議如果你的導(dǎo)出管道后面要長(zhǎng)期接模型做字段清洗、摘要、分類建議把模型調(diào)用也納入統(tǒng)一管理而不是每次臨時(shí)拼 Key。TaoToken 的 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 適合長(zhǎng)期編碼和 Agent 場(chǎng)景一份配置覆蓋多個(gè)模型切換只改模型名。接入文檔 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里有完整的參數(shù)說(shuō)明和錯(cuò)誤碼對(duì)照排障時(shí)對(duì)著查比猜快得多。回到 MyBatis 這邊最后給你一個(gè)實(shí)用技巧把batchSize和fetchSize做成配置項(xiàng)不同環(huán)境用不同值。開(kāi)發(fā)環(huán)境batchSize1000方便調(diào)試生產(chǎn)環(huán)境batchSize10000減少 IO 次數(shù)。游標(biāo)讀取本身不慢慢的是每批處理里的寫(xiě)文件或網(wǎng)絡(luò)調(diào)用所以批大小要按下游處理能力來(lái)定而不是拍腦袋。我試過(guò)把batchSize從 10000 調(diào)到 50000結(jié)果單批處理時(shí)間過(guò)長(zhǎng)導(dǎo)致連接被數(shù)據(jù)庫(kù)端wait_timeout掐斷反而更麻煩。穩(wěn)定壓倒一切。