Cursor查詢實戰(zhàn):用TaoToken統(tǒng)一Key跑通流式讀取)
1. 百萬級數(shù)據(jù)導(dǎo)出為什么會 OOM從分頁查詢到 Mybatis Cursor 流式讀取先說結(jié)論Mybatis Cursor 查詢適合百萬級數(shù)據(jù)導(dǎo)出、批量同步、離線報表這類一次要讀很多行、但每行處理完就能丟的場景。它和分頁查詢最大的區(qū)別在于分頁是查一批、處理一批、再查下一批而 Cursor 是數(shù)據(jù)庫游標(biāo)一直開著應(yīng)用端一行一行地拉。前者要反復(fù)拼 SQL、維護(hù) offset后者靠 JDBC 的 fetchSize 控制每次網(wǎng)絡(luò)往返拉多少行。我見過太多項目在導(dǎo)出百萬級數(shù)據(jù)時直接select * from record然后ListRecord接住結(jié)果堆內(nèi)存瞬間飆到幾個 GGC 瘋狂停頓最后java.lang.OutOfMemoryError: Java heap space。也有人用分頁limit 0,10000、limit 10000,10000一路翻下去數(shù)據(jù)量一大 offset 越翻越慢深分頁在 MySQL 上幾乎是災(zāi)難。Cursor 的思路完全不同。它實現(xiàn)了Closeable和Iterable你可以用迭代器逐條拿數(shù)據(jù)數(shù)據(jù)庫連接在事務(wù)內(nèi)保持打開結(jié)果集不會一次性全部加載到 JVM 堆里。核心接口長這樣public interface CursorT extends Closeable, IterableT { boolean isOpen(); // 取數(shù)據(jù)前判斷游標(biāo)是否打開 boolean isConsumed(); // 判斷結(jié)果是否全部取完 int getCurrentIndex(); // 已獲取多少條 }這篇就圍繞Mybatis Cursor 流式查詢在百萬級數(shù)據(jù)導(dǎo)出場景下的落地來講從 Mapper 返回 Cursor到 try-with-resources 關(guān)閉再到 fetchSize 與 ResultHandler 的取舍最后給出可復(fù)制的配置片段、遍歷代碼和內(nèi)存占用對比驗證步驟。同時說明怎么用 TaoToken 統(tǒng)一 Key 管理調(diào)用憑證避免多工具切換時反復(fù)改配置。適合誰看正在做數(shù)據(jù)導(dǎo)出、批量同步、離線任務(wù)的后端同學(xué)被 OOM 和深分頁折磨過的想搞清楚 fetchSize 到底怎么生效的。下面每一步都能跟著做。2. TaoToken 前置準(zhǔn)備統(tǒng)一 Key 管理調(diào)用憑證避免多工具反復(fù)改配置在正式寫 Cursor 代碼之前先把調(diào)用憑證這件事理順。很多同學(xué)在做數(shù)據(jù)導(dǎo)出時往往不止一個工具在跑本地 IDE 里調(diào)試、CI 里跑批、線上定時任務(wù)每個環(huán)境一套 Key改來改去很容易出錯。TaoToken 的作用就是把這些調(diào)用憑證統(tǒng)一管理起來一個 Key 走通多個工具不用每次切環(huán)境都去翻配置文件。TaoToken 官網(wǎng)地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 。注意 API 地址不帶 UTM 參數(shù)配置的時候別把查詢串帶進(jìn)去否則有些客戶端會報簽名或路徑錯誤。你需要準(zhǔn)備的東西其實就三樣Base URL、API Key、Model ID。這三件套在后面的配置片段里會反復(fù)出現(xiàn)尤其是用 Claude Code、Cline、Codex 這類工具時缺一個都連不上。獲取 Key 的路徑是進(jìn)控制臺在 API Keys 頁面創(chuàng)建??刂婆_地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API Keys 頁面是 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。創(chuàng)建完記得復(fù)制保存頁面刷新后就看不到完整 Key 了。如果你只是想先驗證模型能不能通可以用模型對話頁面 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 直接發(fā)一條消息試試。長期做編碼和 Agent 任務(wù)的可以看 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。接入文檔在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 遇到配置問題先翻文檔比到處問快。這里要強調(diào)一點TaoToken 是統(tǒng)一管理調(diào)用憑證的服務(wù)不是讓你拿它去替代數(shù)據(jù)庫連接或者編輯器。Mybatis 的 Cursor 查詢走的是你自己的 JDBC 連接TaoToken 管的是你在開發(fā)、調(diào)試、跑批過程中調(diào)用模型能力的憑證。兩者是配合關(guān)系別搞混。把 Key 準(zhǔn)備好之后我們進(jìn)入正題先看 Mybatis 的配置怎么寫。3. 可復(fù)制配置Mybatis Cursor 的 fetchSize、事務(wù)與 Mapper 寫法這一節(jié)給的都是能直接抄的片段。先看 Mybatis 的核心配置。Cursor 能不能流式生效關(guān)鍵在fetchSize和resultSetType。在 Mybatis 的配置里defaultFetchSize可以設(shè)一個默認(rèn)值但更推薦在具體 Statement 上單獨指定因為不同查詢的數(shù)據(jù)量差別很大。下面是一個mybatis-config.xml的片段configuration settings setting namedefaultFetchSize value1000/ setting namedefaultStatementTimeout value300/ setting namemapUnderscoreToCamelCase valuetrue/ /settings /configuration如果你用的是 Spring Boot 的application.yml可以這樣配mybatis: configuration: default-fetch-size: 1000 default-statement-timeout: 300 map-underscore-to-camel-case: truefetchSize設(shè)成 1000 的意思是JDBC 每次從數(shù)據(jù)庫網(wǎng)絡(luò)往返拉 1000 行到客戶端。注意MySQL 的 JDBC 驅(qū)動要真正流式生效必須滿足兩個條件fetchSize設(shè)為Integer.MIN_VALUE或者配合useCursorFetchtrue并且resultSetType是FORWARD_ONLY。很多人配了 fetchSize 卻發(fā)現(xiàn)內(nèi)存還是爆就是因為驅(qū)動沒開 cursor fetch。MySQL 連接串建議這樣寫jdbc:mysql://127.0.0.1:3306/demo?useCursorFetchtrueuseServerPrepStmtstruerewriteBatchedStatementstrueuseCursorFetchtrue是讓驅(qū)動用服務(wù)端游標(biāo)useServerPrepStmtstrue配合服務(wù)端預(yù)處理。這兩個一起開fetchSize 才會按你設(shè)的值分批拉。然后是 Mapper。返回 Cursor 的方法簽名要寫對Mapper public interface RecordMapper { Select(select id, name, amount, created_at from record order by id) Options(fetchSize 1000, resultSetType ResultSetType.FORWARD_ONLY) CursorRecord streamAllRecords(); }Options里的fetchSize會覆蓋全局默認(rèn)值resultSetType FORWARD_ONLY保證結(jié)果集只能向前讀這是流式的前提。如果你用 XML 寫 SQL等價寫法是select idstreamAllRecords resultTypecom.demo.entity.Record fetchSize1000 resultSetTypeFORWARD_ONLY select id, name, amount, created_at from record order by id /select接下來是業(yè)務(wù)層。這里有個坑必須提前說Cursor 必須在事務(wù)內(nèi)使用。因為 Cursor 依賴數(shù)據(jù)庫連接保持打開如果方法沒有TransactionalMybatis 在查詢方法返回后就把連接還回連接池了你再遍歷 Cursor 就會報連接已關(guān)閉或者取不到數(shù)據(jù)。Service public class RecordExportService { Autowired private RecordMapper recordMapper; Transactional(readOnly true, timeout 600) public void exportAll() throws Exception { try (CursorRecord cursor recordMapper.streamAllRecords()) { cursor.forEach(record - { // 這里做單行處理寫文件、發(fā)消息、聚合統(tǒng)計 processOne(record); }); } } private void processOne(Record record) { // 具體處理邏輯 } }try-with-resources保證 Cursor 用完自動關(guān)閉即使中間拋異常也會關(guān)。Transactional(readOnly true)告訴數(shù)據(jù)庫這是只讀事務(wù)某些數(shù)據(jù)庫會做優(yōu)化。timeout 600是事務(wù)超時時間百萬級導(dǎo)出可能跑幾分鐘別用默認(rèn)的短超時。如果你用 Cline 或者 Claude Code 這類工具輔助寫代碼配置里同樣需要 Base URL、API Key、Model ID 三件套。以 Cline 的 MCP 配置為例一個典型的 settings 片段是這樣的{ mcpServers: { taotoken: { url: https://taotoken.net/api, headers: { Authorization: Bearer YOUR_API_KEY }, model: YOUR_MODEL_ID } } }注意url用的是https://taotoken.net/api不帶任何查詢參數(shù)。Authorization頭里放你的 Keymodel填你在控制臺看到的 Model ID。這三樣對齊了工具才能正常調(diào)用。Codex 的auth.json也是類似結(jié)構(gòu)把 Base URL、Key、Model ID 填進(jìn)去就行。CC Switch 切換配置時確保這三個字段跟著環(huán)境走別只改了 Key 忘了 Model ID。配置部分到這里就齊了。下一節(jié)我們實際跑一遍看請求怎么發(fā)、結(jié)果怎么驗證。4. 驗證請求與成功結(jié)果Cursor 遍歷、內(nèi)存對比與 ResultHandler 取舍配置寫完之后先做一個小數(shù)據(jù)量的驗證確認(rèn) Cursor 真的在流式讀而不是一次性加載。驗證分三步先跑通遍歷再看內(nèi)存占用最后對比 ResultHandler。第一步寫一個帶計數(shù)和日志的遍歷Transactional(readOnly true, timeout 600) public void exportWithLog() throws Exception { long count 0; long start System.currentTimeMillis(); try (CursorRecord cursor recordMapper.streamAllRecords()) { for (Record record : cursor) { count; if (count % 10000 0) { System.out.println(已處理 count 條當(dāng)前索引 cursor.getCurrentIndex()); } processOne(record); } } long cost System.currentTimeMillis() - start; System.out.println(總計 count 條耗時 cost ms); }跑起來之后你應(yīng)該能看到每處理 1 萬條打一行日志getCurrentIndex()的值跟著漲。如果日志只在最后一次性刷出來說明沒流式生效八成是 fetchSize 或 useCursorFetch 沒配對。第二步看內(nèi)存。在啟動參數(shù)里加上-Xmx256m故意把堆壓小。如果 Cursor 生效百萬級數(shù)據(jù)也能在 256M 堆里跑完如果沒生效跑到幾十萬條就 OOM 了。這是最直觀的驗證方式。你也可以用jconsole或者jstat -gc pid 1000觀察老年代增長流式讀取時老年代應(yīng)該基本平穩(wěn)不會階梯式上漲。第三步對比 ResultHandler。Mybatis 的ResultHandler是另一種處理大結(jié)果集的方式它不返回 Cursor而是在每行結(jié)果上回調(diào)Transactional(readOnly true) public void exportWithHandler() { recordMapper.scanAllRecords(context - { Record record context.getResultObject(); processOne(record); }); }對應(yīng)的 Mapper 方法返回voidSelect(select id, name, amount, created_at from record order by id) Options(fetchSize 1000, resultSetType ResultSetType.FORWARD_ONLY) void scanAllRecords(ResultHandlerRecord handler);兩者的取舍是這樣的Cursor 更靈活你可以在遍歷過程中隨時break、可以拿到getCurrentIndex()、可以嵌套其他邏輯適合需要精細(xì)控制的場景ResultHandler 更簡潔Mybatis 幫你管遍歷適合每行處理邏輯固定、不需要中斷的場景。但 ResultHandler 有個限制它和某些延遲加載、嵌套查詢配合時行為不太一樣而且你沒法在回調(diào)里方便地控制游標(biāo)狀態(tài)。實測下來百萬級導(dǎo)出我一般優(yōu)先用 Cursor因為可控性強出問題好排查。ResultHandler 適合那種純粹的讀一行寫一行的 ETL。驗證成功的標(biāo)志有三個日志按批次輸出、小堆內(nèi)存不 OOM、getCurrentIndex()單調(diào)遞增到總數(shù)。三個都滿足說明流式讀取真正跑通了。5. 本篇常見錯排查401、local proxy failed、reading choices、OAuth 報錯對照這一節(jié)把常見的報錯列出來對照著排查。注意有些報錯來自數(shù)據(jù)庫層有些來自工具調(diào)用層別混在一起。報錯一java.sql.SQLException: Streaming result set is still active這個通常是因為你在 Cursor 還沒關(guān)閉的時候又發(fā)起了同一個連接上的新查詢。解決辦法是確保 Cursor 在 try-with-resources 里用完即關(guān)別在遍歷過程中調(diào)用同一個 Mapper 的其他方法。如果確實需要嵌套查詢考慮換連接或者先把數(shù)據(jù)落盤。報錯二Connection is closed或取不到數(shù)據(jù)九成是忘了Transactional。Cursor 依賴事務(wù)維持連接方法上沒有事務(wù)注解查詢返回后連接就還回池子了。加上Transactional(readOnly true)即可。另外注意如果你在Transactional方法里又開了新線程去遍歷 Cursor也會出問題因為事務(wù)和連接是綁定線程的。報錯三401 Unauthorized這個一般出現(xiàn)在工具調(diào)用層不是數(shù)據(jù)庫層。檢查你的 API Key 是否正確、有沒有多余空格、Authorization頭格式是不是Bearer YOUR_API_KEY。如果用的是 TaoToken去 API Keys 頁面重新確認(rèn)一下 Key 狀態(tài)。401 基本都是憑證問題跟 Cursor 本身無關(guān)。報錯四local proxy failed這個報錯通常出現(xiàn)在客戶端配置了本地轉(zhuǎn)發(fā)但目標(biāo)地址不通的時候。檢查你的 Base URL 是不是寫成了https://taotoken.net/api有沒有誤加路徑或者查詢參數(shù)。有些工具會把 Base URL 和完整 endpoint 拼錯導(dǎo)致請求發(fā)到不存在的路徑。確認(rèn)配置里只有 Base URL具體路徑由工具自己拼。報錯五reading choices相關(guān)報錯這類報錯一般出現(xiàn)在解析模型返回結(jié)構(gòu)時返回體里沒有choices字段說明請求根本沒到模型或者返回了錯誤結(jié)構(gòu)。先確認(rèn) Base URL、Key、Model ID 三件套是否齊全再看返回的原始 body 是什么。常見原因是 Model ID 填錯或者請求被中間層攔截返回了 HTML 錯誤頁。報錯六OAuth相關(guān)報錯如果你用的是需要 OAuth 的工具報 OAuth 錯誤通常是 token 過期或者回調(diào)地址不匹配。這類問題跟 Mybatis Cursor 無關(guān)屬于工具鏈配置問題。檢查 token 有效期重新走一遍授權(quán)流程。如果工具支持 API Key 模式優(yōu)先用 Key比 OAuth 少一層折騰。報錯七fetchSize 設(shè)了但內(nèi)存還是漲回到第 3 節(jié)確認(rèn) MySQL 連接串帶了useCursorFetchtrue并且resultSetType是FORWARD_ONLY。另外PostgreSQL 的流式需要把 autoCommit 設(shè)為 false且 fetchSize 不能為 0。不同數(shù)據(jù)庫驅(qū)動行為不一樣別拿 MySQL 的配置直接套到 PG 上。排查順序建議先看是不是事務(wù)問題再看驅(qū)動配置最后看工具層憑證。數(shù)據(jù)庫層的錯和工具層的錯分開定位能省很多時間。6. 把 Cursor 流式讀取接進(jìn)你的日常任務(wù)憑證統(tǒng)一與長期編碼Cursor 跑通之后接下來就是把它接進(jìn)日常任務(wù)。百萬級導(dǎo)出、批量同步、離線報表這些場景都可以用同一套模式Mapper 返回 CursorService 層用 try-with-resources 包住事務(wù)注解別忘fetchSize 按數(shù)據(jù)量調(diào)。憑證這塊如果你同時在用多個工具做開發(fā)建議統(tǒng)一走 TaoToken 管理。一個 Key 走通模型對話、編碼輔助、跑批腳本不用每個工具配一套。需要驗證模型能力時用模型對話頁面長期做編碼和 Agent 任務(wù)時看 Coding Plan接入細(xì)節(jié)翻文檔。這樣切換環(huán)境時只改一處減少配置漂移。最后留幾個實用技巧。第一導(dǎo)出任務(wù)盡量在低峰期跑流式讀取雖然省內(nèi)存但長時間持有數(shù)據(jù)庫連接會占用連接池資源連接池大小要留夠。第二fetchSize不是越大越好1000 到 5000 之間比較穩(wěn)太大反而增加單次網(wǎng)絡(luò)傳輸壓力。第三處理邏輯里如果有遠(yuǎn)程調(diào)用考慮批量聚合后再發(fā)別一行一次否則百萬次遠(yuǎn)程調(diào)用比 OOM 還慢。第四記得給導(dǎo)出任務(wù)加監(jiān)控記錄處理條數(shù)和耗時出問題能快速定位是卡在數(shù)據(jù)庫還是卡在業(yè)務(wù)處理。把這些串起來你的百萬級數(shù)據(jù)導(dǎo)出就能穩(wěn)定跑在有限內(nèi)存里憑證管理也不再是負(fù)擔(dān)。