使用Kafka分割槽擴充套件Spring Batch大資料排程批處理 – Arnold

banq發表於2022-03-31

假設有一個您需要定期執行的流程,例如一天結束 (EOD)。假設這個流程中需要處理的資料量在不斷增加。
最初,你可以做一個非常簡單的 Spring 排程(或者 Quartz 或者你有什麼),它只執行一個方法,一次載入所有資料,處理所有資料並將結果寫回資料庫。
如果讀取的行數(例如從資料庫中)是 10,000 行,它可能工作得很好,但如果突然有 10 000 000 行怎麼辦?執行可能會失敗,因為記憶體不足錯誤
或者需要很長的時間才能完成。

遠端分割槽
獲取初始資料集時,例如,如果我們從資料庫中讀取事務(或任何域物件),我們只獲取事務 ID。
將它們劃分為分割槽(不是塊;塊在 Spring Batch 世界中具有不同的含義)並將分割槽傳送給可以處理它們並執行實際業務邏輯的工作人員。

常規分割槽和遠端分割槽的主要區別在於工作者的位置。

  • 在常規分割槽的情況下,作為工作者的程式是與正在進行資料分割槽的程式在同一JVM中的本地執行緒。
  • 但在遠端分割槽的情況下,工作者不是在同一個JVM中執行,而是完全不同的JVM。當有一些工作需要處理時,會透過訊息傳遞系統通知各個工作者。


侷限性
Kafka是基於主題執行的。主題可以有分割槽。你可以擁有的消費者數量(對於同一個消費者組)取決於你對主題的分割槽數量。這意味著,你的分割槽批處理作業的併發係數與主題分割槽的數量直接相關。
一個主題所使用的分割槽數量應在建立該主題時設定。後來,我們可以改變現有主題的分割槽數量,但是你必須注意到某些副作用。

這意味著Kafka不可能根據資料量來動態地擴充套件工作者的數量。我所說的動態是指,有時你需要10個工人,但假設在聖誕節期間資料量大增,你就需要50個。這就需要一些自定義的指令碼了。

畢竟,我認為一個好的經驗法則--在Kafka的情況下--是過度擴大主題分割槽的數量。比方說,如果你在非高峰期需要10個消費者,而在高峰期需要20個,我認為你可以選擇兩倍/三倍的數量,以確保你有增長的空間,而不會有太多的頭痛。因此,我認為60是一個很好的分割槽數字,最多可以支援60個同時進行的消費者。當然,這取決於你的資料量的增長速度,但你應該明白這個道理。

技術棧

  • Spring Batch
  • Spring Integration
  • Spring for Apache Kafka
  • MySQL
  • Liquibase

Manager
我們將從管理器和它的配置開始。讓我們有一個ManagerConfiguration類。我們將需要幾個配置的依賴項和兩個註釋。

@Configuration
@Profile("manager")
public class ManagerConfiguration {
    @Autowired
    private JobBuilderFactory jobBuilderFactory;

    @Autowired
    private RemotePartitioningManagerStepBuilderFactory stepBuilderFactory;

    @Autowired
    private KafkaTemplate kafkaTemplate;
}

@Profile註解是至關重要的,因為我們只想讓這個配置在我們試圖執行管理器時啟動,我們將用Spring的profile來控制它。

JobBuilderFactory將被用來建立我們的分割槽作業。RemotePartitioningManagerStepBuilderFactory將用於為我們的工作建立步驟,使用這個類而不是普通的StepBuilderFactory非常重要。另外,請注意,有一個非常類似的StepBuilderFactory,叫做RemotePartitioningWorkerStepBuilderFactory,它是用來給工人而不是經理使用的。我們很快就會到那裡。

KafkaTemplate是自動為我們配置的,我們將需要它來配置管理器和Kafka之間的通道。

現在,讓我們上新增一個通道,我們將把它作為從應用程式到Kafka的輸出通道。

@Configuration
@Profile("manager")
public class ManagerConfiguration {
    // previous content is omitted for simplicity

    @Bean
    public DirectChannel outboundRequests() {
        return new DirectChannel();
    }
}

DirectChannel只是Spring Integration中對訊息通道的一個抽象。

接下來,讓我們建立一個Partitioner,對我們的資料集進行分割槽。這將是一個新的類,我把它叫做ExamplePartitioner。

