Webentwicklung

MariaDB-Deadlocks in kleinen PHP-Anwendungen - Die Transaktion wiederholen, nicht die letzte Abfrage

MariaDB-Deadlocks in kleinen PHP-Anwendungen - Die Transaktion wiederholen, nicht die letzte Abfrage

Zwei Datenbanktransaktionen können jeweils für sich gültig sein und sich trotzdem gegenseitig am Fortfahren hindern, wenn sie gleichzeitig laufen. Die eine hält eine Sperre, welche die andere benötigt; die zweite hält eine Sperre, welche die erste benötigt. MariaDB gibt daraufhin eine beunruhigend klingende Meldung zurück: Deadlock found when trying to get lock; try restarting transaction.

Was genau soll neu gestartet werden? Nur die letzte Abfrage zu wiederholen, kann sie von den vorherigen Prüfungen und Schreibvorgängen trennen. Endlose Wiederholungen können ein Entwurfsproblem verdecken. Den Fehler zu ignorieren, lässt ansonsten gültige Arbeit unvollendet. Die sinnvolle Reaktion ist enger gefasst: den Sperrzyklus verstehen, InnoDB einen Versuch beenden lassen und die gesamte Datenbanktransaktion nur unter kontrollierten Bedingungen wiederholen.

Dieser Artikel konzentriert sich auf InnoDB über PHP PDO. Er verspricht nicht, dass sich Deadlocks beseitigen lassen, und legt keine einheitliche Wiederholungsrichtlinie für jede Anwendung fest.

Ein Deadlock ist ein Zyklus, nicht nur eine langsame Abfrage

Stellen wir uns zwei Überweisungen vor, die auf die Kontenzeilen 1 und 2 zugreifen. Transaktion A aktualisiert zuerst Zeile 1 und fordert dann Zeile 2 an. Fast gleichzeitig aktualisiert Transaktion B zuerst Zeile 2 und fordert danach Zeile 1 an.

  1. A hält die Sperre für Zeile 1 und wartet auf Zeile 2.
  2. B hält die Sperre für Zeile 2 und wartet auf Zeile 1.
  3. Keine der beiden Transaktionen kann ihre erste Sperre freigeben, ohne abgeschlossen oder zurückgerollt zu werden.

Dieser Zyklus ist der Deadlock. Die InnoDB-Dokumentation erklärt, dass dasselbe Muster mehrere Tabellen, Zeilen, Indexeinträge oder Indexlücken betreffen kann. Anwendungscode spricht möglicherweise von logischen Zeilen, während InnoDB Sperren auf Zeilenebene über Indexeinträge umsetzt. Deshalb kann ein echter Deadlock weniger offensichtlich sein als die Skizze mit zwei Zeilen.

Ein gewöhnliches Warten auf eine Sperre ist etwas anderes. Wenn A eine Sperre hält und B darauf wartet, kann A einen Commit ausführen und B anschließend fortfahren. In einem Deadlock-Zyklus führt bloßes Warten nicht weiter. MariaDB dokumentiert den Deadlock-Fehler 1213 mit SQLSTATE 40001; ein Lock-Wait-Timeout ist der separate Fehler 1205 mit SQLSTATE HY000. Beide sollten nicht in einer pauschalen Wiederholungsregel zusammengefasst werden.

InnoDB unterbricht den Zyklus durch die Auswahl eines Opfers

Wenn innodb_deadlock_detect aktiviert ist, wie es standardmäßig der Fall ist, erkennt InnoDB den Zyklus und rollt eine der beteiligten Transaktionen zurück, damit die andere fortfahren kann. Die fehlgeschlagene Transaktion wird häufig als Opfer bezeichnet. Welcher Teilnehmer verliert, ist eine Entscheidung der Engine und kein Vertrag mit der Anwendung. Der Code darf sich daher nicht darauf verlassen, dass eine bestimmte Anfrage gewinnt.

