Producer Kafka : acs, retry et idempotence correctement

1. Tổng quan

Lorsqu’un producer envoie un message à Kafka, il y a trois questions auxquelles vous devez répondre : comment savoir que l’envoi est terminé, comment gérer les erreurs avec retry, et comment éviter que les retry ne créent des doublons. Cet article explique acks, retries et enable.idempotence — trois configurations qui déterminent la fiabilité et la correction du producer. Après la lecture, vous saurez configurer un producer “production-ready” et éviter les erreurs courantes qui entraînent la perte ou les doublons de données.

2. Dépendance requise

<dependency>
    <groupId>org.apache.kafka</groupId>
    <artifactId>kafka-clients</artifactId>
    <version>3.7.0</version>
</dependency>

Les exemples dans cet article utilisent le client Kafka pur (KafkaProducer), sans passer par Spring Kafka, pour bien voir comment chaque config a un impact.

3. acs : quand le producer considère l’envoi comme réussi

acs détermine combien de brokers le producer doit attendre avant de considérer le message comme envoyé.

Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
props.put(ProducerConfig.ACKS_CONFIG, "all");
  • acks=0 : le producer n’attend aucune confirmation. Plus rapide, mais risque de perte si le broker tombe en panne immédiatement après la réception — sans même savoir qu’il y a eu perte.
  • acks=1 : n’attend que le leader de la partition pour écrire dans le log local et renvoyer l’ACK. Si le leader crash avant que le follower n’ait le temps de synchroniser, le message est perdu.
  • acks=all (ou -1) : attend que tous les replicas dans l’ISR (in-sync replica set) confirment. Le plus sûr, mais latence plus élevée.

Note : acks=all n’est vraiment sûr que s’il est accompagné de min.insync.replicas >= 2 au broker. Si min.insync.replicas=1, acks=all n’attend qu’1 seul replica — pas plus sûr que acks=1.

3.1. Configuration du broker associée

# server.properties ou config du topic
min.insync.replicas=2

Avec replication factor = 3 et min.insync.replicas=2, le système peut tolérer qu’un broker tombe en panne tout en restant écrivable, et garantit que les données résident sur au moins 2 répliques avant d’envoyer l’ACK.

4. retries : gérer les erreurs temporaires

Le producer peut rencontrer des erreurs temporaires comme NotLeaderForPartitionException ou une brève perte de connexion réseau. retries permet au producer de renvoyer automatiquement sans avoir besoin de code de gestion personnalisé.

props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);
props.put(ProducerConfig.RETRY_BACKOFF_MS_CONFIG, 100);
props.put(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, 120_000);
  • retries : nombre maximal de tentatives de retry. À partir de Kafka 2.1, il est conseillé de le fixer très haut (ou Integer.MAX_VALUE) et d’utiliser delivery.timeout.ms pour contrôler le temps total d’attente plutôt que de compter le nombre de tentatives.
  • retry.backoff.ms : temps d’attente entre les tentatives, pour éviter de surcharger le broker.
  • delivery.timeout.ms : temps maximal total depuis l’appel de send() jusqu’à la réception du résultat (succès ou erreur finale). Doit être supérieur à request.timeout.ms + linger.ms.

Problème : sans attention, retry peut causer des doublons de messages ou désordre des messages.

4.1. Pourquoi les retries causent des doublons

1. Le producer envoie un batch contenant le message A
2. Le broker écrit A avec succès et renvoie l'ACK
3. L'ACK est perdu sur le réseau (network timeout)
4. Le producer ne reçoit pas l'ACK -> considère cela comme une erreur -> retry
5. Le broker reçoit la requête à nouveau -> écrit A une 2e fois
=> Le topic a 2 copies du message A

C’est une situation classique : le broker a écrit avec succès mais le producer ne le sait pas, donc il envoie à nouveau. L’idempotence dans la section suivante résout correctement ce problème.

4.2. Pourquoi les retries causent un désordre de messages

Si max.in.flight.requests.per.connection > 1, le producer peut envoyer plusieurs batches en parallèle. Le batch 2 peut être écrit par le broker avant le batch 1 si le batch 1 a une erreur et est en cours de retry.

max.in.flight.requests.per.connection=5

Avec cette configuration sans idempotence, l’ordre des messages dans la partition n’est pas garanti en cas de retry.

5. Idempotence : résoudre les doublons dus aux retries

enable.idempotence=true aide le producer à éviter automatiquement les écritures dupliquées lors des retries, grâce à Kafka qui attache un Producer ID (PID) et un sequence number à chaque batch.

props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);

Quand l’idempotence est activée, Kafka définit automatiquement les valeurs suivantes (vous n’avez pas besoin de les configurer manuellement, mais c’est bien de le savoir) :

acs = all
retries = Integer.MAX_VALUE (ou très haut)
max.in.flight.requests.per.connection <= 5

5.1. Fonctionnement du mécanisme

Chaque session producer reçoit un PID unique du broker. Chaque message envoyé dans une partition a un sequence number qui augmente. Le broker enregistre le dernier sequence number écrit pour chaque PID + partition.

