Kafka producer : acks, idempotence et batching
1. Aperçu
Envoyer un message vers Kafka ne nécessite qu’une ligne send(). La partie difficile réside dans les questions qui s’ensuivent : le message a-t-il vraiment été sauvegardé, sera-t-il écrit en doublon lors d’une nouvelle tentative du producteur, et comment envoyer des dizaines de milliers de messages par seconde sans surcharger le broker ?
Cet article explore trois groupes de configuration qui répondent à ces trois questions : acks, enable.idempotence et le batching (batch.size, linger.ms, compression.type). Une fois cet article lu, vous saurez comment configurer un producteur à la fois sûr et rapide, avec le client Kafka pur et Spring Boot 3.x, et comment le tester.
2. Configuration initiale
Avec Java pur, il vous suffit de kafka-clients :
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>3.9.0</version>
</dependency>
Avec Spring Boot 3.x, utilisez spring-kafka. La version est gérée par Spring Boot, et elle entraîne déjà kafka-clients :
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
Tout au long de cet article, nous enverrons des événements de commande :
public record Order(String id, String customerId, BigDecimal total) {
public String toJson() {
return """
{"id":"%s","customerId":"%s","total":%s}""".formatted(id, customerId, total);
}
}
3. acks : quand un message est-il considéré comme envoyé ?
acks définit le nombre de confirmations que le producteur doit attendre du broker avant de considérer une requête comme réussie.
| Valeur | Signification | Risque |
|---|---|---|
acks=0 |
Ne pas attendre | Perte de message sans le savoir |
acks=1 |
Attendre la fin de l’écriture du leader | Perte de message si le leader meurt avant la réplication |
acks=all (-1) |
Attendre l’écriture de tous les réplicas dans l’ISR | Latence légèrement plus élevée |
Exemple de configuration avec le client pur :
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.ACKS_CONFIG, "all");
try (var producer = new KafkaProducer<String, String>(props)) {
var order = new Order("o-1001", "c-42", new BigDecimal("250000"));
RecordMetadata meta = producer
.send(new ProducerRecord<>("orders", order.customerId(), order.toJson()))
.get();
System.out.printf("partition=%d offset=%d%n", meta.partition(), meta.offset());
}
L’appel de .get() rend l’envoi synchrone. Ici, nous le faisons seulement pour imprimer le résultat. Avec acks=all, get() ne revient que lorsque tous les réplicas de l’ISR ont terminé l’écriture :
partition=2 offset=17
Nous utilisons customerId comme clé, donc toutes les commandes du même client vont dans la même partition et conservent l’ordre correct.
3.1. acks=all n’est pas suffisant sans min.insync.replicas
acks=all signifie « tous les réplicas actuellement dans l’ISR ». Supposons que l’ISR se réduise à seulement le leader. Dans ce cas, acks=all ne diffère pas de acks=1. Vous devez configurer également sur le topic ou le broker :
kafka-topics.sh --bootstrap-server localhost:9092 --create --topic orders \
--partitions 6 --replication-factor 3 \
--config min.insync.replicas=2
Avec replication-factor=3 et min.insync.replicas=2, chaque écriture doit avoir au moins 2 réplicas. Si l’ISR tombe en dessous de 2, le producteur reçoit une NotEnoughReplicasException au lieu d’écrire silencieusement une seule copie.
Note : Ne fixez pas
min.insync.replicaségal àreplication-factor. Dans ce cas, il suffit d’un simple redémarrage du broker pour que le topic refuse toutes les écritures.
4. Idempotence : retry sans duplication d’écriture
acks=all entraîne des retries. Considérons le scénario suivant : le broker a écrit le message mais la réponse est perdue en chemin. Le producteur pense que l’envoi a échoué, alors il le renvoie, et le message est écrit deux fois.
Le producteur idempotent résout ce scénario exact. Lorsque vous activez enable.idempotence=true :
- Le broker attribue un ID producteur (PID) au producteur.
- Chaque lot envoyé vers une partition porte un numéro de séquence croissant.
- Le broker supprime les lots avec un numéro de séquence déjà vu et rejette les lots qui sautent dans la séquence. Ainsi, l’ordre est préservé même lorsque plusieurs requêtes sont en vol.
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
props.put(ProducerConfig.ACKS_CONFIG, "all");
props.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5);
props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);
props.put(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, 120_000);
La signification de chaque ligne :
ENABLE_IDEMPOTENCE_CONFIGactive le mécanisme PID et numéro de séquence.- L’idempotence force
acks=all. Si vous définissez explicitementacks=1avecenable.idempotence=true, le producteur lève uneConfigExceptionà l’initialisation. max.in.flight.requests.per.connectiondoit être ≤ 5. Dans cette limite, Kafka garantit toujours l’ordre dans la partition.- Mettez
retriestrès élevé. Le délai d’attente réel des retries est limité pardelivery.timeout.ms(par défaut 2 minutes).
À partir de Kafka 3.0, acks=all et enable.idempotence=true sont par défaut. Néanmoins, vous devez déclarer explicitement. Sinon, il suffit que quelqu’un mette acks=1 pour que l’idempotence soit silencieusement désactivée.
4.1. Ce que l’idempotence ne peut pas faire
Voici la partie souvent mal comprise :
- Il ne prévient les doublons que dans une session de producteur, par partition. Si l’application redémarre, le producteur reçoit un nouveau PID. Les messages renvoyés après un redémarrage sont toujours écrits deux fois.
- Il ne prévient pas les doublons causés par votre code appelant
send()deux fois, par exemple si vous effectuez vous-même un retry danscatch. - Il n’offre pas d’écriture atomique sur plusieurs partitions.
Ces cas nécessitent des transactions (transactional.id) ou un consumer capable de dédupliquer par orderId.
5. Batching : échanger la latence pour le débit
Le producteur n’envoie pas message par message. send() place simplement l’enregistrement dans un buffer en mémoire, organisé par partition. Un thread de fond (sender) groupe les enregistrements en lots puis les envoie. Trois paramètres principaux :
| Paramètre | Par défaut | Rôle |
|---|---|---|
batch.size |
16384 byte | Taille maximale d’un lot pour une partition |
linger.ms |
0 (5 à partir de Kafka 4.0) | Temps d’attente supplémentaire pour remplir le lot |
compression.type |
none |
Compresser le lot entier : gzip, snappy, lz4, zstd |
buffer.memory |
32 MB | Mémoire totale pour tous les lots en attente |
Un lot est envoyé quand il est plein (batch.size) ou quand linger.ms expire, selon ce qui se produit en premier. Avec linger.ms=0, le lot est presque toujours envoyé immédiatement, il ne contient donc généralement que quelques enregistrements.
props.put(ProducerConfig.BATCH_SIZE_CONFIG, 64 * 1024);
props.put(ProducerConfig.LINGER_MS_CONFIG, 10);
props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4");
Cette configuration accepte une latence supplémentaire d’environ 10 ms au maximum. En échange, chaque requête porte 64 KB de données compressées. Avec des données JSON répétitives comme les événements de commande, lz4 ou zstd réduit généralement considérablement la bande passante réseau et l’espace disque sur le broker. La compression par lot est beaucoup plus efficace que la compression par message, car plus le lot est grand, plus il y a de chaînes répétitives à exploiter.
Vous pouvez vérifier cela par les métriques du producteur :
producer.metrics().forEach((name, metric) -> {
if (name.name().equals("batch-size-avg") || name.name().equals("records-per-request-avg")) {
System.out.println(name.name() + " = " + metric.metricValue());
}
});
En envoyant 10 000 commandes consécutives avec linger.ms=0, puis en répétant avec linger.ms=10, vous verrez records-per-request-avg augmenter sensiblement :
# linger.ms=0
batch-size-avg = 1830.4
records-per-request-avg = 21.7
# linger.ms=10
batch-size-avg = 41022.9
records-per-request-avg = 486.2
Les chiffres exacts dépendent de la machine et de la charge, mais la tendance est toujours la même.
Note : Si le buffer est plein (la lenteur du consumer n’est pas pertinente ici, il s’agit du broker lent ou du réseau congestionné),
send()va bloquer jusqu’àmax.block.ms(60 secondes par défaut) puis lancer uneTimeoutException. N’appelez passend()dans un thread traitant une requête HTTP sans tenir compte de cette possibilité.
Note :
delivery.timeout.msdoit être ≥linger.ms + request.timeout.ms. Sinon, le producteur refuse de s’initialiser.
6. Configuration dans Spring Boot 3.x
Spring Boot a des propriétés dédiées pour les paramètres courants. Les autres paramètres passent par properties :
spring:
kafka:
bootstrap-servers: localhost:9092
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
acks: all
batch-size: 64KB
compression-type: lz4
buffer-memory: 64MB
properties:
enable.idempotence: true
linger.ms: 10
max.in.flight.requests.per.connection: 5
delivery.timeout.ms: 120000
Le publisher utilise KafkaTemplate. À partir de Spring Kafka 3.0, send() retourne un CompletableFuture :
@Service
public class OrderEventPublisher {
private static final Logger log = LoggerFactory.getLogger(OrderEventPublisher.class);
private final KafkaTemplate<String, String> kafkaTemplate;
public OrderEventPublisher(KafkaTemplate<String, String> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public CompletableFuture<SendResult<String, String>> publish(Order order) {
return kafkaTemplate.send("orders", order.customerId(), order.toJson())
.whenComplete((result, ex) -> {
if (ex != null) {
log.error("L'envoi de la commande {} a échoué", order.id(), ex);
} else {
var meta = result.getRecordMetadata();
log.info("Commande {} -> partition {} offset {}",
order.id(), meta.partition(), meta.offset());
}
});
}
}
Notez trois points :
- Le callback s’exécute sur le thread sender du producteur. Ne faites pas de travail lourd ni n’appelez de blocage dans
whenComplete, car cela ralentirait tous les autres envois. - Quand le callback reçoit une erreur, le producteur a épuisé toutes ses tentatives dans la limite de
delivery.timeout.ms. Ne relancez pas manuellement en rappelantsend()immédiatement. C’est précisément la source de doublons que l’idempotence ne peut pas bloquer. - Retourner un
CompletableFuturepermet à l’appelant de décider s’il faut attendre ou non.
7. Test du comportement du producteur
kafka-clients fournit MockProducer pour tester sans broker. Pour l’utiliser, nous séparons la logique dans une classe qui accepte l’interface Producer :
public class OrderSender {
private final Producer<String, String> producer;
public OrderSender(Producer<String, String> producer) {
this.producer = producer;
}
public Future<RecordMetadata> send(Order order) {
return producer.send(new ProducerRecord<>("orders", order.customerId(), order.toJson()));
}
public static Properties reliableConfig(String bootstrap) {
Properties p = new Properties();
p.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrap);
p.put(ProducerConfig.ACKS_CONFIG, "all");
p.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
p.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5);
p.put(ProducerConfig.LINGER_MS_CONFIG, 10);
p.put(ProducerConfig.BATCH_SIZE_CONFIG, 64 * 1024);
p.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4");
return p;
}
}
Test avec JUnit 5 et AssertJ :
class OrderSenderTest {
@Test
void whenSendingOrders_thenKeyIsCustomerId() {
var mock = new MockProducer<>(true, new StringSerializer(), new StringSerializer());
var sender = new OrderSender(mock);
sender.send(new Order("o-1", "c-42", new BigDecimal("100")));
sender.send(new Order("o-2", "c-42", new BigDecimal("200")));
assertThat(mock.history())
.hasSize(2)
.extracting(ProducerRecord::key)
.containsOnly("c-42");
}
@Test
void whenBrokerFails_thenFutureCompletesExceptionally() {
var mock = new MockProducer<>(false, new StringSerializer(), new StringSerializer());
var sender = new OrderSender(mock);
Future<RecordMetadata> future = sender.send(new Order("o-3", "c-7", BigDecimal.TEN));
mock.errorNext(new NotEnoughReplicasException("ISR < min.insync.replicas"));
assertThatThrownBy(future::get)
.hasCauseInstanceOf(NotEnoughReplicasException.class);
}
@Test
void reliableConfig_isAccepted() {
var props = OrderSender.reliableConfig("localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
var config = new ProducerConfig(props);
assertThat(config.getString(ProducerConfig.ACKS_CONFIG)).isEqualTo("-1");
assertThat(config.getBoolean(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG)).isTrue();
}
}
Chaque test vérifie un comportement :
- Le premier test utilise
autoComplete=true, donc chaquesend()réussit immédiatement.history()permet de vérifier la clé, confirmant que l’ordre par client est préservé. - Le deuxième test utilise
autoComplete=falsepuiserrorNext(...)pour simuler un broker refusant l’écriture. C’est comment tester votre code de gestion d’erreur. - Le dernier test initialise
ProducerConfigpour que Kafka valide lui-même la combinaison de configuration. Une combinaison incorrecte, commeacks=1avec l’idempotence, fera échouer le test avec uneConfigExceptionau lieu d’exploser au déploiement. Kafka normaliseallen"-1", donc l’assertion compare avec"-1".
8. Quand faut-il assouplir ?
La configuration “maximalement sûre” n’est pas toujours correcte :
- Métriques, logs, clickstream : la perte de quelques enregistrements est acceptable. Dans ce cas,
acks=1,linger.msélevé et compressionzstdpour le meilleur débit. - Systèmes nécessitant une faible latence par message (par exemple, cotations en temps réel) : gardez
linger.mspetit (0–2 ms). Dans ce cas, n’attendez pas de grands lots. - Paiements, commandes : utilisez toujours
acks=all, idempotence etmin.insync.replicas=2. Si vous avez besoin de lire-traiter-écrire exactement une fois, envisagez les transactions.
Note : Définir
max.in.flight.requests.per.connection=1« pour être sûr » n’est pas nécessaire lorsque l’idempotence est activée. Cela réduit simplement le débit.
9. Conclusion
Trois groupes de configuration résolvent trois problèmes différents :
acks=allassocié àmin.insync.replicasgarantit qu’aucun message n’est perdu.enable.idempotencegarantit que les retries ne causent pas de doublons et préservent l’ordre dans la partition. Son champ d’application est limité à une session de producteur.batch.size,linger.msetcompression.typedéterminent le débit, en échange d’une légère latence.
Déclarez explicitement ces valeurs au lieu de vous fier aux valeurs par défaut. Validez-les par des tests pour éviter les surprises au déploiement. Et ne relancez pas vous-même send() dans le code quand le producteur le fait déjà pour vous.