Dieses Rollback ist eine Wiederherstellung und kein Hinweis darauf, dass InnoDB die Daten beschädigt hat. Die Datenbankänderungen des fehlgeschlagenen Versuchs bleiben nicht halb angewendet zurück. Die Anwendung hat allerdings weiterhin einen unvollständigen Geschäftsvorgang. Die MariaDB-Referenz zu Fehler 1213 fordert ausdrücklich dazu auf, die Transaktion neu zu starten.

Das Wort Transaktion ist entscheidend. Angenommen, der fehlgeschlagene Versuch hat einen Kontostand gelesen, das erste Konto aktualisiert, einen Ledger-Eintrag geschrieben und ist dann beim zweiten Konto in einen Deadlock geraten. Nur das letzte UPDATE erneut auszuführen, würde die vorherigen Lese- und Schreibvorgänge überspringen, die zurückgerollt wurden. Ein neuer Versuch muss die gesamte Einheit der Datenbankarbeit mit dem jetzt vorhandenen Zustand wiederholen.

Ein begrenztes Wiederholungsmuster mit PDO

Der folgende Helper ist eine beispielhafte Grenze für eine kleine Anwendung. Er prüft sowohl den von PDOException::getCode() bereitgestellten SQLSTATE als auch die MariaDB-Treiberfehlernummer in errorInfo. Er wiederholt nur Fehler 1213, begrenzt die Versuche und startet jedes Mal eine neue Transaktion.

<?php
declare(strict_types=1);

function runWithDeadlockRetry(
    PDO $pdo,
    callable $work,
    int $maxAttempts = 3
): mixed {
    if ($maxAttempts < 1) {
        throw new InvalidArgumentException('maxAttempts must be at least 1');
    }

    for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
        try {
            $pdo->beginTransaction();
            $result = $work($pdo);
            $pdo->commit();

            return $result;
        } catch (PDOException $error) {
            if ($pdo->inTransaction()) {
                $pdo->rollBack();
            }

            $driverCode = (int) ($error->errorInfo[1] ?? 0);
            $isMariaDbDeadlock = $error->getCode() === '40001'
                && $driverCode === 1213;

            if (!$isMariaDbDeadlock || $attempt === $maxAttempts) {
                throw $error;
            }

            $delayMicroseconds = random_int(20_000, 80_000) * $attempt;
            usleep($delayMicroseconds);
        } catch (Throwable $error) {
            if ($pdo->inTransaction()) {
                $pdo->rollBack();
            }

            throw $error;
        }
    }

    throw new LogicException('Unreachable retry state');
}

Das PHP-Handbuch dokumentiert, dass eine PDOException den SQLSTATE und Treiberfehlerinformationen enthält, während die Transaktionsmethoden von PDO die Grenze für Beginn, Commit und Rollback definieren. Die doppelte Prüfung oben ist beabsichtigt: Die SQLSTATE-Klasse 40001 kann Bedingungen für einen Transaktions-Rollback jenseits dieses einen MariaDB-Treiberfehlers darstellen.

Drei Versuche und die kurze zufällige Verzögerung sind Beispiele und keine gemessenen universellen Werte. Eine begrenzte Verzögerung kann eine sofortige erneute Kollision reduzieren, korrigiert aber keine inkonsistente Sperrreihenfolge. Die letzte Exception bleibt sichtbar, damit die Anfrage ehrlich fehlschlagen, mit sicherem Kontext protokolliert oder in einen übergeordneten Wiederherstellungsablauf aufgenommen werden kann.

Der Callback muss sicher erneut ausgeführt werden können

Ein Datenbank-Rollback kann keine E-Mail zurückholen, keine HTTP-Anfrage zurücknehmen, keine bereits an einen Broker zugestellte Nachricht löschen und keinen beliebigen Dateischreibvorgang rückgängig machen. Wenn $work eine solche Aktion vor dem Commit ausführt, kann eine Wiederholung sie zweimal ausführen, obwohl die Datenbank in einem konsistenten Zustand endet.

