Spring Batch中透過多執行緒和非同步處理提高效能

banq發表於2024-04-12

自計算機使用興起以來,公司出於不同的目的始終依賴批處理資料,要麼是在應用程式之間移動資料 (ETL),要麼是進行一些需要很長時間才能實時完成的平行計算。

處理大量資料的挑戰始終在於如何充分利用可用的計算資源,從而最佳化時間和成本。

在批處理這個領域,許多解決方案都將自己作為標準,其中之一就是Spring Batch,它是 JAVA 世界中構建高效能和最佳化的批處理應用程式的事實上的標準。

案例準備
這裡將透過一個簡單的作業來演示此功能,該作業執行從資料庫讀取資料、對其應用一些轉換,最後將其寫入檔案。

資料表:

create table transactions
(
  id                      integer      not null,
  transaction_date        date         not null,
  amount                  numeric      not null,
  created_at              date,
  constraint pk_transactions primary key(id)
);

為了初始化資料庫,我建立了以下 postgres 指令碼,以向事務表中填充 1 億條記錄。

DO $$
<<block>>
declare
counter     integer := 0;
rec         RECORD;

begin
  --
  for rec in (
    with nums as (
      SELECT
         a id
      FROM generate_series(1, 1000000) as s(a)
    ),
    dates as(
      select row_number() OVER (ORDER BY a) line, a::date as date
      from generate_series(
            '2020-01-01'::date,
            '2020-12-31'::date,
            '1 day'
          ) s(a)
    )
    select row_number() OVER (ORDER BY id) id,
      d.date,
      200*random() amount
    from nums n, dates d
    where d.line <= 100
  )
  loop
    insert into transactions values (rec.id, rec.date, rec.amount, CURRENT_TIMESTAMP);

    counter := counter + 1;

    if MOD(counter, 10000) = 0 then
      raise notice 'Commiting at : %', counter;
      commit ;
    end if;
  end loop;

  raise notice 'Value: %', counter;

END block $$;

為了模擬 1 毫秒的處理時間,我新增了對 Thread.sleep 方法的呼叫。在現實生活中,處理邏輯可能非常複雜,每個專案的處理時間可能不止 1 毫秒(例如:呼叫外部網路服務)。

 

  @Bean
    public ItemProcessor<TransactionVO, TransactionVO>.   multithreadedchProcessor() {
        return (transaction) -> {
            Thread.sleep(1);            
            return transaction;
        };
    }


在單執行緒處理過程中,讀取、處理和寫入都是在單執行緒中同步執行的。


多執行緒步驟
在本文中,我們瞭解瞭如何使用 TaskExecutor 對作業步驟進行多執行緒處理,從而與單執行緒步驟相比獲得大量處理時間。

多執行緒在步驟級別啟用,並且它在自己的執行緒中執行每個塊。

在 Spring Batch 作業中使用多個執行緒非常簡單。您定義一個taskExecutor並在相關步驟中引用它。這會為每個資料塊建立一個新執行緒,同時處理它們。這可以顯著提高效能。

然而,大多數 Spring Batch 讀者都持有狀態(進度資訊)。這對於作業重新啟動很有用。在多執行緒環境中,如果不正確同步,此類有狀態讀取器可能會導致資料不一致。

@Bean
public Step multithreadedManagerStep(StepBuilderFactory stepBuilderFactory) throws Exception {
    return stepBuilderFactory
            .get(<font>"Multithreaded : Read -> Process -> Write ")
            .<TransactionVO, TransactionVO>chunk(1000)
            .reader(multithreadedcReader(null))
            .processor(multithreadedchProcessor())
            .writer(multithreadedcWriter())
            .taskExecutor(taskExecutor())
            .build();
}
@Bean
public TaskExecutor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(64);
    executor.setMaxPoolSize(64);
    executor.setQueueCapacity(64);
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    executor.setThreadNamePrefix(
"MultiThreaded-");
    return executor;
}

以下是整個多執行緒演示程式的原始碼。

@SpringBootApplication
@EnableBatchProcessing
public class MultithreadedBatchPerformanceApplication {

    public static void main(String[] args) {
        SpringApplication.run(MultithreadedBatchPerformanceApplication.class, args);
    }

    @Bean
    public Job multithreadedJob(JobBuilderFactory jobBuilderFactory) throws Exception {
        return jobBuilderFactory
                .get(<font>"Multithreaded JOB")
                .incrementer(new RunIdIncrementer())
                .flow(multithreadedManagerStep(null))
                .end()
                .build();
    }

