Distributed systems · beginner
Distributed lock contention spikes: find the widened critical section
A operational playbook for engineers who suspect a distributed lock (e.g., Redis-backed) is causing elevated contention because the protected critical section has grown. Covers how to distinguish lock-queue buildup from external latency, how to measure hold time and acquisition frequency, and how to prove that a recent code change widened the guarded block before changing the lock strategy.
The symptoms
- •Caller-side latency for the locked operation rises in a staircase pattern while upstream service latency stays flat, suggesting queued waiters rather than slow downstream calls.
- •Lock-acquisition wait time (time-to-acquire) increases while lock hold time per successful acquirer also increases, so total service time grows faster than either metric alone.
- •Throughput on the guarded resource plateaus or drops even when the calling service's CPU, thread pool, and request rate are unchanged from a known-good baseline.
- •Number of concurrent waiters on the lock (queue depth) climbs steadily during business hours rather than spiking only at peak traffic.
- •Cache-aside miss storms concentrate on the same key namespace that the lock protects, because the lock is held long enough that cache misses cannot be coalesced by concurrent callers.
Likely causes
- •A code change added synchronous I/O (database write, HTTP call, file read, or blocking SDK call) inside the critical section that was previously outside it.
- •Retry-with-backoff logic was moved inside the lock, so each acquirer now sleeps and re-reads state while still holding the lock.
- •A new validation, serialization, or hydration step runs on every acquire rather than being cached or done once per result.
- •Lock TTL was raised (or auto-extension was added) to mask slow holders, increasing the worst-case wait for every other caller.
- •Acquisition frequency increased because a higher-level orchestrator now calls the locked operation in a tight loop or fan-out pattern.
- •Key cardinality narrowed (e.g., per-tenant key collapsed to a global key) so many independent callers now serialize on one lock.
First ten minutes
- 01Confirm the symptom is the lock, not the resource it guards: compare the locked operation's p95 to an unlocked sibling endpoint on the same service; if only the locked path is degraded, the lock is implicated.
- 02Pull the last 7 days of deploy and config-change notes and align them with the first observed uptick in wait time; a widened critical section almost always tracks a recent change.
- 03Instrument or scrape two metrics for the lock: time-to-acquire (wait before ownership) and hold time (ownership duration); both should be captured with percentiles, not just averages.
- 04Capture a single representative slow trace end-to-end so you can see exactly which statements execute while the lock is held; this is your baseline for the proof step.
- 05Decide between two hypotheses before changing code: (a) hold time grew because the critical section widened, or (b) acquisition frequency grew because callers increased; the metric shape distinguishes them.
Evidence to collect
- •Time-to-acquire histogram and p50/p95/p99 for the lock key, with timestamps aligned to deploy boundaries.
- •Hold-time histogram and percentiles per acquirer, including distribution of outliers (long tail vs uniform shift).
- •Concurrent-waiter gauge or queue depth for the lock, sampled at 10-second resolution across one business cycle.
- •One end-to-end trace of a slow successful acquisition showing every span executed between acquire and release.
- •Deploy diff and config-change log covering the period when wait time and hold time both began to climb.
- •Caller-side call rate and fan-out factor for the locked operation, compared to a known-good window.
Where to look
- •The lock-client wrapper around the distributed lock (acquire, extend, release calls) where wait time and hold time are measured; this is the primary boundary.
- •The application code path executed between successful acquire and release; look for new synchronous calls, logging, validation, or retry loops added in the suspect release.
- •The boundary between the cache-aside miss handler and the locked path; per the cache-aside pattern, a missed read is normally followed by a populate step, and the populate is often what got moved inside the lock.
- •The deploy and configuration change ledger for the service that owns the lock; compare to the first anomaly timestamp.
- •The caller's scheduling layer (job queue, cron, orchestrator) for changes in fan-out or retry count that would raise acquisition frequency without raising hold time.
Diagnostic steps
- 01Plot time-to-acquire and hold time on the same time axis; if both rise together with queue depth, suspect a widened critical section; if hold time is flat but acquisition rate rises, suspect caller fan-out instead.
- 02Compare hold-time p95 of the slow window against the previous baseline; a sustained shift (not a long tail) indicates the critical section itself grew, not occasional slow acquirers.
- 03Inspect one slow trace and list every statement that runs while the lock is held; flag any statement that performs network I/O, disk I/O, or blocking waits that did not exist in the previous baseline.
- 04Diff the guarded code path against the last known-good commit and enumerate statements that were moved into the lock scope or added between acquire and release.
- 05Check the lock key strategy: if cardinality was reduced (per-tenant to global, per-region to single), that alone can serialize previously independent callers and look like a widened critical section.
- 06Correlate the anomaly start with the most recent deploy or config change; a match within minutes is strong evidence that the critical section widened in that change.
Common mistakes
- •Concluding contention is caused by the lock store itself (e.g., Redis) when hold time is the dominant signal; the store is usually fast, the critical section is usually slow.
- •Raising lock TTL or adding auto-extension to mask slowness; this lengthens the worst-case wait for every other caller and makes the symptom worse.
- •Adding more locking around the lock (double-check locking, local mutex around the distributed lock) without first measuring where time is actually spent inside the critical section.
- •Switching lock implementations or sharding keys before confirming the critical section widened; this changes the failure mode without addressing the root cause.
- •Reading only averages; averages hide a widened critical section because most acquirers still finish quickly while a long tail blocks the queue.
Safe fixes
- •If the diff shows synchronous I/O added inside the lock, move that I/O outside the critical section: do the slow read after acquire-and-check, or cache the result so subsequent acquirers skip the network call entirely.
- •If retries were moved inside the lock, hoist them out so the lock guards only the state mutation; this directly shortens hold time without changing semantics.
- •If validation or hydration runs on every acquire, memoize the validated object with a short TTL so the critical section only mutates, not recomputes.
- •If acquisition frequency rose because callers fan out, rate-limit or batch at the caller rather than widening the lock; this addresses the frequency side of Little's law for the queue.
- •If key cardinality was reduced, restore the original partitioning after confirming with the lock-client logs that the narrowed key now serializes independent tenants.
Prove the fix
- 01Hold-time p95 returns to within 10% of the pre-incident baseline, and the long-tail component is gone (not just the average).
- 02Time-to-acquire p95 drops to near the network round-trip cost of the lock store, indicating waiters are no longer queued behind slow holders.
- 03Concurrent-waiter gauge returns to its pre-incident range across a full business cycle, including peak hours.
- 04A new end-to-end trace of the locked operation shows that the statements inside the acquire/release window match the pre-incident baseline, confirming the critical section was actually narrowed.
- 05A canary run with the fix enabled shows the same throughput at a higher request rate than the pre-incident ceiling, demonstrating the queue is no longer the bottleneck.
Prevention and next steps
- •Add a code-review checklist item that flags any synchronous I/O, retry loop, or blocking SDK call placed between acquire and release of a distributed lock.
- •Emit hold-time and time-to-acquire as first-class metrics with percentiles, and alert when hold-time p95 shifts by more than a configured margin from a rolling baseline.
- •Keep the lock-client wrapper thin so the acquire/release span is a single, easily diffable boundary in traces; a fat wrapper hides what runs inside.
- •Require a key-cardinality review whenever a lock key strategy changes; narrowing cardinality is a silent serialization change.
- •Document the expected hold-time budget per lock so regressions against that budget are caught before they affect callers.
Safe commands and checks
# Inspect the lock-client acquire/release wrapper (replace path with the actual file): grep -nE 'acquire|release|tryAcquire|unlock' path/to/lock-client.ts | head -n 50
# Find statements added between the last known-good commit and HEAD that sit inside the lock scope: git log --oneline -- path/to/locked-operation.go | head -n 20
# Compare hold-time percentiles across two windows using your metrics backend (replace placeholders with your metric names and window): <metrics_query> histogram_quantile(0.95, sum by (le) (rate(lock_hold_time_seconds_bucket{service=\"<service>\"}[5m])))
# Compare time-to-acquire percentiles across the same windows: <metrics_query> histogram_quantile(0.95, sum by (le) (rate(lock_wait_time_seconds_bucket{service=\"<service>\"}[5m])))
# Sample concurrent-waiter gauge at 10s resolution: <metrics_query> avg_over_time(lock_waiters{service=\"<service>\"}[1h])