SF
{ }
Symfony · Lock · Redis · Distributed Systems · PHP 8.4
Symfony Lock:
Distributed Locks mit Redis

Race Conditions in verteilten PHP-Anwendungen führen zu Doppelbuchungen, korrupten Daten und schwer reproduzierbaren Bugs. Symfony Lock mit Redis löst dieses Problem mit einem sauberen API für Distributed Locks — keine manuellen SETNX-Skripte, keine fragilen Datenbankzeilen-Locks, sondern eine getestete, automatisch ablaufende Sperre.

15 Min. Lesezeit LockFactory · RedisStore · TTL · Blocking · Cron · Queue Symfony 7.x · PHP 8.4 · Redis 7

1. Das Race-Condition-Problem in verteilten PHP-Apps

Race Conditions entstehen, wenn zwei oder mehr Prozesse denselben Zustand gleichzeitig lesen, unabhängig voneinander eine Entscheidung treffen und dann denselben Zustand schreiben — ohne zu wissen, dass der andere Prozess dasselbe tut. Das klassische Beispiel in E-Commerce: Zwei Bestellungen für dasselbe Produkt kommen millisekunden-zeitgleich an. Beide Prozesse prüfen den Lagerbestand, beide sehen noch 1 Stück verfügbar, beide bestätigen die Bestellung, beide schreiben den Lagerbestand auf 0. Ergebnis: Lagerbestand bei -1, Kunde hat eine Bestätigung ohne Ware. Dieser Fehler ist nicht reproduzierbar in Einzelinstanz-Tests und taucht erst unter Last mit mehreren Workern oder Container-Instanzen auf.

Die naive Lösung — Datenbankzeilen-Locks mit SELECT ... FOR UPDATE — funktioniert in einer einzelnen Datenbank, skaliert aber schlecht und verlängert Transaktionen unnötig. Redis-basierte Distributed Locks lösen das Problem auf Infrastrukturebene: Redis ist schnell, atomar (SETNX ist single-threaded) und unterstützt TTL-basiertes automatisches Ablaufen. Symfony Lock abstrahiert Redis-Operationen, Lua-Skripte und Retry-Logik in ein einfaches PHP-API — eine LockFactory, ein Lock-Objekt, zwei Methoden: acquire() und release().

2. Installation und Store-Auswahl

Das Paket symfony/lock wird per Composer installiert und benötigt für Redis-Betrieb zusätzlich predis/predis oder die PHP-Extension ext-redis. Symfony Flex legt automatisch die Grundkonfiguration in config/packages/lock.yaml an. Die wichtigste Entscheidung bei der Installation ist die Wahl des Lock-Stores: Der Store bestimmt, wo die Lock-Information gespeichert wird und ob der Lock tatsächlich distributed ist. Für Einzelinstanz-Anwendungen ohne Skalierungsanforderungen reicht FlockStore oder SemaphoreStore auf dem lokalen Dateisystem. Für mehrere Container, mehrere Server oder horizontale Skalierung ist RedisStore oder PostgreSqlStore die richtige Wahl.

Die Konfiguration in lock.yaml ist minimal: Ein DSN-String oder ein Service-Verweis auf eine Redis-Verbindung reicht. Symfony Lock registriert dann automatisch die LockFactory als Service, der per Constructor Injection in jeden Service injiziert werden kann. Mehrere Lock-Stores für verschiedene Anwendungsfälle sind möglich: Ein schneller RedisStore für kurzlebige Locks, ein PostgreSqlStore für langlebige Locks, die auch einen Datenbankausfall überstehen sollen. Die LockFactory-Instanzen werden im Container unter eigenen Service-IDs registriert und per #[Autowire] selektiv injiziert.


<?php
// config/packages/lock.yaml configuration equivalent:
//
// framework:
//   lock: '%env(REDIS_URL)%'
//
// Or with named stores for different use cases:
// framework:
//   lock:
//     default: '%env(REDIS_URL)%'
//     postgres_lock: 'postgresql://user:pass@localhost/mydb'

// Installation commands:
// composer require symfony/lock
// composer require predis/predis  # OR ensure ext-redis is installed

declare(strict_types=1);

namespace App\Service;

use Symfony\Component\Lock\LockFactory;

/**
 * Example service using LockFactory via constructor injection.
 * LockFactory is automatically available after installing symfony/lock.
 */
