🎯 OBJECTIF
Comprendre comment :
enable.idempotence protège contre les doublons réseau et le réordonnancementmax.poll.interval.ms n'est pas session.timeout.ms🧠 MODÈLE MENTAL
Chaque paramètre Kafka est la réponse à un problème concret. Avant de toucher quoi que ce soit, pose la question : quel est mon arbitrage ? Débit ou latence ? Durabilité ou performance ? Il n'y a pas de config universelle — il y a des configs cohérentes vis-à-vis d'un objectif.
Le producer ne t'envoie pas chaque message un par un — il accumule en mémoire, forme un batch, compresse, envoie. Le consumer ne reçoit pas passivement — il tire (poll()), surveille deux horloges simultanément, et peut se faire expulser du groupe s'il traite trop lentement. Comprendre ces mécaniques internes permet de diagnostiquer les problèmes plutôt que d'augmenter aveuglément les timeouts.
Prérequis : kafka-infra-cluster (ISR, acks, réplication), kafka-core-model (partitions, consumer groups, offsets) et kafka-schema-registry (sérialisation Avro, wire format, compatibilité).
Quand le code appelle producer.send(), le message ne part pas immédiatement sur le réseau. Il entre dans un buffer en mémoire où Kafka le regroupe avec d'autres messages pour former un batch.
producer.send(msg)
↓
Buffer mémoire (RecordAccumulator)
↓
Envoi quand : batch plein OU linger.ms expiré
↓
Compression du batch (si activée)
↓
Requête réseau → broker leader
↓
Confirmation selon acks
linger.ms — combien de temps attendre avant d'envoyer, même si le batch n'est pas plein. À 0 (défaut) : envoi immédiat, petits batchs, nombreuses requêtes réseau. À 20 : attente 20ms, batchs plus gros, débit plus élevé.batch.size — taille max d'un batch en octets (défaut : 16 Ko). Si atteinte avant linger.ms, le batch part immédiatement.// Config prod typique — bon compromis débit/latence
props.put(ProducerConfig.LINGER_MS_CONFIG, 20);
props.put(ProducerConfig.BATCH_SIZE_CONFIG, 65536); // 64 Ko
props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4");java🚨 Compression sans linger.ms = CPU gaspillé
Avec linger.ms=0, chaque message est compressé individuellement — ratio quasi nul, CPU consommé pour rien. La compression n'a de sens que sur des batchs suffisamment grands. Toujours augmenter linger.ms en même temps. lz4 est le meilleur rapport vitesse/ratio pour la majorité des cas.
Buffer plein = blocage puis exception
Si le buffer (buffer.memory, défaut 32 Mo) est saturé, send() bloque pendant max.block.ms puis lève une TimeoutException. C'est le signal que le producer produit plus vite que le broker ne peut absorber.
Quand la clé est null, l'ancien comportement (round-robin pur) répartissait chaque message sur une partition différente — résultat : N partitions × 1 message = N batchs minuscules. Le sticky partitioner (DefaultPartitioner 2.4+, puis UniformStickyPartitioner 2.4+, puis comportement intégré depuis 3.3 via KIP-794) change la stratégie : coller plusieurs messages d'affilée sur la même partition jusqu'à ce que le batch parte (batch.size atteint ou linger.ms expiré), puis basculer sur une autre partition au prochain batch.
| Stratégie | Comportement sans clé | Conséquence |
|---|---|---|
| Round-robin (legacy <2.4) | 1 message par partition à tour de rôle | Batchs minuscules, latence ↑, compression inefficace |
| Sticky (défaut 2.4+) | Messages collés sur une partition jusqu'à envoi du batch | Batchs pleins, débit ↑, latence ↓ |
# Défaut moderne — souvent inutile à préciser, mais utile à connaître
partitioner.class=org.apache.kafka.clients.producer.internals.DefaultPartitionerpropertiesSticky ≠ ordre métier
Le sticky partitioner améliore les performances mais ne donne aucune garantie d'ordre par objet métier. Si l'ordre des messages d'une même entité compte (mouvements d'un même stock par exemple), il faut une clé explicite — le partitioner ne devine pas la sémantique.
🚨 Sticky peut créer des hot partitions transitoires
En sticky, tant qu'un batch n'est pas envoyé, toutes les écritures sans clé partent sur la même partition. Sur un burst soudain, une partition peut prendre 100% du débit pendant plusieurs centaines de ms avant la rotation. Si tu observes un déséquilibre temporaire d'I/O sur un broker, c'est probablement ça — c'est normal et acceptable, mais ne pas confondre avec un vrai problème de répartition.
Sans précaution, un timeout réseau génère des doublons :
sequenceDiagram
participant P as Producer
participant B as Broker
P->>B: send(msg1)
B->>B: écrit msg1 ✓
Note over B,P: réponse perdue sur le réseau
P->>P: timeout → retry
P->>B: send(msg1) [DOUBLON]
B->>B: écrit msg1 une 2e fois ❌mermaidSolution : enable.idempotence=true — chaque producer reçoit un producerID, chaque message un sequenceNumber. Le broker ignore automatiquement les doublons. Active aussi implicitement acks=all et max.in.flight=5.
Avec retries > 0 et max.in.flight > 1, si la requête 1 échoue et est retentée pendant que la requête 2 réussit, les messages arrivent dans le mauvais ordre :
Envoi : [msg1, msg2]
Requête 1 (msg1) → échoue, retentée
Requête 2 (msg2) → succès immédiat
Requête 1 retry → succès
Topic résultant : [msg2, msg1] ← ordre cassé ❌
enable.idempotence=true résout également ce problème — Kafka garantit l'ordre même avec des retries.
🚨 Ce que l'idempotence ne couvre PAS
Elle protège uniquement dans une même session producer. Si le producer redémarre, il obtient un nouveau producerID — les doublons cross-session ne sont pas couverts. Pour l'exactly-once cross-session, il faut les transactions Kafka (transactional.id).
Le consumer ne reçoit pas de messages passivement — il les tire activement via poll(). C'est la boucle centrale de tout consumer Kafka :
while (true) {
ConsumerRecords<K, V> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<K, V> record : records) {
process(record); // ✓ traitement métier
}
consumer.commitSync(); // ✓ commit après traitement
}javapoll() fait deux choses simultanément : récupérer les messages ET envoyer un heartbeat au coordinator.
flowchart LR
HB["Thread heartbeat every heartbeat.interval.ms (3s)"]
PT["Thread poll surveillé par max.poll.interval.ms (5min)"]
CO["Coordinator"]
HB -->|session.timeout.ms| CO
PT -->|max.poll.interval.ms| COmermaid| Paramètre | Ce qu'il surveille | Que se passe-t-il si dépassé |
|---|---|---|
session.timeout.ms (45s) |
Heartbeat absent | Consumer considéré mort → rebalance |
max.poll.interval.ms (5min) |
poll() non appelé |
Consumer exclu même si vivant → rebalance |
heartbeat.interval.ms (3s) |
Fréquence d'envoi | N/A — doit être < 1/3 de session.timeout.ms |
Consumer traite un gros batch pendant 6 minutes
↓
Heartbeat thread : OK → coordinator pense que le consumer est vivant
poll() non appelé depuis 6min > max.poll.interval.ms (5min)
↓
Coordinator : "vivant mais incapable de progresser" → exclut le consumer
↓
Rebalance → partitions redistribuées
↓
Consumer finit son traitement, rappelle poll()
→ CommitFailedException : "tu n'es plus membre du groupe"
↓
Réinscription → nouveau rebalance → retraitement des messages
🚨 Ne pas augmenter max.poll.interval.ms aveuglément
La bonne solution est de réduire max.poll.records (défaut : 500) — traiter moins de messages par batch pour que poll() soit appelé plus souvent. Augmenter max.poll.interval.ms masque le problème sans le corriger et retarde la détection des vrais blocages.
L'offset commité est la position de reprise en cas de crash ou rebalance. La règle absolue : commiter après que le travail est fait et persisté, jamais avant.
enable.auto.commit=true — le piège par défautpoll() → 100 messages récupérés
→ 2s plus tard : auto-commit → offset +100 commité
→ traitement échoue sur le message 50 → crash
→ redémarrage : reprise à offset +100
→ messages 50 à 100 perdus définitivement ❌
try {
List<Record> batch = fetchBatch(records);
processAll(batch); // ✓ traitement métier
ecrireEnBase(batch); // ✓ persistance
consumer.commitSync(); // ✓ commit en dernier
} catch (Exception e) {
// ⚠️ pas de commit → les messages seront retraités (at-least-once)
log.error("Processing failed, will retry from last committed offset", e);
}javacommitSync() bloque jusqu'à confirmation du broker. commitAsync() n'attend pas — meilleur débit, mais sans garantie en cas d'échec réseau. À utiliser uniquement avec un callback de monitoring, pas seul.
auto.offset.reset — comportement au démarrage sans offset| Valeur | Comportement | Risque |
|---|---|---|
latest (défaut) |
Repart depuis maintenant | Messages produits pendant l'arrêt ignorés silencieusement |
earliest |
Repart depuis le début | Retraitement de tous les anciens messages |
🚨 Piège fréquent
auto.offset.reset=latest + consumer arrêté pendant 24h = tous les messages produits pendant l'arrêt sont silencieusement ignorés au redémarrage. Aucune erreur, aucun log — juste des messages sautés.
# Producer
acks: "1"
linger.ms: 20
batch.size: 65536
compression.type: lz4
# Consumer
enable.auto.commit: "true"
max.poll.records: 500yamlUsage : logs applicatifs, métriques, analytics non critiques.
# Producer
acks: all
enable.idempotence: "true"
max.in.flight.requests.per.connection: 5
retries: 2147483647 # Integer.MAX_VALUE
delivery.timeout.ms: 120000
linger.ms: 5
compression.type: lz4
# Broker/topic
min.insync.replicas: 2
replication.factor: 3
# Consumer
enable.auto.commit: "false"
max.poll.records: 100
max.poll.interval.ms: 300000yamlUsage : paiements, commandes, événements métier critiques.
max.poll.records: 10
max.poll.interval.ms: 600000
enable.auto.commit: "false"yamlSi le traitement est intrinsèquement long (appel API externe, inférence), traiter en parallèle avec un thread pool — mais commiter l'offset seulement après que tous les messages du batch sont traités.
⚡ TL;DR — chaque concept en une ligne
linger.ms + batch.size
✓ Regroupe les messages en batchs avant envoi — levier principal sur le débit.
⚠ linger.ms=0 (défaut) envoie immédiatement — batchs minuscules, compression inutile, nombreuses requêtes réseau.
Sticky partitioner ✓ Défaut depuis 2.4 — colle les messages sans clé sur une même partition par batch pour densifier les envois. ⚠ Aucune garantie d'ordre par objet métier — pour ça, clé explicite obligatoire.
enable.idempotence ✓ Protège contre les doublons réseau et le réordonnancement via producerID + sequenceNumber. ⚠ Ne couvre pas les doublons cross-session (redémarrage producer) — pour ça, utiliser les transactions Kafka.
max.poll.interval.ms
✓ Surveille que le consumer avance dans son traitement — indépendant du heartbeat.
⚠ Réduire max.poll.records est la bonne réponse à un rebalance en cascade, pas augmenter ce timeout.
Commit manuel
✓ Seul pattern fiable : traitement → persistance → commitSync().
⚠ enable.auto.commit=true peut commiter avant que le traitement soit persisté — perte silencieuse au crash.
🎓 À retenir
compression.type=lz4 sans augmenter linger.ms ne fait que gaspiller du CPU sur des messages individuels.enable.idempotence=true est safe en prod par défaut — il active implicitement acks=all et limite max.in.flight=5, ce qui est la config recommandée pour les topics critiques. Pas de raison de ne pas l'activer.