Der wiederholbare Callback sollte sich nach Möglichkeit auf Datenbankarbeit beschränken. Eine nicht kritische Benachrichtigung kann nach einem erfolgreichen Commit erfolgen. Wenn die Zustellung einen Absturz zwischen Commit und Versand überstehen muss, kann eine dauerhafte Outbox die Absicht in derselben Datenbanktransaktion aufzeichnen, während ein separater Worker sie zustellt. Der Worker benötigt weiterhin eine Strategie für Duplikate; eine Transaktion verwandelt ein Netzwerk nicht in eine Exactly-once-Zustellung.

Auch Eingabewerte verdienen Aufmerksamkeit. Dieselben validierten Anfragedaten zu wiederholen, ist etwas anderes, als bei jedem Versuch die aktuelle Zeit abzufragen, eine neue Kennung zu erzeugen oder veränderlichen Prozesszustand zu verbrauchen. Es muss entschieden werden, welche Werte zum Geschäftsvorgang gehören und welche innerhalb einer frischen Transaktion neu berechnet werden sollen.

Den Zyklus reduzieren, bevor man sich auf Wiederholungen verlässt

Sperren in einer konsistenten Reihenfolge anfordern

Wenn jede Ueberweisung die kleinere Konto-ID vor der größeren sperrt, bilden die beiden gegenläufigen Vorgänge eine Warteschlange, statt dasselbe Paar in umgekehrter Reihenfolge anzufordern. Sowohl der InnoDB-Leitfaden zur Behandlung von Deadlocks als auch die PostgreSQL-Diskussion über Deadlocks empfehlen eine konsistente Reihenfolge beim Anfordern von Sperren als zentrale strukturelle Schutzmaßnahme.

Das ist eine Risikoreduzierung und kein Beweis dafür, dass ein Deadlock unmöglich ist. Unterschiedliche Abfragepläne, sekundäre Indizes, Fremdschlüsselprüfungen, Bereichssperren und andere Schreibpfade können Sperren einführen, die eine einfache Regel zur Zeilenreihenfolge nicht erfasst. Die Regel muss jeden Pfad abdecken, der an derselben Invariante beteiligt ist.

Die Transaktion fokussiert und kurz halten

Die Transaktion sollte nahe an der zu schützenden Datenbankarbeit geöffnet und danach zeitnah committed werden. Benutzereingaben, entfernte APIs, Uploads oder nicht zugehörige Berechnungen sollten nicht abgewartet werden, während Sperren gehalten werden. Ein kürzeres Überschneidungsfenster gibt konkurrierenden Transaktionen weniger Gelegenheiten zur Kollision, obwohl auch kurze Transaktionen in einen Deadlock geraten können.

Indizes verwenden, die zu den sperrenden Anweisungen passen

Ein geeigneter Index kann dafür sorgen, dass ein UPDATE oder ein sperrender Lesevorgang weniger Indexeinträge untersucht und sperrt. Falsch wäre der Schluss, dass irgendein zusätzlicher Index Deadlocks behebt. Die tatsächlichen Prädikate und Pläne müssen geprüft werden; ein Index verändert die Einträge und Bereiche, die die Engine besucht, und kann damit das Sperrverhalten auf eine Weise ändern, die getestet werden muss.

Den Deadlock-Bericht lesen, nicht nur die Exception

SHOW ENGINE INNODB STATUS enthält nach einem Deadlock einen Abschnitt LATEST DETECTED DEADLOCK. Er kann die Transaktionen, Anweisungen, gehaltenen Sperren, angeforderten Sperren und die zurückgerollte Transaktion anzeigen. Er sollte als Wartezyklus gelesen werden:

  • Welche Anweisungen waren beteiligt?
  • Welche Indizes und Einträge waren betroffen?
  • Was hielt jede Transaktion bereits?
  • Was forderte jede Transaktion als Nächstes an?
  • Können beide Codepfade diese Ressourcen in derselben Reihenfolge anfordern?