    @Bean
    public Step multithreadedManagerStep(StepBuilderFactory stepBuilderFactory) throws Exception {
        return stepBuilderFactory
                .get(
"Multithreaded : Read -> Process -> Write ")
                .<TransactionVO, TransactionVO>chunk(1000)
                .reader(multithreadedcReader(null))
                .processor(multithreadedchProcessor())
                .writer(multithreadedcWriter())
                .taskExecutor(taskExecutor())
                .build();
    }

    @Bean
    public TaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(64);
        executor.setMaxPoolSize(64);
        executor.setQueueCapacity(64);
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.setThreadNamePrefix(
"MultiThreaded-");
        return executor;
    }

    @Bean
    public ItemProcessor<TransactionVO, TransactionVO> multithreadedchProcessor() {
        return (transaction) -> {
            Thread.sleep(1);
           
//og.info(Thread.currentThread().getName());<i>
            return transaction;
        };
    }

    @Bean
    public ItemReader<TransactionVO> multithreadedcReader(DataSource dataSource) throws Exception {

        return new JdbcPagingItemReaderBuilder<TransactionVO>()
                .name(
"Reader")
                .dataSource(dataSource)
                .selectClause(
"SELECT * ")
                .fromClause(
"FROM transactions ")
                .whereClause(
"WHERE ID <= 1000000 ")
                .sortKeys(Collections.singletonMap(
"ID", Order.ASCENDING))
                .rowMapper(new TransactionVORowMapper())
                .build();
    }

    @Bean
    public FlatFileItemWriter<TransactionVO> multithreadedcWriter() {

        return new FlatFileItemWriterBuilder<TransactionVO>()
                .name(
"Writer")
                .append(false)
                .resource(new FileSystemResource(
"transactions.txt"))
                .lineAggregator(new DelimitedLineAggregator<TransactionVO>() {
                    {
                        setDelimiter(
";");
                        setFieldExtractor(new BeanWrapperFieldExtractor<TransactionVO>() {
                            {
                                setNames(new String[]{
"id", "date", "amount", "createdAt"});
                            }
                        });
                    }
                })
                .build();
    }
}

執行該批處理後,大約 6 分鐘就能將 100 萬條記錄寫入檔案。

任務:[FlowJob: [name=Synchronous JOB]] 已完成,引數如下:{run.id=10}]和以下狀態:[完成],時間為 6m12s265ms

在這種規模下,單執行緒步驟處理 100 萬個專案所需的時間幾乎是多執行緒步驟的 4 倍。考慮到在我們的示例中,處理時間為 1ms/條目,這是一個巨大的收益。

缺點
由於配置簡單,多執行緒步驟比其他功能具有巨大的優勢,但它們也不乏缺點,其中:

  • 作業無法重新啟動。如果作業失敗,它就無法從原來的位置繼續執行
  • 沒有處理物品的訂單保證
  • 您應該考慮執行緒安全(示例可能不使用基於遊標的 JDBC 專案讀取器)

非同步處理
非同步處理是另一種提升 Spring Batch 效能的技術。它將透過在單獨的執行緒中執行來擴充套件每個專案的處理,一旦完成,它就會返回AsyncItemWriter 將處理的Future 。

當步驟的瓶頸是處理時,這種縮放技術特別有用,例如,您有一個讀取器從 csv 檔案讀取專案,並且對於每個專案讀取,專案處理器需要訪問外部 API 並執行一些複雜的操作計算,然後將結果寫入目的地。

在 Spring 批處理中有兩個類可以幫助實現此機制:AsyncItemProcessor 和 AsyncItemWriter,它們分別是 ItemProcess 和 ItemWriter 的裝飾器。 AsyncItemWriter 只是為了方便解開不同 AsyncItemProcessor 返回的Futures 。

過使用 2 個裝飾器類啟用非同步處理:

  • 非同步專案處理器

該類將處理委託給 ItemProcessor,並允許透過設定taskExecutor 進行多執行緒處理。