final readonly class OrderProcessingService
{
    public function __construct(
        private LockFactory $lockFactory,
        private OrderRepository $orderRepository,
        private InventoryService $inventoryService,
    ) {}

    /**
     * Process an order exclusively — no concurrent processing for the same order.
     *
     * @throws \Symfony\Component\Lock\Exception\LockConflictedException
     */
    public function processOrder(string $orderId): void
    {
        // Create a named lock — only one process can hold this lock at a time
        $lock = $this->lockFactory->createLock(
            resource: 'order_processing_' . $orderId,
            ttl: 30.0, // Auto-release after 30 seconds if process crashes
        );

        if (!$lock->acquire()) {
            // Another worker is already processing this order
            return;
        }

        try {
            $this->doProcessOrder($orderId);
        } finally {
            // Always release, even on exception
            $lock->release();
        }
    }

    private function doProcessOrder(string $orderId): void
    {
        // Critical section — only one process executes this at a time
        $order = $this->orderRepository->find($orderId);
        $this->inventoryService->deductStock($order);
        // ...
    }
}

3. RedisStore konfigurieren und LockFactory nutzen

Der RedisStore in Symfony Lock nutzt intern ein Lua-Skript, das atomar prüft, ob ein Key existiert, ihn setzt und eine TTL aktiviert — alles in einem einzigen Redis-Befehl. Das ist wichtig, weil einzelne Redis-Befehle atomar sind, aber eine Sequenz von Befehlen nicht. Ohne Lua-Skript könnte zwischen dem Prüfen (SETNX) und dem Setzen der TTL (EXPIRE) ein anderer Prozess denselben Key setzen. Mit dem Lua-Skript ist diese Kombination atomar und damit thread-safe — selbst bei parallelen PHP-Workern auf verschiedenen Servern.

Die LockFactory ist der zentrale Service für alle Lock-Operationen. Sie erzeugt Lock-Objekte mit der Methode createLock(resource, ttl). Der resource-String ist der Lock-Name — er identifiziert eindeutig, was gesperrt wird. Die Konvention ist ein sprechender, ressourcenspezifischer String: inventory_update_sku_12345, payment_capture_order_abc oder cron_daily_report. Die TTL ist die maximale Laufzeit des Locks in Sekunden — wichtig als Sicherheitsnetz für den Fall, dass ein Prozess ohne release() endet: Absturz, OOM, SIGKILL. Der Lock läuft dann automatisch ab und gibt die Ressource frei, ohne dass manueller Eingriff nötig ist.

4. TTL-Strategie: Locks, die sich selbst entsperren

Die TTL-Wahl ist eine der kritischen Entscheidungen bei Symfony Lock mit Redis. Eine zu kurze TTL führt dazu, dass der Lock abläuft, während der Prozess noch läuft — ein anderer Prozess kann dann eintreten, obwohl der erste noch nicht fertig ist. Eine zu lange TTL bedeutet, dass nach einem Prozessabsturz die Ressource für unnötig lange Zeit blockiert bleibt. Als Faustregel gilt: TTL auf das Zwei- bis Dreifache der erwarteten maximalen Laufzeit setzen. Für einen Cron-Job, der normalerweise 5 Sekunden läuft und in Ausnahmefällen bis 30 Sekunden, ist eine TTL von 60 bis 90 Sekunden angemessen.

Symfony Lock unterstützt automatisches Refresh der TTL während der Prozess läuft: $lock->refresh() verlängert die TTL um den konfigurierten Wert. Das erlaubt Locks für Lang-Läufer ohne das Risiko eines vorzeitigen Ablaufs. Ein Background-Thread oder ein regelmäßiger refresh()-Aufruf im Prozess hält den Lock aktiv. Das Prinzip nennt sich „Heartbeat" und ist essenziell für Batch-Jobs und Queue-Worker, die keine feste Laufzeitobergrenze haben. Alternativ kann man mit einem Symfony Messenger Middleware Lock und Refresh in einem gemeinsamen Kontext halten — eine elegante Lösung für Queue-basierte Workloads.


<?php

declare(strict_types=1);

namespace App\Service;

use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\SharedLockInterface;

/**
 * Demonstrates TTL strategy and lock refresh for long-running processes.
 */
final readonly class InventoryBatchProcessor
{
    // Expected max runtime: 5 minutes — TTL set to 10 minutes as safety net
    private const LOCK_TTL = 600.0;

    // Refresh every 2 minutes to keep the lock alive
    private const REFRESH_INTERVAL = 120;

    public function __construct(
        private LockFactory $lockFactory,
    ) {}

    /**
     * Process inventory update exclusively with automatic TTL refresh.
     */
    public function processBatch(string $batchId, iterable $items): void
    {
        $lock = $this->lockFactory->createLock(
            resource: 'inventory_batch_' . $batchId,
            ttl: self::LOCK_TTL,
            autoRelease: true, // Automatically released when $lock goes out of scope
        );

        if (!$lock->acquire(blocking: false)) {
            throw new \RuntimeException("Batch {$batchId} is already being processed.");
        }

        $lastRefresh = time();

        try {
            foreach ($items as $item) {
                // Refresh TTL every REFRESH_INTERVAL seconds
                if (time() - $lastRefresh >= self::REFRESH_INTERVAL) {
                    $lock->refresh(self::LOCK_TTL);
                    $lastRefresh = time();
                }

                $this->processItem($item);
            }
        } finally {
            $lock->release();
        }
    }

    private function processItem(mixed $item): void
    {
        // Item processing logic here
    }
}

