← 返回题目列表

MyBatis 查询百万级数据会 OOM,怎么用 Cursor 或 ResultHandler 流式处理?

困难 第 23 / 24 题 更新于 2026/07/28
CursorResultHandler流式查询大结果集

简化版

用普通的 selectList 查一张几百万行的表,MyBatis 会把结果全部加载到内存的 List 里,直接 OOM。解决办法是「流式查询」——不一次性把所有数据读进内存,而是「一条一条(或一批一批)地处理」,处理完一条就丢,内存里始终只有少量数据。MyBatis 提供两种流式方式:Cursor<T>(游标,推荐)——Mapper 方法返回 Cursor<T>,它是一个可迭代的游标,用 for 遍历时逐条从数据库拉取,处理完的对象可被 GC,内存占用恒定;ResultHandler——传一个回调,MyBatis 每查出一条就回调你的 handleResult 处理一条,不返回 List。但光靠这两个还不够——还必须让数据库驱动也「流式」返回,否则驱动会先把全部结果拉到客户端内存(等于没流式):MySQL 要设 fetchSize = Integer.MIN_VALUE(或用游标 fetch),配合 ResultSetType.FORWARD_ONLY核心:流式查询 = MyBatis 层用 Cursor/ResultHandler 逐条处理 + JDBC 驱动层配置成流式返回,两者缺一不可。

详细版

普通查询 vs 流式查询

维度selectList(普通)Cursor/ResultHandler(流式)
数据加载一次性全部进内存 List逐条/逐批拉取
内存占用随结果集线性增长(易 OOM)恒定(少量)
适用结果集小(几千几万)结果集大(百万级)
驱动配合无需特殊配置必须配 fetchSize 流式
// ① Cursor 方式(推荐,返回可迭代游标)
@Select("SELECT * FROM big_table")
@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = Integer.MIN_VALUE)
Cursor<User> scanAll();

// 使用:必须在事务/SqlSession 打开期间遍历
@Transactional
public void process() {
    try (Cursor<User> cursor = userMapper.scanAll()) {
        for (User user : cursor) {      // 逐条拉取、逐条处理
            handle(user);               // 处理完这条,它就能被 GC
        }
    }   // 用完关闭游标
}

// ② ResultHandler 方式(回调处理每一条)
sqlSession.select("scanAll", new ResultHandler<User>() {
    public void handleResult(ResultContext<? extends User> ctx) {
        handle(ctx.getResultObject());  // 每查出一条回调一次
    }
});

⚠️ 流式查询最容易踩的坑是「只在 MyBatis 层用了 Cursor,但 JDBC 驱动还是把全部结果拉到了内存」——等于白流式。JDBC 默认行为是「执行查询后把整个结果集读到客户端内存」(fetchSize 默认拉全部)。所以流式的两个必要条件:① MyBatis 层用 Cursor/ResultHandler 逐条处理;② JDBC 层配置成流式返回——MySQL 必须设 fetchSize = Integer.MIN_VALUE(这是 MySQL 驱动开启流式的「魔法值」)+ FORWARD_ONLY,PostgreSQL 要关自动提交并设合理 fetchSize。少了第二个,驱动照样把百万行全拉进内存,Cursor 也救不了你。另外,遍历 Cursor 期间必须保持 SqlSession/连接/事务打开(游标依赖活着的连接),所以通常在一个 @Transactional 方法里完整遍历完。

完整版教学

一、问题:大结果集为什么 OOM

先理解「普通查询查大表为什么会 OOM」:

selectList 查一张 500 万行的表:
  UserMapper.selectList()  → List<User> (500万个 User 对象)

MyBatis 的默认行为:
  1. 执行 SQL
  2. 把结果集的每一行映射成 User 对象
  3. 全部塞进一个 ArrayList
  4. 返回这个装了 500 万对象的 List

问题:
  500 万个对象同时在内存里 → 可能几个 GB → 堆放不下 → OOM

根源:
  "一次性全量加载"——所有数据同时驻留内存
  数据量小(几千几万)没问题,百万级就爆了

想要的:不要同时装那么多,能"处理完一条丢一条"
  → 内存里始终只有少量数据 → 不 OOM

大结果集 OOM 的根源是「一次性全量加载」——selectList 把所有行都映射成对象塞进一个 List 返回,500 万个对象同时驻留内存可能几个 GB,堆放不下就 OOM。数据量小没问题,百万级就爆。想要的是「处理完一条丢一条」,内存里始终只有少量数据。理解「selectList 一次性全量加载所有对象进 List、百万级同时驻留内存 OOM、要处理完一条丢一条」,就理解了流式查询要解决的问题。

二、流式查询的思路:逐条处理

