Heimserver & Self-Hosting

Überlappende geplante Jobs - Eine Sperre passend zur Arbeit wählen

Überlappende geplante Jobs - Eine Sperre passend zur Arbeit wählen

Ein Job ist alle fünf Minuten eingeplant, doch ein Durchlauf benötigt gelegentlich sechs Minuten. Soll beim nächsten Auslöser eine zweite Instanz starten, hinter der ersten warten oder diesen Durchlauf auslassen?

Der Zeitplan allein kann diese Frage nicht beantworten. Gleichzeitige Durchläufe können bei einem schreibgeschützten Bericht harmlos sein, bei einem Job, der Dateien rotiert, Inhalte veröffentlicht oder dieselben Datensätze aktualisiert, jedoch gefährlich werden. Das Verhindern von Überschneidungen beginnt mit einer Richtlinienentscheidung und danach mit einer Sperre, deren Geltungsbereich zu allen Prozessen passt, die den Job ausführen können.

Dieser Artikel betrachtet einen geplanten PHP-Befehl auf einem Debian-Server. Das Verhalten von systemd-Timern, lokale Dateisperren und benannte MariaDB-Sperren dienen als drei unterschiedliche Koordinationsgrenzen. Keine davon liefert eine universelle „Exactly once“-Garantie. Diese Einschränkung ist ebenso wichtig wie die Konfiguration.

Festlegen, wie ein konkurrierender Durchlauf reagieren soll

„Nicht überlappen“ lässt weiterhin drei mögliche Richtlinien offen:

  • Überspringen: Ist bereits ein anderer Durchlauf aktiv, wird dieser Zustand protokolliert und der neue Durchlauf erfolgreich beendet. Das kann zu einem Aktualisierungsjob passen, bei dem der nächste geplante Lauf ausreicht.
  • Warten: Der Prozess bleibt blockiert, bis der aktuelle Durchlauf die Sperre freigibt. Dadurch bleibt jeder Auslöser erhalten, wartende Prozesse können sich jedoch ansammeln, wenn die Arbeit dauerhaft länger als das Intervall dauert.
  • In eine Queue stellen: Ein oder mehrere ausstehende Jobs werden dauerhaft gespeichert und von Workern gezielt übernommen. Das erfordert mehr Infrastruktur, ist aber meist die ehrliche Wahl, wenn jeder angeforderte Durchlauf eigenständige Arbeit darstellt, die nicht verworfen werden darf.

Eine Sperre setzt Exklusivität um; sie wählt nicht die richtige Richtlinie. Ein nicht blockierender Versuch unterstützt auf natürliche Weise das Überspringen. Ein blockierender Versuch setzt das Warten um, benötigt aber weiterhin ein Zeitlimit oder eine andere Begrenzung, falls unbegrenztes Warten nicht akzeptabel ist. Eine Queue bildet ausstehende Arbeit ab, statt sie hinter schlafenden Prozessen zu verbergen.

Die Wahl beeinflusst auch das Monitoring. Eine übersprungene Aktualisierung kann normal sein, während ein übersprungener Rechnungsexport fehlende Arbeit bedeuten kann. Ein Sperrkonflikt sollte ein eigenes Log-Ereignis oder eine eigene Metrik erhalten, statt nicht von Erfolg oder Fehler unterscheidbar zu sein.

Den Scheduler serialisieren lassen, wenn sein Vertrag ausreicht

Ein systemd-Timer aktiviert eine andere Unit, normalerweise einen Service mit demselben Basisnamen. Das Debian-Handbuch systemd.timer(5) erklärt, dass systemd die Ziel-Unit weiterlaufen lässt, wenn sie beim Ablaufen des Timers bereits aktiv ist. Die Unit wird weder neu gestartet noch wird eine weitere Service-Instanz erzeugt.

Damit erhält ein einfacher Type=oneshot-Service eine nützliche Grenze auf Ebene einer einzelnen Unit:

# /etc/systemd/system/catalog-refresh.service
[Unit]
Description=Refresh the local catalog

[Service]
Type=oneshot
User=www-data
ExecStart=/usr/bin/php /srv/example/bin/refresh-catalog.php

