kafka
Avancé

Exactly-Once Semantics dans Kafka : Le Guide Complet

Exactly-Once Semantics dans Kafka : Le Guide Complet

Comprendre les garanties de livraison Kafka : at-most-once, at-least-once et exactly-once. Quand et comment les utiliser.

Florian Courouge
12 min de lecture
1,169 mots
0 vues
#Kafka
#Exactly-Once
#Idempotence
#Transactions
#Fondamentaux

Exactly-Once Semantics dans Kafka

Les garanties de livraison sont cruciales dans les systèmes distribués. Ce guide explique les trois niveaux de garantie et comment atteindre l'exactly-once avec Kafka.


Les Trois Garanties de Livraison

At-Most-Once (Au plus une fois)

Le message est envoyé une fois, sans retry. S'il est perdu, il est perdu.

Producer ──► Broker ──► Consumer
    │
    └─► ❌ Si échec, pas de retry
         Message potentiellement perdu

Configuration :

props.put("acks", "0");
props.put("retries", 0);

Usage : Logs non critiques, métriques où la perte est acceptable.


At-Least-Once (Au moins une fois)

Le message est envoyé jusqu'à confirmation. Peut créer des doublons.

Producer ──► Broker ──► Consumer
    │          │
    │ timeout  │
    │◄─────────┘
    │
    └──► Broker (retry)  ← Message déjà écrit = DOUBLON

Configuration :

props.put("acks", "all");
props.put("retries", Integer.MAX_VALUE);
props.put("enable.idempotence", "false");

Usage : La plupart des cas où les doublons peuvent être gérés côté consumer.


Exactly-Once (Exactement une fois)

Le message est traité exactement une fois, même en cas de retry ou de crash.

Producer ──► Broker ──► Consumer
    │          │
    │ timeout  │
    │◄─────────┘
    │
    └──► Broker (retry avec PID+sequence)
              │
              └─► Détecte le doublon, ignore

Configuration :

props.put("acks", "all");
props.put("enable.idempotence", "true");

Usage : Transactions financières, comptages, opérations critiques.


Idempotence : La Base de l'Exactly-Once

Comment ça Marche

Kafka assigne à chaque producer un Producer ID (PID) et un sequence number par partition :

┌──────────────────────────────────────┐
│ Message Header                       │
├──────────────────────────────────────┤
│ Producer ID (PID): 12345             │
│ Epoch: 0                             │
│ Sequence Number: 42                  │
│ Partition: 0                         │
├──────────────────────────────────────┤
│ Key: order-123                       │
│ Value: {"amount": 100}               │
└──────────────────────────────────────┘

Le broker maintient le dernier sequence number par (PID, Partition). Si un message arrive avec un sequence déjà vu → rejeté comme doublon.

Activer l'Idempotence

Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");

// Activer l'idempotence
props.put("enable.idempotence", "true");

// Ces configs sont automatiquement définies avec idempotence:
// acks=all
// retries=Integer.MAX_VALUE
// max.in.flight.requests.per.connection=5

KafkaProducer<String, String> producer = new KafkaProducer<>(props);

Limites de l'Idempotence

L'idempotence garantit l'exactly-once pour une seule partition :

Partition 0: msg1, msg2, msg3 ✅ Pas de doublon
Partition 1: msg4, msg5, msg6 ✅ Pas de doublon

MAIS cross-partition non garanti sans transactions

Transactions : Exactly-Once Multi-Partition

Le Problème

Sans transactions, une opération multi-partition peut être partiellement complétée :

1. Écrire dans Partition 0 ✅
2. Écrire dans Partition 1 ✅
3. Écrire dans Partition 2 ❌ Crash!

Résultat: État incohérent

La Solution : Transactions

Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("enable.idempotence", "true");
props.put("transactional.id", "my-transactional-producer");  // Requis!

KafkaProducer<String, String> producer = new KafkaProducer<>(props);

// Initialisation (une seule fois)
producer.initTransactions();

try {
    // Démarrer la transaction
    producer.beginTransaction();

    // Envoyer plusieurs messages
    producer.send(new ProducerRecord<>("topic-a", "key1", "value1"));
    producer.send(new ProducerRecord<>("topic-b", "key2", "value2"));
    producer.send(new ProducerRecord<>("topic-c", "key3", "value3"));

    // Commit atomique
    producer.commitTransaction();

} catch (Exception e) {
    // Abort si erreur
    producer.abortTransaction();
}

Isolation des Transactions

Les consumers peuvent choisir leur niveau d'isolation :

