尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Mybatis千万级数据查询解决方案,避免OOM的流式分页与游标实战

Mybatis千万级数据查询解决方案,避免OOM的流式分页与游标实战 1. 千万级数据查询为什么会 OOM从一次报表导出事故说起先说结论MyBatis 默认的查询方式会把整个结果集一次性映射成 Java 对象列表塞进堆内存数据量到千万级时堆根本扛不住。这不是配置问题是使用姿势问题。我遇到过的真实场景是这样的一个对账报表导出接口平时查几十万行没问题某天业务方把时间范围拉到了半年单表命中 1200 万行。接口跑了大概 40 秒后直接抛java.lang.OutOfMemoryError: Java heap space服务实例被拖垮连带影响了同节点上的其他接口。事后看堆转储ArrayList里躺着 800 多万个实体对象每个对象还带着一堆 String 字段光这一块就吃掉了 3 个多 G。这个问题的本质要拆成三层来看。第一层是 JDBC 驱动层。MySQL 的 JDBC 驱动默认会把整个 ResultSet 全部读到客户端内存里再交给应用Statement的fetchSize默认值是 0意思是全部拉取。你写select * from t_order驱动就老老实实把千万行一次性搬进内存。第二层是 MyBatis 映射层。Mapper 方法返回ListT时MyBatis 会遍历 ResultSet为每一行创建一个对象全部 add 进 ArrayList最后一次性返回。这个 List 就是压垮堆的最后一根稻草。第三层是应用层。很多同学拿到 List 之后还要做二次处理比如再 map 一遍、再 collect 一次内存里同时存在两份甚至三份大集合OOM 来得更快。所以治理思路也就清晰了不要让结果集在内存里堆积。具体有三条路可以走——流式查询Cursor、游标分页基于有序主键的 seek 分页、ResultHandler 逐条消费。这三者不是互斥的实际项目里经常组合使用。下面我会把每条路的配置、代码、参数组合都写清楚并且给出用jstat和堆转储验证内存占用的具体动作让你能自己确认到底有没有生效。适合谁看正在做报表导出、批量数据同步、离线对账这类大结果集场景的后端同学被 OOM 折腾过、想搞清楚 MyBatis 内存行为的人以及想给团队定一套大查询规范的技术负责人。2. 前置准备MyBatis 流式查询的依赖与连接配置在动手改代码之前有几件事必须先确认否则后面会踩坑。首先是版本。org.apache.ibatis.cursor.Cursor这个接口从 MyBatis 3.4.0 就有了但早期版本在 Spring 集成下有些边界问题建议用 3.5.x 及以上。如果你用的是 MyBatis-Plus它底层也是 MyBatisCursor 一样能用但要注意 MP 自带的分页插件和流式查询不要混用分页插件会拦截 SQL 改写可能破坏流式语义。依赖上其实不需要额外引入什么mybatis或mybatis-spring-boot-starter本身就带 Cursor。真正需要关注的是 JDBC 连接参数。MySQL 驱动要支持真正的流式读取必须在 URL 上显式声明两点useCursorFetchtrue和设置defaultFetchSize。前者让驱动使用服务端游标后者给一个合理的批量拉取大小。一个可用的连接串长这样spring.datasource.urljdbc:mysql://127.0.0.1:3306/report_db?useCursorFetchtruedefaultFetchSize1000useSSLfalseserverTimezoneAsia/ShanghairewriteBatchedStatementstrue spring.datasource.usernamereport_user spring.datasource.passwordyour_password spring.datasource.driver-class-namecom.mysql.cj.jdbc.Driver这里defaultFetchSize1000的意思是每次从服务端游标拉 1000 行到客户端处理完再拉下一批。这个值太小会导致网络往返次数暴增太大又失去流式的意义1000 到 5000 之间是比较舒服的区间具体看单行数据大小。然后是 MyBatis 的全局配置。默认的defaultExecutorType是 SIMPLE每次查询新建 Statement。流式查询建议保持 SIMPLE不要用 BATCHBATCH 会把语句攒起来反而增加内存。真正要确认的是defaultStatementTimeout大查询要给它留足时间否则跑到一半超时断开游标也跟着废了。mybatis: configuration: default-fetch-size: 1000 default-statement-timeout: 3600 map-underscore-to-camel-case: true还有一个容易被忽略的点数据库连接池。流式查询期间连接是独占且长时间持有的如果连接池最大连接数只有 10同时来 10 个导出请求池子瞬间打满后面的请求全部阻塞。所以要么给这类接口单独配一个小连接池要么用信号量限制并发导出数。我一般会在导出接口上加一个Semaphore(2)保证同时最多两个大查询在跑。最后提醒一句流式查询期间不要在这个连接上执行其他 SQL。因为游标没读完之前这个连接被占着你插一条别的语句进去结果集就乱了。这也是为什么下面几种方案都强调用完即关。3. 可复制配置Cursor、fetchSize 与 ResultHandler 三套写法这一节是核心我把三种方案的完整代码都贴出来你可以直接抄。3.1 Cursor 流式查询 SqlSessionFactory 手工管理连接先看 Mapper返回类型写成CursorTMapper public interface OrderMapper { Select(select id, order_no, amount, create_time from t_order where create_time #{start} order by id) CursorOrder streamByCreateTime(Param(start) LocalDateTime start); }注意这里没有 limit也没有分页就是一条完整查询。MyBatis 看到返回类型是 Cursor就知道要走流式。然后是 Service 层。关键点是必须自己开 SqlSession保证连接在遍历期间不关闭Service public class OrderExportService { Autowired private SqlSessionFactory sqlSessionFactory; public void export(LocalDateTime start, ConsumerOrder consumer) { try (SqlSession session sqlSessionFactory.openSession()) { CursorOrder cursor session.getMapper(OrderMapper.class).streamByCreateTime(start); cursor.forEach(consumer); } catch (IOException e) { throw new RuntimeException(流式查询关闭失败, e); } } }try-with-resources保证 Cursor 和 SqlSession 都会关闭。cursor.forEach内部就是迭代器逐条取每取一条处理一条内存里始终只有一条记录加上驱动的一批缓冲。如果你在 Spring 事务里也可以用TransactionTemplate效果一样Autowired private TransactionTemplate transactionTemplate; public void exportInTx(LocalDateTime start, ConsumerOrder consumer) { transactionTemplate.executeWithoutResult(status - { try (CursorOrder cursor orderMapper.streamByCreateTime(start)) { cursor.forEach(consumer); } catch (IOException e) { throw new RuntimeException(e); } }); }或者最省事的Transactional注解但记住它只在外部调用时生效同类内部方法调用不走代理注解等于没加。3.2 游标分页基于主键 seek 的高效分页流式查询虽好但它要求连接一直开着对连接池压力大而且一旦中途异常整个查询要重来。如果你的场景允许分批处理、每批独立提交游标分页也叫 seek 分页更稳。它的核心思想是不用limit offset, size而是用上一批的最大 id 作为下一批的起点。select id, order_no, amount, create_time from t_order where id #{lastId} and create_time #{start} order by id limit #{size}对应的 MapperSelect(select id, order_no, amount, create_time from t_order where id #{lastId} and create_time #{start} order by id limit #{size}) ListOrder pageById(Param(lastId) Long lastId, Param(start) LocalDateTime start, Param(size) int size);Service 层循环拉取public void exportBySeek(LocalDateTime start, int batchSize, ConsumerListOrder batchConsumer) { long lastId 0L; while (true) { ListOrder batch orderMapper.pageById(lastId, start, batchSize); if (batch.isEmpty()) { break; } batchConsumer.accept(batch); lastId batch.get(batch.size() - 1).getId(); if (batch.size() batchSize) { break; } } }每批处理完就可以提交事务、释放连接内存里最多只有 batchSize 条记录。batchSize 设 1000 到 5000 都行。这种方式的代价是要求排序字段这里是 id有索引且唯一否则会漏数据或重复。3.3 ResultHandler 逐条消费ResultHandler 是 MyBatis 提供的回调接口每映射一行就回调一次你可以在回调里直接写文件、发消息、累加统计完全不攒集合。public class OrderResultHandler implements ResultHandlerOrder { private final ConsumerOrder consumer; private long count 0; public OrderResultHandler(ConsumerOrder consumer) { this.consumer consumer; } Override public void handleResult(ResultContext? extends Order context) { Order order context.getResultObject(); consumer.accept(order); count; } public long getCount() { return count; } }调用方式public long exportByHandler(LocalDateTime start, ConsumerOrder consumer) { OrderResultHandler handler new OrderResultHandler(consumer); try (SqlSession session sqlSessionFactory.openSession()) { session.select(com.example.mapper.OrderMapper.streamByCreateTime, Map.of(start, start), handler); } return handler.getCount(); }注意select的第一个参数是 statement 的全限定名也就是 Mapper 接口全名加方法名。ResultHandler 方式同样需要连接保持打开所以还是得手工开 SqlSession。三种方案怎么选我一般这样判断需要边查边写文件、边查边发 MQ 的用 Cursor 或 ResultHandler需要分批独立事务、失败可重试的用游标分页数据量在百万级以内、想代码最简的Cursor 加Transactional就够了。4. 验证请求与成功结果用 jstat 和堆转储确认内存真的降下来了代码改完不代表问题解决了必须验证。我习惯用两个动作来确认。第一个动作是jstat观察 GC 和堆使用。假设你的应用 PID 是 12345执行jstat -gcutil 12345 1000 30这条命令每秒打印一次 GC 统计共 30 次。重点看O老年代使用率和FGCFull GC 次数。改造前跑千万级导出你会看到老年代使用率一路飙到 90% 以上Full GC 频繁触发甚至 GC 都回收不动。改造后老年代使用率应该稳定在一个较低水位比如 30% 到 50% 之间波动Full GC 次数基本不增长。第二个动作是抓堆转储对比对象数量。在导出进行到一半时执行jmap -dump:live,formatb,file/tmp/heap.hprof 12345然后用 MAT 或 JProfiler 打开看Order对象的实例数和ArrayList的 retained size。改造前你会看到几百万个 Order 实例改造后这个数字应该只有几百到几千取决于 fetchSize 和批大小。我实测过一次对比同一张 1200 万行的表改造前堆峰值 3.8G接口跑 40 秒 OOM改成 Cursor fetchSize2000 后堆峰值稳定在 400M 左右接口跑完 1200 万行耗时约 6 分钟全程无 Full GC。耗时变长是正常的因为流式是细水长流牺牲了吞吐换内存安全。如果你的场景对耗时敏感可以把 fetchSize 调大或者用游标分页加多线程分批处理。还有一个验证点是确认游标真的在流式读而不是驱动偷偷全拉了。可以在 MySQL 服务端执行show processlist;流式查询进行中你会看到对应连接的 State 是Sending data并且随着处理推进这个连接一直存在。如果查询瞬间就结束了说明驱动还是全量拉取回去检查useCursorFetchtrue有没有生效。5. 本篇常见错误排查401、local proxy failed、reading choices 与 OAuth 报错对照这一节把大查询场景里高频出现的报错列出来对照着排查。java.lang.IllegalStateException: A Cursor is already closed.这是最经典的。原因就是 Mapper 方法执行完连接被 Spring 关掉了Cursor 跟着失效。解决办法就是前面说的三种手工开 SqlSession、TransactionTemplate、或者Transactional且确保是外部调用。如果你在同类内部调用带Transactional的方法注解不生效照样报这个错。java.sql.SQLException: Streaming result set is still active.这个报错通常出现在你流式查询还没读完就在同一个连接上执行了别的 SQL。比如在cursor.forEach里面又调了一次 Mapper 查询。解决方法是把要查的辅助数据提前查好缓存起来或者换一个连接。OutOfMemoryError: Java heap space如果改造后还出现先确认三件事连接串有没有加useCursorFetchtrueMapper 返回类型是不是真的写成了Cursor而不是List有没有在流式处理过程中把数据又 collect 成一个大 List。我见过有人在forEach里result.add(order)等于白改。401 Unauthorized和local proxy failed这类报错一般出现在你调用外部模型服务或 API 网关做数据补全时。比如导出过程中要调远程接口翻译字段结果 token 过期或者网络策略不通。这类问题跟 MyBatis 本身无关排查方向是检查鉴权头、检查出口网络配置。如果你用的是统一的 API 网关来管理这些调用确认 Base URL、API Key、Model ID 三件套都配对缺一个都会 401。reading choices这类报错通常来自模型返回体解析比如你期望 JSON 但拿到了流式分片解析器读不到choices字段。处理办法是确认请求参数里 stream 设置和你的解析逻辑匹配流式就按 SSE 逐块解析非流式就整体解析。OAuth相关报错比如invalid_grant或token expired出现在你用 OAuth 方式获取访问凭证的场景。检查 refresh token 是否还有效、时钟是否同步、scope 是否包含你要调的资源。这类问题建议加一层自动刷新逻辑别等报错了才手动换 token。排查大查询问题我的经验是先看日志里有没有Cursor is already closed有就是连接生命周期问题没有就看堆堆涨就是还在攒集合堆不涨但慢就是 fetchSize 太小或者网络往返太多。按这个顺序走基本能定位到根因。6. 把大查询能力接到统一入口TaoToken 的 API Key 与文档怎么用前面讲的都是 MyBatis 侧的内存治理。实际项目里报表导出和批量同步往往还要跟外部服务打交道比如导出完成后调模型做数据摘要、或者把结果推给下游系统。这时候你需要一个稳定的调用入口来管理这些 API 凭证和配额。TaoToken 提供的就是这样一个入口。你可以先到 TaoToken 控制台 创建项目然后在 API Keys 页面 生成一个 Key。这个 Key 就是你调用时的凭证Base URL 用https://taotoken.net/api不要带任何多余路径。拿到 Key 之后建议先到 模型对话 页面做一次连通性验证确认 Key 能用、模型能返回。这一步很重要因为很多导出跑到一半失败的问题根因其实是外部调用鉴权没过而不是数据库查询本身。如果你要做的是长期跑的批量同步任务或者需要 Agent 自动处理数据可以看下 Coding Plan它更适合这种持续调用的场景。接入细节和参数说明都在 接入文档 里遇到 401 或者参数不匹配先翻文档对照一遍比盲目试错快得多。回到 MyBatis 这条线我的建议是数据库侧用 Cursor 或游标分页把内存压住外部调用侧用统一的 Key 和 Base URL 管理凭证两边都稳了千万级导出才能真正跑得完、跑得稳。最后留一个实用技巧——给导出接口加一个进度打点每处理一万条往日志里写一行这样出问题时你能立刻知道卡在哪一段而不是对着一个转圈的页面干等。
返回列表