Ist catalog-refresh.service noch aktiv, startet der zugehörige Timer keine zweite Instanz dieser Unit. Das ist enger gefasst als „der Job kann sich niemals überschneiden“. Ein Administrator könnte den PHP-Befehl direkt ausführen, eine andere Service-Unit könnte ihn aufrufen oder ein zweiter Host könnte dieselbe Anwendung ausführen. Diese Pfade liegen außerhalb der Grenze zwischen diesem Timer und seiner Unit.

Versionsdetails sind wichtig, wenn der Rhythmus eines Timers geändert wird. DeferReactivation= wurde beispielsweise mit systemd 257 eingeführt und verändert, wie ein Kalendertimer sein nächstes Ablaufen plant, nachdem der Service inaktiv geworden ist. Die Option sollte nicht in ältere Versionen übernommen werden und ersetzt keine Koordination auf Anwendungsebene, wenn mehrere Startpfade existieren.

Eine eigene Dateisperre für einen Host verwenden

Wenn alle möglichen Runner dasselbe lokale Dateisystem sehen, kann eine Dateisperre die Grenze innerhalb des PHP-Befehls setzen. Die offizielle PHP-Dokumentation zu flock() beschreibt eine beratende Leser-/Schreibersperre. „Beratend“ bedeutet, dass der Schutz nur funktioniert, wenn konkurrierende Programme kooperieren und dasselbe Objekt sperren.

Dies ist eine kompakte Richtlinie zum Überspringen:

<?php

declare(strict_types=1);

$lockPath = '/run/lock/example/catalog-refresh.lock';
$lock = fopen($lockPath, 'c');

if ($lock === false) {
    fwrite(STDERR, "Cannot open lock file.\n");
    exit(1);
}

if (!flock($lock, LOCK_EX | LOCK_NB)) {
    fwrite(STDOUT, "Another run is still active; skipping.\n");
    fclose($lock);
    exit(0);
}

try {
    refreshCatalog();
} finally {
    flock($lock, LOCK_UN);
    fclose($lock);
}

LOCK_EX fordert eine exklusive Sperre an, während LOCK_NB die Anforderung nicht blockierend macht. Das eigene Sperrverzeichnis und die Datei müssen für den Service-Benutzer beschreibbar sein und sollten nicht von unbeteiligten Benutzern beschrieben werden können. Das Verzeichnis sollte bei der Bereitstellung oder beim Service-Start mit bewusst gewählten Eigentumsrechten erstellt werden. Ein stiller Rückfall auf einen Lauf ohne Sperre würde die Grenze unwirksam machen.

Das Stream-Handle ist kein nebensächliches Detail. Laut PHP-Handbuch wird die Sperre freigegeben, wenn der Stream geschlossen oder von der Garbage Collection entfernt wird. Das Handle muss deshalb während des gesamten kritischen Abschnitts erreichbar bleiben. Das Beispiel gibt die Sperre außerdem in finally frei, obwohl auch das Prozessende und das Schließen des Streams sie normalerweise freigeben.

Das Beispiel wurde auf Syntax geprüft und lokal mit zwei gleichzeitig laufenden CLI-Prozessen ausgeführt: Der erste hielt die Sperre, der zweite nahm den Pfad zum Überspringen und ein späterer Prozess erhielt die Sperre nach ihrer Freigabe. Das verifiziert dieses kleine Beispiel auf dem geprüften Host. Es belegt kein identisches Verhalten für ein anderes Dateisystem, eine andere PHP-Laufzeit oder eine andere Deployment-Topologie.

Die Existenz der Sperrdatei nicht mit der Sperre verwechseln

Die Datei kann zwischen Durchläufen auf dem Datenträger verbleiben. Ihre Existenz beweist nicht, dass ein Prozess noch arbeitet; der gehaltene Kernel-Lock ist der relevante Zustand. Das Löschen und erneute Erstellen einer Sperrdatei kann sogar irreführend sein, weil verschiedene Prozesse anschließend Dateideskriptoren halten können, die auf unterschiedliche zugrunde liegende Dateien verweisen.

