🎯 OBJECTIF
Comprendre comment :
🧠 MODÈLE MENTAL
Kafka n'est pas une queue. Une queue classique retire le message dès qu'il est consommé. Kafka est un journal distribué : les messages sont écrits à la fin d'un log ordonné, conservés pendant une durée configurable, et chaque consumer choisit lui-même sa position de lecture via un offset. Le même message peut être lu par dix consumers différents sans interférence.
Ce modèle a une conséquence directe : l'ordre n'est garanti qu'au sein d'une partition. Dès qu'un topic a plusieurs partitions, l'ordre global entre elles disparaît. Toute l'architecture de clés, de consumer groups et d'idempotence découle de cette contrainte fondamentale.
stock-moved, order-created)..log + .index + .timeindex. Un seul segment actif, N segments fermés derrière.value=null utilisé pour signaler une suppression dans un topic compacted.Un topic est divisé en partitions indépendantes. Chaque partition est un log append-only : les messages s'accumulent dans l'ordre d'arrivée, chacun reçoit un offset unique et immuable.
flowchart LR
subgraph T["Topic: stock-moved"]
subgraph P0["Partition 0"]
direction LR
m0["offset 0\nSKU-A"] --> m1["offset 1\nSKU-C"] --> m2["offset 2\nSKU-A"]
end
subgraph P1["Partition 1"]
direction LR
n0["offset 0\nSKU-B"] --> n1["offset 1\nSKU-D"]
end
subgraph P2["Partition 2"]
direction LR
o0["offset 0\nSKU-E"]
end
endmermaidL'offset est par partition — il n'y a pas d'offset global entre partitions. La lecture ne supprime pas le message, il reste jusqu'à expiration de la rétention. L'ordre est garanti à l'intérieur d'une partition, jamais entre partitions.
Le producer envoie (topic, key, value). La partition cible est calculée par hash(key) % nombre_de_partitions. Même clé → même partition → ordre garanti pour cette clé. C'est le seul mécanisme d'ordre disponible dans Kafka.
// ✓ Tous les événements du même stock arrivent dans la même partition
producer.send(new ProducerRecord<>("stock-moved", stockId, event));java🚨 Clé null = round-robin (ou sticky depuis 2.4)
Sans clé, Kafka distribue les messages — historiquement en round-robin pur, mais depuis Kafka 2.4 le sticky partitioner est devenu le défaut côté producer Java : il colle plusieurs messages d'affilée sur la même partition pour mieux remplir les batchs, puis change. Le résultat reste non ordonné par objet métier — toujours définir une clé métier significative si l'ordre compte. Détail dans kafka-producer-consumer-tuning.
Un consumer group permet à plusieurs instances du même service de se partager les partitions. Chaque partition est assignée à un seul consumer dans le groupe à la fois.
flowchart LR
subgraph G["Consumer group: stock-service"]
C1["Pod A"]
C2["Pod B"]
C3["Pod C"]
end
P0["Partition 0"] --> C1
P1["Partition 1"] --> C2
P2["Partition 2"] --> C3mermaidDeux consumers de groupes différents peuvent lire le même topic indépendamment — chaque groupe a ses propres offsets.
Le rapport entre le nombre de pods (consumers) et le nombre de partitions décide qui consomme quoi. Deux régimes opposés.
Cas 1 — plus de pods que de partitions (3 partitions, 5 pods) :
| Pod | Partition assignée |
|---|---|
| Pod 1 | P0 |
| Pod 2 | P1 |
| Pod 3 | P2 |
| Pod 4 | — (oisif) |
| Pod 5 | — (oisif) |
Un pod oisif n'est pas inutile : il reste membre du groupe, envoie ses heartbeats, participe aux rebalances et sert de hot standby. Si un pod actif tombe, le coordinator lui réassigne une partition au prochain rebalance.
Pod 2 tombe → rebalance → Pod 4 récupère P1 et devient actif.Cas 2 — plus de partitions que de pods (5 partitions, 3 pods) :
| Pod | Partitions assignées |
|---|---|
| Pod 1 | P0, P1 |
| Pod 2 | P2, P3 |
| Pod 3 | P4 |
Tous les pods consomment, toutes les partitions sont couvertes, mais le parallélisme reste plafonné à 3 — chaque pod traite ses partitions assignées séquentiellement.
consumers actifs = min(partitions, pods)| Partitions | Pods | Consumers actifs | Pods oisifs |
|---|---|---|---|
| 3 | 5 | 3 | 2 |
| 5 | 3 | 3 | 0 |
| 5 | 5 | 5 | 0 |
| 10 | 3 | 3 | 0 |
🔑 Conclusion clé
Le parallélisme max d'un consumer group est borné par le nombre de partitions, jamais par le nombre de pods. Ajouter des pods au-delà du nombre de partitions n'augmente pas le débit — ça ne fait que constituer un banc de standby. Le nombre de partitions est donc une décision d'architecture à prendre avant le déploiement (cf. À retenir — l'augmenter a posteriori casse l'ordre par clé).
Le coordinator déclenche un rebalance quand un consumer rejoint ou quitte le groupe, quand session.timeout.ms est dépassé (heartbeat absent), ou quand max.poll.interval.ms est dépassé (consumer bloqué sur le traitement).
Deux protocoles coexistent — comprendre lequel est actif change radicalement l'impact opérationnel.
| Protocole | Mécanique | Impact |
|---|---|---|
Eager (historique : RangeAssignor, RoundRobinAssignor) |
Stop-the-world : tous les consumers révoquent toutes leurs partitions, puis le coordinator redistribue. | Toute consommation du groupe suspendue pendant le rebalance. |
Cooperative (CooperativeStickyAssignor, défaut depuis Kafka 3.0 client) |
Rebalance incrémental : seules les partitions qui doivent changer de main sont révoquées, les autres continuent. | Latence largement réduite, surtout sur les rolling restarts et l'ajout/retrait d'un consumer. |
# Recommandé en prod sur clients récents
partition.assignment.strategy=org.apache.kafka.clients.consumer.CooperativeStickyAssignorproperties🚨 Rebalance + offset non commité = doublons
Si un consumer crashe après traitement mais avant de commiter l'offset, le nouveau consumer assigné à cette partition repart depuis le dernier offset commité et retraite les mêmes messages. C'est le cas nominal de at-least-once — l'idempotence côté traitement est la seule protection. Cooperative rebalance réduit la fenêtre d'impact, mais ne supprime pas la nécessité d'idempotence.
Le commit d'offset déclare à Kafka "j'ai traité jusqu'ici". C'est la position de reprise en cas de crash ou rebalance. La règle absolue : commiter après que le traitement est persisté, jamais avant.
sequenceDiagram
participant C as Consumer
participant K as Kafka
participant DB as Base de données
C->>K: poll() → messages 0..99
C->>DB: INSERT / UPDATE
DB-->>C: OK
C->>K: commitSync() → offset 100
Note over C,K: crash ici → reprise à 100, pas de pertemermaidtry {
process(records); // ✓ traitement métier
ecrireEnBase(records); // ✓ persistance
consumer.commitSync(); // ✓ commit après succès garanti
} catch (Exception e) {
// ⚠️ pas de commit → les messages seront retraités
}java| Semantic | Comportement | Usage |
|---|---|---|
| At most once | Commit avant traitement — perte possible, jamais de doublon | Logs, métriques non critiques |
| At least once | Commit après traitement — pas de perte, doublons possibles | Standard production |
| Exactly once (Kafka) | Transactions Kafka ↔ Kafka uniquement | Kafka Streams, aggregations |
🚨 Exactly-once Kafka ≠ exactly-once end-to-end
Les transactions Kafka garantissent l'exactly-once entre topics Kafka uniquement. Elles ne protègent pas les écritures en base de données. Pour un traitement DB idempotent, il faut une clé unique ou une contrainte d'unicité côté DB — indépendamment de la config Kafka.
La rétention décide quand un message ancien disparaît. Deux politiques mutuellement combinables via cleanup.policy. Pour comprendre quand chacune déclenche, il faut d'abord comprendre les segments — l'unité physique sur laquelle elles opèrent.
Une partition n'est pas un seul fichier — c'est une suite ordonnée de segments, chacun composé de plusieurs fichiers sur disque :
/var/kafka-logs/stock-moved-0/
├── 00000000000000000000.log ← messages (base offset 0)
├── 00000000000000000000.index ← offset → position fichier
├── 00000000000000000000.timeindex ← timestamp → offset
├── 00000000000000012453.log ← segment suivant (base offset 12453)
├── 00000000000000012453.index
├── 00000000000000012453.timeindex
└── 00000000000000024891.log ← segment actif (writes en cours)
Le nom de fichier est le base offset : l'offset du premier message du segment, padé à 20 chiffres. Quand un consumer demande l'offset 15000, Kafka trouve le segment 12453.log (plus grand base offset ≤ 15000), utilise .index pour sauter directement à la bonne position dans le fichier, lit le message.
| Fichier | Rôle |
|---|---|
.log |
Messages séquentiels — append-only, jamais modifié après fermeture du segment |
.index |
Index sparse offset → byte position (un point tous les ~4 Ko par défaut, index.interval.bytes) |
.timeindex |
Index sparse timestamp → offset, pour les requêtes par date (offsetsForTimes) |
À tout instant, une partition a un seul segment actif (le dernier, celui où les nouveaux messages sont appendés) et N segments fermés derrière.
écritures → [segment actif] | [segment fermé] [segment fermé] [segment fermé]
↑ ↑
writes ici plus ancien
Conditions de rotation (le segment actif se ferme, un nouveau démarre) :
segment.bytes atteint (défaut : 1 Go)segment.ms écoulé depuis l'ouverture (défaut : 7 jours)Un segment fermé est immuable. Plus aucune écriture, jamais. C'est ce qui rend Kafka efficace : pas de mise à jour en place, pas de réécriture, juste append puis archivage.
delete (défaut) — purge par âge ou taillecleanup.policy=delete
retention.ms=604800000 # 7 jours (défaut)
retention.bytes=-1 # illimité (défaut)
segment.ms=604800000 # rotation segment toutes les 7j
segment.bytes=1073741824 # 1 Go par segmentpropertiesLa règle de purge ne s'applique qu'aux segments fermés, et au segment entier. Conséquences concrètes :
1. La rétention effective est toujours ≥ celle configurée. Si retention.ms=1h mais que segment.ms=24h, un message émis à T+0 dans un segment qui se ferme à T+24h ne sera purgeable qu'à partir de T+25h (rétention basée sur le timestamp du dernier message du segment). Tu peux observer 25h de rétention réelle pour 1h configurée — c'est normal, pas un bug.
2. La rétention se compte depuis le timestamp du dernier message du segment, pas depuis sa fermeture. Un segment qui couvre T+0 → T+24h devient éligible à la purge à T+24h + retention.ms.
3. Le segment actif n'est jamais purgé, même si tous ses messages ont dépassé retention.ms. Forcer la rotation (segment.ms court) accélère la purge sur les topics peu actifs.
# Topic peu actif (heartbeat, métriques rares) — forcer la rotation
segment.ms=3600000 # 1h au lieu de 7j
retention.ms=86400000 # garde 24hpropertiesSans ce réglage, un topic à 10 messages/jour garderait potentiellement des semaines de données — le segment actif ne tournerait jamais sur segment.bytes.
retention.ms et retention.bytes s'appliquent en parallèle (OR) — le premier seuil atteint déclenche le nettoyage.
La taille d'un segment résulte du premier seuil atteint entre segment.bytes et segment.ms. Le défaut (1 Go / 7 jours) convient aux topics actifs ; sur les autres, il faut arbitrer. Les deux extrêmes coûtent cher.
Segments trop gros (segment.ms très long, ou segment.bytes jamais atteint sur un topic peu actif) :
| Effet | Détail |
|---|---|
| Rétention imprécise | Le segment actif n'est jamais purgé — les données vivent jusqu'à sa rotation, bien au-delà de retention.ms (cf. règle de purge ci-dessus). |
| Disque gonflé | Kafka garde le fichier entier tant qu'il est ouvert, même si 99 % des messages ont déjà expiré. |
| Compaction plus lente | En compact, de gros segments = plus d'I/O et de CPU par passe du log cleaner. |
| Redémarrage plus lent | Au boot, Kafka rouvre les segments et relit/reconstruit les index — des fichiers énormes rallongent l'opération. |
Segments trop petits (segment.ms=1s ou segment.bytes minuscule) :
| Effet | Détail |
|---|---|
| Explosion de fichiers | Un .log + .index + .timeindex par segment — vite des milliers de fichiers par partition. |
| Surcharge métadonnées | Plus d'index à charger en mémoire, plus d'appels système (open/mmap), plus de descripteurs ouverts. |
| Overhead fixe | Chaque segment porte un coût incompressible indépendant de son contenu. |
Cadrage pratique — caler segment.ms sur l'ordre de grandeur de la rétention :
| Rétention voulue | segment.ms conseillé |
|---|---|
| 1 heure | 5 – 15 min |
| 24 heures | 30 min – 1 h |
| 7 jours | 1 – 6 h |
| Gros topic très actif | piloté par segment.bytes, pas par le temps |
Règle de pouce
segment.ms doit être inférieur ou proche de retention.ms. Dès qu'il le dépasse largement, la rétention effective dérive — les données restent tant que le segment ne tourne pas. Garder en tête que ce sont deux leviers distincts : retention.ms = combien de temps je garde mes données ; segment.ms = à quelle fréquence Kafka découpe ses fichiers. Ce ne sont pas des synonymes.
compact — dernière valeur par clécleanup.policy=compact
min.cleanable.dirty.ratio=0.5
delete.retention.ms=86400000 # rétention des tombstones (24h)
min.compaction.lag.ms=0propertiesAu lieu de purger par âge, Kafka conserve uniquement la dernière valeur pour chaque clé. Le log cleaner ne touche pas au segment actif. Il scanne uniquement les segments fermés, en deux phases :
Phase 1 — Build : log cleaner parcourt les segments fermés
construit en mémoire une map { clé → dernier offset connu }
Phase 2 — Sweep : réécrit les segments en supprimant les entrées
dont l'offset est < dernier offset de leur clé
→ de plus petits segments fusionnés
Un message avec value=null (tombstone) marque la clé comme supprimée. Il reste lui-même delete.retention.ms puis disparaît.
Avant compaction :
k=A,v=1 k=B,v=10 k=A,v=2 k=C,v=100 k=B,v=null k=A,v=3
Après compaction :
k=C,v=100 k=B,v=null k=A,v=3
Conséquence majeure : l'offset n'est plus continu dans un topic compacted. Un consumer qui boucle de 0 à N verra des "trous" — normal, ne pas en faire un bug.
Paramètres qui retardent la compaction :
min.cleanable.dirty.ratio (défaut 0.5) — le cleaner ne démarre que quand bytes_compactable / bytes_total dépasse le seuil. Sur un topic peu actif, ça peut tarder des heures.min.compaction.lag.ms — empêche la compaction d'un message tant qu'il n'a pas atteint cet âge. Utile pour garantir aux consumers une fenêtre de lecture de toutes les versions.delete.retention.ms (défaut 24h) — combien de temps une tombstone reste après compaction avant d'être elle-même éliminée. Trop court = un consumer en retard peut rater la suppression.🚨 Tombstone vs segment actif
Une tombstone publiée mais qui reste dans le segment actif ne purge rien. La suppression effective de la clé n'arrive qu'au prochain run du cleaner, après rotation du segment. Sur un topic compacted peu actif, planifier segment.ms court (ex: 1h) si la latence de suppression compte.
delete,compact — combinécleanup.policy=delete,compactpropertiesCompaction + plafond de rétention temps/taille. Utile pour limiter la croissance d'un topic compacted dont l'espace des clés grossit indéfiniment.
| Cas | Politique | Pourquoi |
|---|---|---|
| Événements transactionnels (commande, mouvement de stock) | delete |
L'historique chronologique a un sens métier — rétention 7-30j. |
| État courant d'une entité (snapshot stock par SKU) | compact |
Seule la dernière valeur compte — replay = reconstruire le state. |
Topics internes Kafka Connect (connect-configs, connect-offsets, connect-status) |
compact |
On veut la version courante de chaque clé, pas l'historique. |
Topics internes Kafka (__consumer_offsets, __cluster_metadata) |
compact |
Idem — dernière position par groupe/clé suffit. |
| Topics changelog Kafka Streams | compact |
Reconstruction du state store par replay. |
| Audit log légal à conserver 7 ans | delete + retention.ms long |
Historique exigé — ne pas compacter. |
🚨 Compaction ≠ instantané
Le log cleaner ne tourne pas en continu et compacte les segments fermés uniquement. Pas attendre une compaction au bout de quelques secondes — selon la taille du topic et l'activité, ça peut prendre des minutes à des heures avant qu'un duplicate disparaisse. La zone active du log n'est jamais compactée.
Prérequis compaction
Une clé null est interdite dans un topic compacted (le cleaner ne saurait pas regrouper). Toujours définir une clé métier explicite. Et pour qu'une suppression se propage, publier une tombstone (key=K, value=null) — pas juste arrêter d'émettre la clé.
# Lister les segments d'une partition
ls -lh /var/kafka-logs/stock-moved-0/
# Dump lisible du contenu d'un segment
kafka-dump-log.sh --files 00000000000000012453.log --print-data-log
# Vérifier intégrité de l'index
kafka-dump-log.sh --files 00000000000000012453.log --index-sanity-checkbashUtile pour diagnostiquer un consumer qui voit des "trous" (compaction normale) vs un vrai bug de production, ou pour vérifier qu'une rétention configurée a bien pris effet.
Puisque at-least-once est la norme, le consumer doit être idempotent : exécuté plusieurs fois sur le même message, il produit le même résultat.
// ❌ Pas idempotent — exécuté 2 fois = stock faux
stock.setQuantity(stock.getQuantity() + 10);
// ✓ Idempotent — exécuté N fois = même résultat
stock.setQuantity(100);
// ✓ Idempotent via clé de déduplication
if (!dejaTraite(event.getId())) {
traiter(event);
marquerTraite(event.getId()); // ✓ constraint UNIQUE en DB
}java⚡ TL;DR — chaque concept en une ligne
Log distribué ✓ Fichier append-only partitionné sur plusieurs brokers — les messages ne sont jamais modifiés ni supprimés à la lecture. ⚠ Pas une queue : plusieurs consumers peuvent lire indépendamment le même message.
Partition ✓ Unité d'ordre et de parallélisme — les messages d'une partition sont strictement ordonnés. ⚠ L'ordre entre partitions n'est pas garanti — une même clé doit toujours aller sur la même partition.
Consumer group ✓ Plusieurs instances se partagent les partitions d'un topic pour scaler la consommation. ⚠ Parallélisme max = min(partitions, pods) — au-delà du nombre de partitions, les pods en trop sont oisifs (hot standby, pas du débit).
Cooperative rebalance ✓ Défaut sur clients récents — seules les partitions changeant de main sont révoquées, le reste continue. ⚠ N'élimine pas les doublons — l'idempotence reste requise.
Offset commit ✓ Le consumer déclare explicitement sa position de lecture après traitement. ⚠ Commiter avant que le traitement soit persisté = perte silencieuse de messages en cas de crash.
At-least-once ✓ Garantie standard : aucune perte, mais des doublons sont possibles après crash/rebalance. ⚠ N'élimine pas les doublons — l'idempotence côté consumer est obligatoire.
Segment
✓ Unité de granularité interne du log — toute purge/compaction opère sur des segments fermés entiers.
⚠ Le segment actif n'est jamais touché — segment.ms doit rester proche de retention.ms, ni trop gros (rétention imprécise, disque, restart lent) ni trop petit (explosion de fichiers/index).
Rétention delete
✓ Purge par âge (retention.ms) ou taille (retention.bytes), au niveau du segment fermé.
⚠ Jamais message par message — la rétention effective est toujours ≥ celle configurée.
Log compaction
✓ Conserve la dernière valeur par clé — replay reconstruit le state. Base des topics internes Kafka et des changelog Streams.
⚠ Offsets non continus, zone active jamais compactée, clé null interdite, suppression = tombstone explicite.
🎓 À retenir
auto.offset.reset=latest est silencieux — si un consumer redémarre sans offset commité (premier démarrage ou offset expiré), tous les messages produits pendant son absence sont ignorés sans erreur ni log visible.cleanup.policy=delete avec une rétention longue, pas compact.retention.ms=1h avec segment.ms=7d peut garder des jours de données. Toujours dimensionner segment.ms cohérent avec la rétention voulue, surtout sur les topics peu actifs (heartbeat, métriques rares, audit léger).segment.ms, segment.bytes, retention.ms, min.cleanable.dirty.ratio, min.compaction.lag.ms