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

自从用了 Spring Batch,效率飙升500%!

这段批处理代码,我第一眼就不太信。

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%,框架只是其中一部分。

更关键的是,终于不再让数据库陪着那段单条循环一起受罪了。

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