查一页,慢一页。查到第 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 驱动、连接参数、数据库类型一起看。线上真要上,先拿一张大表跑一下,看堆内存、连接占用、处理耗时,再决定。