Jagd auf einen 0,18%-Bug: Redis Lock Expiration in Crunz

Wenn „sporadisch" eigentlich „mathematisch unvermeidlich" heißt

In RedisStore.php line 185:
[Symfony\Component\Lock\Exception\LockConflictedException]

Eine Zeile. Kein Stack Trace. Kein Kontext. Nur eine LockConflictedException in den Logs unseres crunz-Schedulers — jeden einzelnen Tag. Mal einmal, mal ein Dutzend Mal. Kein erkennbares Muster, kein offensichtlicher Auslöser. Ein nicht-deterministischer Bug wie aus dem Lehrbuch.

Ich hab’s eine Weile ignoriert. Dann hab ich gerechnet.

Es war gar nicht zufällig. Es war eine probabilistische Gewissheit, eingebaut in den Lock-Refresh-Mechanismus von Crunz — ein Bug, der ungefähr 1 von 550 Lock-Operationen trifft. Bei 42 Redis-gesicherten Cronjobs über den Tag verteilt war die Frage nie ob es passiert, sondern wie oft.

Was ist ein Lock, und warum läuft er ab?

Permalink to "Was ist ein Lock, und warum läuft er ab?"

Wer sich mit Distributed Locking und Mutual Exclusion bereits auskennt: einfach weiterspringen. Für alle anderen — ein kurzes Gedankenmodell.

Stell dir einen Lock vor wie eine Parkuhr. Du wirfst Münzen rein (eine TTL — Time to Live), und solange die Uhr läuft, gehört der Parkplatz dir. Niemand sonst kann ihn nehmen. Aber sobald die Zeit abläuft, ist der Platz wieder frei — auch wenn du noch im Laden einkaufst.