流式查询的核心思路是「不攒到一起,边拉边处理」:

普通查询(批处理式):
  查库 → 全部读进内存 List → 返回 → 你遍历 List
  (数据先全部到内存,再处理)

流式查询(流水线式):
  查库 → 拉一条 → 处理一条 → 丢弃 → 拉下一条 → ...
  (数据像水流一样,一条条流过,处理完就丢)

内存对比:
  普通:内存 = 全部数据(500万对象)
  流式:内存 = 当前处理的一条(或一小批 fetchSize)

关键:处理完的对象没有被 List 持有 → 可以被 GC 回收
  → 内存占用恒定(和总数据量无关)

代价:
  - 遍历期间要保持连接/事务打开(游标依赖活连接)
  - 不能随机访问、只能顺序遍历一遍
  - 处理慢会长时间占用连接

流式查询的思路是「流水线式:拉一条→处理一条→丢弃→拉下一条」,而非「批处理式:全部读进内存再处理」。内存占用恒定(只有当前处理的一条或一小批),因为处理完的对象不被 List 持有、可被 GC。代价是遍历期间要保持连接/事务打开、只能顺序遍历一遍、处理慢会长占连接。理解「流式=流水线逐条处理丢弃、内存恒定(处理完可 GC)、代价是要保持连接打开且只能顺序遍历」,就理解了流式查询的核心思路。

三、Cursor:可迭代的游标

MyBatis 的第一种流式方式是 Cursor<T>(推荐):

Cursor<T> 是一个"可迭代的游标":
  Mapper 方法返回类型写成 Cursor<T>(不是 List<T>)
    @Select("SELECT * FROM big_table")
    Cursor<User> scanAll();

  遍历它:
    try (Cursor<User> cursor = mapper.scanAll()) {
        for (User u : cursor) {   // 每次 next 从数据库拉一条
            handle(u);
        }
    }

特点:
  - 遍历时"惰性拉取":for 循环每次迭代才从结果集取下一条
  - 处理完的 User 不被持有 → 可 GC → 内存恒定
  - Cursor 实现 Closeable,用完要 close(try-with-resources)
  - 只能遍历一次、顺序向前

必须的前提:
  - 遍历期间 SqlSession/连接 要打开(游标依赖活连接)
    → 通常放在 @Transactional 方法里,整个遍历在一个事务内完成
  - 还要配 JDBC 驱动流式(见后)

第一种流式方式是 Cursor<T>(可迭代游标)——Mapper 方法返回 Cursor<T>(不是 List<T>),用 try-with-resources + for 遍历,每次迭代惰性地从数据库拉一条,处理完的对象可 GC、内存恒定。CursorCloseable,用完要关;只能顺序遍历一次;遍历期间要保持 SqlSession/连接打开(通常放 @Transactional 方法里整个遍历在一个事务内)。理解「Cursor 返回可迭代游标、for 遍历惰性逐条拉取、处理完可 GC 内存恒定、要 try-with-resources 关闭、遍历期间保持连接打开(放 @Transactional)」,就掌握了 Cursor 方式。

四、ResultHandler:回调处理每一条

第二种流式方式是 ResultHandler(回调式):

ResultHandler 是一个"处理每一条结果的回调":
  不返回 List,而是每查出一条,MyBatis 就回调你一次

  sqlSession.select("mapper.scanAll", new ResultHandler<User>() {
      public void handleResult(ResultContext<? extends User> ctx) {
          User u = ctx.getResultObject();
          handle(u);    // 处理这一条
          // ctx.stop() 可以中途停止遍历
      }
  });

对比 Cursor:
  Cursor:你主动 for 遍历(拉模式,pull)
  ResultHandler:MyBatis 回调你(推模式,push)
  → 功能类似,都是逐条处理、内存恒定
  → Cursor 用起来更自然(就是个 for 循环),是较新的推荐方式
  → ResultHandler 更底层、更老,也能中途 stop

注意:
  用 ResultHandler 时,Mapper 方法通常返回 void
  (结果通过回调处理,不返回集合)
  同样需要 JDBC 驱动流式配合

第二种是 ResultHandler(回调式)——不返回 List,MyBatis 每查出一条就回调 handleResult 处理一条(可 ctx.stop() 中途停止)。对比 Cursor:Cursor 是拉模式(你主动 for 遍历)、ResultHandler 是推模式(MyBatis 回调你),功能类似(都逐条处理、内存恒定);Cursor 更自然(就是 for 循环)是较新的推荐方式,ResultHandler 更底层更老。用 ResultHandler 时 Mapper 方法通常返回 void。理解「ResultHandler 回调式每查一条回调 handleResult(可 stop)、推模式 vs Cursor 拉模式、功能类似 Cursor 更自然推荐」,就掌握了 ResultHandler 方式。

