Exatamente uma vez processando em Kafka

Exatamente uma vez processando em Kafka

1. Visão geral

Neste tutorial, veremos comoKafka ensures exactly-once delivery between producer and consumer applications through the newly introduced Transactional API.

Além disso, usaremos essa API para implementar produtores e consumidores transacionais para obter uma entrega de ponta a ponta exatamente uma vez em um exemplo de WordCount.

2. Entrega de mensagens em Kafka

Devido a várias falhas, os sistemas de mensagens não podem garantir a entrega de mensagens entre os aplicativos do produtor e do consumidor. Dependendo de como os aplicativos clientes interagem com esses sistemas, a seguinte semântica de mensagens é possível:

  • Se um sistema de mensagens nunca duplicará uma mensagem, mas pode perder uma mensagem ocasional, chamamos isso deat-most-once

  • Ou, se ele nunca perder uma mensagem, mas pode duplicar a mensagem ocasional, nós o chamamos deat-least-once

  • Mas, se ele sempre entrega todas as mensagens sem duplicação, isso éexactly-once

Inicialmente, o Kafka suportava apenas entrega de mensagens no máximo uma vez e pelo menos uma vez.

No entanto,the introduction of Transactions between Kafka brokers and client applications ensures exactly-once delivery in Kafka. Para entender melhor, vamos revisar rapidamente a API do cliente transacional.

3. Dependências do Maven

Para trabalhar com a API de transação, precisaremos deKafka’s Java client em nosso pom:


    org.apache.kafka
    kafka-clients
    2.0.0

4. Um Loop Transacionalconsume-transform-produce

Para o nosso exemplo, vamos consumir mensagens de um tópico de entrada,sentences.

Então, para cada frase, contaremos todas as palavras e enviaremos as contagens de palavras individuais para um tópico de saída,counts.

No exemplo, vamos supor que já haja dados transacionais disponíveis no tópicosentences.

4.1. Um produtor ciente de transações

Então, vamos primeiro adicionar um produtor Kafka típico.

Properties producerProps = new Properties();
producerProps.put("bootstrap.servers", "localhost:9092");

Além disso, precisamos especificar umtransactional.ide habilitaridempotence:

producerProps.put("enable.idempotence", "true");
producerProps.put("transactional.id", "prod-1");

KafkaProducer producer = new KafkaProducer(producerProps);

Como habilitamos a idempotência, Kafka usará esse id de transação como parte de seu algoritmo paradeduplicate any message this producersends, garantindo a idempotência.

Simplificando, se o produtor acidentalmente enviar a mesma mensagem para Kafka mais de uma vez, essas configurações permitirão que ele seja notado.

Tudo o que precisamos fazer émake sure the transaction id is distinct for each producer, embora consistente nas reinicializações.

4.2. Habilitando o Produtor para Transações

Quando estivermos prontos, também precisamos chamarinitTransaction para preparar o produtor para usar as transações:

producer.initTransactions();

Isso registra o produtor com o corretor como aquele que pode usar transações,identifying it by its transactional.id and a sequence number, or epoch. Por sua vez, o broker os utilizará para antecipar as ações em um log de transações.

E, conseqüentemente,the broker will remove any actions from that log that belong to a producer with the same transaction id and earlierepoch, upondo que sejam de transações extintas.

4.3. Um consumidor ciente de transações

Quando consumimos, podemos ler todas as mensagens em uma partição de tópico em ordem. Porém,we can indicate with isolation.level that we should wait to read transactional messages until the associated transaction has been committed:

Properties consumerProps = new Properties();
consumerProps.put("bootstrap.servers", "localhost:9092");
consumerProps.put("group.id", "my-group-id");
consumerProps.put("enable.auto.commit", "false");
consumerProps.put("isolation.level", "read_committed");
KafkaConsumer consumer = new KafkaConsumer<>(consumerProps);
consumer.subscribe(singleton(“sentences”));

Usar um valor deread_committed garante que não lemos nenhuma mensagem transacional antes da conclusão da transação.

O valor padrão deisolation.level éread_uncommitted.

4.4. Consumindo e Transformando por Transação