5. Blocking Locks und Wartestrategien

Standardmäßig versucht $lock->acquire() einmalig, den Lock zu erwerben, und gibt false zurück, wenn er nicht verfügbar ist. Das ist das Non-Blocking-Verhalten — sinnvoll für Prozesse, die bei Konkurrenz einfach überspringen sollen (Cron-Jobs, Worker-Deduplication). Für Prozesse, die warten müssen, bis eine Ressource freigegeben wird, gibt es Blocking Locks: $lock->acquire(blocking: true) blockiert den PHP-Prozess, bis der Lock erworben werden kann. Das ist für Queue-Consumer gedacht, die auf die Verarbeitung einer bestimmten Ressource warten müssen.

Zwischen Non-Blocking und unbegrenzt blockierend gibt es einen Mittelweg: Wiederholtes Versuchen mit Sleep. Symfony Lock stellt dafür den RetryTillSaveStore-Decorator bereit, der bei nicht verfügbarem Lock mehrmals mit konfigurierbarer Wartezeit versucht, den Lock zu erwerben. Die Konfiguration umfasst maximale Wartezeit, Intervall zwischen Versuchen und Jitter (zufällige Variation des Intervalls), um thundering-herd-Probleme zu vermeiden — wenn alle wartenden Prozesse gleichzeitig versuchen, denselben Lock zu erwerben, sobald er freigegeben wird. Für hochprekise Szenarien kann man den ExpirationStore-Ansatz mit Lua-Skripten auf Redis nutzen, um direkt auf den Lock-Expiry-Event zu reagieren, statt zu pollen.

6. Cron-Jobs gegen Mehrfachausführung absichern

Das häufigste Einsatzgebiet von Symfony Lock in PHP-Anwendungen ist die Absicherung von Cron-Jobs gegen Mehrfachausführung. Wenn ein Cron-Job alle fünf Minuten läuft, aber in seltenen Fällen länger als fünf Minuten dauert, starten zwei Instanzen desselben Jobs gleichzeitig. Mit einem Distributed Lock ist das ausgeschlossen: Die zweite Instanz prüft den Lock, findet ihn besetzt und beendet sich sofort. Ein Symfony Console Command integriert Locks elegant: LockableCommandTrait stellt die Methode $this->lock() bereit, die automatisch einen Lock mit dem Command-Namen anlegt und ihn beim Beenden des Commands wieder freigibt.

Für Commands ohne den Trait gilt dasselbe Muster: Lock vor dem Beginn der eigentlichen Arbeit erwerben, den Lock in einem finally-Block am Ende wieder freigeben. Die Kombination aus kurzer Prüfung und sofortiger Rückkehr bei besetztem Lock macht Cron-Job-Locking mit Symfony Lock zu einer Ein-Zeilen-Ergänzung in bestehende Commands. Kein Datenbankzugriff, keine PID-Dateien, keine systemd-Mechanismen — der Lock lebt in Redis mit einer TTL als Sicherheitsnetz und gibt sich selbst frei, wenn der Command normal oder durch einen Absturz endet.


<?php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\LockableTrait;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Lock\LockFactory;

/**
 * Daily report command protected against concurrent execution via Symfony Lock.
 */
#[AsCommand(name: 'app:daily-report', description: 'Generate daily sales report')]
final class DailyReportCommand extends Command
{
    // LockableTrait provides $this->lock() and $this->release()
    use LockableTrait;

    public function __construct(
        private readonly LockFactory $lockFactory,
        private readonly ReportGenerator $reportGenerator,
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        // Attempt to acquire lock — non-blocking, fails immediately if locked
        if (!$this->lock()) {
            $output->writeln('<comment>Command is already running. Exiting.</comment>');
            return Command::SUCCESS;
        }

        try {
            $output->writeln('Generating daily report...');
            $this->reportGenerator->generate();
            $output->writeln('<info>Report generated successfully.</info>');

            return Command::SUCCESS;
        } finally {
            // Always release lock, even on exception or early return
            $this->release();
        }
    }
}