public class ExamplePartitioner implements Partitioner {
    public static final String PARTITION_PREFIX = "partition";

    @Override
    public Map<String, ExecutionContext> partition(int gridSize) {
        int partitionCount = 50;
        Map<String, ExecutionContext> partitions = new HashMap<>();
        for (int i = 0; i < partitionCount; i++) {
            ExecutionContext executionContext = new ExecutionContext();
            executionContext.put("data", new ArrayList<Integer>());
            partitions.put(PARTITION_PREFIX + i, executionContext);
        }
        for (int i = 0; i < 1000; i++) {
            String key = PARTITION_PREFIX + (i % partitionCount);
            ExecutionContext executionContext = partitions.get(key);
            List<Integer> data = (List<Integer>) executionContext.get("data");
            data.add(i + 1);
        }
        return partitions;
    }
}



這個分割槽器的實現沒有做任何有趣的事情。它建立了50個分割槽,對於每個分割槽,它把一些數字放入一個列表中,在關鍵資料下可以訪問。
這意味著,每個分割槽在列表中會有20個數字。

這就是你可以想象獲得交易或任何你想處理的ID的地方,稍後下線,工作者將從資料庫中載入相應的行。

很好,讓我們建立工作步驟job steps並從分割槽器中建立一個bean。

@Configuration
@Profile("manager")
public class ManagerConfiguration {
    // previous content is omitted for simplicity

    @Bean
    public ExamplePartitioner partitioner() {
        return new ExamplePartitioner();
    }

    @Bean
    public Step partitionerStep() {
        return stepBuilderFactory.get("partitionerStep")
                .partitioner(Constants.WORKER_STEP_NAME, partitioner())
                .outputChannel(outboundRequests())
                .build();
    }
}



沒有什麼特別的,我們建立了呼叫分割槽器的步驟,以及我們想把分割槽傳送到的輸出通道。
另外,這裡有一個對常量類的引用,讓我給你看看它的內容。

public class Constants {
    public static final String TOPIC_NAME = "work";
    public static final String WORKER_STEP_NAME = "simpleStep";
    public static final int TOPIC_PARTITION_COUNT = 3;
}


這就是全部。我們將呼叫Kafka主題工作,它將有3個主題分割槽,我們想在分割槽資料集上呼叫的工作步驟被稱為simpleStep。

很好,現在我們來建立分割槽器工作。

@Configuration
@Profile("manager")
public class ManagerConfiguration {
    // previous content is omitted for simplicity

    @Bean(name = "partitionerJob")
    public Job partitionerJob() {
        return jobBuilderFactory.get("partitioningJob")
                .start(partitionerStep())
                .incrementer(new RunIdIncrementer())
                .build();
    }
}


同樣,沒有什麼特別的,只是引用了我們之前建立的分割槽器步驟,並在作業中新增了RunIdIncrementer,這樣我們就可以輕鬆地重新執行作業。

很好。現在,我想說的是最複雜的東西,如何將通道接入Kafka,並確保主題分割槽被正確利用。

我們也會用Spring Integration來做這個:

@Configuration
@Profile("manager")
public class ManagerConfiguration {
    // previous content is omitted for simplicity

    @Bean
    public IntegrationFlow outboundFlow() {
        KafkaProducerMessageHandler messageHandler = new KafkaProducerMessageHandler(kafkaTemplate);
        messageHandler.setTopicExpression(new LiteralExpression(Constants.TOPIC_NAME));
        return IntegrationFlows
                .from(outboundRequests())
                .log()
                .handle(messageHandler)
                .get();
    }
}


首先,我們需要一個KafkaProducerMessageHandler,它將接收到的訊息並將其釋出到Kafka主題中。
該主題由setTopicExpression方法呼叫來標記,最後,我們只需將所有東西作為一個整合流來連線。

然而,這還不會利用主題分割槽,訊息將被髮布到同一個分割槽。
讓我們透過setPartitionIdExpression方法為其新增一個自定義表示式。

@Configuration
@Profile("manager")
public class ManagerConfiguration {
    // previous content is omitted for simplicity

