02Architektur5 min

Derselbe Concurrency-Bug ist mir in drei verschiedenen Projekten begegnet

Ein Zähler, der unter Last hochzählt, verliert Inkremente, und einer, der abzieht, kann dasselbe zweimal berechnen. Wenn der Zähler Geld ist, ist das kein Detail, das man später behebt.

Von Salva Castells

Titelbild: kisnikee, CC0.
In diesem Beitrag

Du schickst hundert gleichzeitige Anfragen gegen denselben API-Key. Der Nutzungszähler steht auf siebenundachtzig. Kein Fehler in den Logs, keine Anfrage ist fehlgeschlagen, niemand hat sich beschwert. Es fehlen einfach dreizehn Inkremente.

Dieser Fehler ist mir dreimal passiert, in Projekten, die sich keine einzige Codezeile teilen, immer aus demselben Grund: Der Zähler wurde geschrieben, als gäbe es nur einen schreibenden Prozess.

Das Muster: lesen, addieren und schreiben in drei Schritten

Der Code, der das erzeugt, sieht nie gefährlich aus. In einer Transkriptions-API tat das Authentifizieren eines Schlüssels zwei Dinge: prüfen, dass er nicht widerrufen ist, und useCount und lastUsedAt aktualisieren. Zeile lesen, im Speicher eins addieren, Ergebnis schreiben.

Mit einem Nutzer nach dem anderen funktioniert das. Mit Batch-Workern, die gegen denselben Schlüssel hämmern, nicht: Zwischen dem Lesen und dem Schreiben kommt eine andere Anfrage herein, liest denselben Wert und schreibt dasselbe Ergebnis. Zwei Nutzungen, ein Inkrement. Mit k6 bei 10, 50 und 100 virtuellen Nutzern gemessen, lag der Verlust zwischen 8 und 14 Prozent.

Der Fix passt in ein einziges Statement:

UPDATE api_key
SET use_count = use_count + 1,
    last_used_at = now()
WHERE id = $1 AND revoked_at IS NULL
RETURNING use_count;

Wichtig ist nicht nur das use_count + 1, das ist das Offensichtliche. Wichtig ist, dass das revoked_at IS NULL im selben UPDATE steht. Vorher waren die Widerrufsprüfung und das Inkrement zwei getrennte Runden, es gab also ein Fenster, in dem jemand den Schlüssel genau nach der Validierung widerrief und die Nutzung trotzdem gezählt wurde. Mit dem Filter drinnen erfüllt die Zeile entweder die Bedingung und wird inkrementiert, oder es kommt nichts zurück und es fliegt ein 401. Es gibt keinen Zwischenzustand zu verwalten.

Nach der Änderung: neun Durchläufe des Concurrency-Tests (drei Stufen mal drei Wiederholungen), null Verluste.

Der umgekehrte Fall: dasselbe zweimal berechnen

Der zweite Fall lief andersherum. In einer semantischen Suche verbraucht jede Suche eine Kontingenteinheit und speichert eine Zeile mit dem, was sie gekostet hat: Embedding-Modell, Tokens, Rerank-Modell, Input- und Output-Tokens.

Es gab Deduplizierung: Wenn du dieselbe Suche wiederholtest, gab die Reservierung duplicate zurück, nutzte die ursprüngliche Zeile wieder und belastete das Kontingent nicht noch einmal.

Das Problem ist, dass die deduplizierte Suche trotzdem ausgeführt wurde. Sie rief wieder Embeddings auf, rief wieder den Rerank auf, und der Anbieter stellte es wieder in Rechnung. Diese Kosten hatten keinen Platz: Die ursprüngliche Zeile trug schon ihre eigenen, und sie zu überschreiben hätte die Ausgabe des ersten Versuchs gelöscht. Also verschwanden sie. Das Admin-Panel behauptete, die Suchen kosteten weniger, als sie kosteten.

Die Versuchung war, eine weitere Zeile in dieselbe Tabelle zu schreiben, mit einem anderen Status. Schlechte Idee, aus zwei konkreten Gründen:

  • Jede Kontingentabfrage zählt Zeilen ungefiltert. Eine zusätzliche Zeile dort würde Kontingent belasten, das niemand verbraucht hat.
  • Die Duplikatsuche filtert ebenfalls nicht nach Status. Sie nimmt die neueste Zeile mit diesem Hash. Die neue Zeile würde zum Anker der nächsten Deduplizierung, eine Zeile, die nie eine Kontingenteinheit war.