7. Queue-Worker und Ressourcen-Locks

Message-Queue-Worker sind ein weiteres wichtiges Einsatzgebiet von Symfony Lock. Wenn mehrere Worker-Prozesse parallel laufen und Nachrichten aus einer Queue verarbeiten, müssen bestimmte Nachrichten exklusiv verarbeitet werden — zum Beispiel alle Nachrichten, die denselben Kunden betreffen, oder alle Nachrichten für dasselbe externe API-Konto. Eine Symfony Messenger Middleware implementiert dieses Locking-Pattern transparent: Bevor die Message dem Handler übergeben wird, erwirbt die Middleware den Lock; nach dem Handler gibt sie ihn frei.

Das Lock-Schlüssel-Design ist dabei entscheidend. Ein globaler Lock für alle Worker blockiert Parallelverarbeitung vollständig — das ist selten das Ziel. Ein ressourcenspezifischer Lock — z.B. payment_gateway_stripe_account_acct_123 — erlaubt parallele Verarbeitung für verschiedene Stripe-Accounts, aber exklusive Verarbeitung für denselben Account. Dieser granulare Ansatz maximiert den Durchsatz, ohne Race Conditions zwischen Nachrichten für dieselbe Ressource zu riskieren. Mit Symfony Lock und Redis ist dieser granulare Lock ohne Mehraufwand implementierbar: Der Schlüssel ist der einzige Unterschied.

8. Locks in Tests mit InMemoryStore

Tests, die echte Redis-Verbindungen brauchen, sind langsam und von externer Infrastruktur abhängig. Symfony Lock bietet für Tests den InMemoryStore, der das vollständige Lock-Verhalten im Arbeitsspeicher simuliert — inklusive TTL, Blocking-Semantik und gleichzeitiger Locks auf verschiedene Ressourcen. Der InMemoryStore implementiert dasselbe LockStoreInterface wie der RedisStore, sodass kein Produktionscode geändert werden muss. Die LockFactory wird in Tests mit dem InMemoryStore konfiguriert und per Constructor Injection in den zu testenden Service übergeben.

Für Concurrent-Lock-Tests kann man zwei LockFactory-Instanzen mit demselben InMemoryStore erstellen und damit Race-Condition-Szenarien testen: Erster Lock acquire erfolgreich, zweiter Lock acquire gibt false zurück. Nach Release des ersten Locks kann der zweite acquire. Diese Tests verifizieren das Locking-Verhalten des Services vollständig ohne echten Redis-Server, ohne Netzwerklatenz und ohne State-Verschmutzung zwischen Tests. Das macht die Lock-Logik testbar, deterministisch und in CI-Pipelines ohne Redis-Sidecar ausführbar.

9. Lock-Store-Optionen im Vergleich

Die Wahl des richtigen Lock-Stores für Symfony Lock hängt von der Infrastruktur, den Skalierbarkeitanforderungen und der Akzeptanz von Einschränkungen ab. Nicht jeder Store ist für jeden Anwendungsfall geeignet.

Store Distributed TTL Einsatz
RedisStore Ja Ja (automatisch) Multi-Server, Kubernetes, Skalierung
PostgreSqlStore Ja Nein (Advisory Locks) Wenn Redis nicht verfügbar
FlockStore Nein Nur via Prozess-Ende Einzelner Server, einfache Cron
InMemoryStore Nein Ja (simuliert) Tests — keine echte Infrastruktur
CombinedStore Ja (mehrere) Ja Hochverfügbarkeit mit mehreren Redis

Der CombinedStore ist das Äquivalent zu RedLock — dem Distributed-Lock-Algorithmus von Redis-Erfinder Salvatore Sanfilippo. Er erfordert, dass ein Lock auf der Mehrheit der konfigurierten Redis-Instanzen erworben wird. Das schützt gegen den Ausfall einer Redis-Instanz, erhöht aber die Latenz beim Lock-Erwerb. Für die meisten Symfony-Anwendungen ist ein einzelner RedisStore mit Sentinel oder Cluster-Konfiguration ausreichend — CombinedStore ist für Szenarien mit sehr hohen Verfügbarkeitsanforderungen.

Mironsoft

Symfony Architektur, Distributed Systems und Redis-Integration

Race Conditions und Concurrent-Processing-Probleme lösen?

Wir implementieren Distributed Locks mit Symfony Lock und Redis für skalierbare PHP-Anwendungen — von Cron-Job-Absicherung über Queue-Worker-Koordination bis zur kompletten Concurrent-Processing-Architektur.

Lock-Architektur

Store-Auswahl, TTL-Strategie und granulare Ressourcen-Locks für euren Stack

