Skip to content

Core Concepts ​

Understanding how distributed locks work and why they're safe.

What is a Distributed Lock? ​

A distributed lock coordinates access to a shared resource across multiple processes or servers.

Lock Key ​

Identifies the resource being locked:

_lock:payment:order-123
  |      |       |
  |      |       +-- Resource identifier
  |      +-- Resource type  
  +-- Prefix (configurable)
typescript
// Examples of lock keys
'payment:order-123'      // Specific order
'inventory:sku-abc'      // Specific item
'user:123:profile'       // User profile
'report:daily:2025-01'   // Daily report
'sync:products'          // Global sync

Lock Token ​

Unique identifier proving lock ownership:

typescript
// Token format: pid-timestamp-random
'12345-1706123456789-k7x9m2p'
   |          |          |
   |          |          +-- Random string
   |          +-- Timestamp (ms)
   +-- Process ID

Why tokens matter: They prevent accidental release by another process.

Lock TTL ​

Time-To-Live prevents deadlocks:

TTLUse CaseRisk
5sQuick operationsLow risk
30sStandard operationsDefault
5minLong operationsUse auto-renew
>10minVery long tasksMust use auto-renew

Lock Safety Properties ​

1. Mutual Exclusion ​

Only one client holds the lock at a time:

lua
-- Redis SET with NX (only if Not eXists)
SET lock:resource token NX PX 30000

2. Deadlock Freedom ​

TTL ensures locks are eventually released.

3. Fault Tolerance ​

Token verification prevents accidental release:

lua
-- Release only if we own it
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
else
  return 0  -- Not our lock!
end

Lock vs Mutex vs Semaphore ​

ConceptDescriptionNestJS RedisX
MutexBinary lock, one holder@WithLock
SemaphoreN concurrent holdersManual (counter)
Read-Write LockMany readers OR one writerManual
Reentrant LockSame holder can re-acquireNot supported

Redis Data Model ​

+-------------------------------------------------------------+
| Redis                                                       |
+-------------------------------------------------------------+
| Key                        | Value          | TTL           |
+-------------------------------------------------------------+
| _lock:payment:order-123    | 12345-abc-xyz  | 30000ms       |
| _lock:inventory:sku-001    | 67890-def-uvw  | 5000ms        |
| _lock:sync:products        | 11111-ghi-rst  | 60000ms       |
+-------------------------------------------------------------+

Guarantees and Limitations ​

Guaranteed ​

PropertyDescription
AtomicityAcquire/release are atomic (Lua)
ExclusivitySingle holder at any time
Auto-expiryTTL prevents permanent locks
Safe releaseToken verification

Not Guaranteed ​

LimitationReasonMitigation
Clock synchronizationDifferent server clocksUse reasonable TTL margins
Split-brainNetwork partitionUse Redis Cluster or Sentinel
Exactly-onceProcess may die after workImplement idempotency

What Locks Guarantee and What They Don't ​

Guaranteed (normal network, live lease) ​

  • Mutual exclusion while TTL is valid — if your operation completes within TTL (or auto-renewal keeps it alive), no other process holds the lock
  • Ownership verification — only the holder can release or extend (Lua-atomic token check)
  • Deadlock freedom — TTL ensures every lock eventually expires

NOT Guaranteed ​

  • Consensus-level safety — this is a single-Redis lock, not Raft/Paxos. Network partition or Redis failover can violate mutual exclusion
  • Fencing semantics — no monotonic token that downstream can verify. If your process holds a stale lock (TTL expired, then re-acquired by another), nothing prevents the stale holder from writing
  • Exactly-once execution — a crash mid-operation leaves partial state. Lock only serializes access, it doesn't make operations atomic

What to Do for Payments, DB Writes, and Critical Paths ​

Locks alone are not enough for financial operations. Use defense-in-depth:

typescript
// Layer 1: Idempotency — detect duplicate requests
@Idempotent({ key: 'payment:{orderId}' })

// Layer 2: Lock — serialize concurrent attempts
@WithLock({ key: 'order:{orderId}', ttl: 30 })

async processPayment(orderId: string) {
  // Layer 3: DB constraint — final safety net
  // INSERT ... ON CONFLICT (idempotency_key) DO NOTHING
  await this.db.insertPayment({
    orderId,
    idempotencyKey: this.idempotencyKey,
  });
}
LayerProtects AgainstAlone Sufficient?
LockConcurrent executionNo — TTL expiry, Redis failover
IdempotencyDuplicate requestsNo — doesn't prevent concurrent first attempts
DB constraintEverything aboveYes, but poor UX (errors instead of dedup)
All threeMost real-world failure modesYes

Idempotency key must be business-meaningful

Use a stable business identifier (orderId, paymentIntentId, requestId) — not a random hash or UUID generated per request. A random key defeats the purpose: every retry looks like a new operation.

Rule of thumb

If losing the lock mid-operation would cost money or corrupt data — add idempotency + DB constraints. If it would just cause a retry or a slow response — lock alone is fine.

Next Steps ​

Released under the MIT License.