Webentwicklung

Atomarer Dateiersatz in PHP - Reader sehen die alte oder die neue Datei

Atomarer Dateiersatz in PHP - Reader sehen die alte oder die neue Datei

Eine kleine PHP-Anwendung speichert vielleicht ein generiertes Manifest, eine JSON-Konfigurationsdatei oder einen kleinen Cache auf dem Datenträger. Eine solche Datei zu schreiben ist einfach. Schwieriger ist die Frage, was eine andere Anfrage sieht, während diese Datei ersetzt wird. Öffnet sie eine vollständige alte oder eine vollständige neue Version, oder trifft sie die Zieldatei nach dem Leeren, aber bevor das letzte Byte geschrieben wurde?

Für die Aktualisierung einer vollständigen Datei auf einem lokalen Linux-Dateisystem ist es sinnvoll, zunächst eine temporäre Datei neben dem Ziel zu schreiben und sie anschließend über die alte Datei umzubenennen. Diese kurze Beschreibung verbirgt jedoch mehrere Bedingungen. Atomare Sichtbarkeit ist nicht dasselbe wie die Koordination konkurrierender Writer, und beides ist nicht dasselbe wie das Überstehen eines Stromausfalls. Dieser Artikel trennt diese Garantien und entwickelt dafür einen bewusst begrenzten PHP-Helper.

Warum das direkte Überschreiben der Zieldatei fragil ist

Wird eine vorhandene Datei in einem abschneidenden Modus wie wb geöffnet, wird ihre Länge reduziert, bevor der Ersatzinhalt geschrieben wurde. Auch der folgende Schreibvorgang kann nur einen Teil der angeforderten Daten abschließen: PHP dokumentiert den Rückgabewert von fwrite() als Anzahl der geschriebenen Bytes oder als false bei einem Fehler. Einen einzelnen Aufruf als Alles-oder-nichts-Operation zu behandeln, setzt daher mehr voraus, als die API verspricht.

Man stelle sich vor, ein gültiges JSON-Dokument mit 20 KB wird ersetzt. Ein Reader, der im falschen Moment eintrifft, könnte eine leere Datei, ein gültiges, aber abrupt endendes Fragment oder ein vollständiges Dokument vorfinden. Eine Sperre kann kooperierende Reader warten lassen, dann müssen jedoch alle Reader daran teilnehmen. Oft ist es einfacher, den veröffentlichten Pfad unverändert zu lassen, während die nächste Version an anderer Stelle aufgebaut wird.

Drei Garantien statt einer

1. Atomare Sichtbarkeit

Die POSIX-Spezifikation für rename() besagt, dass der Verzeichniseintrag während des gesamten Vorgangs sichtbar bleibt, wenn das Ziel bereits existiert, und entweder auf die alte oder die neue Datei verweist. Unter Linux beschreibt das rename(2)-Handbuch den Ersatz als atomar: Ein anderer Prozess, der das Ziel sucht, findet keinen Zeitpunkt vor, an dem es fehlt.

Diese Eigenschaft verhindert, dass ein gewöhnlicher Reader eine nur teilweise kopierte Zieldatei öffnet. Ein Reader, der die alte Datei bereits geöffnet hat, kann sie weiter lesen; ein späteres Öffnen führt zur Ersatzdatei. Für generierte Snapshots ist dieses Verhalten meist erwünscht.

2. Koordination der Writer

Ein atomarer Ersatz entscheidet nicht, welcher von zwei gleichzeitigen Writern gewinnen soll. Beide können eine gültige temporäre Datei vorbereiten, und der später erfolgreich abgeschlossene Rename kann das frühere Ergebnis ersetzen. Es erscheint keine unvollständige Datei, dennoch kann eine logische Aktualisierung verloren gehen.

Wenn Writer einen Read-Modify-Write-Ablauf ausführen, sollte eine stabile Lock-Datei die gesamte Abfolge schützen. PHP beschreibt flock() als advisory locking: Es koordiniert nur Prozesse, die derselben Locking-Konvention folgen. Das ist Zusammenarbeit auf Anwendungsebene und keine Barriere gegen unabhängige Programme.

3. Dauerhaftigkeit bei Abstürzen

Ein erfolgreicher Funktionsaufruf und ein atomarer Namenswechsel beweisen nicht automatisch, dass jede Speicherschicht die Änderung dauerhaft gesichert hat. PHP 8.1 führte fsync() ein. Die Funktion fordert das Betriebssystem auf, Daten und Metadaten einer Datei mit dem Speichermedium zu synchronisieren. Die temporäre Datei vor dem Rename zu synchronisieren verbessert die Abfolge, aber der Rename verändert auch einen Verzeichniseintrag. Strenge Anforderungen an die Crash-Konsistenz können eine Synchronisierung des enthaltenden Verzeichnisses über plattformspezifische Schnittstellen erfordern. Außerdem müssen Dateisystem, Mount-Optionen, Laufwerkscache und Hardware berücksichtigt werden.