Aus demselben Grund ist eine in eine Datei geschriebene PID eine Diagnoseinformation und kein vollständiges Sperrprotokoll. PIDs können wiederverwendet werden, und eine alte Nummer beweist nicht, dass der frühere kritische Abschnitt noch aktiv ist. Das Sperrverfahren sollte über den Besitz entscheiden; Metadaten sollten nur die Diagnose erleichtern.

Die Sperre außerhalb von PHP platzieren, wenn der Befehl die Grenze bildet

Das util-linux-Handbuch flock(1) dokumentiert eine Befehlsform, die eine Sperre hält, während ein Kindprozess läuft. Ein Cron-Eintrag kann deshalb dieselbe nicht blockierende Richtlinie ausdrücken, ohne das PHP-Programm zu ändern:

*/5 * * * * flock --nonblock /run/lock/example/catalog-refresh.lock /usr/bin/php /srv/example/bin/refresh-catalog.php

Das ist attraktiv, wenn jeder Aufruf durch den Scheduler kontrolliert wird. Die Sperre umschließt den vollständigen Kindprozess und wird freigegeben, wenn der zugehörige Dateideskriptor geschlossen wird. Der Nachteil betrifft die Sichtbarkeit: Standardmäßig können sowohl ein Sperrkonflikt als auch ein gewöhnlicher Fehler des Kindprozesses zu einem von null verschiedenen Befehlsergebnis führen. Das Handbuch bietet --conflict-exit-code, wenn eine Automatisierung beide Fälle unterscheiden muss.

Ein Shell-Wrapper und eine Sperre im Programm sollten nicht beiläufig mit unterschiedlichen Dateien oder Namen kombiniert werden. Zwei nicht übereinstimmende Grenzen können falsche Sicherheit vermitteln. Stattdessen sollte eine maßgebliche lokale Sperridentität gewählt, dokumentiert und von jedem konkurrierenden Startpfad verwendet werden.

Die Koordination zu MariaDB verlagern, wenn kein Dateisystem gemeinsam genutzt wird

Eine lokale Dateisperre kann zwei Anwendungshosts nicht koordinieren, wenn sie das Dateisystem der Sperre nicht gemeinsam nutzen. Verwenden alle Runner denselben MariaDB-Server, kann eine benannte beratende Sperre einen anderen Geltungsbereich bieten. Die offizielle MariaDB-Dokumentation zu GET_LOCK() beschreibt Namen als serverweit und empfiehlt anwendungs- oder datenbankspezifische Namen, um Kollisionen zu reduzieren.

SELECT GET_LOCK('example.catalog-refresh', 0);
-- Run the protected work on this connection.
SELECT RELEASE_LOCK('example.catalog-refresh');

Mit einem Timeout von null Sekunden liefert GET_LOCK() sofort ein Ergebnis: 1 bedeutet, dass die Sperre erworben wurde, 0 bedeutet einen Timeout, weil sie nicht verfügbar war, und NULL kennzeichnet einen Fehler. Der Anwendungscode muss alle drei Ergebnisse unterscheiden.

Die Verbindung ist Teil der Lebensdauer dieser Sperre. MariaDB gibt die benannte Sperre frei, wenn diese Verbindung endet, auch bei einem abnormalen Ende. COMMIT gibt sie nicht frei, weil benannte Sperren nicht mit Transaktionen interagieren. Ein Verbindungspool oder eine Abstraktion, die unterhalb des Jobs die Verbindung austauscht, kann daher eine ansonsten ordentlich wirkende Implementierung beschädigen. Bis zur Freigabe muss dieselbe bekannte Verbindung gehalten werden.

Auch dies bleibt kooperatives Advisory Locking. Ein anderer Client kann die Konvention ignorieren oder bei schlecht gewähltem Namen sogar dieselbe Sperre erwerben. Der Geltungsbereich umfasst außerdem einen MariaDB-Server und nicht auf magische Weise unabhängige Server. MariaDB warnt davor, dass Anweisungen mit GET_LOCK() für statement-basierte Replikation unsicher sind. Daher muss die tatsächliche Datenbanktopologie geprüft werden, statt den Mechanismus als universelle verteilte Sperre zu behandeln.

Eine Sperre macht die Arbeit nicht wiederholungssicher