    @Bean
    public IntegrationFlow outboundFlow() {
        KafkaProducerMessageHandler messageHandler = new KafkaProducerMessageHandler(kafkaTemplate);
        messageHandler.setTopicExpression(new LiteralExpression(Constants.TOPIC_NAME));
        Function<Message<?>, Long> partitionIdFn = (m) -> {
            StepExecutionRequest executionRequest = (StepExecutionRequest) m.getPayload();
            return executionRequest.getStepExecutionId() % Constants.TOPIC_PARTITION_COUNT;
        };
        messageHandler.setPartitionIdExpression(new FunctionExpression<>(partitionIdFn));
        return IntegrationFlows
                .from(outboundRequests())
                .log()
                .handle(messageHandler)
                .get();
    }
}


我們提供一個FunctionExpression,它將動態地解開訊息,獲得stepExecutionId屬性並與modulo運算子相結合。
分割槽計數的當前值是3。這意味著分割槽ID表示式將從[0, 1, 2]範圍內返回一個值,這將表示目標主題分割槽。
這算是在分割槽之間提供了一種平均分配,但不是100%。
如果你需要一個複雜的分割槽ID決定器,你肯定可以調整實現。

另外,你也可以同樣使用setMessageKeyExpression方法來提供一個類似的FunctionExpression來計算訊息key,而不是直接告訴Kafka要使用哪個分割槽。

還有一點需要注意的是,我在整合流程中加入了log(),所以傳送出去的訊息會被記錄下來;只是為了除錯的目的。

這就是管理器的配置。

 

Worker
工作者的配置將是類似的。讓我們建立一個WorkerConfiguration類。

@Configuration
@Profile("worker")
public class WorkerConfiguration {
    @Autowired
    private JobBuilderFactory jobBuilderFactory;

    @Autowired
    private RemotePartitioningWorkerStepBuilderFactory stepBuilderFactory;

    @Autowired
    private DataSource dataSource;

    @Bean
    public IntegrationFlow inboundFlow(ConsumerFactory<String, String> cf) {
        return IntegrationFlows
                .from(Kafka.messageDrivenChannelAdapter(cf, Constants.TOPIC_NAME))
                .channel(inboundRequests())
                .get();
    }

    @Bean
    public QueueChannel inboundRequests() {
        return new QueueChannel();
    }
}


幾個依賴關係,一個用於入站訊息的訊息通道,並將其與Spring Integration連線起來。

讓我們來建立一個工作步驟。

@Configuration
@Profile("worker")
public class WorkerConfiguration {
    // previous content is omitted for simplicity

    @Bean
    public Step simpleStep() {
        return stepBuilderFactory.get(Constants.WORKER_STEP_NAME)
                .inputChannel(inboundRequests())
                .<Integer, Customer>chunk(100)
                .reader(itemReader(null))
                .processor(itemProcessor())
                .writer(itemWriter())
                .build();
    }
}

這將建立步驟定義,將其與入站訊息通道相連,並引用 ItemReader、ItemProcessor 和 ItemWriter 例項。這些看起來如下。

@Configuration
@Profile("worker")
public class WorkerConfiguration {
    // previous content is omitted for simplicity
    
    @Bean
    @StepScope
    public ItemReader<Integer> itemReader(@Value("#{stepExecutionContext['data']}") List<Integer> data) {
        List<Integer> remainingData = new ArrayList<>(data);
        return new ItemReader<>() {
            @Override
            public Integer read() {
                if (remainingData.size() > 0) {
                    return remainingData.remove(0);
                }

                return null;
            }
        };
    }
}

ItemReader是一個Bean,它將在Spring Batch執行上下文中的資料鍵下接收分割槽資料作為一個引數。請注意,必須在Bean定義上使用@StepScope,以便為該步驟啟用後期繫結。

實現很簡單。我們將把收到的ID儲存在一個本地列表中,在每個ItemReader呼叫期間,我們將從列表中刪除一個專案,直到沒有剩餘。

@Configuration
@Profile("worker")
public class WorkerConfiguration {
    // previous content is omitted for simplicity
    
    @Bean
    public ItemWriter<Customer> itemWriter() {
        return new JdbcBatchItemWriterBuilder<Customer>()
                .beanMapped()
                .dataSource(dataSource)
                .sql("INSERT INTO customers (id) VALUES (:id)")
                .build();
    }