Agora que temos o produtor e o consumidor configurados para escrever e ler transacionalmente, podemos consumir registros do nosso tópico de entrada e contar cada palavra em cada registro:

ConsumerRecords records = consumer.poll(ofSeconds(60));
Map wordCountMap =
  records.records(new TopicPartition("input", 0))
    .stream()
    .flatMap(record -> Stream.of(record.value().split(" ")))
    .map(word -> Tuple.of(word, 1))
    .collect(Collectors.toMap(tuple ->
      tuple.getKey(), t1 -> t1.getValue(), (v1, v2) -> v1 + v2));

Observe que não há nada transacional no código acima. Mas,since we used read_committed, it means that no messages that were written to the input topic in the same transaction will be read by this consumer until they are all written.

Agora, podemos enviar a contagem calculada de palavras para o tópico de saída.

Vamos ver como podemos produzir nossos resultados, também transacionalmente.

4.5. Send API

Para enviar nossas contas como novas mensagens, mas na mesma transação, chamamosbeginTransaction:

producer.beginTransaction();

Em seguida, podemos escrever cada um no tópico "contagens", com a chave sendo a palavra e a contagem como valor:

wordCountMap.forEach((key,value) ->
    producer.send(new ProducerRecord("counts",key,value.toString())));

Observe que, como o produtor pode particionar os dados pela chave, isso significa quetransactional messages can span multiple partitions, each being read by separate consumers. Portanto, o corretor Kafka armazenará uma lista de todas as partições atualizadas para uma transação.

Observe também que,within a transaction, a producer can use multiple threads to send records in parallel.

4.6. Comprometendo compensações

E, finalmente, precisamos comprometer nossas compensações que acabamos de consumir. With transactions, we commit the offsets back to the input topic we read them from, like normal. Além disso,we send them to the producer’s transaction.

Podemos fazer tudo isso em uma única chamada, mas primeiro precisamos calcular os deslocamentos para cada partição de tópico:

Map offsetsToCommit = new HashMap<>();
for (TopicPartition partition : records.partitions()) {
    List> partitionedRecords = records.records(partition);
    long offset = partitionedRecords.get(partitionedRecords.size() - 1).offset();
    offsetsToCommit.put(partition, new OffsetAndMetadata(offset + 1));
}

Observe que o que comprometemos com a transação é a compensação futura, o que significa que precisamos adicionar 1.

Em seguida, podemos enviar nossas compensações calculadas para a transação:

producer.sendOffsetsToTransaction(offsetsToCommit, "my-group-id");

4.7. Comprometendo ou Abortando a Transação

E, finalmente, podemos confirmar a transação, que gravará atomicamente os deslocamentos noconsumer_offsets topic, bem como na própria transação:

producer.commitTransaction();

Isso libera qualquer mensagem em buffer para as respectivas partições. Além disso, o corretor Kafka disponibiliza todas as mensagens nessa transação para os consumidores.

Claro, se algo der errado enquanto estamos processando, por exemplo, se pegarmos uma exceção, podemos chamarabortTransaction:

try {
  // ... read from input topic
  // ... transform
  // ... write to output topic
  producer.commitTransaction();
} catch ( Exception e ) {
  producer.abortTransaction();
}

E solte todas as mensagens em buffer e remova a transação do broker.

If we neither commit nor abort before the broker-configured max.transaction.timeout.ms, the Kafka broker will abort the transaction itself. O valor padrão para esta propriedade é 900.000 milissegundos ou 15 minutos.

5. Outros loops deconsume-transform-produce

O que acabamos de ver é um loopconsume-transform-produce básico que lê e grava no mesmo cluster Kafka.

Por outro lado,applications that must read and write to different Kafka clusters must use the older commitSync and commitAsync API. Normalmente, os aplicativos armazenam as compensações do consumidor em seu armazenamento de estado externo para manter a transacionalidade.

6. Conclusão

Para aplicativos críticos para dados, o processamento de ponta a ponta exatamente uma vez é muitas vezes imperativo.

Neste tutorial,we saw how we use Kafka to do exactly this, using transactions, e implementamos um exemplo de contagem de palavras com base em transação para ilustrar o princípio.

Sinta-se à vontade para verificar todos oscode samples on GitHub.