Gegenseitiger Ausschluss beantwortet die Frage: „Können sich zwei kooperierende Durchläufe jetzt in diesem kritischen Abschnitt befinden?“ Er beantwortet nicht: „Hat der vorherige Durchlauf jede Nebenwirkung abgeschlossen?“

Angenommen, ein Job ruft eine entfernte Veröffentlichungs-API auf, die API akzeptiert die Anfrage und der PHP-Prozess stürzt ab, bevor er den Abschluss speichert. Die Sperre wird beim Prozessende freigegeben. Ein späterer Durchlauf kann sie problemlos erwerben und den API-Aufruf wiederholen. Es gab keine gleichzeitige Überschneidung, dennoch kann die externe Wirkung zweimal eintreten.

Dieser Fehler erfordert ein separates Design: einen vom nachgelagerten API akzeptierten Idempotency Key, einen dauerhaften lokalen Zustandsübergang, einen Eindeutigkeits-Constraint oder einen Abgleich mit dem maßgeblichen System. Das passende Verfahren hängt von der Wirkung ab. Eine Sperre kann einen Zustandsübergang schützen, aber keine lokale Transaktion über einen unabhängigen entfernten Dienst ausdehnen.

Auch eine lange Sperrdauer verdient Prüfung. Es kann notwendig sein, die Sperre während langsamer Netzwerkarbeit zu halten, um Überschneidungen zu verhindern; dadurch steigen jedoch die Zahl übersprungener Läufe oder die Wartezeit. Manchmal ist eine kurze Grenze besser: Einen dauerhaften Job innerhalb einer Transaktion übernehmen, die Claim-Sperre freigeben und anschließend die Arbeit mit ausdrücklich entworfenen Wiederholungen ausführen.

Die vollständige Grenze prüfen

  • Alle Pfade auflisten, die den Job starten können: Timer, Cron, Kommandozeile, Webanfrage, Queue-Worker und andere Hosts.
  • Festlegen, ob ein Konflikt übersprungen, mit einer Begrenzung abgewartet oder als dauerhafte Arbeit in eine Queue gestellt werden soll.
  • Eine stabile, mit einem Namespace versehene Sperridentität für alle kooperierenden Runner verwenden.
  • Das Datei-Handle oder die Datenbankverbindung während des vollständigen kritischen Abschnitts halten.
  • Das Scheitern beim Erstellen oder Erwerben der Sperre als bewusstes Ergebnis behandeln, niemals als Erlaubnis für einen ungesperrten Lauf.
  • Sperrkonflikte getrennt von Jobfehlern und erfolgreicher Arbeit protokollieren.
  • Dateisystem- und Mount-Verhalten prüfen, bevor Dateisperren über gemeinsam genutzten Speicher verwendet werden.
  • Externe Nebenwirkungen auch nach Abstürzen und mehrdeutigen Timeouts wiederholungssicher gestalten.
  • Konflikte, normale Freigabe, abruptes Beenden und den nächsten geplanten Durchlauf testen.

Fazit

Die kleinste korrekte Kontrolle gegen Überschneidungen ist diejenige, deren Sichtbarkeit zu jedem Runner passt. Ein systemd-Timer kann eine aktive Unit serialisieren. Eine PHP- oder util-linux-Dateisperre kann kooperierende Prozesse koordinieren, die dasselbe geeignete Dateisystem sehen. Eine benannte MariaDB-Sperre kann Clients eines Datenbankservers koordinieren, wenn eine lokale Datei keine gemeinsame Grenze bildet.

Keine davon sollte als Exactly-once-Ausführung bezeichnet werden. Zuerst ist zu entscheiden, ob ein konkurrierender Durchlauf übersprungen, wartend blockiert oder in eine Queue gestellt werden soll. Danach muss die gewählte Sperre für den beabsichtigten kritischen Abschnitt gehalten, ein Konflikt sichtbar gemacht und die Arbeit unabhängig davon für eine sichere Wiederherstellung entworfen werden. Die nützliche Frage lautet nicht nur „Gibt es eine Sperre?“, sondern „Welche Ausführungen können sie sehen, und was bleibt nach ihrer Freigabe ungewiss?“

References