Diese Unterscheidung ist wichtig. „Reader sehen keine halbe Datei“ ist ein engeres und besser vertretbares Versprechen als „die neueste Datei übersteht jeden Absturz“.

Eine begrenzte PHP-Implementierung

Der folgende Helper ist für ein reguläres lokales Dateisystem unter POSIX/Linux gedacht. Er erzeugt die temporäre Datei im Zielverzeichnis, prüft, dass tempnam() nicht auf einen anderen Ort ausgewichen ist, schreibt jedes Byte, synchronisiert die Datei optional, schließt sie und ersetzt anschließend das Ziel. Jeder Fehler vor dem Rename lässt die vorhandene Zieldatei unverändert und versucht, die temporäre Datei zu entfernen.

<?php
function atomicReplace(string $path, string $data, bool $syncFile = false): void
{
    $directory = dirname($path);
    $realDirectory = realpath($directory);

    if ($realDirectory === false || !is_dir($realDirectory) || !is_writable($realDirectory)) {
        throw new RuntimeException('Destination directory is not writable');
    }

    $temporary = tempnam($realDirectory, '.replace-');
    if ($temporary === false) {
        throw new RuntimeException('Could not create a temporary file');
    }

    // tempnam() may fall back to the system temp directory.
    if (realpath(dirname($temporary)) !== $realDirectory) {
        @unlink($temporary);
        throw new RuntimeException('Temporary file was created on another path');
    }

    $handle = null;

    try {
        $handle = fopen($temporary, 'wb');
        if ($handle === false) {
            throw new RuntimeException('Could not open the temporary file');
        }

        $length = strlen($data);
        $offset = 0;

        while ($offset < $length) {
            $written = fwrite($handle, substr($data, $offset));
            if ($written === false || $written === 0) {
                throw new RuntimeException('Could not write the complete file');
            }
            $offset += $written;
        }

        if (!fflush($handle)) {
            throw new RuntimeException('Could not flush the temporary file');
        }

        if ($syncFile) {
            if (!function_exists('fsync') || !fsync($handle)) {
                throw new RuntimeException('Could not synchronize the temporary file');
            }
        }

        if (!fclose($handle)) {
            throw new RuntimeException('Could not close the temporary file');
        }
        $handle = null;

        if (!rename($temporary, $path)) {
            throw new RuntimeException('Could not replace the destination');
        }
    } finally {
        if (is_resource($handle)) {
            fclose($handle);
        }
        if (is_file($temporary)) {
            @unlink($temporary);
        }
    }
}

Die Prüfung des Speicherorts ist nicht bloß dekorativ. Die offizielle tempnam()-Dokumentation erklärt, dass PHP die Datei im temporären Systemverzeichnis erstellen kann, wenn das angeforderte Verzeichnis nicht vorhanden oder nicht beschreibbar ist. POSIX erlaubt, dass ein Rename über Dateisystemgrenzen hinweg mit EXDEV fehlschlägt, und Linux verweigert auch Renames über Mount-Punkte hinweg. Wird die temporäre Datei neben dem Ziel angelegt, bleibt der normale Vorgang auf demselben eingehängten Dateisystem.

Der Helper prüft außerdem im Schreibloop, ob kein Fortschritt erzielt wurde. Eine Schleife, die endlos wiederholt, wenn fwrite() null zurückgibt, kann aus einem I/O-Fehler eine festhängende Anfrage machen. Die Funktion wirft stattdessen eine Exception, und der finally-Block räumt auf, was sich aufräumen lässt.

Das PHP-Handbuch zu rename() erklärt, dass eine vorhandene Zieldatei überschrieben und Erfolg als true gemeldet wird. Der Code prüft dieses Ergebnis dennoch, weil Berechtigungen, ein volles Dateisystem, ein schreibgeschützter Mount oder ein I/O-Fehler den Vorgang scheitern lassen können. Würde dieser Fehler unterdrückt, würde aus einer weiterhin nutzbaren alten Datei eine fälschlich als erfolgreich gemeldete Aktualisierung.

Wann eine Lock-Datei den Ersatz umschließen sollte

Für unabhängige Snapshots, bei denen „der zuletzt fertiggestellte Writer gewinnt“ akzeptabel ist, kann der atomare Ersatz ausreichen. Für einen Zähler, eine Queue oder einen anderen Read-Modify-Write-Vorgang reicht er nicht. Die Sperre muss vor dem Lesen des aktuellen Zustands beginnen und bis zum erfolgreichen Rename gehalten werden.

$lock = fopen($path . '.lock', 'c');
if ($lock === false || !flock($lock, LOCK_EX)) {
    throw new RuntimeException('Could not acquire the update lock');
}

try {
    $current = is_file($path) ? file_get_contents($path) : '{}';
    if ($current === false) {
        throw new RuntimeException('Could not read the current file');
    }

    $next = buildNextDocument($current);
    atomicReplace($path, $next, true);
} finally {
    flock($lock, LOCK_UN);
    fclose($lock);
}

