// Resilience & Messaging · 09
Distributed Lock
Only one process, across many machines, does this at a time.
How it fits together
- Instance A uses / calls Lock store (1 set if absent)
- Lock store creates Instance A (2 token 7)
- Instance A uses / calls Invoices table (3 write token 7)
- Instance B uses / calls Lock store (1 set if absent)
- Lock store fails / error path Instance B (2 held)
- Instance B fails / error path Skipped run (no lock: skip)
Spot it
- A scheduled job runs on every instance at once
- Two workers process the same file
- Races that only appear when scaled out
It costs
Another component that can fail, and leases make correctness depend on timing.
In code
public sealed class InvoiceJob(IDatabase redis, AppDb db){ private const string Key = "invoice-job"; private const string ReleaseScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) end return 0"; public async Task<long?> TryAcquireAsync(string owner, CancellationToken ct) { var acquired = await redis.StringSetAsync( Key, owner, TimeSpan.FromSeconds(60), When.NotExists); return acquired ? await redis.StringIncrementAsync($"{Key}:fence") : null; } public Task ReleaseAsync(string owner) => redis.ScriptEvaluateAsync(ReleaseScript, [Key], [owner]); public async Task SaveAsync(long fence, IEnumerable<Invoice> invoices, CancellationToken ct) { var job = await db.JobFences.SingleAsync(f => f.Name == Key, ct); if (job.LastFence >= fence) throw new InvalidOperationException("Stale fencing token."); job.LastFence = fence; db.Invoices.AddRange(invoices); await db.SaveChangesAsync(ct); }}Go deeperThe full explanation and what to ask for · 1 min readExpandCollapse
A distributed lock is a lease, not a mutex: it expires so a crashed holder cannot block forever. That expiry is also the danger. A GC pause or network delay can outlast the lease, and the old holder carries on believing it owns the lock while a new holder works.
The classic mistake is a lock without fencing tokens. Each acquisition should get a monotonically increasing number, and the protected resource must reject writes carrying an older one. Release must check the value too, or a slow holder deletes a lock that is now someone else's.
Lean on locks for efficiency (avoiding duplicate work) more readily than for correctness. When correctness depends on it, prefer the database: optimistic concurrency, a unique constraint, or application locks inside the transaction (SQL Server's sp_getapplock, PostgreSQL advisory locks). An idempotent operation often removes the need for a lock entirely.
Ask for it
- lease / TTL
- fencing token
- set-if-absent with expiry
- lock renewal
- leader election
- Redlock
Run the nightly invoice job on one instance only: take a lock on the job key with a 60s expiry (set only if absent), renew every 20s, release with a compare-and-delete, and take an increasing fencing token that the invoices store rejects if it is older than the last one written.