Blog
Teil 6 von 13 Backend Patterns

Backpressure

4 min Lesezeit
kafka patterns distributed-systems

Dein Consumer schafft 200 msg/s. Dein Producer macht 800. Was passiert?

Nicht sofort was. Am Anfang sieht alles gut aus. Die Consumer Lag steigt, klar, aber das System läuft. Dann füllt sich der Heap. Kafka merkt, dass dein Consumer nicht mehr pollt. Rebalance. Der Consumer stoppt, Partitions werden neu verteilt. Während der Rebalance produziert der Producer weiter. Die Lag wird schlimmer. Der Consumer kommt zurück, kämpft gegen die gewachsene Lag an, Memory füllt sich schneller, nächste Rebalance. Death Spiral.

Das Gemeine daran: die Rebalance, die eigentlich helfen soll, macht alles schlimmer. Jede Sekunde ohne Consumer ist eine Sekunde mehr Lag.

Stell den Producer-Durchsatz hoch und schau was passiert:

Kafka Consumer Group
IDLE
Producer Rate400 msg/s
100Consumer: 200 msg/s1000
Consumer Lag0
Memory0%
Rebalances0 / 3

Bei 200 msg/s oder weniger bleibt alles stabil. Schieb den Slider auf 400, 600, 800. Beobachte wie die Lag wächst, der Memory steigt, und dann die Rebalances einsetzen. Nach der dritten ist die Consumer Group tot. Und der Producer hat die ganze Zeit weiter Nachrichten geschickt.

Consumer Lag ist die wichtigste Metrik in Kafka. Wenn du sie nicht trackst, fliegst du blind. kafka_consumergroup_lag in Prometheus, Alert bei > 10.000 und steigend.

Das ist kein Edge Case. Das passiert in Produktion wenn dein Traffic-Pattern sich ändert, wenn ein Deployment langsamer ist als erwartet, wenn ein Downstream-Service plötzlich langsamer antwortet und dein Consumer deshalb weniger durchbekommt.

Drei Strategien

Es gibt kein Patentrezept, aber drei grundsätzlich verschiedene Ansätze. Jeder tauscht etwas anderes ein.

Unbounded Buffer ist der Kafka-Default. Du liest einfach alles und hoffst, dass der Consumer schnell genug ist. Wenn nicht: siehe oben.

Drop Oldest setzt ein Limit auf den internen Buffer. Wenn er voll ist, werden die ältesten Nachrichten verworfen. Du verlierst Daten, aber das System bleibt stabil. Für manche Use Cases — Metriken, Logs, Sensor-Daten — ist das akzeptabel.

Rate Limiting am Producer. Der Consumer signalisiert dem Producer, langsamer zu machen. Kein Datenverlust, aber der Producer muss mitspielen. Funktioniert gut in Systemen wo du beides kontrollierst.

Gleicher Traffic, drei verschiedene Ausgänge:

Unbounded Buffer
Lag0
Memory0%
OK
Drop Oldest
Lag0
Memory0%
Dropped0
OK
Rate Limit
Lag0
Memory0%
Producer800 msg/s
OK

Links: OOM nach ein paar Sekunden. Mitte: Lag gecapped, Messages gedroppt, aber das System lebt. Rechts: Producer gedrosselt, Lag pendelt sich ein, Memory bleibt niedrig.

Unbounded Buffer (der Default)

Kafka selbst hat kein eingebautes Backpressure-Signal vom Consumer zum Producer. Wenn dein Consumer nicht hinterherkommt, wächst die Lag. Das ist by design: Kafka ist ein Log, keine Queue.

max.poll.records=500
max.poll.interval.ms=300000

max.poll.records begrenzt wie viele Records ein poll() zurückgibt. Weniger Records pro Poll heißt weniger Memory pro Batch, aber auch weniger Throughput. Es ist ein Pflaster, keine Lösung.

Drop Oldest

Wenn du akzeptieren kannst, dass Nachrichten verloren gehen, bau einen Ringbuffer zwischen poll() und deiner Processing-Logik:

BlockingQueue<ConsumerRecord> buffer = new ArrayBlockingQueue<>(5000);

// Poll-Thread
while (true) {
    var records = consumer.poll(Duration.ofMillis(100));
    for (var record : records) {
        if (!buffer.offer(record)) {
            buffer.poll();          // älteste raus
            buffer.offer(record);   // neue rein
            droppedCounter.increment();
        }
    }
}

Offset-Management wird mit Drop Oldest tricky. Wenn du Messages droppst, darfst du den Offset trotzdem nicht committen, sonst sind die Nachrichten wirklich weg. Commit nur den Offset der letzten verarbeiteten Nachricht.

Dein Memory-Verbrauch ist gedeckelt, das ist der Vorteil. Der Nachteil: Datenverlust. Für Payment-Events keine Option, für CPU-Temperatur-Readings kein Problem.

Rate Limiting

Der sauberste Ansatz, aber auch der aufwendigste. Der Consumer meldet seine aktuelle Kapazität, und der Producer drosselt entsprechend.

async function produceWithBackpressure(messages: Message[]) {
  const lag = await getConsumerLag('my-group', 'my-topic');

  let rate = BASE_RATE;
  if (lag > 10_000) rate = Math.max(rate * 0.5, MIN_RATE);
  if (lag > 50_000) rate = MIN_RATE;
  if (lag < 1_000)  rate = Math.min(rate * 1.2, MAX_RATE);

  await rateLimiter.acquire(rate);
  await producer.send({ topic: 'my-topic', messages });
}

Das ist im Prinzip ein Feedback-Loop: Consumer Lag als Signal, Producer Rate als Stellschraube. Funktioniert, solange du den Producer kontrollierst. Bei externen Events wie Webhooks oder IoT-Devices hast du diese Option nicht.

In der Praxis

Die meisten Teams die ich kenne machen folgendes: sie ignorieren das Problem bis es knallt, und dann scalen sie horizontal. Mehr Consumer-Instanzen, mehr Partitions. Das funktioniert, bis es nicht mehr funktioniert. Horizontal Scaling kauft dir Zeit, keine Lösung.

Mein Rat: track Consumer Lag von Tag eins. Setz einen Alert bei steigender Lag. Und dann entscheid bevor es brennt, welche Strategie zu deinem Use Case passt. Drop Oldest für alles was nicht kritisch ist. Rate Limiting wenn du beides kontrollierst. Und den Unbounded Buffer nur, wenn du sicher bist dass dein Consumer langfristig schneller ist als dein Producer — und “langfristig” heißt nicht “im Durchschnitt”, sondern “auch an Black Friday um 20 Uhr”.