Queue & Cron

Messenger-Middleware und Command-Locks für zuverlässige Hintergrundprozesse

Testing

InMemoryStore-Tests für vollständige Lock-Coverage ohne Redis-Infrastruktur

10. Zusammenfassung

Symfony Lock mit Redis löst Race Conditions in verteilten PHP-Anwendungen mit einem klaren, testbaren API. Die LockFactory erzeugt benannte Locks mit TTL, die automatisch ablaufen, wenn ein Prozess abstürzt — kein manuelles Aufräumen, keine verwaisten Locks, kein gespeicherter State. Der RedisStore nutzt atomare Lua-Skripte für sichere Distributed Locks auf beliebig vielen Servern und Container-Instanzen. Cron-Jobs werden mit LockableTrait in einer Zeile gegen Mehrfachausführung abgesichert. Queue-Worker erhalten granulare Ressourcen-Locks ohne globale Blockade.

Der entscheidende Vorteil gegenüber manuellen Redis-SETNX-Implementierungen oder Datenbankzeilen-Locks: Symfony Lock ist getestet, dokumentiert und mit dem InMemoryStore vollständig in Unit-Tests nachbildbar. Neue Entwickler im Team müssen kein Redis-Locking-Know-how mitbringen — sie nutzen das abstrahierte API und erhalten trotzdem alle Sicherheits- und Resilienz-Eigenschaften eines gut implementierten Distributed Lock.

Symfony Lock mit Redis — Das Wichtigste auf einen Blick

LockFactory

createLock(resource, ttl) erzeugt benannte Locks. acquire() non-blocking, acquire(true) blocking. Immer in finally mit release().

TTL-Strategie

TTL auf 2–3× der erwarteten max. Laufzeit setzen. refresh() für Lang-Läufer. Bei Absturz läuft der Lock automatisch ab — kein manuelles Cleanup.

Cron-Absicherung

LockableTrait in Console Commands: $this->lock() verhindert Mehrfachausführung mit einer Zeile. Automatische Freigabe beim Command-Ende.

Testing

InMemoryStore simuliert vollständiges Lock-Verhalten im Arbeitsspeicher — Concurrent-Lock-Tests ohne Redis, ohne Infrastruktur, deterministisch.

11. FAQ: Symfony Lock und Distributed Locks mit Redis

1Was ist Symfony Lock?
Komponente für Mutual-Exclusion-Locks in PHP. Abstrahiert Redis, PostgreSQL und Dateisystem als Lock-Stores mit einheitlichem API, TTL und Blocking-Unterstützung.
2Was ist ein Distributed Lock?
Sperre über mehrere Prozesse, Server oder Container hinweg — koordiniert exklusiven Ressourcen-Zugriff in verteilten Systemen, nicht nur innerhalb eines Prozesses.
3Warum Redis für Locks?
Atomare Lua-Skripte, automatische TTL-Ablaufzeiten und hohe Performance. Der RedisStore nutzt diese für sichere Distributed Locks mit automatischer Freigabe bei Prozessabsturz.
4Absturz: Was passiert mit dem Lock?
Lock verfällt automatisch nach der TTL. Redis löscht den Key — kein manuelles Aufräumen, keine verwaisten Locks. Deshalb TTL auf 2–3× der max. Laufzeit setzen.
5acquire(true) vs acquire(false)?
false = non-blocking, sofort false zurück. true = blocking, wartet auf Lock. Cron/Deduplication: non-blocking. Queue-Consumer: blocking.
6Cron-Jobs absichern?
LockableTrait in Console Commands: $this->lock() erwerbt Lock mit dem Command-Namen. Nicht verfügbar → sofort beenden. $this->release() in finally freigeben.
7Testen ohne Redis?
InMemoryStore: new LockFactory(new InMemoryStore()). Simuliert vollständiges Lock-Verhalten im Arbeitsspeicher — deterministisch, schnell, ohne Infrastruktur.
8Was ist der CombinedStore?
RedLock-Äquivalent: Lock gilt als erworben bei Mehrheit der konfigurierten Stores. Hochverfügbarkeit bei Ausfall einer Redis-Instanz — für kritische Produktions-Szenarien.
9TTL-Refresh für Lang-Läufer?
$lock->refresh(ttl) verlängert den gehaltenen Lock. Heartbeat-Pattern: regelmäßig refresh() aufrufen, damit der Lock nicht vor Prozessende abläuft.
10Ohne Symfony Framework nutzbar?
Ja. symfony/lock ist eigenständig und funktioniert in jedem PHP-Projekt. Service-Registrierung im Container ist optional — LockFactory direkt instanziieren.