Idempotente Pipelines: Warum jede Datenpipeline beliebig oft laufen können muss
Kennst du diesen Moment, wenn eine Datenpipeline mitten in der Nacht fehlschlägt - und du morgens vor der Entscheidung stehst: Einfach nochmal starten? Oder laufen da jetzt doppelte Daten durch das System?
Wenn du in dieser Situation zögerst, fehlt deiner Pipeline Idempotenz. Und das ist kein kleines Detail. Das ist der Unterschied zwischen einem System, dem du vertraust, und einem, das dir Bauchschmerzen macht.
Was Idempotenz eigentlich bedeutet
Der Begriff klingt sperriger als er ist. Eine Operation ist idempotent, wenn du sie zehnmal ausführen kannst und das Ergebnis trotzdem dasselbe ist wie nach dem ersten Mal.
Stell es dir so vor: Du drückst den Lichtschalter. Licht geht an. Du drückst nochmal. Licht bleibt an. Du drückst zehnmal. Licht… ist immer noch an. Das ist Idempotenz. Nicht zehn Lichter, nicht Fehlermeldung – einfach der erwartete Zustand, egal wie oft du die Aktion wiederholst.
Für Pipelines heißt das: Egal ob die Pipeline heute Nacht einmal läuft, wegen eines Timeouts dreimal neugestartet wird oder jemand aus Versehen auf “Run” geklickt hat - das Ergebnis in deiner Zieltabelle oder deinem Dateistore sollte immer gleich sein.
Warum das so schwer ist
Die meisten Pipelines wachsen organisch. Erst ein kleines Script, dann ein bisschen mehr Logik, dann Abhängigkeiten. Und irgendwann hat man ein System, das INSERTs macht ohne zu prüfen, ob der Datensatz schon existiert.
Das klassische Problem sieht (vereinfacht als SQL) ungefähr so aus:
INSERT INTO orders_processed
SELECT * FROM orders_raw
WHERE date = '2026-05-12';
Läuft die Pipeline zweimal? Dann sind alle Bestellungen vom 12. Mai zweimal drin. Läuft sie dreimal? Dreimal. Viel Spaß beim Erklären an den Stakeholder, warum der Umsatz sich verdreifacht hat.
Und das Tückische: Meistens merkt man es nicht sofort. Die Pipeline läuft grün. Keine Fehlermeldung. Alles gut - bis jemand die Zahlen prüft. Oder man saubere Datenqualitätsregularien implementiert, die die Daten regelmäßig prüfen.
Das Grundprinzip: Truncate und Reload, oder Upsert
Es gibt im Wesentlichen zwei Wege, Idempotenz sicherzustellen.
Weg 1: Truncate und Reload
Der simpelste Ansatz. Bevor du Daten schreibst, löschst du alles, was schon da ist - für die jeweilige Partition, den jeweiligen Zeitraum, die jeweilige Batch-ID.
DELETE FROM orders_processed
WHERE date = '2026-05-12';
INSERT INTO orders_processed
SELECT * FROM orders_raw
WHERE date = '2026-05-12';
Läuft die Pipeline zehnmal? Kein Problem. Du löschst immer erst, dann schreibst du neu. Das Ergebnis ist immer korrekt.
Der Nachteil: Bei sehr großen Tabellen kann das teuer sein. Wenn du jeden Tag 50 Millionen Zeilen neu schreibst, nur um sicherzugehen, dass die gestrigen 500.000 korrekt sind, verlierst du Zeit und Rechenleistung.
Weg 2: Upsert (INSERT … ON CONFLICT)
Eleganter, aber etwas mehr Aufwand. Statt zu löschen und neu zu schreiben, sagst du der Datenbank: “Wenn der Datensatz schon existiert, aktualisiere ihn. Wenn nicht, füge ihn ein.”
In PostgreSQL:
INSERT INTO orders_processed (order_id, date, amount, status)
SELECT order_id, date, amount, status
FROM orders_raw
WHERE date = '2026-05-12'
ON CONFLICT (order_id)
DO UPDATE SET
amount = EXCLUDED.amount,
status = EXCLUDED.status;
Das ist robust. Egal wie oft du das laufen lässt - jeder Order-Datensatz existiert genau einmal, mit dem aktuellsten Stand.
Voraussetzung: Du hast einen eindeutigen Schlüssel. Wenn der fehlt, musst du zunächst dafür sorgen, dass deine Daten überhaupt einen natürlichen Identifier haben.
Weg 3: Inkrementelles Laden per max(Zeitstempel)
Für Zeitreihendaten gibt es einen dritten Weg, der besonders effizient ist: Bevor du irgendetwas aus der Quelle abrufst, fragst du deine Zieltabelle, was der aktuellste Datensatz dort ist. Dann holst du aus der Quelle nur das, was seitdem hinzugekommen ist.
-- Schritt 1: Letzten bekannten Zeitstempel aus der Zieltabelle ermitteln
SELECT MAX(event_timestamp) AS last_loaded
FROM orders_processed;
-- Schritt 2: Nur neue Datensätze aus der Quelle abrufen
SELECT *
FROM orders_raw
WHERE event_timestamp > '2026-05-12 08:34:00' -- Ergebnis aus Schritt 1
ORDER BY event_timestamp;
Der Vorteil: Du überträgst nur, was wirklich neu ist. Bei einer Tabelle mit Millionen historischer Zeilen macht das einen enormen Unterschied - statt alles neu zu laden, holst du vielleicht nur die letzten paar Tausend Zeilen.
Das ist idempotent, solange du sicherstellst, dass du den > (größer als, nicht größer-gleich) korrekt verwendest und dein Zeitstempel eindeutig genug ist. Bei Systemen, die mehrere Datensätze mit exakt demselben Timestamp liefern können, ist ein Upsert als Kombination sicherer - dann nimmst du >= und lässt den Konflikthandler die Duplikate abfangen.
Wichtig: Vertrau nicht blind darauf, dass Quelldaten immer streng monoton wachsen. Nachträgliche Korrekturen, verzögerte Schreibvorgänge oder Zeitzonenfehler können dazu führen, dass Datensätze mit alten Timestamps nachgeliefert werden. Ein kleiner Sicherheitspuffer (zum Beispiel MAX(event_timestamp) - INTERVAL '1 hour') kann dich vor solchen Lücken schützen, auf Kosten von etwas Mehrarbeit bei der Duplikatvermeidung.
Partitionierung als Freund der Idempotenz
Ein oft unterschätztes Konzept: Wenn deine Zieltabelle nach Zeit partitioniert ist, wird Idempotenz plötzlich viel einfacher.
Statt die ganze Tabelle im Blick zu behalten, sagst du: “Alles, was diese Pipeline für den 12. Mai produziert, gehört in die Partition 2026-05-12. Wenn die Pipeline nochmal läuft, ersetzt sie genau diese Partition.”
In BigQuery heißt das WRITE_TRUNCATE auf Partitionsebene. In Spark kannst du partitionOverwriteMode = dynamic setzen. Das Prinzip ist dasselbe: Überschreib nur den Teil, für den du verantwortlich bist. Den Rest lass in Ruhe.
# Spark Beispiel
df.write \
.mode("overwrite") \
.option("partitionOverwriteMode", "dynamic") \
.partitionBy("date") \
.parquet("//my-bucket/orders/")
Läuft das zweimal für denselben Tag? Der Parquet-Ordner für diesen Tag wird überschrieben. Der Rest bleibt unberührt. Fertig.
Verarbeitungslog: Wissen, was schon gelaufen ist
Manchmal ist Idempotenz nicht nur eine Frage des Überschreibens, sondern auch des Nicht-nochmal-Tuns. Vielleicht rufst du eine externe API auf und willst sicher sein, dass du dieselbe Bestellung nicht zweimal anschaust.
Hier hilft ein einfaches Verarbeitungslog:
CREATE TABLE pipeline_runs (
pipeline_name TEXT,
partition_key TEXT,
run_at TIMESTAMP,
status TEXT,
PRIMARY KEY (pipeline_name, partition_key)
);
Bevor die Pipeline losläuft, prüfst du: Gibt es für diesen partition_key bereits einen erfolgreichen Run? Wenn ja, überspringen oder früh abbrechen. Wenn nein, loslegen und am Ende den Erfolg eintragen.
Das ist kein Overhead - das ist Klarheit. Du weißt jederzeit, was bereits verarbeitet wurde.
Der Worst Case: Externe Seiteneffekte
Idempotenz wird richtig knifflig, wenn deine Pipeline Seiteneffekte hat, die du nicht zurückdrehen kannst: E-Mails versenden, Webhooks aufrufen, Zahlungen auslösen.
Hier gibt es kein einfaches “Einfach nochmal laufen”. Du brauchst eine Idempotency-Key-Strategie - einen eindeutigen Identifier pro Aktion, den du mitschickst, damit das Zielsystem doppelte Anfragen erkennen und ignorieren kann.
Gute APIs unterstützen das von Haus aus. Stripe zum Beispiel akzeptiert einen Idempotency-Key-Header. Wenn du dieselbe Zahlungsanfrage zweimal schickst, wird die zweite einfach ignoriert, weil der Key schon gesehen wurde.
Für selbst gebaute Systeme: Generiere vor dem Aufruf einen UUID, speichere ihn zusammen mit dem Ergebnis und prüfe bei erneutem Aufruf, ob der Key schon existiert.
Kurz zusammengefasst
Eine idempotente Pipeline:
- Liefert immer dasselbe Ergebnis, egal wie oft sie läuft
- Verwendet Truncate-and-Reload oder Upserts, um Duplikate zu vermeiden
- Nutzt Partitionierung, um nur den relevanten Datenbereich zu überschreiben
- Führt Buch über verarbeitete Partitionen in einem Verarbeitungslog
- Behandelt externe Seiteneffekte mit Idempotency-Keys
Das ist keine Raketenwissenschaft. Aber es ist die Grundlage, auf der du nachts ruhig schlafen kannst - auch wenn die Pipeline um 3 Uhr morgens dreimal neugestartet wurde.