Distributed Locks: Coordinating Across Multiple Servers
Understand why single-process locking mechanisms like flock() are insufficient once an application runs on multiple servers, and correctly reason about leases, failure modes, and safe distributed-locking practice in PHP.
A PHP application that runs as a single process on a single machine can serialize access to a shared resource with something as simple as flock() on a file — the operating system guarantees that only one process holds the lock at a time, and if that process dies, the OS releases the lock automatically. The moment your application runs behind a load balancer across several servers, or as several PHP-FPM workers, or as several containers in a Kubernetes deployment, flock() stops helping at all: it only serializes access within one machine's filesystem, and every other server has its own separate, unrelated lock. Coordinating 'only one instance of this job runs at a time' or 'only one request touches this row at a time' across independent machines requires a genuinely distributed lock, and distributed locks are one of the places in backend engineering where naive implementations look correct in testing and fail in production under exactly the conditions they were built to prevent.
Why a Single-Machine Lock Isn't Enough
flock() (and similar OS-level primitives like POSIX semaphores or PHP's sem_acquire()) coordinate processes that share the same filesystem or kernel. That's an extremely useful guarantee for, say, ensuring a cron job doesn't overlap with a slow previous run of itself on the same box. But a typical PHP deployment doesn't run on one box: PHP-FPM worker pools are usually spread across multiple application servers behind a load balancer for both capacity and redundancy, and a background job runner might be replicated across several worker containers for the same reasons. Each of those processes has its own filesystem, its own kernel, its own flock() table — there is no shared state for the OS-level lock to coordinate through.
Once coordination needs to span machines, the lock's state has to live somewhere all the machines can reach: a shared datastore. In practice this is almost always Redis (fast, purpose-built primitives for exactly this) or the same relational database the application already uses (via a row lock, an advisory lock, or a dedicated 'locks' table with a unique constraint). The core idea is unchanged from a single-machine lock — only one holder at a time, others wait or fail — but now 'checking and setting the lock' has to be an atomic operation against that shared store, because multiple independent machines might attempt to acquire it at effectively the same instant.
• flock() only coordinates processes sharing one machine's filesystem/kernel — useless across servers. • Multi-server PHP deployments need lock state in a shared store every instance can reach: Redis or the database. • The acquire operation must be atomic against that shared store to handle simultaneous attempts from different machines.
Leases: Locks That Expire
A plain mutual-exclusion lock that is held until explicitly released has an obvious failure mode in a distributed system: what happens when the process holding it crashes, gets killed by the platform for exceeding memory, or the container it runs in is rescheduled — without ever calling 'release'? With a purely 'held until released' lock, the answer is that nobody else can ever acquire it again; the resource is now permanently unavailable, and the only fix is manual intervention to clear the lock. This is unacceptable in a production system, since process death is a routine, expected event at scale, not a rare edge case.
The standard fix is a lease: a lock that carries a time-to-live (TTL) and expires automatically if it isn't renewed or released within that window. When a process acquires a lease-based lock, it's really acquiring 'exclusive ownership for up to N seconds' rather than 'exclusive ownership forever'. If the holder finishes its work quickly, it releases the lock explicitly and other waiters can proceed immediately. If the holder crashes without releasing it, the lease simply expires on its own after the TTL elapses, and the resource becomes acquirable again without any human stepping in. Redis's SET key value NX EX ttl (set-if-not-exists with an expiry) is the textbook building block for exactly this pattern.
Choosing the TTL is a real trade-off, not a formality. Too short, and a legitimately slow-but-healthy holder can have its lease expire out from under it while it's still working, letting a second process acquire the same lock and both now believe they have exclusive access simultaneously. Too long, and a crashed holder blocks everyone else for an uncomfortably long window before the lease finally expires. Systems that do real work under a lease often extend it ('renew the lease') periodically while the work is ongoing, rather than picking one static TTL meant to cover the absolute worst case up front.
• A lease-based lock has a TTL and expires automatically if not renewed/released, so a crashed holder doesn't block others forever. • Redis SET key val NX EX ttl is the canonical atomic-acquire-with-expiry primitive. • TTL is a real trade-off: too short risks the holder losing the lock mid-work; too long delays recovery from a real crash. • Long-running holders often periodically renew ('heartbeat') the lease rather than relying on one fixed TTL.
The Classic Failure Mode: Pause-Past-Expiry
Leases solve the crashed-and-never-released problem, but they introduce a subtler correctness problem that any serious treatment of distributed locking has to confront directly: a process can hold the lease, then be paused for longer than the lease's TTL — by a garbage-collection stall, an OS scheduling delay, a network partition that cuts it off without killing it, a container orchestrator freezing it during a migration — and then resume execution with absolutely no idea that time has passed and its lease has already expired. From that process's own point of view, it never stopped holding the lock; it simply continued its critical-section work exactly as if nothing happened.
Meanwhile, because the lease expired while that process was paused, a second process was free to acquire the same lock and is now also inside the critical section, believing correctly that it holds exclusive access. The result is two processes simultaneously acting as though each has exclusive ownership of the same resource — the very thing the lock existed to prevent — and this happens without any obvious error on either side; both processes believe, individually and reasonably, that they are the sole lock holder. This is widely known in distributed-systems literature (most famously discussed by Martin Kleppmann in his critique of naive Redlock usage) as the fundamental reason a lease's expiry alone cannot be trusted as an absolute correctness guarantee for actions with real, irreversible side effects.
The practical implication is not 'don't use leases' — leases are still the right tool for availability (recovering from crashes automatically) — but 'don't assume merely holding a lease means your action is safe to perform unconditionally', especially for actions like writing to shared storage or external systems that can't be undone. The safeguard is a fencing token: a monotonically increasing number issued alongside each lock grant, which the protected resource itself checks and rejects if it's lower than the highest token it has already seen — so even a 'zombie' holder that wakes up after its lease expired and tries to write anyway gets rejected by the resource, because its fencing token is now stale.
• A paused-then-resumed process can act on a lease it no longer actually holds — the classic distributed-locking correctness gap. • Two processes can each correctly believe, locally, that they hold exclusive access — with no error raised on either side. • A lease alone guarantees availability (recovery from crashes), not correctness (mutual exclusion under pauses/GC stalls/network partitions). • Fencing tokens (a monotonically increasing number checked by the protected resource itself) close this gap for actions with irreversible side effects.
Safe Locking Practice
Given how easy it is to build something that looks like a correct distributed lock but silently fails exactly under the load-and-latency conditions where correctness matters most, the strong recommendation from experienced practitioners is: use a well-tested, widely reviewed implementation rather than hand-rolling the acquire/release/expiry logic yourself. For Redis, that means either Redlock (the multi-instance algorithm Redis's own documentation describes, run against an odd number of independent Redis nodes to tolerate a minority failing) via a maintained client library, or — for many applications where availability across a Redis outage matters less than simplicity — a single well-configured Redis instance with SET NX EX plus a fencing token for any action with real consequences.
For applications that already depend on a relational database and would rather not introduce Redis purely for locking, PostgreSQL's advisory locks (pg_advisory_lock / pg_try_advisory_lock) or a dedicated locks table with a unique constraint and an expires_at column (checked and cleaned up by every acquire attempt) are both proven, well-understood alternatives. Whichever mechanism you choose, the same core disciplines apply: keep critical sections as short as practically possible so lease TTLs can stay short too; always release the lock in a finally block so an exception inside the critical section doesn't leave it needlessly held until expiry; and reserve fencing tokens for anything where a duplicate concurrent action would cause real, hard-to-undo damage.
• Prefer a well-tested library implementation (Redlock, a maintained PHP Redis-lock package, Postgres advisory locks) over a hand-rolled acquire/release. • Keep the critical section short so lease TTLs can stay short, reducing the crash-recovery delay. • Always release inside a finally block so exceptions don't leave a lock held until expiry unnecessarily. • Add fencing tokens specifically where a duplicate concurrent action would be genuinely damaging, not for every lock.
Code Examples
The NX/EX flags make acquisition atomic and self-expiring in one Redis command, so a crashed holder's lock disappears on its own after 30 seconds. The release uses a Lua script to atomically check the stored token before deleting, which prevents accidentally releasing a DIFFERENT process's lock that acquired the key after our own lease already expired.
$redis->set($lockKey, $token, ['NX', 'EX' => $ttlSeconds]) — atomic acquire-with-expiry in one round trip.
if ($redis->exists($lockKey) === 0) { $redis->set($lockKey, 1); $redis->expire($lockKey, 30); /* do work */ }
$redis->set($lockKey, $token, ['NX', 'EX' => 30]); /* do work */ // release via token-checked Lua script
Checking exists() and then set()+expire() as separate commands is not atomic — two processes can both see the key absent and both proceed to set it, defeating the entire purpose of the lock. A single NX/EX SET call is what makes acquisition atomic.
Why doesn't PHP's flock() solve locking for an application running on multiple servers behind a load balancer? A. flock() is too slow for production use B. flock() only coordinates processes sharing the same machine's filesystem/kernel; other servers have entirely separate lock state C. flock() only works with SQLite databases D. flock() cannot be used from within PHP-FPM
B. flock() only coordinates processes sharing the same machine's filesystem/kernel; other servers have entirely separate lock state — flock() is an OS-level primitive scoped to one machine. Once your application spans multiple independent servers, there is no shared kernel/filesystem for flock() to coordinate through, so each server's lock is invisible to the others.
What problem does a lease (TTL-based expiry) solve that a plain 'held until explicitly released' lock does not? A. It makes the lock faster to acquire B. It ensures the lock is automatically released if the holder crashes without releasing it, instead of blocking everyone forever C. It removes the need for any atomic acquire operation D. It eliminates the possibility of two processes ever holding a lock at once
B. It ensures the lock is automatically released if the holder crashes without releasing it, instead of blocking everyone forever — Without a TTL, a crashed holder that never calls release leaves the lock permanently unavailable, requiring manual intervention. A lease-based lock expires on its own, restoring availability automatically — though it does NOT, by itself, eliminate the pause-past-expiry correctness problem described in this lesson.
A process holds a lease, is paused by a long GC stall past the lease's TTL, then resumes and continues its critical-section work. What is the danger here? A. None — the lease automatically pauses along with the process B. A second process may have already acquired the same (now-expired) lock, so both processes can now be acting inside the critical section simultaneously, each believing it has exclusive access C. The paused process's PHP session will simply throw a fatal error on resume D. This can only happen with flock(), never with Redis-based locks
B. A second process may have already acquired the same (now-expired) lock, so both processes can now be acting inside the critical section simultaneously, each believing it has exclusive access — This is the classic distributed-locking correctness gap: the lease's expiry is invisible to the paused process, which resumes as if nothing happened, while a different process may have legitimately acquired the lock in the meantime. Fencing tokens, checked by the protected resource itself, are the standard mitigation for actions where this would cause real damage.
<?php
declare(strict_types=1);
function withDistributedLock(Redis $redis, string $lockKey, int $ttlSeconds, callable $criticalSection): mixed
{
// A random token identifies THIS acquisition, so we only ever
// release a lock we actually still own.
$token = bin2hex(random_bytes(16));
// SET key token NX EX ttl — atomic acquire-if-absent-with-expiry.
$acquired = $redis->set($lockKey, $token, ['NX', 'EX' => $ttlSeconds]);
if (!$acquired) {
throw new RuntimeException("Could not acquire lock: {$lockKey}");
}
try {
return $criticalSection();
} finally {
// Release only if the lock still holds OUR token — a Lua script
// makes the check-and-delete atomic, avoiding releasing a lock
// that expired and was already re-acquired by someone else.
$releaseScript = <<<'LUA'
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
LUA;
$redis->eval($releaseScript, [$lockKey, $token], 1);
}
}
// Usage: only one instance of this job, across ALL app servers, runs at a time.
$redis = new Redis();
$redis->co
…