这段批处理代码,我第一眼就不太信。
for (Long id : customerIds) {
Customer customer = customerDao.findById(id);
customer.setLevel(levelService.calculate(customer));
customerDao.update(customer);
}
查一条,算一次,再更新一条。
数据少的时候看不出毛病,跑几万条以后,数据库连接一直占着,日志半天不动。更麻烦的是,任务执行到一半挂了,第二次启动还得从头再来。
这种代码不能说错,但拿它处理批量数据,多少有点为难数据库。
后来把任务换成 Spring Batch,处理效率大约提升了 5 倍。提升并不是因为 Spring Batch 有什么神秘优化,而是原来那套代码里,单条查询、单条提交、失败重跑这些浪费,全被一次收拾了。
原来的任务是给一批长期未活跃客户重新计算标签。业务逻辑不复杂,真正拖慢速度的是数据库交互。
执行过程中能看到类似的 SQL:
select id, last_login_time, order_amount
from customer_profile
where id = ?;
update customer_profile
set customer_tag = ?, updated_at = now()
where id = ?;
一条数据两次数据库操作,十万条就是二十万次交互。计算标签只占很少一部分时间,大部分时间都耗在连接、网络传输和事务提交上。
我没有急着加线程。
这类任务一上来就开十几个线程,通常只是让慢 SQL 并发执行,最后把数据库连接池打满。先把单条处理改成分块读取、批量提交,收益反而更直接。
Spring Batch 的关键配置不多,下面这段就是任务的主要骨架:
@Bean
public Step rebuildCustomerTagStep(
JobRepository jobRepository,
PlatformTransactionManager transactionManager,
ItemReader<CustomerSnapshot> customerReader,
ItemProcessor<CustomerSnapshot, CustomerTagChange> tagProcessor,
ItemWriter<CustomerTagChange> tagWriter) {
return new StepBuilder("rebuildCustomerTagStep", jobRepository)
.<CustomerSnapshot, CustomerTagChange>chunk(500, transactionManager)
.reader(customerReader)
.processor(tagProcessor)
.writer(tagWriter)
.faultTolerant()
.retry(TransientDataAccessException.class)
.retryLimit(2)
.listener(batchProgressListener())
.build();
}
这里的chunk(500)很重要。
它表示每读取、处理 500 条数据,再统一提交一次事务。原来是一条数据提交一次,现在变成 500 条提交一次,数据库压力会小很多。
不过 500 不是标准答案。
字段比较大、单条对象占内存多,就要往小调;数据库写入能力强、记录又很轻,可以适当往上加。我一般先从 200 或 500 开始,观察连接池、事务耗时和 JVM 内存,再决定要不要改。
读取数据时,也没必要先把所有 ID 查进内存。让 Spring Batch 分页往后读就行:
@Bean
public JdbcPagingItemReader<CustomerSnapshot> customerReader(DataSource dataSource) {
Map<String, Order> sortKeys = new LinkedHashMap<>();
sortKeys.put("id", Order.ASCENDING);
return new JdbcPagingItemReaderBuilder<CustomerSnapshot>()
.name("customerReader")
.dataSource(dataSource)
.pageSize(500)
.selectClause("""
select id, last_login_time, order_amount
""")
.fromClause("from customer_profile")
.whereClause("where status = 'ACTIVE'")
.sortKeys(sortKeys)
.rowMapper((rs, rowNum) -> new CustomerSnapshot(
rs.getLong("id"),
rs.getTimestamp("last_login_time").toLocalDateTime(),
rs.getBigDecimal("order_amount")
))
.build();
}
这里必须有稳定排序字段,最好直接用主键。
没有排序,分页过程中数据一旦发生变化,就可能出现重复读取或者漏读。批处理最怕这种问题,任务显示成功,数据却少了一截,查起来比直接报错还麻烦。
Processor 只做业务计算,不查数据库,也不远程调用接口:
@Bean
public ItemProcessor<CustomerSnapshot, CustomerTagChange> tagProcessor() {
return customer -> {
String tag;
if (customer.orderAmount().signum() == 0) {
tag = "NO_ORDER";
} else if (customer.lastLoginTime().isBefore(
LocalDateTime.now().minusMonths(6))) {
tag = "SILENT";
} else {
tag = "NORMAL";
}
return new CustomerTagChange(customer.id(), tag);
};
}
我比较嫌弃在 Processor 里调用远程接口。
一条数据调一次接口,Spring Batch 也救不了。那只是把原来的慢循环,换了一个更正规的地方继续慢。真有远程依赖,要么提前把数据拉到本地,要么让对方提供批量接口。
写入部分直接使用 JDBC 批量更新:
@Bean
public JdbcBatchItemWriter<CustomerTagChange> tagWriter(DataSource dataSource) {
return new JdbcBatchItemWriterBuilder<CustomerTagChange>()
.dataSource(dataSource)
.sql("""
update customer_profile
set customer_tag = :tag,
updated_at = current_timestamp
where id = :customerId
""")
.beanMapped()
.assertUpdates(true)
.build();
}
assertUpdates(true)我通常会保留。
如果某个 ID 没有更新到数据,任务会直接暴露问题,而不是悄悄吞掉。批处理里“成功执行但没写进去”不算容错,只算埋雷。
Spring Batch 还有一个很实用的地方:任务状态会落库。
某个 Step 已经完成,下次使用相同参数启动时,不会无脑从头跑。执行到中途失败,也能利用已保存的执行上下文继续处理。以前自己写断点续跑,要维护进度表、状态字段和补偿逻辑,现在框架已经把这些脏活接过去了。
任务日志我只关心几个数字:
@Bean
public StepExecutionListener batchProgressListener() {
return new StepExecutionListener() {
@Override
public ExitStatus afterStep(StepExecution step) {
log.info(
"batch finished, read={}, write={}, skip={}, status={}",
step.getReadCount(),
step.getWriteCount(),
step.getSkipCount(),
step.getStatus()
);
return step.getExitStatus();
}
};
}
读取数、写入数、跳过数对不上,就别急着宣布任务成功。
至于多线程,我反而放在最后。
只有 Processor 足够独立、数据没有顺序要求、写入操作能够保证幂等,我才会考虑给 Step 加线程池。否则效率是上去了,重复更新、乱序提交和锁等待也一起上来了。
Spring Batch 真正有价值的地方,不是替你写了一个循环。
它把读取、处理、写入、事务、重试、监控和断点恢复拆开了。以前所有逻辑挤在一个for循环里,哪里慢只能靠猜;拆开以后,Reader 慢就查 SQL,Writer 慢就看批量提交,Processor 慢就看业务计算。
这次效率提升接近 500%,框架只是其中一部分。
更关键的是,终于不再让数据库陪着那段单条循环一起受罪了。