Der Befehl zeigt den letzten erkannten Deadlock und keine dauerhafte Historie jedes Vorfalls. MariaDB dokumentiert außerdem innodb_print_all_deadlocks, um die Details aller Deadlocks in das Serverfehlerprotokoll zu schreiben. Umfangreicheres Logging sollte bewusst aktiviert werden. Die entstehenden Logs müssen geschützt werden, weil Anweisungen sensible Zusammenhänge enthalten können, und dauerhaft ausführliches Logging sollte nicht als erste Reparatur gelten.

Verlockende Reaktionen, die das Problem verschlimmern

  • Nur die fehlgeschlagene Anweisung wiederholen: Der Rest der Opfertransaktion wurde zurückgerollt, sodass die Anweisung ihren ursprünglichen Transaktionskontext nicht mehr besitzt.
  • Jede PDOException wiederholen: Syntaxfehler, Constraint-Verletzungen, Authentifizierungsfehler und viele andere Fehler werden durch Wiederholung nicht korrekt.
  • Ohne Begrenzung wiederholen: Ein dauerhafter Fehler in der Sperrreihenfolge kann Worker belegen und die Konkurrenz erhöhen, statt einen Fehler sichtbar zu machen.
  • Annehmen, dass eine Anfrage immer gewinnt: Die Opferauswahl ist keine stabile Geschäftsregel.
  • Die Deadlock-Erkennung beilaufig deaktivieren: MariaDB dokumentiert dies als spezialisierten Kompromiss für bestimmte Workloads mit hoher Parallelität; bei ausgeschalteter Erkennung verlässt sich der Server stattdessen auf das Lock-Wait-Timeout. Es ist keine gewöhnliche Reparatur auf Anwendungsebene.
  • Häufige Deadlocks hinter erfolgreichen Wiederholungen verbergen: Wiederholungen erhalten die Verfügbarkeit bei gelegentlichen Kollisionen, doch ein zunehmendes oder konzentriertes Muster muss weiterhin untersucht werden.

Eine praktische Prüfreihenfolge

  1. Den Fehler anhand von SQLSTATE und Treibercode bestätigen, statt Meldungstext abzugleichen.
  2. Sichere Anfrage- und Vorgangskennungen erfassen, aber keine Zugangsdaten oder sensiblen gebundenen Werte protokollieren.
  3. Den letzten Deadlock-Bericht untersuchen und die Sperrreihenfolge aller Beteiligten aufzeichnen.
  4. Die Ressourcenreihenfolge nach Möglichkeit über alle Codepfade hinweg vereinheitlichen.
  5. Langsame oder unumkehrbare Arbeit außerhalb der Datenbank aus der offenen Transaktion verschieben.
  6. Prädikate und Indizes prüfen, die bestimmen, wie viele Einträge oder Bereiche berührt werden.
  7. Eine begrenzte Wiederholung der gesamten Transaktion für die konkrete wiederholbare Bedingung hinzufügen.
  8. Ausgeschöpfte Wiederholungen getrennt von wiederhergestellten Versuchen überwachen, damit die Wiederholungen das Signal nicht auslöschen.

Fazit

Ein Deadlock bedeutet nicht, dass Transaktionen als Konzept versagt haben. Er bedeutet, dass konkurrierende Transaktionen einen Sperrzyklus gebildet haben und die Datenbank einen Versuch beenden musste, damit es weitergeht. Die Antwort auf Anwendungsebene besteht darin, die genaue Bedingung zu erkennen, die gesamte Datenbanktransaktion mit einer klaren Begrenzung neu zu starten und unumkehrbare Nebenwirkungen außerhalb dieser Wiederholungsgrenze zu halten.

Wiederholungen behandeln gelegentliche Wettläufe. Eine konsistente Sperrreihenfolge, enge Transaktionsgrenzen, geeignete Indizes und Deadlock-Berichte behandeln die Struktur, die sie hervorbringt. Das ehrliche Ziel lautet nicht Deadlocks können niemals auftreten, sondern ein seltener Deadlock ist wiederherstellbar, während ein wiederholtes Muster sichtbar genug bleibt, um es zu beheben.

References