20. August 2026 · 7 Min. Lesezeit
Die alte Pipeline verlor ~47k Spans. Tat sie nicht. Wir haben das Falsche gezählt.
Ein nebeneinander gestellter Per-Service-Span-Count während einer Parallel-Run-Validierung zeigte, dass die neue Pipeline etwa 0,2% der Spans pro Service über ein 60-Minuten-Fenster verlor. Als Regression der neuen Pipeline gelesen, hätte es einen Rollback ausgelöst. Richtig gelesen, fügte die alte Pipeline in einem wenige-Sekunden-Fenster Zehntausende doppelter Zeilen ein, und die neue Pipeline glich ihr span-für-span, sobald die Duplikate ausgeschlossen waren. Die Lehre ist nicht, dass die neue Pipeline in Ordnung war. Sie ist, dass die Einheit, die eine Speicherschicht als “count” ausgibt, nicht die Einheit ist, die der Operator annimmt, wenn die Speicherschicht keine Eindeutigkeitsbeschränkung auf das Gezählte hat.
Das erste Exponat: die alarmierende Per-Service-Differenz
Das count()-Panel des Traces Explorers gibt die Zeilenanzahl aus. Pro Service fehlten der neuen Pipeline etwa 0,2% im Fenster. Die Form des Fehlbetrags war über die Services gleichförmig, und genau das war der Teil, der als echtes Signal gelesen wurde. Ein gleichförmiger Per-Service-Fehlbetrag ist die Signatur eines systemischen Problems und nicht eines einzelnen fehlerhaften Bestandteils, und der natürliche Reflex war “die neue Pipeline lässt Spans fallen.” Dieser Reflex ist der Gegenstand des Beitrags, weil der Reflex in der Richtung falsch war. Die neue Pipeline ließ nichts fallen. Die alte Pipeline duplizierte, und eine Duplikation in der alten Pipeline liest sich, von der Warte der neuen Pipeline aus, genau wie ein Verlust.
Der Reflex, einen gleichförmigen Fehlbetrag als Regression zu lesen, ist nicht dumm; er ist der richtige Reflex bei den meisten Fehlern. Der Reflex ist, was man haben sollte, wenn eine von zwei Pipelines fehlerhaft ist. Das Problem am Reflex ist, dass er annimmt, eine der beiden Pipelines sei die Quelle der Abweichung, und behandelt die andere als Grundwahrheit. Das Parallel-Run-Setup machte die alte Pipeline zur Referenz, weil sie zuerst da war, und ein gleichförmiger Fehlbetrag gegen eine Referenz sieht aus wie eine Abweichung der Test-Pipeline, nicht wie eine Abweichung der Referenz. Diese Asymmetrie ist es, was als Regression gelesen wird.
Das zweite Exponat: der Drill-Down, der das Bild umkehrte
Per-Stunde-Counts wurden berechnet und direkt gegen die Datenbanken beider Pipelines verglichen. Zwanzig aufeinanderfolgende Stunden glichen sich span-für-span. Eine Stunde nicht.
Für diese Stunde las der count() der alten Pipeline etwa 620k, während ihr uniqExact(spanID) etwa 580k las. Die beiden Counts der neuen Pipeline lasen beide etwa 580k. Der Fehlbetrag lag nicht in der neuen Pipeline. Der Fehlbetrag war, dass die alte Pipeline innerhalb dieser Stunde Zehntausende doppelter Zeilen hatte, und die Duplikation konzentrierte sich auf ein kleines Fenster darin. Pro Service glich der count() der neuen Pipeline exakt dem uniqExact(spanID) der alten Pipeline, und genau diese Beziehung kehrt die Diagnose um. Die alte Pipeline war nicht größer als die neue. Die alte Pipeline war um genau die Anzahl der Duplikate kleiner, die sie zweimal eingefügt hatte.
Der Drill-Down war entscheidend, weil die Abweichung auf Stunden-Ebene die Ebene war, auf der der falsche Reflex steckengeblieben wäre. Der Drill-Down auf Fenster-Ebene zeigte die Duplikation; der Per-Service-Abgleich zeigte die Richtung. Beide Teile waren nötig; jeder für sich hätte die Diagnose mehrdeutig gelassen. Die Form der Abweichung (alter count() überzeichnet; alter uniqExact stimmt mit neuem überein) ist der Fingerabdruck eines Duplikat-Einfügepfads, nicht der Fingerabdruck eines Span-Verlust-Pfads.
Das dritte Exponat: der Log-Sweep, der die rauchende Pistole fand
Die Logs des alten Collectors wurden im Fenster nach Fehlern durchsucht. Ein MEMORY_LIMIT_EXCEEDED auf dem Error-Tabellen-Insert tauchte auf, aufgetreten nachdem der Index-Tabellen-Insert für denselben Batch bereits gelandet war. Der Exporter wiederholte den gesamten Batch. Die nicht-transaktionale Multi-Tabellen-Schreibung plus die Wiederholung ist der Duplikationsmechanismus: die Indextabelle committet, die Errortabelle scheitert an ihrer Speichergrenze, die Wiederholung committet die Indextabelle ein zweites Mal. Die spaltenorientierte Speicherschicht hat keine Eindeutigkeitsbeschränkung auf spanID und kein Dedup-on-Insert, also landet das zweite Commit als zwei identische Zeilen. Das Fehlerlog zeigt Hunderte früherer MEMORY_LIMIT_EXCEEDED-Vorkommen auf dem alten Collector, was bedeutet, dass die Duplikation wiederkehrend ist, nicht anomal.
Die Speichergrenze selbst ist der Auslöser, und sie ist arbeitslastabhängig. Der Error-Tabellen-Insert scheitert, wenn der Error-Batch groß genug ist, um das Speicherbudget zu überschreiten, was bei Lastspitzen wahrscheinlicher ist, was wiederum das gleiche Fenster ist, in dem jemand auf Per-Service-Span-Counts achtet. Die Duplikation ist kein Rauschen; sie ist mit dem Arbeitsmuster korreliert, das die Abweichung sichtbar macht, und genau das lässt das Symptom so überzeugend als Regression der neuen Pipeline wirken.
Warum ein Count mit einem Eindeutigkeits-Count nicht übereinstimmte
Das count()-Panel des Traces Explorers gibt die Zeilenanzahl aus. Die spanID-Spalte des Speichers ist kein Primärschlüssel; nichts weist ein doppeltes Insert zurück. Die beiden Zahlen, count() und uniqExact(spanID), stimmen überein, wenn die Pipeline gesund ist, und divergieren, wenn sie es nicht ist. Die Divergenz ist die Richtung des Bugs: count() überzeichnet, weil es Duplikate zählt. Die beiden Counts der neuen Pipeline stimmten exakt überein, weil der Insert-Pfad des neuen Collectors nicht in einer Weise wiederholt, die Duplikate erzeugt. Die Abweichung zwischen den beiden Pipelines war keine Regression in der neuen Pipeline. Es war die duplikationsgetriebene Überzeichnung der alten Pipeline, die als Unterzeichnung der neuen Pipeline fehlgelesen wurde.
Dies ist der Teil des Beitrags, der verallgemeinerbar ist. Ein Zeilen-Count und ein eindeutigkeitsgeschlüsselter Count sind zwei verschiedene Messungen desselben Speichers, und sie sind nur dann äquivalent, wenn der Speicher Duplikate zurückweist. Sobald der Speicher Duplikate annimmt, divergieren die beiden Messungen, und die Divergenz ist das Signal. Die Richtung der Divergenz sagt Ihnen, welche Seite dupliziert, nicht welche Seite verliert. Ein kurzes Panel gegen eine Referenz kann verursacht werden, indem die kurze Seite verliert, die Referenz dupliziert, oder beides; die Abweichung zwischen count() und uniqExact auf der Referenzseite ist die Diagnostik, die Ihnen sagt, welches von beiden.
Die Falle des wiederkehrenden Fehlers
Die Hunderte früherer MEMORY_LIMIT_EXCEEDED-Vorkommen sind es, was eine einmalige Debugging-Sitzung in eine übertragbare Regel verwandelt. Ein einzelner Batch, der nach einem Mid-Batch-Fehler wiederholt wird, ist transient; derselbe Batch-Pfad, der jedes Mal fehlschlägt, wenn eine Arbeitslast Spitzen hat, ist eine Eigenschaft der alten Pipeline. Das Parallel-Run wird das Muster “neue Pipeline verliert Spans” jedes Mal zeigen, wenn der Fehlerpfad der alten Pipeline ausgelöst wird, und die Divergenz wird sich über das Validierungsfenster akkumulieren.
Der zu korrigierende Reflex ist “vergleiche mit uniqExact”. Das ist die Symptombehandlung. Die Regel ist breiter: wählen Sie die Vergleichsmetrik, die nicht vom Wiederholungsverhalten der alten Pipeline abhängt. Ein eindeutigkeitsgeschlüsselter Count ist eine solche Metrik; eine Metrik, die Error-Ereignisse pro Zeile zählt, würde Ihnen auch sagen, dass die Duplikation passiert ist; eine Metrik, die distinkte Trace-IDs pro Zeile zählt, würde Ihnen sagen, dass die Duplikation den Trace-Graphen nicht beeinflusst hat. Jede Metrik, deren Definition invariant unter doppelter Einfügung ist, ist robust gegen diese Fehlerklasse. Der Zeilen-Count ist es nicht, und ihn als Vergleichsmetrik im Parallel-Run zu verwenden garantiert, dass jedes Fenster, das ein Duplikationsereignis überlappt, als Regression der Test-Pipeline gelesen wird.
Die Regel und die Falle
Wenn ein Count und ein Eindeutigkeits-Count nicht übereinstimmen, sagt Ihnen die Speicherschicht, welchen Sie verwenden sollen, und die Nichtübereinstimmung ist das Signal. Eine nicht-transaktionale Multi-Tabellen-Schreibung plus eine Wiederholung ist ein Duplikationspfad, ob er nun ausgelöst wird oder nicht; er wird ausgelöst, wenn eine der Tabellen mid-batch an ihre Grenze stößt, und sobald er ausgelöst wurde, landet die Duplikation dauerhaft im Speicher, weil nichts sie beim Insert zurückweist.
Wenn beim nächsten Mal ein nebeneinander gestelltes Panel eine Pipeline um einen kleinen, gleichförmigen Prozentsatz zu kurz zeigt, dann lautet die Frage nicht “welche Pipeline verliert”. Die Frage lautet “welche Pipeline doppelt-zählt”, und die Antwort ist diejenige, deren Zeilen-Count für dasselbe Fenster ihren eindeutigkeitsgeschlüsselten Count übersteigt. Die beiden Pipelines sind in einem Parallel-Run nicht symmetrisch: die Referenzseite, die zuerst da war, hat mehr Zeit gehabt, die Fehler zu akkumulieren, für die ihr Insert-Pfad anfällig ist. Das Defizit der kurzen Seite ist im gewöhnlichen Fall der Überschuss der Referenzseite, aus der falschen Richtung ausgedrückt.