Le producer envoie un batch seq=5 pour la partition 0
Broker : "le dernier sequence que j'ai enregistré est 4, 5 est le suivant -> écrire, enregistrer seq=5"
Le producer envoie à nouveau (en raison d'un retry) le batch seq=5
Broker : "seq=5 j'ai déjà écrit -> ignorer, renvoyer l'ACK quand même"

Grâce à cela, les retries ne créent pas de doublons — le broker détecte et supprime automatiquement les doublons en fonction du sequence number, sans que le producer ou le consumer n’aient besoin de gérer cela.

Note : l’idempotence ne garantit pas de doublons dans une session producer unique et dans l’étendue d’une partition. Si le producer redémarre (un nouveau PID est attribué), le mécanisme de dédup basé sur l’ancien PID ne s’applique plus. Pour obtenir exactly-once à travers plusieurs partitions/topics, vous avez besoin de transactions (transactional.id) — ce qui dépasse le cadre de l’idempotence seule.

5.2. Exemple de configuration producer production-ready

public ProducerFactory() {
    Properties props = new Properties();
    props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
    props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
    props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());

    // Activer l'idempotence -> définit automatiquement acs=all, retries élevés, in-flight <= 5
    props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
    props.put(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, 120_000);

    this.producer = new KafkaProducer<>(props);
}

public void sendOrderEvent(String orderId, String payload) {
    ProducerRecord<String, String> record =
            new ProducerRecord<>("order-events", orderId, payload);

    producer.send(record, (metadata, exception) -> {
        if (exception != null) {
            log.error("Échec de l'envoi de la commande {}", orderId, exception);
        } else {
            log.info("Commande {} écrite dans la partition {} offset {}",
                    orderId, metadata.partition(), metadata.offset());
        }
    });
}
  • ENABLE_IDEMPOTENCE_CONFIG = true est la ligne la plus importante — elle implique automatiquement acs=all.
  • Le callback dans send() indique le résultat réel : si exception != null, cela signifie que toutes les retries ont échoué pendant delivery.timeout.ms — c’est à ce moment que vous devez gérer le cas métier (logging, alerte, enregistrement dans un tableau d’erreurs).

6. Vérifier le comportement avec des tests

Pour illustrer la partie dedup, nous simulons un simple MessageDeduplicator qui imite le mécanisme de sequence number au niveau de l’application (à utiliser quand vous ne pouvez pas activer l’idempotence, par exemple sur les systèmes legacy) :

public class MessageDeduplicator {

    private final Map<String, Long> lastSeenSequence = new ConcurrentHashMap<>();

    public boolean shouldWrite(String partitionKey, long sequence) {
        Long last = lastSeenSequence.get(partitionKey);
        if (last != null && sequence <= last) {
            return false; // doublon, ignorer
        }
        lastSeenSequence.put(partitionKey, sequence);
        return true;
    }
}
class MessageDeduplicatorTest {

    @Test
    void shouldSkipDuplicateSequence() {
        MessageDeduplicator deduplicator = new MessageDeduplicator();

        assertThat(deduplicator.shouldWrite("order-1", 5)).isTrue();
        assertThat(deduplicator.shouldWrite("order-1", 5)).isFalse(); // seq dupliqué du retry
        assertThat(deduplicator.shouldWrite("order-1", 6)).isTrue();
    }

    @Test
    void shouldAllowDifferentPartitionKeysIndependently() {
        MessageDeduplicator deduplicator = new MessageDeduplicator();

        assertThat(deduplicator.shouldWrite("order-1", 1)).isTrue();
        assertThat(deduplicator.shouldWrite("order-2", 1)).isTrue();
    }
}

Le premier test affirme : le même sequence renvoyé une deuxième fois est ignoré — c’est le vrai sens de l’idempotence du producer. Le deuxième test affirme que la dédup ne s’applique qu’au sein d’une clé/partition unique, sans affecter d’autres clés.

7. Tableau de synthèse de la configuration selon les besoins

Besoin acs enable.idempotence Risque
Logs, métriques peu importants 0 false Perte de messages en cas d’erreur, mais throughput maximal
Équilibre vitesse/sécurité 1 false Perte de messages si le leader tombe en panne avant que le follower ne se synchronise
Données métier critiques (commandes, paiements) all true Latence plus élevée, mais pas de perte, pas de doublons dus aux retries

8. Erreurs courantes

  • Définir acs=all mais oublier min.insync.replicas : vous croyez que c’est sûr, mais le broker n’a besoin que d’1 ISR pour envoyer l’ACK — ce n’est pas différent de acs=1.
  • Écrire votre propre logique de retry en parallèle avec le retry par défaut du client Kafka : cela risque de doubler l’envoi des doublons car les deux couches de retry fonctionnent.
  • Activer l’idempotence mais laisser max.in.flight.requests.per.connection > 5 : Kafka rejettera cette configuration dès l’initialisation du producer (ConfigException), car elle dépasse la limite qu’idempotence supporte.
  • Confondre l’idempotence avec exactly-once sur tout le système : l’idempotence ne protège que producer-to-broker. Si un consumer traite un message puis appelle un side-effect (envoyer un email, débiter un compte) qui n’est pas idempotent, vous pouvez quand même traiter des doublons côté consumer.

Note : ne pas activer enable.idempotence=true si le système exécute une version de Kafka antérieure à 0.11 — cette fonctionnalité n’est pas encore prise en charge par le broker et le producer génèrera une erreur lors de l’initialisation.

9. Conclusion

acs, retries et enable.idempotence sont trois pièces indissociables quand vous avez besoin d’un producer fiable. acs=all garantit que les données sont répliquées suffisamment avant de considérer comme réussi ; retries aide à surmonter les erreurs temporaires ; et l’idempotence garantit que ces retries ne créent pas de doublons. Pour les données métier critiques, utilisez par défaut tous les trois ensemble — le coût de latence est bien inférieur au coût du traitement des données erronées ou dupliquées ultérieurement.

kafkaproducteuracsidempotencesystèmes-distribués