批量处理出现卡顿,很多时候并不是服务器性能不够,而是执行方式本身就存在问题。请求线程一边查询数据库,一边调用外部接口,一边把整批数据都保留在堆内存里,数据量一大,吞吐量自然迅速下降。要真正解决问题,需要从执行模型和内存策略两个方向同时下手,把任务从请求链路中剥离,并严格控制每批数据的规模和对象生命周期。

一、为什么同步批量处理容易卡顿甚至崩溃
最常见的做法是在一个接口里同步完成所有批量任务。比如前端上传一份包含几万条记录的文件,后端在Controller中循环处理每一行,每处理一行就调用一次远程服务或者写一次数据库。这个过程中,HTTP工作线程一直被占用,Tomcat或Netty的线程池很快就会被耗尽。后续请求只能排队等待,用户看到的直接现象就是页面转圈、接口长时间无响应。
内存层面的问题更隐蔽。很多开发者会先执行一次查询,把所有需要处理的数据加载到一个List中,再对这个List做遍历。假设单条记录关联了多个子对象,几万条数据很容易产生几百MB甚至上GB的堆内存占用。这些大对象往往直接进入老年代,垃圾回收器很难及时回收,最终导致频繁Full GC。Full GC一旦频繁发生,应用线程会被暂停,接口响应时间进一步恶化,严重时进程直接被操作系统杀掉。
下面是一个典型的反例代码:
// 反例:全量加载并同步处理
List<Order> orders = orderMapper.selectAll();
for (Order order : orders) {
// 同步调用外部物流接口
logisticsService.sync(order);
// 同步写文件
fileWriter.append(order.toCsv());
}
这段代码不仅会让List中的几万条订单对象长时间驻留内存,还把所有耗时操作都压在同一个线程里。如果循环中途某一条数据抛异常,整个任务会中断,已经处理过的数据也无法记录状态,后续重跑时还要从头再来。与其在单点代码上反复优化,不如先改变任务执行模式。
二、后台模式:将批量任务从请求线程中剥离
后台模式的核心思路是让接口只负责受理任务,不负责真正执行。请求进来后,服务把任务写入队列,然后立即返回一个受理结果给调用方。真正处理数据的工作交给后台消费者线程异步完成。这样请求线程可以快速释放,HTTP连接、数据库连接等资源也不会被长时间占用。任务的产生速度和执行速度通过队列解耦,系统压力变得可控。
实现后台模式通常使用线程池配合有界队列。有界队列的意义在于防止任务无限积压。如果生产速度远大于消费速度,队列会很快被填满,此时可以设置拒绝策略来保护系统。常见的做法是让调用方稍后重试,或者把任务先写入数据库或消息中间件,由另一个定时任务拉取执行。直接使用无界队列虽然不会丢任务,但可能导致内存无限增长,违背了后台模式的初衷。
下面是一个简化版的后台任务管理器:
public class BackgroundTaskManager {
private final ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, 4, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new ThreadFactory() {
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "batch-worker");
t.setDaemon(true);
return t;
}
},
new ThreadPoolExecutor.CallerRunsPolicy());
public void submit(Task task) {
executor.submit(() -> processSafely(task));
}
private void processSafely(Task task) {
try {
task.run();
} catch (Exception e) {
task.markFailed(e);
// 写入重试表或触发告警
}
}
}
消费者内部需要做好幂等和状态记录。通常可以为批量任务设计一张任务表,字段包括pending、processing、done、failed等状态。消费者开始处理前把状态更新为processing,处理完成或失败后再更新为done或failed。这样即使服务重启,也可以根据状态找出未完成的任务继续执行,避免重复处理或漏处理。
并发数并不是越大越好。数据库连接池、外部接口限流和CPU核数共同决定了最优并发。如果瓶颈在外部接口响应慢,适当提高并发可以提升吞吐;但如果瓶颈在数据库锁或磁盘写入,盲目增加线程只会加剧资源竞争和死锁概率。一般可以从2到4个消费者开始压测,观察接口响应时间、队列积压量和GC频率,再逐步调整。
三、内存管理:分批读取、游标遍历与及时释放
解决内存问题的核心是不要一次性把所有数据加载进内存。分批读取是最直观的方式,每次只取固定条数处理,处理完一批再取下一批。分批时不要使用limit offset过大,因为偏移量越大,数据库扫描和丢弃的行越多,性能会越来越差。更推荐使用游标或基于主键的递增查询,例如每次记录上一批最大的主键值,下一批从大于该主键的位置继续读取。
下面是一个按主键分批处理的示例:
public void processInBatches() {
long lastId = 0L;
int batchSize = 500;
while (true) {
List<Order> batch = orderMapper.selectByGtId(lastId, batchSize);
if (batch.isEmpty()) {
break;
}
for (Order order : batch) {
handle(order);
}
lastId = batch.get(batch.size() - 1).getId();
// 每批结束释放本批引用,允许GC回收
batch.clear();
}
}
使用游标查询可以进一步降低内存占用。JDBC中可以设置fetchSize提示数据库驱动按批返回结果集,而不是一次性拉到客户端。MyBatis也支持通过Cursor或ResultHandler进行流式处理。需要注意的是,游标查询通常要求数据库连接在遍历期间保持打开,因此要在事务或连接关闭前完成处理。处理完一批后应尽快释放引用,不要把游标中的数据再复制到一个大的静态集合里。
流式处理同样适用于文件导出和日志写入。比如导出CSV时,不要把所有行拼成一个巨大的StringBuilder,而应该每处理一行就写入一行到BufferedWriter中。导出JSON时可以使用JsonGenerator流式输出,避免在内存中构建完整的JSON数组。很多卡顿问题就出在循环里不断向List添加对象,结果越攒越大,最后一次性序列化时直接打满堆内存。
try (SqlSession session = sqlSessionFactory.openSession()) {
Cursor<Order> cursor = session.selectCursor("selectOrders");
Iterator<Order> it = cursor.iterator();
while (it.hasNext()) {
Order order = it.next();
handle(order);
// 单条处理完毕后不再保留对象引用
}
}
还要警惕循环内重复创建大对象以及把临时结果塞进静态缓存。例如每处理一条记录就创建一个新的SimpleDateFormat或Pattern,既不高效也会增加GC压力。对于需要汇总的场景,可以考虑先把中间结果写入临时文件,最后再统一归并,而不是在内存中一直保留全部聚合数据。
四、参数调优与监控指标
后台模式和分批内存管理落地之后,还需要给运行时环境设置合理的参数。对于JVM来说,堆内存并不是越大越好。堆设置过大会延长单次GC暂停时间,设置过小又会频繁触发GC。容器环境下要结合cgroup限制来配置,不要超过容器内存上限。批处理场景通常推荐使用G1GC,它可以更好地控制停顿时间,避免因为大对象或碎片化导致的长暂停。
java -Xms2g -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -jar batch-service.jar
监控指标是判断优化是否生效的重要依据。需要重点关注队列积压数量、任务失败数、平均处理耗时、堆内存使用率以及Full GC次数。如果队列积压持续上升,说明消费能力不足,需要扩容消费者或降低任务产生速度。如果Full GC频繁,通常说明单批数据量仍然过大,或者处理过程中有对象被长时间强引用,需要进一步检查批次大小和引用释放情况。
最后,建议为批量任务设计断点续跑机制。任务状态表记录每批处理的游标位置或主键位置,处理完一批就更新一次进度。这样即使中途失败,也可以从断点继续,而不是重新加载所有数据。断点机制不仅能减少重复工作,也能避免因为重复处理已成功数据而带来的额外内存和CPU开销。
批量处理卡顿很少是单一原因造成的。把任务从同步请求中剥离,用后台队列和消费者控制并发,再通过分批读取、游标遍历和流式写入控制内存占用,配合合理的运行时参数和监控指标,才能让批量任务在数据量持续增长后仍然保持稳定运行。