Die Lösung war eine separate Tabelle, unbilled_search_spend, mit denselben Nutzungsspalten und einer Referenz auf die Reservierung, mit der das Duplikat aufgelöst wurde. Diese Referenz dient der Auditierung, mit ON DELETE set null, und ist keine Kontingentreferenz. Die Kosten tauchen in den Berichten auf, gehen aber nicht in die Kosten pro abgerechneter Suche ein, und das ist die Zahl, mit der über Preise entschieden wird.

Der dritte: zwei Guthaben, die getrennt gelesen werden

Der dritte war ein SaaS zur Bildgenerierung. Ich habe tägliche Credits zusätzlich zu den Abo-Credits eingeführt, und die Generierung funktionierte nicht mehr für alle, die nur die täglichen hatten. Der Endpoint prüfte profile.subCredits < estimatedCost und gab einen 402 zurück, wobei er dailyCredits komplett ignorierte. Die Credits existierten, sie wurden in der Seitenleiste angezeigt, und der Server sagte, es seien nicht genug.

Das ist kein Concurrency-Bug, sondern einer vom Typ "Zähler in zwei teilen und einen davon vergessen". Aber die Wurzel ist dieselbe: Das Guthaben war keine Zahl mehr, sondern das Ergebnis einer über den Code verteilten Operation, an jeder Stelle von Hand gemacht.

const totalAvailableCredits = (profile?.dailyCredits || 0) + (profile?.subCredits || 0);

Diese Zeile taucht einmal in der Prüfung auf und einmal bei der Berechnung des Restguthabens, das an den Client zurückgeht. Zwei Stellen, an denen man ans Addieren denken muss. An dem Tag, an dem ein dritter Credit-Typ auftaucht, sind es drei.

Und der, der kein Bug ist, sondern eine ungeschriebene Policy

Der vierte Fall ist anders, gehört aber zur selben Familie. Der invoice.paid-Webhook überschrieb bei jeder Verlängerung das Guthaben:

balanceCents: pricing.balanceCents, // überschrieb den vorherigen Kontostand

Alles nicht Verbrauchte aus dem Vormonat verdampfte am Tag der Verlängerung. Bei rund vierzig Nutzern eines Plans mit 1499 Cent Guthaben lag der Median des Verlorenen bei 310 Cent, und das 90. Perzentil erreichte 890. In manchen Fällen fast 60 Prozent des Plans weggeworfen.

Interessant daran ist, dass niemand das entschieden hatte. Es war nicht die Policy, es war die Konsequenz daraus, eine Zuweisung statt einer Summe geschrieben zu haben. Jetzt wird akkumuliert, mit einer Obergrenze von drei Monaten:

const nextBalance = Math.min(
  current.balanceCents + pricing.balanceCents,
  pricing.balanceCents * 3
);

Die Obergrenze ist nicht kosmetisch: Ohne sie sammelt ein Konto mit sporadischer Nutzung zwölf Monate Guthaben an und verbraucht es dann in einer Spitze, und genau das willst du nicht, wenn du stromaufwärts einen KI-Anbieter nach Nutzung bezahlst.

Die Regel

Jeder Zähler, der Geld repräsentiert, wird ab dem ersten Tag mit drei Dingen geschrieben. Erstens: Das Inkrement und seine Bedingungen leben in einem einzigen Statement, SET x = x + 1 WHERE ..., niemals in einem Read-Modify-Write. Zweitens: Wenn es Deduplizierung gibt, müssen die echten Kosten irgendwo landen können, auch wenn das Kontingent nicht belastet wird. Drittens: Die Policy darüber, was bei der Verlängerung übrig bleibt, steht irgendwo geschrieben, und sei es in einem Kommentar, denn wenn nicht, schreibt sie der Zuweisungsoperator für dich.

Keines der drei kostet Zeit, wenn man es am Anfang macht. Alle drei kosten einen Incident, wenn man sie später macht.