首页
学习
活动
专区
圈层
工具
发布

还在用分页?试试MyBatis流式查询,强的一批!

查一页,慢一页。查到第 50 万条的时候,接口早就开始抖了,JVM 也不高兴,GC 一阵一阵冒。很多人一看这种批量导出、离线处理、数据对账,第一反应还是limit offset翻页。这个办法小表能凑合,表一大,后面几页基本就是拿数据库硬扛。

这类场景,我现在一般不太愿意先谈分页优化,先看一句:你到底是想“分页展示”,还是想“把一大批数据顺着拿出来处理”?这俩不是一回事。前者给用户看的,后者是给程序干活的。给程序干活,还在一页一页翻,很多时候方向就偏了。

MyBatis 其实有个挺好用的东西,很多项目里都没怎么碰过:流式查询

它不是一下把结果全塞进内存,而是查一条、处理一条,或者查一批、消费一批。这个味道更像游标,不像传统分页。特别适合导出、扫描修复、批量通知、历史数据归档这种活。

先看一个常见但别扭的写法:

int pageNo = 1;

int pageSize = 2000;

while (true) {

  List<OrderExportDTO> list = orderMapper.selectPage(pageNo, pageSize);

  if (list == null || list.isEmpty()) {

      break;

  }

  for (OrderExportDTO row : list) {

      exportWriter.write(row);

  }

  pageNo++;

}

这段代码最大的问题,不是丑,是数据库越翻越累。

select id, order_no, user_id, amount

from t_order

where status = 2

order by id

limit 2000 offset 200000;

offset小的时候问题不大,越往后越难看。执行计划不一定会按你想象得那么“聪明”,前面那些数据它很多时候还是得跳过去。你以为只拿 2000 条,数据库实际可能已经扫了几十万。

再看 MyBatis 的流式查询写法,味道就顺很多:

@Mapper

public interface OrderMapper {

  @Options(fetchSize = 500)

  @Select("""

      select id, order_no, user_id, amount

      from t_order

      where status = 2

      order by id

  """)

  void scanFinishedOrders(ResultHandler<OrderExportDTO> handler);

}

业务层直接消费:

public void exportFinishedOrders() {

  AtomicInteger count = new AtomicInteger();

  orderMapper.scanFinishedOrders(context -> {

      OrderExportDTO row = context.getResultObject();

      exportWriter.write(row);

      if (count.incrementAndGet() % 1000 == 0) {

          log.info("export processed rows={}", count.get());

      }

  });

}

这个写法有两个好处很实在。

第一,内存稳。不是一次查 10 万行回来,List 往那一摆,老年代先热起来。 第二,处理链路短。数据到了就处理,不用等整页装满再说。

但这地方别高兴太早。流式查询强,不代表无脑上就行。我见过有人开了流式,然后在ResultHandler里又把数据攒进一个大 List,最后还是 OOM。那你这是把锅从 MyBatis 端搬到业务代码端,没意义。

比如这种,我第一眼就不太信:

List<OrderExportDTO> cache = new ArrayList<>();

orderMapper.scanFinishedOrders(context -> {

  cache.add(context.getResultObject());

});

你都流式了,还全收集回来,那还流个啥。

更像线上能用的写法,应该是边查边干:

orderMapper.scanFinishedOrders(context -> {

  OrderExportDTO row = context.getResultObject();

  mqProducer.send(buildMessage(row));

  markExported(row.getId());

});

当然,这里又有一个坑:别在流式回调里搞重 SQL、远程调用、长事务。 不然数据库连接会被你一直占着,查是流了,链路却拖长了。尤其是你一边扫库,一边 Feign,一边更新状态,这种代码我一般都会多看两眼。现场大概率会冒出连接占满、事务时间过长、从库延迟这些副作用。

更稳一点的做法,是把职责拆开:

orderMapper.scanFinishedOrders(context -> {

  OrderExportDTO row = context.getResultObject();

  buffer.add(row);

  if (buffer.size() >= 500) {

      batchSend(buffer);

      buffer.clear();

  }

});

if (!buffer.isEmpty()) {

  batchSend(buffer);

}

这样至少你知道慢在哪一段,也知道该控哪一段。

还有一个经常被忽略的点:不是所有数据库驱动都能把 fetchSize 用出你想要的效果。有些驱动默认就是先全量拉取,你代码写得像流,底层未必真流。这个事别只看代码,要结合 JDBC 驱动、连接参数、数据库类型一起看。线上真要上,先拿一张大表跑一下,看堆内存、连接占用、处理耗时,再决定。

  • 发表于:
  • 原文链接https://page.om.qq.com/page/Ot1lECwMQ1aygvo3ss0e9sXg0
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。
领券