    @Bean
    public ItemProcessor<Integer, Customer> itemProcessor() {
        return new ItemProcessor<>() {
            @Override
            public Customer process(Integer item) {
                return new Customer(item);
            }
        };
    }
}


ItemProcessor和ItemWriter則更簡單。ItemProcessor只是將ID轉換為Customer物件,模擬對DTO的某種處理,ItemWriter只是將Customers寫入資料庫。

客戶類是一個簡單的POJO,沒有什麼特別的。

public class Customer {
    private int id;

    public Customer(int id) {
        this.id = id;
    }

    public int getId() {
        return id;
    }
}


 

最後的配置步驟
接下來我們需要做的是用所需的分割槽數量建立Kafka主題,所以讓我們建立一個新的KafkaConfiguration類。

@Configuration
public class KafkaConfiguration {
    @Bean
    public NewTopic topic() {
        return TopicBuilder.name(Constants.TOPIC_NAME)
                .partitions(Constants.TOPIC_PARTITION_COUNT)
                .build();
    }
}


如果分割槽計數還不存在,這將自動建立一個主題。

接下來,我們需要建立資料庫結構來儲存我們的客戶,並允許Spring管理其狀態。讓我們在 src/main/resources/db/changelog 資料夾下建立一個 db.changelog-master.xml 檔案,內容如下。

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
                   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                   xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
    <changeSet id="0001-initial" author="Arnold Galovics">
        <createTable tableName="customers">
            <column name="id" type="number">
            </column>
        </createTable>
        <sqlFile path="classpath:/org/springframework/batch/core/schema-mysql.sql" relativeToChangelogFile="false"/>
    </changeSet>
</databaseChangeLog>


createTable很簡單,SQL檔案的匯入是由Spring Batch的核心模組提供的東西。

讓我們在application.properties中新增一些配置。

spring.datasource.url=jdbc:mysql://localhost:3306/db_example?createDatabaseIfNotExist=true
spring.datasource.username=root
spring.datasource.password=mysql
spring.datasource.driver-class-name=com.mysql.jdbc.Driver

spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml


用Liquibase配置DataSource。然後是Kafka生產者的配置。

spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.LongSerializer
spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer
spring.kafka.producer.group-id=producer-g


這裡最重要的是使用JsonSerializer,這樣Spring Batch要傳送的訊息就會被編碼成JSON。

同樣地,消費者:

spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.LongDeserializer
spring.kafka.consumer.value-deserializer=org.springframework.kafka.support.serializer.JsonDeserializer
spring.kafka.consumer.group-id=consumer-g


還有一件事:
spring.kafka.consumer.properties.spring.json.trusted.packages=*
 
 

執行
建立用於啟動應用程式的資訊庫。我將建立一個docker-compose.yml。

version: "3"
services:
  zookeeper:
    image: 'bitnami/zookeeper:latest'
    ports:
      - '2181:2181'
    environment:
      - ALLOW_ANONYMOUS_LOGIN=yes
  kafka:
    image: 'bitnami/kafka:latest'
    ports:
      - '9092:9092'
    environment:
      - KAFKA_BROKER_ID=1
      - KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper:2181
      - ALLOW_PLAINTEXT_LISTENER=yes
      - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=CLIENT:PLAINTEXT,EXTERNAL:PLAINTEXT
      - KAFKA_CFG_LISTENERS=CLIENT://:9093,EXTERNAL://:9092
      - KAFKA_CFG_ADVERTISED_LISTENERS=CLIENT://kafka:9093,EXTERNAL://localhost:9092
      - KAFKA_CFG_INTER_BROKER_LISTENER_NAME=CLIENT
    depends_on:
      - zookeeper
  kafka-ui:
    image: provectuslabs/kafka-ui:latest
    ports:
      - '8080:8080'
    environment:
      - KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS=kafka:9093
    depends_on:
      - kafka
  mysql:
    image: mysql
    ports:
      - '3306:3306'
    command: --default-authentication-plugin=mysql_native_password
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: mysql


我就不多說了。它啟動了一個Kafka代理,一個位於8080埠的Kafka UI例項,如果你想檢視主題的狀態,還有一個MySQL伺服器。

docker-compose up啟動一切。

完整程式碼可在GitHub 上獲得。

相關文章