Redis Locks funktionieren genauso. Crunz — ein PHP-Cronjob-Scheduler — nutzt die Symfony Lock-Komponente mit einem RedisStore, um überlappende Task-Ausführung zu verhindern. Wenn ein Task startet, holt er sich einen Lock mit Zeitlimit via ZADD (Redis’ „Sorted Set Add"-Befehl — speichert einen einzigartigen Token mit einem Score, der den Expiry-Timestamp darstellt). Solange der Task den Lock vor Ablauf erneuert, ist alles sicher. Aber kommt die Erneuerung zu spät — auch nur um den Bruchteil einer Sekunde — betrachtet Redis den Lock als weg. Der nächste Refresh-Versuch findet einen leeren Platz und wirft LockConflictedException.

Der Clou: Crunz erneuert den Lock nicht zuverlässig. Es nutzt einen probabilistischen Ansatz. Dazu gleich mehr.

Redis Distributed Locks in PHP

Permalink to "Redis Distributed Locks in PHP"

Ein Distributed Lock mit Redis stellt sicher, dass nur ein Prozess gleichzeitig einen kritischen Abschnitt ausführt — selbst über mehrere Server hinweg. In PHP macht die Symfony Lock-Komponente das unkompliziert. Ein einfaches Redis-Lock-Beispiel:

use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\RedisStore;

$redis = new \Predis\Client(['host' => 'localhost', 'port' => 6379]);
$store = new RedisStore($redis);
$factory = new LockFactory($store);

$lock = $factory->createLock('my-task', ttl: 300);

if ($lock->acquire()) {
    try {
        // Kritischer Abschnitt — nur ein Prozess führt das aus
        doExpensiveWork();
    } finally {
        $lock->release();
    }
}

Unter der Haube nutzt RedisStore ZADD mit einem einzigartigen Token und einer TTL. Der Lock existiert als Redis-Sorted-Set-Member — wenn die TTL abläuft, wird der Member entfernt und der Lock ist weg. Für langlaufende Tasks ruft man $lock->refresh() auf, um die TTL vor Ablauf zu verlängern.

Genau das macht Crunz. Aber der Refresh-Mechanismus hat ein Problem.

Crunz: PHP-Cronjob-Scheduler

Permalink to "Crunz: PHP-Cronjob-Scheduler"

Crunz ist ein PHP-Cronjob-Scheduler, mit dem man geplante Tasks in Code statt in Crontab-Dateien definiert. Man schreibt Task-Definitionen in PHP, und ein einziger Cron-Eintrag (* * * * * crunz schedule:run) führt sie nach ihren konfigurierten Zeitplänen aus.

$schedule->run('php artisan reports:generate')
    ->daily()
    ->at('03:00')
    ->preventOverlapping($redisStore);

Dieser preventOverlapping()-Aufruf ist der Schlüssel. Er weist den Crunz-Scheduler an, vor der Task-Ausführung einen Distributed Lock zu holen — läuft der gleiche Cronjob noch vom vorherigen Durchlauf, wartet die neue Instanz statt Duplikate oder Race Conditions zu erzeugen.

Der Lock Store kann alles sein, was Symfony Lock unterstützt: Redis, Dateisystem (FlockStore), PostgreSQL oder Memcached. Unsere Anwendung nutzt Redis für 42 von 43 geplanten Tasks. Und da beginnt das Problem.

Den Callstack analysieren

Permalink to "Den Callstack analysieren"

Die Exception kommt aus RedisStore::putOffExpiration() (in vendor/symfony/lock/Store/RedisStore.php). Darin führt ein Lua-Skript einen atomaren Check-and-Extend durch — atomar, weil Redis Lua-Skripte als einzelne Operation ausführt und damit Race Conditions zwischen dem „existiert mein Lock noch?“-Check und dem „verlängere ihn”-Update verhindert:

if not redis.call("ZSCORE", key, uniqueToken) then
    return false  -- Lock ist weg, löst LockConflictedException aus
end

Wenn das Skript false zurückgibt, wirft Symfony Lock die Exception. Der Lock ist bereits in Redis abgelaufen — unser Token wurde entfernt, weil die TTL ausgelaufen ist.

Zurück durch den Crunz-Quellcode (unter vendor/crunzphp/crunz/src/):

schedule_run.sh
  └─ crunz schedule:run
       └─ EventRunner::handle()
            └─ EventRunner::manageStartedEvents()
                 ├─ 10% Chance: Event::refreshLock()
                 │    └─ Lock::refresh()
                 │         └─ RedisStore::putOffExpiration()  ← wirft hier
                 └─ usleep(250000)  // 250ms zwischen Iterationen

Der Lock wird mit $ttl = 30 Sekunden erstellt — hardcoded in Event::createLockObject(). Der EventRunner überwacht laufende Tasks in einer Schleife, alle 250ms. Aber er erneuert den Lock nicht bei jeder Iteration. Er würfelt.

Das Refresh-Glücksspiel

Permalink to "Das Refresh-Glücksspiel"

Hier wird’s interessant. Crunz’ EventRunner erneuert Locks nicht deterministisch. Er nutzt ein zweischichtiges probabilistisches Gate:

Schicht 1 — Der Würfelwurf. Jede Loop-Iteration (alle 250ms) hat nur eine 10%-Chance, einen Refresh überhaupt zu versuchen:

// EventRunner::manageStartedEvents()
if (mt_rand(1, 100) <= 10) {
    $event->refreshLock();
}

Schicht 2 — Der Zeitcheck. Selbst wenn ausgewählt, handelt refreshLock() nur, wenn weniger als 15 Sekunden auf dem Lock verbleiben:

// Event::refreshLock()
$remainingLifetime = $this->lock->getRemainingLifetime();
if ($remainingLifetime < 15) {
    $this->lock->refresh();
}

Zurück zur Parkuhr: Stell dir vor, du musst alle 30 Sekunden nachwerfen. Aber statt auf die Uhr zu schauen, würfelst du jede Viertelsekunde mit einem zehn-seitigen Würfel. Nur bei einer 1 gehst du überhaupt nachschauen. Und selbst dann wirfst du nur eine Münze ein, wenn die Uhr weniger als 15 Sekunden anzeigt.

Meistens geht das gut. Aber „meistens" ist nicht „immer". Das ist keine Race Condition im klassischen Sinn — es ist ein Wahrscheinlichkeitsspiel.

So sieht ein gescheiterter Lock-Refresh auf der Timeline aus:

Alle 250ms in der Gefahrenzone überspringt der Würfelwurf den Refresh. Nach 60 aufeinanderfolgenden Fehlversuchen — ist der Lock weg.

Die Mathematik, die alles erklärt

Permalink to "Die Mathematik, die alles erklärt"

Der Lock startet mit einer 30-Sekunden-TTL. Der Refresh greift erst in den letzten 15 Sekunden — das ist das kritische Fenster.

In diesen 15 Sekunden läuft die Schleife 60 Mal (15s / 0,25s). Jede Iteration hat eine 10%-Chance auf Refresh, also eine 90%-Chance, nicht zu refreshen.

Auf Deutsch: Der Lock bekommt 60 Chancen auf Erneuerung, aber jede Chance hat 90% Wahrscheinlichkeit, übersprungen zu werden. Die Wahrscheinlichkeit, dass alle 60 übersprungen werden:

0,9^60 = 0,00179...

0,18% pro Lock-Operation. Ungefähr 1 von 550.

Klingt selten — bis man die Skalierung bedenkt. Bei 42 Redis-gesicherten Tasks auf verschiedenen Zeitplänen über den Tag, viele davon mehrmals pro Stunde, sind es hunderte Lock-Operationen täglich. Bei 0,18% pro Stück sind mehrere tägliche Fehlschläge kein Zufall. Sie sind das erwartete Ergebnis.

Stell dir eine gezinkte Münze vor, die zu 90% auf Kopf landet. Wirf sie 60 Mal hintereinander — wie stehen die Chancen auf jedes Mal Kopf? Nur 0,18%. Das ist der Lock, der überlebt. Die restlichen 99,82% kommt mindestens ein Refresh durch. Aber 0,18% von hunderten täglichen Operationen summiert sich schnell.

Wenn die Exception feuert, bricht der Task ab — keine überlappende Ausführung, keine Datenkorruption. Ein Fail-Safe-Crash, keine stille Verfälschung. Aber tägliche Ausfälle bei geplanten Läufen sind trotzdem inakzeptabel.

Der Fix liegt auf der Hand, sobald man die Zahlen sieht — die TTL erhöhen. Da der < 15-Schwellwert hardcoded ist, braucht ein längerer TTL länger, um in die Gefahrenzone zu fallen, und gibt dem probabilistischen Refresh mehr Iterationen zum Erfolg:

TTL Refresh-Fenster Iterationen P(alle verpasst) Fehlerrate
30s 15s 60 0,9^60 ~1 von 550
60s 45s 180 0,9^180 ~1 von 10^8
300s 285s 1140 0,9^1140 praktisch 0

Bei 60 Sekunden fällt die Fehlerrate auf ungefähr 1 zu 100 Millionen — praktisch null. Bei 300 Sekunden erzeugt 0,9^1140 eine Zahl, die so klein ist, dass sie für jedes reale Szenario effektiv null ist.

Ein wichtiges Detail: dieses Problem betrifft nur TTL-basierte Stores wie RedisStore. Datei-basiertes Locking mit FlockStore ist nicht betroffen — diese Locks laufen nicht auf Timer ab. Sie werden freigegeben, wenn der Prozess endet. Wer LockConflictedException mit Redis gesehen hat, aber nie mit Dateisystem-Locks, weiß jetzt warum. (Mehr zum Redis-Locking-Verhalten in Symfony: Redis Session Locking Fallstricke in Symfony — gleicher Locking-Mechanismus, anderer Kontext.)

Warum wirft Crunz LockConflictedException?

Permalink to "Warum wirft Crunz LockConflictedException?"

Das ist kein isoliertes Problem. AzuraCast — die Open-Source-Radio-Automatisierungsplattform — hatte exakt die gleiche Exception (#5424, #5937, #7207). Gleiche Symptome, gleiche Verwirrung, gleiche Ursache: kurze TTL kombiniert mit Background-Workern, die nicht schnell genug refreshen. Das Symfony-Lock-Repository hat verwandte Diskussionen in #38541 und #31426. In Crunz selbst? Nichts. Keine Issues, keine Dokumentation. Der Code ist identisch von v3.4.1 bis v3.9.0.

So behebst du Redis Lock Expiration in Crunz

Permalink to "So behebst du Redis Lock Expiration in Crunz"

Der hardcodierte $ttl = 30 lebt in Event::createLockObject(). Mein erster Instinkt war, einfach auf 300 zu ändern und Feierabend. Aber das fühlte sich falsch an — andere User könnten legitime Gründe für eine kürzere TTL haben.

Der bessere Ansatz: konfigurierbar machen. Drei Änderungen an Event.php:

1. Property hinzufügen:

private int $lockTtl = 30;

2. In preventOverlapping() akzeptieren:

public function preventOverlapping(object $store = null, int $lockTtl = 30)
{
    $this->lockTtl = $lockTtl;
    // ... bestehende Logik
}

3. In createLockObject() verwenden:

$ttl = $this->lockTtl;  // vorher: $ttl = 30;

Der Default bleibt bei 30 Sekunden — voll abwärtskompatibel. Aber jetzt kann jeder Task eine längere TTL wählen:

// Vorher: würfelt mit 30s
$event->preventOverlapping($lockStore);

// Nachher: 300s TTL, praktisch null Fehlerchance
$event->preventOverlapping($lockStore, 300);

Angewendet via cweagans/composer-patches, damit es composer update überlebt:

{
    "extra": {
        "patches": {
            "crunzphp/crunz": {
                "Make preventOverlapping lock TTL configurable": "patches/crunz-increase-lock-ttl.patch"
            }
        }
    }
}

Alle 42 Redis-gesicherten Tasks aktualisiert von preventOverlapping($lockStore) auf preventOverlapping($lockStore, 300). Der einzelne FlockStore-Task — ein Übersetzungsimport mit Dateisystem-Locks — blieb unangetastet, da nicht betroffen.

Ein Unit-Test bestätigt das Verhalten:

public function testDefaultTtlRemainsThirty(): void
{
    $event = new Event('test-mutex', 'php -v');
    $event->preventOverlapping();
    $this->assertSame(30, $event->getLockTtl());
}

public function testCustomTtlIsStored(): void
{
    $event = new Event('test-mutex', 'php -v');
    $event->preventOverlapping(null, 300);
    $this->assertSame(300, $event->getLockTtl());
}

Seit dem Deployment des Patches über alle 42 Redis-gesicherten Tasks: null LockConflictedException in über zwei Wochen Produktionsbetrieb. Nicht eine.

Der Trade-off: Zombie Locks

Permalink to "Der Trade-off: Zombie Locks"

Eine längere TTL ist nicht kostenlos. Wenn ein Worker hart stirbt — SIGKILL, OOM oder Server-Crash — bleibt der Lock in Redis bis die TTL abläuft. Bei einer 300-Sekunden-TTL bedeutet das bis zu 5 Minuten, in denen kein anderer Prozess den Lock holen kann und möglicherweise den nächsten geplanten Lauf blockiert.

Für unseren Use Case ist dieser Trade-off es wert. Eine 5-Minuten-Verzögerung nach einem Crash schlägt dutzende tägliche LockConflictedException-Fehler im Normalbetrieb. Sind deine Tasks kurzlebig (unter 30 Sekunden) und Crashes dein größeres Problem, gibt dir eine TTL von 60 Sekunden das Beste aus beiden Welten — nahezu null Fehlerrate bei nur 1-Minute Zombie-Lock-Fenster.

Eine robustere Alternative: den probabilistischen Refresh komplett durch einen deterministischen Heartbeat ersetzen — bedingungslos alle N Sekunden refreshen. Aber das erfordert einen Patch der EventRunner-Schleife selbst, nicht nur des TTL-Parameters.


„Sporadisch" ist ein gefährliches Wort in Bug-Reports. Es klingt nach „selten" und „unvorhersehbar", was dein Gehirn in „nicht lohnenswert zu untersuchen" übersetzt. Aber sporadisch bedeutet oft nur, dass die Wahrscheinlichkeit niedrig genug ist, um zufällig zu wirken — während sie hoch genug ist, um bei Skalierung garantiert aufzutreten. Wenn das nächste Mal eine Exception ohne Muster auftaucht: greif nicht zur Retry-Logik. Greif zum Taschenrechner.