Ohhnews

分类导航

$ cd ..
foojay原文

在Spring Batch中使用DuckDB:用一条SQL语句替代内存Java循环

#duckdb#spring batch#sql#数据处理#性能优化

Spring Batch 作业通常遵循相同的模式:ItemReader 逐行读取数据,ItemProcessor 对每一行进行转换,ItemWriter 则按块写出结果。面向块的步骤将这三者串联起来:

$ java
new StepBuilder("transform", jobRepository)
        .<Order, Summary>chunk(1_000, transactionManager)
        .reader(reader)       // stream rows
        .processor(processor) // transform each row
        .writer(writer)       // write the chunk
        .build();

面向块的处理模型(Spring Batch 参考文档)。

这种模式非常适合在系统之间移动记录。

但许多批处理工作并不是在移动数据,而是在转换数据:分组、聚合以及计算派生值。对于这类工作,逐行处理就成了瓶颈。每一行都要在单线程上完成解析、装箱、哈希和垃圾回收,当处理数百万行时,这些开销会不断累积。

DuckDB 是一种嵌入式、进程内的分析型数据库,理念上与 SQLite 类似,但专为分析而构建。它只是一个 JDBC 依赖,无需服务器,并在 JVM 内部运行向量化、多核查询引擎。这使得它非常适合承担批处理作业中的转换步骤。为了量化差异,我构建了两个 Spring Boot 和 Spring Batch 应用程序,它们对相同的数据运行相同的作业,唯一的不同在于执行转换的引擎。

测试设置

两个应用程序都会生成确定性的 orders.csv(列:id, customer_id, category, quantity, amount),并按 (customer_id, category) 分组计算:订单数、sum(amount * quantity)sum(quantity)avg(amount)max(amount * quantity)生成器不包含任何随机性,因此输入在字节级别完全相同,输出可以直接进行比较。

传统应用使用 Tasklet 遍历所有行,并将它们累加到一个内存中的 HashMap

$ java
Map<Long, Acc> groups = new HashMap<>();
while ((line = reader.readLine()) != null) {
    // parse id,customer_id,category,quantity,amount
    long key = customerId * 8 + category;
    Acc acc = groups.computeIfAbsent(key, k -> new Acc());
    acc.count++;
    acc.totalRevenue += amount * quantity;
    // ...
}

来源:JavaTransformTasklet.java

DuckDB 应用则以一条 SQL 语句完成相同的转换,同样是在一个很小的 Tasklet 中:

$ java
try (Connection c = DriverManager.getConnection("jdbc:duckdb:");
     Statement st = c.createStatement()) {
    st.execute("""
        COPY (
          SELECT customer_id, category,
                 count(*)                AS order_count,
                 sum(amount * quantity)  AS total_revenue,
                 sum(quantity)           AS total_quantity,
                 avg(amount)             AS avg_amount,
                 max(amount * quantity)  AS max_revenue
          FROM read_csv('orders.csv', header = true)
          GROUP BY customer_id, category
          ORDER BY customer_id, category
        ) TO 'summary.csv' (FORMAT CSV, HEADER true)
        """);
}

来源:DuckDbTransformTasklet.java

这就是完整的转换过程。无需配置 reader、processor、writer 或 chunk 大小;DuckDB 在单次遍历中完成了读取、分组和写出。

两者仍然是真正的 Spring Batch 作业:一个 Job 包含生成步骤和转换步骤(BatchConfig.java),并使用 H2 作为 JobRepository。只有转换步骤不同。

测试结果

仅对转换步骤计时,测试环境为 Apple Silicon 机器(12 线程,Java 21,DuckDB 1.5.5),每次运行都使用全新的 JVM,因此 Java 的数据包含了单次批处理作业实际会经历的 JIT 预热开销:

行数Java(内存集合)DuckDB(向量化)加速比
10,000,0001.00 s0.17 s~6×
50,000,0004.83 s0.61 s~8×

两个摘要文件经 cmp 验证在字节级别完全一致,因此这是对同一个 8,000 组结果采用两种方式计算得到的输出。差距会随数据规模扩大而增加,如果单行转换的逻辑更重,差距还会更大。

为什么会有差异

有三个因素可以解释这种差异:

  • 向量化。 DuckDB 以约 2,048 个值为一批来处理列,因此 CPU 始终运行在紧凑、分支可预测且对缓存友好的循环中。而 Java 版本则是通过对象引用逐行处理。
  • 并行性。 DuckDB 默认使用所有 CPU 核心。手写的循环是单线程的,要正确地进行并行化需要相当大的投入。
  • 没有逐行创建对象的开销。 解析、将键自动装箱为 Long 以及分配累加器都会产生垃圾,而向量化引擎完全不会产生这些。

这并不是在批评 Spring Batch。它的 chunk 模型非常适合编排、可重启性和 I/O。这里更想强调的是,转换步骤中的计算并不一定非要运行在你自己的循环里。

何时使用

当你在处理大量数据的聚合、连接或派生计算,并且数据源是文件(CSV、Parquet、JSON)或 DuckDB 可读取的数据库时,可以考虑在转换步骤中使用 DuckDB。对于行级的数据丰富、外部调用或写入下游严格系统,仍然使用经典的 reader、processor 和 writer。

入门很简单:添加 org.duckdb:duckdb_jdbc,打开一个 jdbc:duckdb: 连接,然后执行 SQL。它只有一个依赖,并且完全在进程内运行,无需额外运维基础设施。

对于转换密集型步骤,让 DuckDB 来完成工作可能比在 Java 中逐行迭代快得多。

完整可运行代码(两个应用、生成器和基准测试工具)位于 duckdb-samples 仓库的 batch-scenarios 目录下。

该文章《DuckDB in Spring Batch: Replace In-Memory Java Loops with One SQL Statement》最初发布在 foojay