Ein separater, stabiler .lock-Pfad ist wichtig. Wird der Ziel-Inode gesperrt und anschließend das Ziel ersetzt, ändert sich, auf welchen Inode der Pfad verweist. Andere Writer könnten danach die neue Datei sperren, während der erste Prozess noch eine Sperre auf der alten Datei hält. Eine dedizierte Lock-Datei bietet allen kooperierenden Writern denselben Treffpunkt.

Grenzen, die sichtbar bleiben sollten

  • Plattformsemantiken unterscheiden sich. Das Beispiel zielt bewusst auf POSIX/Linux. PHP dokumentiert Windows-spezifische Einschränkungen beim Ersetzen eines vorhandenen Ziels. Ohne ein Windows-spezifisches Design und entsprechende Tests sollte kein identisches Verhalten behauptet werden.
  • Netzwerkdateisysteme benötigen eine eigene Analyse. Das Linux-Handbuch weist auf eine NFS-Mehrdeutigkeit hin: Der Server kann einen Rename abschließen und danach ausfallen, bevor der Client das Ergebnis erhält. Ein erneuter Versuch kann einen Fehler melden, obwohl sich der Name geändert hat. Dieser Helper löst keine Semantik verteilter Dateisysteme.
  • Berechtigungen können sich ändern. tempnam() erzeugt seine Datei mit dem Modus 0600. Nach dem Ersatz wird dieser zum Modus der Zieldatei. Wenn andere Benutzer oder Gruppen die Datei lesen müssen, sollten Eigentümer und Modus nach einer sorgfältig kontrollierten Richtlinie vor der Veröffentlichung gesetzt und geprüft werden.
  • Pfade benötigen weiterhin Vertrauensgrenzen. Der Helper setzt voraus, dass Zielpfad und übergeordnetes Verzeichnis von der Anwendung kontrolliert werden. Er schützt nicht vor einem Angreifer, der Verzeichniseinträge verändern oder beliebige Pfade vorgeben kann.
  • Atomar bedeutet nicht validiert. Das vollständige Dokument sollte vor dem Aufruf des Helpers validiert oder codiert werden. Ein atomarer Ersatz kann ungültiges JSON vollkommen zuverlässig veröffentlichen.
  • Bei hohem Schreibvolumen kann eine Datenbank geeigneter sein. Sperren, temporäre Dateien, Synchronisierungsoperationen, Recovery-Regeln und mehrere Datensätze sind Aufgaben, die Datenbanken bereits strukturieren. Ein Dateisystem-Snapshot ist auch deshalb nützlich, weil sein Umfang klein ist.

Wie sich Fehlerpfade testen lassen

Ein Happy-Path-Test sollte die resultierenden Bytes exakt prüfen und bestätigen, dass keine Datei mit dem Muster .replace-* zurückbleibt. Aufschlussreichere Tests machen das Verzeichnis nicht beschreibbar, verwenden ein fehlendes übergeordnetes Verzeichnis, füllen ein kleines Testdateisystem, starten mehrere kooperierende Writer und lösen absichtlich vor dem Rename eine Exception aus. Nach jedem fehlgeschlagenen Versuch sollte geprüft werden, ob die alte Zieldatei noch intakt ist.

Bei Concurrency-Tests sind zwei Fragen zu trennen. Erstens: Hat ein Reader fehlerhafte oder unvollständige Inhalte gesehen? Zweitens: Hat die Anwendung jede logische Aktualisierung bewahrt? Atomic Rename behandelt die erste Frage; die stabile Writer-Sperre oder ein transaktionaler Datenspeicher behandelt die zweite. Tests der Crash-Dauerhaftigkeit bilden eine weitere Kategorie und sollten dem tatsächlich eingesetzten Dateisystem und Speicher-Stack entsprechen, statt nur eine Entwicklungs-Laptop-Simulation abzubilden.

Fazit

Beim sicheren Ersetzen vollständiger Dateien geht es weniger um ein magisches Write-Flag als darum, die benötigte Garantie genau zu benennen. Eine eindeutige temporäre Datei wird im Zielverzeichnis geschrieben, jeder Schreibvorgang geprüft, die Datei geschlossen und anschließend über den veröffentlichten Pfad umbenannt, damit gewöhnliche Reader keine unvollständigen Inhalte sehen. Eine stabile advisory lock kommt hinzu, wenn Writer serialisiert werden müssen. Synchronisierung und plattformspezifische Arbeit sollten nur ergänzt werden, wenn die Anforderungen an die Dauerhaftigkeit sie rechtfertigen.

Dieses Muster eignet sich für überschaubare, ersetzbare Snapshots. Sobald die Datei zu einem Datenmodell mit mehreren Writern, einer Queue oder einer Quelle kritischen dauerhaften Zustands wird, lautet die hilfreichere Frage möglicherweise, ob sie überhaupt eine Datei bleiben sollte.

References