@Bean
public AsyncItemProcessor<TransactionVO, TransactionVO> asyncProcessor() {
    AsyncItemProcessor<TransactionVO, TransactionVO> asyncItemProcessor = new AsyncItemProcessor<>();
    asyncItemProcessor.setDelegate(itemProcessor());
    asyncItemProcessor.setTaskExecutor(taskExecutor());

    return asyncItemProcessor;
}
@Bean
public ItemProcessor<TransactionVO, TransactionVO> itemProcessor() {
    return (transaction) -> {
        Thread.sleep(1);
        return transaction;
    };
}

  • 非同步專案編寫器

由專案處理器處理的專案被包裝到Future中並傳遞給編寫器以解開包裝並寫入其目的地。

@Bean
public AsyncItemWriter<TransactionVO> asyncWriter() {
    AsyncItemWriter<TransactionVO> asyncItemWriter = new AsyncItemWriter<>();
    asyncItemWriter.setDelegate(itemWriter());
    return asyncItemWriter;
}


以下是整個非同步處理演示程式的原始碼。

@SpringBootApplication
@EnableBatchProcessing
public class AsyncProcessingBatchPerformanceApplication {

    public static void main(String[] args) {
        SpringApplication.run(AsyncProcessingBatchPerformanceApplication.class, args);
    }

    @Bean
    public Job asyncJob(JobBuilderFactory jobBuilderFactory) {
        return jobBuilderFactory
                .get(<font>"Asynchronous Processing JOB")
                .incrementer(new RunIdIncrementer())
                .flow(asyncManagerStep(null))
                .end()
                .build();
    }

    @Bean
    public Step asyncManagerStep(StepBuilderFactory stepBuilderFactory) {
        return stepBuilderFactory
                .get(
"Asynchronous Processing : Read -> Process -> Write ")
                .<TransactionVO, Future<TransactionVO>>chunk(1000)
                .reader(asyncReader(null))
                .processor(asyncProcessor())
                .writer(asyncWriter())
                .taskExecutor(taskExecutor())
                .build();
    }

    @Bean
    public AsyncItemProcessor<TransactionVO, TransactionVO> asyncProcessor() {
        AsyncItemProcessor<TransactionVO, TransactionVO> asyncItemProcessor = new AsyncItemProcessor<>();
        asyncItemProcessor.setDelegate(itemProcessor());
        asyncItemProcessor.setTaskExecutor(taskExecutor());

        return asyncItemProcessor;
    }

    @Bean
    public AsyncItemWriter<TransactionVO> asyncWriter() {
        AsyncItemWriter<TransactionVO> asyncItemWriter = new AsyncItemWriter<>();
        asyncItemWriter.setDelegate(itemWriter());
        return asyncItemWriter;
    }

    @Bean
    public TaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(64);
        executor.setMaxPoolSize(64);
        executor.setQueueCapacity(64);
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.setThreadNamePrefix(
"MultiThreaded-");
        return executor;
    }

    @Bean
    public ItemProcessor<TransactionVO, TransactionVO> itemProcessor() {
        return (transaction) -> {
            Thread.sleep(1);
            return transaction;
        };
    }

    @Bean
    public ItemReader<TransactionVO> asyncReader(DataSource dataSource) {

        return new JdbcPagingItemReaderBuilder<TransactionVO>()
                .name(
"Reader")
                .dataSource(dataSource)
                .selectClause(
"SELECT * ")
                .fromClause(
"FROM transactions ")
                .whereClause(
"WHERE ID <= 1000000 ")
                .sortKeys(Collections.singletonMap(
"ID", Order.ASCENDING))
                .rowMapper(new TransactionVORowMapper())
                .build();
    }

    @Bean
    public FlatFileItemWriter<TransactionVO> itemWriter() {

        return new FlatFileItemWriterBuilder<TransactionVO>()
                .name(
"Writer")
                .append(false)
                .resource(new FileSystemResource(
"transactions.txt"))
                .lineAggregator(new DelimitedLineAggregator<TransactionVO>() {
                    {
                        setDelimiter(
";");
                        setFieldExtractor(new BeanWrapperFieldExtractor<TransactionVO>() {
                            {
                                setNames(new String[]{
"id", "date", "amount", "createdAt"});
                            }
                        });
                    }
                })
                .build();
    }
}

執行該批處理後,大約 2 分鐘就能將 100 萬條記錄寫入檔案。

任務:[FlowJob: [name=Asynchronous Processing JOB]] 已完成,引數如下:{run.id=2}]和以下狀態:[完成],時間為 2m6s76ms


缺點

  • 沒有處理條目的順序保證

本文的原始碼可以在GitHub 儲存庫中找到。

相關文章