// Consumer config
props.put("isolation.level", "read_committed");  // Voir seulement les transactions committées
// OU
props.put("isolation.level", "read_uncommitted"); // Voir tous les messages (défaut)
Timeline:
─────────────────────────────────────────────►
  │ msg1 │ msg2 │ msg3 │ COMMIT │ msg4 │

read_uncommitted: voit msg1, msg2, msg3, msg4
read_committed:   voit msg1, msg2, msg3 seulement après COMMIT

Pattern Read-Process-Write

Le cas d'usage classique : lire d'un topic, traiter, écrire dans un autre topic.

Sans Exactly-Once (Risque de doublon)

1. Consumer lit message
2. Traitement
3. Producer écrit résultat
4. Consumer commit offset    ← Crash ici = message retraité

Avec Exactly-Once

// Producer transactionnel
props.put("transactional.id", "process-orders");
KafkaProducer<String, String> producer = new KafkaProducer<>(props);
producer.initTransactions();

// Consumer
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(consumerProps);
consumer.subscribe(Collections.singletonList("input-topic"));

while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));

    if (!records.isEmpty()) {
        producer.beginTransaction();

        try {
            for (ConsumerRecord<String, String> record : records) {
                // Traitement
                String result = process(record.value());

                // Écrire le résultat
                producer.send(new ProducerRecord<>("output-topic", record.key(), result));
            }

            // Commit offset DANS la transaction
            Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
            for (TopicPartition partition : records.partitions()) {
                List<ConsumerRecord<String, String>> partitionRecords = records.records(partition);
                long lastOffset = partitionRecords.get(partitionRecords.size() - 1).offset();
                offsets.put(partition, new OffsetAndMetadata(lastOffset + 1));
            }
            producer.sendOffsetsToTransaction(offsets, consumer.groupMetadata());

            // Commit atomique: messages + offsets
            producer.commitTransaction();

        } catch (Exception e) {
            producer.abortTransaction();
        }
    }
}

Kafka Streams : Exactly-Once Simplifié

Kafka Streams gère automatiquement l'exactly-once :

Properties props = new Properties();
props.put(StreamsConfig.APPLICATION_ID_CONFIG, "my-streams-app");
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");

// Activer exactly-once
props.put(StreamsConfig.PROCESSING_GUARANTEE_CONFIG, StreamsConfig.EXACTLY_ONCE_V2);

StreamsBuilder builder = new StreamsBuilder();

builder.stream("input-topic")
    .mapValues(value -> process(value))
    .to("output-topic");

KafkaStreams streams = new KafkaStreams(builder.build(), props);
streams.start();

Avec EXACTLY_ONCE_V2, Kafka Streams :

  • Utilise des transactions automatiquement
  • Gère les offsets dans les transactions
  • Gère les crashes et recovery

Comparaison des Garanties

Aspect At-Most-Once At-Least-Once Exactly-Once
Perte de message Possible Non Non
Doublons Non Possible Non
Performance ⚡ Maximum 🔶 Bonne 🔴 Plus lente
Complexité Simple Simple Plus complexe
Coût broker Faible Moyen Plus élevé

Quand Utiliser Chaque Garantie

At-Most-Once

  • Logs applicatifs
  • Métriques non critiques
  • Données remplaçables

At-Least-Once

  • La plupart des cas d'usage
  • Consumer idempotent (peut gérer les doublons)
  • Event sourcing avec déduplication

Exactly-Once

  • Transactions financières
  • Comptages et agrégations
  • Données de facturation
  • Systèmes de paiement

Impact sur les Performances

L'exactly-once a un coût :

Latence (P99):
At-least-once:  ~10ms
Exactly-once:   ~50ms (5x plus lent)

Throughput:
At-least-once:  100,000 msg/s
Exactly-once:   20,000 msg/s (5x moins)

Conseil : N'utilisez l'exactly-once que quand c'est vraiment nécessaire. Pour la plupart des cas, un consumer idempotent avec at-least-once suffit.


Résumé

Garantie Configuration Usage
At-most-once acks=0, retries=0 Logs non critiques
At-least-once acks=all, retries=MAX Cas standard
Exactly-once enable.idempotence=true Single partition
EOS transactions transactional.id Multi-partition
Kafka Streams EXACTLY_ONCE_V2 Stream processing

Fin de la Série Fondamentaux

Vous avez maintenant les bases pour comprendre et utiliser Kafka efficacement :

  1. Qu'est-ce que Kafka
  2. Architecture
  3. Producers & Consumers
  4. Consumer Groups
  5. Réplication
  6. Exactly-Once (cet article)

→ Prochaine étape : Articles avancés Kafka

F

Florian Courouge

Expert DevOps & Kafka | Consultant freelance specialise dans les architectures distribuees et le streaming de donnees.

Articles similaires