五、关键:JDBC 驱动也要流式

流式查询最关键、最容易漏的一点:JDBC 驱动层也必须配置成流式

坑:只在 MyBatis 层用了 Cursor,但 JDBC 驱动没配流式
  → JDBC 默认:"执行查询后把整个结果集读到客户端内存"
  → 驱动照样把 500 万行全拉进内存 → 还是 OOM
  → Cursor 白用了!

必须配 JDBC 驱动流式返回(不同数据库不同):
  MySQL(最典型):
    fetchSize = Integer.MIN_VALUE   ← MySQL 驱动开启流式的"魔法值"
    + resultSetType = FORWARD_ONLY
    + 只读、单向
    → MySQL 驱动改为"逐行从服务器读",不一次性拉全部
    (MySQL 5.0+ 还支持基于游标的 fetch,需 useCursorFetch=true + 正常 fetchSize)

  PostgreSQL:
    autoCommit = false(必须关自动提交)
    + fetchSize = 合理值(如 1000)
    → PG 用服务端游标,一批批拉

MyBatis 里配 fetchSize:
  @Options(fetchSize = Integer.MIN_VALUE, resultSetType = FORWARD_ONLY)
  或 XML 的 <select fetchSize="..." resultSetType="FORWARD_ONLY">

★ 流式的两个必要条件缺一不可:
  ① MyBatis 层:Cursor/ResultHandler 逐条处理
  ② JDBC 层:fetchSize 等配置让驱动流式返回

流式查询最容易漏的关键点:JDBC 驱动也必须配成流式——JDBC 默认「查完把整个结果集读到客户端内存」,只在 MyBatis 层用 Cursor 而驱动没配流式,驱动照样把全部数据拉进内存、还是 OOM。MySQL 要设 fetchSize = Integer.MIN_VALUE(开启流式的魔法值)+ FORWARD_ONLY(或 useCursorFetch=true + 正常 fetchSize);PostgreSQL 要关自动提交 + 合理 fetchSize(用服务端游标)。流式的两个必要条件缺一不可:① MyBatis 层 Cursor/ResultHandler 逐条处理 + ② JDBC 层配置驱动流式返回。理解「JDBC 默认拉全部到内存、只 MyBatis 层流式无用、MySQL 要 fetchSize=Integer.MIN_VALUE+FORWARD_ONLY、两个必要条件缺一不可」,就掌握了流式查询最关键的一环。

六、其他方案与选择

流式查询之外,处理大数据还有别的方案,需权衡选择:

处理大结果集的几种方案:
  ① 流式查询(Cursor/ResultHandler + JDBC 流式):
     一次遍历完所有数据、内存恒定
     适合:一次性扫全表处理(数据迁移、导出、批量计算)
     注意:遍历期间占用连接/事务,处理慢会长占连接

  ② 分页查询(LIMIT offset, size 循环):
     一批批查(每批几千条),处理完再查下一批
     问题:深分页 LIMIT 1000000, 100 很慢(要跳过前面的行)
     优化:用"游标分页"(WHERE id > 上次最大id ORDER BY id LIMIT n)
       → keyset 分页,不用 offset,快

  ③ 按主键范围分批:
     WHERE id BETWEEN 1 AND 10000、10001 AND 20000...
     适合可按主键切分的场景

选择:
  一次性扫全表、要遍历每一条 → 流式(Cursor)
  边处理边可能中断/并行/分布式 → 分页(keyset 分页)
  能按范围切分且要并行 → 主键范围分批

流式的优势:一次遍历、内存恒定、代码简单
流式的劣势:长时间占用一个连接、不易并行

处理大结果集有几种方案:① 流式查询(Cursor/ResultHandler + JDBC 流式,一次遍历、内存恒定,适合扫全表处理,但长占连接);② 分页查询(一批批查,注意深分页慢、用 keyset 分页WHERE id > 上次最大 id)优化);③ 按主键范围分批(可并行)。选择:一次性扫全表遍历每条用流式;要中断/并行/分布式用分页(keyset);能范围切分并行用主键范围。流式优势是一次遍历内存恒定代码简单,劣势是长占连接不易并行。理解「大结果集方案:流式(扫全表内存恒定但长占连接)/分页(keyset 分页避免深分页慢)/主键范围分批(可并行)、按需选择」,就掌握了大数据处理的方案全景。

