
1. 項目概述當MyBatis遇上千萬級數據做后端開發尤其是處理數據報表、數據導出或者大數據量分析的場景你肯定遇到過這樣的頭疼時刻一個查詢需要返回幾十萬甚至上百萬條記錄。如果直接用傳統的ListT一次性加載到內存輕則接口響應緩慢內存飆升重則直接OutOfMemoryError服務掛掉。這時候MyBatis的流式查詢Streaming Query就成了你的救命稻草。它允許你像打開一個水龍頭一樣從數據庫里“流式”地、一條一條地獲取數據而不是把整桶水都先搬到內存里。今天我就結合自己處理千萬級數據導出的實戰經驗來徹底拆解MyBatis流式查詢的原理、實現、坑點以及最佳實踐。無論你是想優化現有的大數據查詢接口還是為即將到來的海量數據處理做準備這篇內容都能給你一套可直接落地的方案。2. 流式查詢的核心原理與為什么需要它2.1 傳統查詢的瓶頸全量加載之痛在深入流式查詢之前我們必須先搞清楚傳統方式為什么不行。當我們執行一個典型的MyBatis查詢例如select idselectLargeData resultTypecom.example.User SELECT id, name, email FROM user WHERE create_time #{startTime} /select對應的Mapper接口方法返回一個ListUser。MyBatis或者說底層的JDBC驅動在執行這個查詢時其默認行為是一次性將所有匹配的結果集從數據庫服務器通過網絡傳輸到應用服務器的內存中并封裝成完整的List對象。這個過程存在幾個致命問題內存壓力假設一條User記錄在內存中占用1KB1000萬條數據就是約10GB。JVM堆內存很可能無法容納直接導致OOM。網絡與數據庫壓力數據庫需要一次性準備并發送整個結果集這期間會長時間占用數據庫連接和網絡帶寬可能導致數據庫響應變慢影響其他查詢。響應延遲應用必須等待所有數據都傳輸、反序列化完成后才能開始處理并返回給客戶端。用戶會經歷漫長的等待體驗極差。2.2 流式查詢的工作機制細水長流流式查詢改變了這個范式。它的核心思想是保持數據庫游標Cursor打開然后讓應用像迭代器Iterator一樣一次從游標中獲取一條或一小批記錄進行處理處理完一條就丟棄一條或批量處理內存中始終只保持少量數據。其背后的技術棧是JDBC層面通過Statement.setFetchSize(Integer.MIN_VALUE)MySQL驅動或使用ResultSet.TYPE_FORWARD_ONLY和CONCUR_READ_ONLY模式并設置合適的fetchSize來告訴驅動我們想要流式獲取結果。MyBatis層面提供了CursorT接口作為流式查詢的返回類型。Cursor實現了IterableT和IteratorT你可以像遍歷普通集合一樣遍歷它但每次next()調用才會驅動JDBC從網絡連接中獲取下一條數據。數據庫層面以MySQL為例當使用流式結果集時數據庫服務器會保持結果集和相關的連接、資源處于打開狀態等待客戶端逐條請求數據。關鍵區別類比傳統查詢就像點外賣餐廳數據庫必須把所有菜數據都做好、打包好騎手網絡一次性全部送到你家內存你才能開始吃。流式查詢就像吃回轉壽司廚師數據庫不斷地把做好的壽司數據放在傳送帶連接上你應用坐在旁邊看到想吃需要處理的就拿下來吃完盤子就被收走內存釋放。你永遠不需要同時擁有所有的壽司。2.3 哪些場景必須使用流式查詢不是所有查詢都需要流式。引入流式查詢會帶來額外的復雜性如事務和連接管理。判斷標準很簡單數據量極大無法一次性裝入內存這是最直接的信號。當你預計查詢結果在數萬條以上且每條記錄字段較多、體積較大時就應該考慮流式。需要逐條或分批處理且處理邏輯可獨立例如數據導出為CSV/Excel文件、數據清洗后寫入另一個存儲系統如Elasticsearch、另一個數據庫、實時計算統計指標等。處理完的數據可以立即丟棄或轉移。需要提供實時或漸進式響應比如一個大型報表生成你可以邊查詢邊生成文件并即時提供下載鏈接或者通過WebSocket分批推送數據到前端提升用戶體驗。注意流式查詢并非為了提升“查詢速度”。實際上由于需要保持連接和游標整個處理過程的總耗時可能比一次性獲取更長。它的核心價值在于用時間換空間以及提供漸進式處理的能力避免內存瓶頸。3. MyBatis流式查詢的三種實現方式與選型MyBatis提供了不止一種方式來實現流式查詢每種方式各有優劣和適用場景。理解它們之間的區別是正確選型的關鍵。3.1 方式一使用CursorT接口推薦這是MyBatis官方最直接、最現代的支持方式。你只需要將Mapper方法的返回值定義為CursorT類型。定義Mapper接口import org.apache.ibatis.cursor.Cursor; public interface UserMapper { CursorUser selectLargeDataStream(Param(startTime) Date startTime); }編寫XML映射select idselectLargeDataStream resultTypecom.example.User SELECT id, name, email, create_time FROM user WHERE create_time #{startTime} ORDER BY id !-- 流式查詢強烈建議排序保證順序和可重復性 -- /select服務層調用與遍歷Service Transactional // 事務至關重要 public class DataExportService { Autowired private UserMapper userMapper; public void exportLargeData(Date startTime, OutputStream outputStream) { try (CursorUser cursor userMapper.selectLargeDataStream(startTime)) { CSVWriter writer new CSVWriter(new OutputStreamWriter(outputStream)); // 寫入表頭 writer.writeNext(new String[]{ID, Name, Email, Create Time}); for (User user : cursor) { // 這里開始逐條遍歷觸發數據獲取 // 處理每條數據例如寫入CSV writer.writeNext(new String[]{ String.valueOf(user.getId()), user.getName(), user.getEmail(), user.getCreateTime().toString() }); // 可選每處理1000條刷新一次輸出流避免內存堆積 if (cursor.getCurrentIndex() % 1000 0) { writer.flush(); } } writer.flush(); } catch (IOException e) { throw new RuntimeException(導出失敗, e); } // Cursor在try-with-resources中會自動關閉確保資源釋放 } }為什么推薦這種方式語義清晰CursorT類型明確表達了“這是一個流式查詢”。資源管理方便Cursor實現了AutoCloseable配合try-with-resources語法可以確保數據庫游標和連接被正確關閉避免資源泄漏。與Spring事務集成好在Transactional注解的方法內使用可以確保在整個遍歷過程中數據庫連接和事務保持一致。3.2 方式二使用ResultHandler更底層控制ResultHandler是一個回調接口。MyBatis在從數據庫獲取到每一行結果時都會調用這個接口的handleResult方法。這種方式將處理邏輯完全交給開發者控制粒度最細。定義ResultHandlerimport org.apache.ibatis.session.ResultHandler; public class UserExportResultHandler implements ResultHandlerUser { private final CSVWriter writer; private int count 0; public UserExportResultHandler(CSVWriter writer) { this.writer writer; writer.writeNext(new String[]{ID, Name, Email}); } Override public void handleResult(ResultContext? extends User resultContext) { User user resultContext.getResultObject(); // 處理單條記錄 writer.writeNext(new String[]{ String.valueOf(user.getId()), user.getName(), user.getEmail() }); count; if (count % 1000 0) { writer.flush(); } // 你甚至可以根據條件停止處理 // if (count 10000) { // resultContext.stop(); // } } public int getCount() { return count; } }Mapper接口和XML定義Mapper接口方法返回值為void并增加ResultHandler參數。public interface UserMapper { void selectLargeDataWithHandler(Param(startTime) Date startTime, ResultHandlerUser handler); }XML映射文件不需要特殊改動和普通查詢一樣。服務層調用Service Transactional public class DataExportServiceV2 { Autowired private UserMapper userMapper; public void exportLargeData(Date startTime, OutputStream outputStream) throws IOException { try (CSVWriter writer new CSVWriter(new OutputStreamWriter(outputStream))) { UserExportResultHandler handler new UserExportResultHandler(writer); // 執行查詢結果將通過handler處理 userMapper.selectLargeDataWithHandler(startTime, handler); writer.flush(); System.out.println(共處理數據: handler.getCount() 條); } } }適用場景與注意事項優點絕對的控制權可以在處理每條數據時做任何事甚至可以中途停止resultContext.stop()。缺點代碼更復雜需要自己創建和管理ResultHandler實例。資源關閉的邏輯也需要更小心主要關閉SqlSession。適用當你需要對結果集進行非常復雜的、有狀態的逐行處理時。3.3 方式三自定義ExecutorType為REUSE或BATCH誤區澄清網上有些資料會提到在SqlSession上設置ExecutorType為REUSE或BATCH來實現“流式”或“批量”效果。這里必須澄清一個常見的誤區ExecutorType.SIMPLE默認執行器。每次執行完語句就關閉Statement對象。ExecutorType.REUSE復用Statement對象。對于同一模式的SQL例如多次插入不同參數可以復用預編譯的Statement提升效率。但它不改變結果集的獲取方式。ExecutorType.BATCH批處理執行器。將多個更新操作INSERT, UPDATE, DELETE攢在一起一次性發送給數據庫大幅提升批量寫入性能。它只針對更新語句對SELECT查詢無效。結論ExecutorType主要用于優化寫入性能無法實現SELECT查詢的流式讀取。流式查詢的核心在于對ResultSet的處理方式而不是Statement的執行方式。實現流式查詢必須依靠Cursor或ResultHandler或者在JDBC層面直接設置fetchSize。3.4 選型決策指南特性CursorT方式ResultHandler方式易用性高。符合Java迭代器習慣代碼簡潔。中。需要實現回調接口代碼稍顯分散。控制粒度中。可以逐條處理也能獲取當前索引。高。可以訪問ResultContext能中途停止、跳過。資源管理優。支持try-with-resources自動關閉。需注意。需要在正確的作用域內確保SqlSession關閉。與Spring集成優。在Transactional中工作良好。良。同樣需要事務上下文。推薦場景絕大多數流式查詢場景如數據導出、批量轉換。需要精細控制處理流程或提前終止的場景。對于90%的開發者首選CursorT方式。它平衡了易用性、安全性和功能性。4. 流式查詢的實戰配置、陷阱與深度優化知道怎么用只是第一步用得好、不出錯才是關鍵。這部分是真正的干貨來自大量實戰踩坑后的總結。4.1 強制要求事務管理與連接持有這是流式查詢最核心、也最容易出錯的地方。流式查詢的本質是保持一個數據庫游標打開。而游標是依附于數據庫連接Connection和事務Transaction的。錯誤示范// 沒有事務注解 public void exportData() { CursorUser cursor userMapper.selectLargeDataStream(...); // 遍歷cursor... // 問題方法執行過程中MyBatis可能會在每次cursor.next()時從連接池獲取新連接 // 導致游標所在的連接被關閉拋出 Connection is closed 異常。 }正確做法必須確保整個遍歷過程在一個數據庫事務內從而保證始終使用同一個物理連接。Service public class ExportService { Transactional // 關鍵確保方法在一個事務內執行 public void exportWithTransaction() { try (CursorUser cursor mapper.selectLargeDataStream(...)) { for (User u : cursor) { // 處理數據 } } } }為什么Transactional會為這個方法創建一個事務上下文。Spring會為此上下文綁定一個獨立的數據庫連接。在整個方法執行期間所有數據庫操作包括Cursor的遍歷都使用這個連接游標得以保持。連接池注意事項常用的連接池如HikariCP、Druid都有連接回收機制。如果沒有事務保護連接可能在Cursor未關閉時就被回收到池中造成狀態混亂。事務阻止了連接被提前歸還。4.2 數據庫驅動與FetchSize的奧秘流式查詢的行為高度依賴于JDBC驅動的實現。不同數據庫、不同驅動版本配置可能不同。1. MySQL (mysql-connector-java)經典方式Statement.setFetchSize(Integer.MIN_VALUE)。這是告訴MySQL驅動使用流式結果集的“魔法值”。在MyBatis中可以通過在Mapper XML的select標簽里配置fetchSize屬性來實現。select idselectLargeDataStream fetchSize-2147483648 resultType... SELECT ... /select驅動版本的影響在較新的驅動版本如8.x中僅設置fetchSize為負值可能還不夠。你可能還需要在JDBC連接字符串中顯式指定使用流式讀取spring.datasource.urljdbc:mysql://localhost:3306/db?useCursorFetchtrue設置useCursorFetchtrue后fetchSize的正值表示每次從服務器獲取的行數實現了“客戶端游標”式的分批流式獲取對服務器更友好。2. PostgreSQLPostgreSQL的驅動對流式支持很好。通常只需要設置一個合理的正數fetchSize即可。select idselectLargeDataStream fetchSize1000 resultType... SELECT ... /select這里fetchSize1000意味著每次網絡往返從服務器獲取1000條記錄。這是一個平衡內存和網絡開銷的常用值。3. OracleOracle JDBC驅動默認就是流式的fetchSize默認是10。對于海量數據你可以根據情況調大fetchSize比如5000來減少網絡通信次數但要注意客戶端內存。實操心得fetchSize沒有銀彈。Integer.MIN_VALUEMySQL流式或一個較小的正數如1000是安全的起點。對于超大數據量可以嘗試調大fetchSize以減少網絡延遲的影響但務必在測試環境中監控客戶端內存使用。一定要查閱你所使用數據庫驅動的最新官方文檔。4.3 SQL語句的編寫禁忌不是所有SQL都適合流式查詢。必須排序ORDER BY流式處理通常意味著順序處理。如果沒有ORDER BY數據庫可能以任意順序返回數據。在多批次處理或中斷重試時可能導致數據重復或丟失。強烈建議使用一個唯一或遞增的字段如主鍵ID、創建時間進行排序。避免大字段BLOB, TEXT, CLOB流式查詢解決的是“行數多”的問題而不是“單行數據大”的問題。如果單行記錄包含一個幾十MB的BLOB字段即使只流式獲取一行也可能撐爆內存。對于包含大字段的表考慮分兩次查詢或者使用數據庫特定的流式讀取大對象API。使用覆蓋索引確保你的WHERE條件和ORDER BY字段能被索引覆蓋。流式查詢雖然減輕了客戶端壓力但數據庫服務器仍然需要執行完整的查詢。一個全表掃描的流式查詢對數據庫同樣是災難。使用EXPLAIN分析你的SQL。4.4 資源泄漏你必須關閉CursorCursor背后是打開的數據庫ResultSet和Statement。如果不關閉就會導致數據庫游標泄漏消耗服務器資源。數據庫連接無法及時釋放回連接池可能導致連接池耗盡。關閉的最佳實踐// 正確做法1: try-with-resources (Java 7) try (CursorUser cursor userMapper.selectLargeDataStream(...)) { for (User user : cursor) { // process } } // 無論是否異常cursor都會自動關閉 // 正確做法2: 在finally塊中手動關閉 CursorUser cursor null; try { cursor userMapper.selectLargeDataStream(...); // ... 遍歷處理 } finally { if (cursor ! null !cursor.isClosed()) { cursor.close(); } }絕對不要在遍歷到一半時直接return而不關閉Cursor。4.5 超時與中斷處理流式查詢可能運行很長時間。你需要考慮超時和用戶中斷。查詢超時可以在MyBatis的select標簽中設置timeout屬性單位秒或者在數據源連接字符串中配置socketTimeout。select idselectLargeDataStream timeout300 ... !-- 5分鐘超時 --事務超時如果你使用了Spring的Transactional可以設置事務超時Transactional(timeout 300)。注意這個超時是從事務開始算起如果事務中還做了其他操作需要留有余地。用戶中斷在Web應用中如果用戶取消了導出請求你需要有能力停止正在進行的流式查詢。這通常需要將Cursor的遍歷放在一個可中斷的線程中。提供一個取消接口該接口設置一個中斷標志。在遍歷循環中定期檢查這個中斷標志如果被中斷則調用cursor.close()并退出。 這是一個相對高級的特性需要結合具體的應用框架如Spring MVC的DeferredResult來實現。5. 性能調優與監控讓千萬級查詢飛起來處理千萬級數據光有流式查詢還不夠需要一套組合拳。5.1 分頁 vs 流式查詢如何選擇很多人面對大數據查詢第一反應是“分頁”。但分頁在處理超大數據量時存在嚴重問題深度分頁性能極差LIMIT 1000000, 100這種查詢數據庫需要先掃描并跳過前100萬條記錄成本極高。數據一致性風險如果數據在分頁過程中被增刪可能導致某一頁數據重復或丟失。決策指南使用流式查詢當你的目的是處理全部數據如導出、ETL、計算總和且不需要將全部數據同時呈現給用戶時。使用分頁當你的目的是在UI上展示數據且用戶只需要瀏覽其中一部分時。對于深度分頁應使用“游標分頁”或“seek method”即WHERE id last_id LIMIT 100利用索引避免偏移。兩者結合有時可以先用流式查詢處理數據將處理結果如聚合后的統計信息、生成的文件存儲起來再通過分頁提供給用戶查看。這是非常成熟的架構模式。5.2 應用層批處理減少I/O開銷即使使用流式查詢逐條獲取如果逐條寫入文件或調用遠程接口I/O效率也會極低。優化在應用層做批處理。try (CursorUser cursor userMapper.selectLargeDataStream(...)) { ListUser buffer new ArrayList(BATCH_SIZE); // 例如 BATCH_SIZE 1000 for (User user : cursor) { buffer.add(user); if (buffer.size() BATCH_SIZE) { // 批量處理寫入文件、插入ES、發送消息等 batchWriteToCSV(buffer, writer); buffer.clear(); writer.flush(); // 定期刷新輸出流 } } // 處理最后一批不滿 BATCH_SIZE 的數據 if (!buffer.isEmpty()) { batchWriteToCSV(buffer, writer); } }通過內存緩沖區積累一定數量的記錄后再進行批量I/O操作可以大幅減少系統調用或網絡請求的次數提升整體吞吐量。5.3 JVM內存與GC優化流式查詢的目標是降低內存壓力但如果處理邏輯不當仍然可能引起GC問題。避免在遍歷中積累數據最忌諱在遍歷Cursor時又將所有數據添加到一個新的ArrayList中這就失去了流式的意義。及時釋放對象引用對于每一條處理完的記錄確保沒有全局的或長時間存活的對象引用它。讓垃圾回收器可以及時回收。調整JVM參數雖然流式查詢降低了堆內存需求但頻繁創建和丟棄大量短期對象User對象可能加劇Young GC。可以適當調整新生代大小-Xmn并考慮使用G1或ZGC這類低延遲垃圾收集器來應對這種“高分配速率”的場景。5.4 數據庫層面的配合優化只查詢需要的字段SELECT *是萬惡之源。明確列出需要的字段減少網絡傳輸和內存占用。使用只讀事務對于純粹的導出查詢可以在Spring事務中設置只讀屬性Transactional(readOnly true)。這會給數據庫一個提示可能觸發一些優化。從庫查詢如果業務允許將這類消耗資源的分析型、導出型查詢路由到只讀從庫避免影響主庫的OLTP事務性能。6. 常見問題排查與實戰案例實錄這里記錄了幾個我在實際項目中遇到的典型問題及其解決方案。6.1 問題一遍歷Cursor時拋出“Connection is closed”異常現象在for (User user : cursor)循環中處理到一部分數據后突然拋出異常提示數據庫連接已關閉。根因分析缺少事務這是最常見的原因。沒有Transactional注解MyBatis可能在使用完一次連接后比如執行完Mapper方法就將其歸還給連接池。當遍歷Cursor需要再次讀取數據時使用的可能已經是另一個連接。事務傳播行為不當如果方法被另一個沒有事務的方法調用且事務傳播行為是REQUIRED默認則不會開啟新事務。需要檢查調用鏈。連接池超時連接池如Druid設置了removeAbandonedTimeout或idleTimeout長時間未歸還的連接被強制回收。流式查詢耗時過長觸發了這個機制。解決方案確保流式查詢的整個遍歷過程在一個Transactional方法內。檢查并調大連接池的超時參數確保其大于流式查詢處理的最大預估時間。對于超長任務考慮將連接池的testOnBorrow或validationQuery屬性打開確保取出的連接是有效的。6.2 問題二流式查詢速度比一次性查詢還慢現象改用Cursor后處理完所有數據的總時間反而變長了。根因分析網絡往返Round-Trip開銷如果fetchSize設置過小比如默認是1每獲取一條記錄都需要一次網絡通信延遲成為主要瓶頸。數據庫端游標開銷保持游標打開本身對數據庫有一定資源消耗特別是當有大量并發流式查詢時。客戶端處理邏輯過重如果每處理一條記錄都要進行復雜的計算或遠程調用那么I/O等待時間會掩蓋流式獲取的優勢。解決方案調整fetchSize根據網絡狀況調整。在局域網內可以設置為1000甚至更大。使用useCursorFetchtrueMySQL并設置一個合適的正數fetchSize。應用層批處理如前所述積累一定數量如1000條再批量處理減少I/O次數。優化SQL和索引確保查詢本身是高效的。流式解決的是內存問題不解決慢查詢問題。6.3 問題三內存使用仍然很高現象使用了Cursor但通過監控發現JVM堆內存使用率依然在持續上升。根因分析內存泄漏在遍歷Cursor時無意中將處理的對象添加到了某個全局集合如Map、List中導致所有對象都無法被GC回收。大對象駐留處理的單條記錄中包含大字段如長文本、Base64圖片即使只存在一條在內存中也可能占用很大空間。框架或驅動緩存某些ORM框架或JDBC驅動可能有內部緩存機制。排查與解決使用jmap或VisualVM等工具做堆轉儲分析查看內存中數量最多的對象是什么。審查處理邏輯確保處理完的對象引用被及時清除。對于大字段考慮在SQL中不查詢它們或者使用數據庫特定的流式API來分段讀取。6.4 一個完整的千萬級數據導出案例需求將過去一年超過2000萬的用戶訂單數據導出為CSV文件。技術棧Spring Boot MyBatis MySQL HikariCP實現步驟Mapper定義public interface OrderMapper { CursorOrderExportDTO streamOrdersForExport(Param(startDate) LocalDate startDate, Param(endDate) LocalDate endDate); }select idstreamOrdersForExport resultTypeOrderExportDTO fetchSize-2147483648 SELECT order_id, user_id, amount, status, create_time FROM orders WHERE create_time BETWEEN #{startDate} AND #{endDate} ORDER BY order_id ASC !-- 按主鍵排序保證順序且利于數據庫掃描 -- /selectService層Service Slf4j public class OrderExportService { private static final int BATCH_SIZE 2000; Transactional(readOnly true, timeout 7200) // 只讀事務2小時超時 public void exportOrdersToCsv(LocalDate startDate, LocalDate endDate, Path outputPath) throws IOException { long start System.currentTimeMillis(); try (BufferedWriter writer Files.newBufferedWriter(outputPath, StandardCharsets.UTF_8); CSVPrinter csvPrinter new CSVPrinter(writer, CSVFormat.DEFAULT.withHeader(HEADERS)); CursorOrderExportDTO cursor orderMapper.streamOrdersForExport(startDate, endDate)) { ListOrderExportDTO batch new ArrayList(BATCH_SIZE); for (OrderExportDTO order : cursor) { batch.add(order); if (batch.size() BATCH_SIZE) { writeBatchToCsv(csvPrinter, batch); batch.clear(); csvPrinter.flush(); // 定期刷新緩沖區到磁盤 } } // 處理剩余數據 if (!batch.isEmpty()) { writeBatchToCsv(csvPrinter, batch); } csvPrinter.flush(); } long duration (System.currentTimeMillis() - start) / 1000; log.info(訂單導出完成耗時: {} 秒, duration); } private void writeBatchToCsv(CSVPrinter printer, ListOrderExportDTO batch) throws IOException { for (OrderExportDTO order : batch) { printer.printRecord( order.getOrderId(), order.getUserId(), order.getAmount(), order.getStatus(), order.getCreateTime() ); } } }關鍵配置application.ymlspring: datasource: hikari: maximum-pool-size: 20 connection-timeout: 30000 idle-timeout: 600000 # 10分鐘確保長事務連接不被回收 max-lifetime: 1800000 # 30分鐘 url: jdbc:mysql://localhost:3306/order_db?useCursorFetchtrueserverTimezoneAsia/Shanghai mybatis: configuration: default-fetch-size: -2147483648 # 全局設置流式獲取監控與告警在導出服務中集成Metrics記錄導出速率行/秒、內存使用情況并設置耗時過長或內存異常的告警。通過這套方案我們成功將單次導出2000萬條訂單數據的內存占用從預期的數十GB如果全量加載降低到穩定的幾百MB批處理緩沖區任務總耗時在可控范圍內且對數據庫主庫的影響降到了最低。