记忆钩子:「MyBatis 查百万行 selectList 会 OOM(一次性全量加载进 List);流式查询:①Cursor(可迭代游标,for 遍历惰性逐条拉取,处理完可 GC 内存恒定,try-with-resources 关闭,遍历期间保持连接打开放 @Transactional)②ResultHandler(回调式每查一条回调 handleResult,推模式);★关键-JDBC 驱动也必须流式否则驱动照样拉全部进内存:MySQL 设 fetchSize=Integer.MIN_VALUE+FORWARD_ONLY(或 useCursorFetch),PG 关自动提交+合理 fetchSize;两个必要条件缺一不可(MyBatis 层逐条+JDBC 层流式);替代方案:keyset 分页(避免深分页慢)/主键范围分批」

七、常见误区与追问

  • 误区:用了 Cursor 就一定不会 OOM。 不够——还必须配 JDBC 驱动流式返回(MySQL 的 fetchSize=Integer.MIN_VALUE+FORWARD_ONLY 等);JDBC 默认把整个结果集读到客户端内存,只在 MyBatis 层用 Cursor 而驱动没配流式,驱动照样拉全部进内存、还是 OOM。
  • 误区:Cursor 遍历完可以随便放,不用管事务。 不行——Cursor 游标依赖活着的数据库连接,遍历期间必须保持 SqlSession/连接/事务打开;通常在一个 @Transactional 方法里完整遍历完,遍历结束才关闭游标和连接;连接提前关了游标就失效。
  • 误区:MySQL 的 fetchSize 设个几千就流式了。 MySQL 驱动的流式开启是「魔法值」fetchSize=Integer.MIN_VALUE(配 FORWARD_ONLY、只读单向)——它让驱动逐行从服务器读;设普通的几千不会触发这个流式模式(除非用 useCursorFetch=true 的服务端游标方式)。
  • 误区:Cursor 和 ResultHandler 功能不同。 功能类似(都逐条处理、内存恒定),区别是模式——Cursor 是拉模式(你主动 for 遍历,更自然,较新推荐),ResultHandler 是推模式(MyBatis 回调 handleResult,更底层,可中途 stop)。
  • 追问:流式查询遍历期间要注意什么? ① 保持连接/事务打开(游标依赖活连接,通常放 @Transactional 里);② 处理逻辑别太慢(会长时间占用连接、影响连接池);③ 不要在遍历中再开新查询占用同一连接(可能冲突);④ 处理完的对象别再被集合持有(否则又攒进内存失去流式意义)。
  • 追问:除了流式,还能怎么处理百万级数据的遍历? 分页查询(一批批查,但要用 keyset 分页 WHERE id>上次最大id ORDER BY id LIMIT n,避免 LIMIT offset 深分页越翻越慢)、或按主键范围分批(WHERE id BETWEEN,可并行);分页/分批更适合需要中断续跑、并行、分布式的场景,流式更适合单机一次性扫全表。
  • 追问:为什么 selectList 查大表会 OOM,而流式不会? selectList 一次性把所有行映射成对象塞进一个 List 返回(全部同时驻留内存);流式(Cursor/ResultHandler)逐条拉取、处理完就丢弃、对象可被 GC,内存里始终只有当前处理的一条或一小批,占用恒定、和总量无关。

八、加强记忆

普通 selectList 查百万行会 OOM(一次性把所有行映射成对象塞进 List,全部驻留内存)。流式查询「处理完一条丢一条、内存恒定」。MyBatis 两种方式:Cursor<T>(推荐)——Mapper 返回 Cursor<T>(不是 List),try-with-resources + for 遍历,惰性逐条拉取、处理完可 GC、内存恒定(是 Closeable 用完关、只能顺序遍历一次、遍历期间要保持连接/事务打开,通常放 @Transactional);ResultHandler——回调式,每查一条回调 handleResult(推模式,Cursor 是拉模式)。最关键、最易漏:JDBC 驱动也必须配成流式返回——JDBC 默认把整个结果集读到客户端内存,只在 MyBatis 层用 Cursor 而驱动没配流式,驱动照样拉全部进内存、还是 OOM;MySQL 设 fetchSize = Integer.MIN_VALUE(流式魔法值)+ FORWARD_ONLY(或 useCursorFetch=true),PostgreSQL 关自动提交 + 合理 fetchSize流式的两个必要条件缺一不可:MyBatis 层逐条处理 + JDBC 层驱动流式。替代方案:keyset 分页WHERE id>上次最大id,避免深分页 LIMIT offset 越翻越慢)、主键范围分批(可并行)。一句话「百万行 selectList OOM(全量进 List);流式 Cursor(for 惰性逐条拉取、内存恒定、遍历期保持连接放 @Transactional)或 ResultHandler(回调);★必须同时配 JDBC 驱动流式(MySQL fetchSize=Integer.MIN_VALUE+FORWARD_ONLY)否则驱动照样拉全部;两条件缺一不可;替代用 keyset 分页」。