Arrange Act Assert

Jag Reehals thinking on things, mostly product development

Dual-Layer Locking: Redis for Speed, PostgreSQL for Reliability

02 Jun 2025

Dual-Layer Locking: Fast and Reliable Concurrency Control

If you've ever tried to update a bank account balance or process a payment in a distributed system, you know the pain: two users act at once, and suddenly your data is wrong. Race conditions aren't just a theoretical risk—they're a source of real-world bugs, lost money, and late-night incident calls.

What if you could combine the speed of Redis with the reliability of PostgreSQL to get the best of both worlds?

In this article, you'll learn a practical, production-ready pattern for safe concurrent updates: the dual-layer lock. We'll show you how to:

You'll get code, diagrams, and a real-world example you can adapt today.


Quick Start: Dual-Layer Locking in Practice

Here's the high-level recipe for safe concurrent updates:

  1. Acquire a Redis lock for the resource you want to update (e.g., a user or account ID). This prevents other processes from starting the same operation at the same time.
  2. Start a PostgreSQL transaction and lock the relevant row(s) with FOR UPDATE. This ensures only your transaction can change the data until you're done.
  3. Perform your update—with a version check for extra safety.
  4. Commit the transaction. If anything fails, roll back.
  5. Release the Redis lock (or let it expire if your process crashes).

Example (money transfer):

// Acquire Redis lock for both accounts
const lockKey = `lock:transfer:${fromAccount}-${toAccount}`;
await acquireLock(lockKey);
try {
  await client.query('BEGIN');
  // Lock both rows
  const fromUser = await client.query(
    'SELECT * FROM accounts WHERE id = $1 FOR UPDATE',
    [fromAccount]
  );
  const toUser = await client.query(
    'SELECT * FROM accounts WHERE id = $1 FOR UPDATE',
    [toAccount]
  );
  if (fromUser.balance < amount) throw new Error('Insufficient funds');
  await client.query(
    'UPDATE accounts SET balance = balance - $1, version = version + 1 WHERE id = $2 AND version = $3',
    [amount, fromAccount, fromUser.version]
  );
  await client.query(
    'UPDATE accounts SET balance = balance + $1, version = version + 1 WHERE id = $2 AND version = $3',
    [amount, toAccount, toUser.version]
  );
  await client.query('COMMIT');
} catch (error) {
  await client.query('ROLLBACK');
  throw error;
} finally {
  await releaseLock(lockKey);
}

Why Not Just One Lock?

Consider a scenario where hundreds of users attempt to update the same bank account simultaneously. Without robust controls:

graph TD
    A[User 1] -->|Updates Balance £100-£20| DB[(Database)]
    B[User 2] -->|Updates Balance £100-£50| DB
    C[Final Balance?] -->|Should be £30| DB
    D[Race Condition!] -->|Could be £50 or £80!| DB
    style D fill:#f96,stroke:#222,stroke-width:2px,color:#111,font-weight:bold;
    style DB fill:#fff,stroke:#222,stroke-width:2px,color:#111;
    style A fill:#fff,stroke:#222,stroke-width:2px,color:#111;
    style B fill:#fff,stroke:#222,stroke-width:2px,color:#111;
    style C fill:#fff,stroke:#222,stroke-width:2px,color:#111;

Our Solution: A Dual-Lock Approach

We employ two distinct types of locks, akin to securing a door with both a deadbolt and a chain:

graph LR
    A[Process] -->| Acquire| B[Redis Lock]
    A -->|Execute| C[PostgreSQL Transaction]
    B -->|Fast, Distributed Lock| D[Prevents Multiple Processes]
    C -->|Row-Level Locking| E[Ensures Data Integrity]
    classDef redis fill:#fff0f0,stroke:#c00,stroke-width:2px,color:#111,font-weight:bold;
    classDef postgres fill:#e0f0ff,stroke:#06c,stroke-width:2px,color:#111,font-weight:bold;
    classDef combined fill:#eaffea,stroke:#090,stroke-width:2.5px,color:#111,font-weight:bold;
    class B redis;
    class C,E postgres;
    style A fill:#fff,stroke:#222,stroke-width:2px,color:#111;
    style D fill:#fff,stroke:#c00,stroke-width:2px,color:#111;
    style E fill:#fff,stroke:#06c,stroke-width:2px,color:#111;

Lock 1: Redis Distributed Lock

This functions as a "reservation sign" placed on a resource before any modification occurs.

// Create a unique key for this specific lock
const lockKey = `lock:user:${userId}`;

// Attempt to acquire the lock with a timeout
const token = await redisClient.set(
  lockKey,
  uniqueToken,
  'PX',
  timeoutMs,
  'NX'
);

How it works:

sequenceDiagram
    participant Service1 as Service Instance 1
    participant Service2 as Service Instance 2
    participant Redis as Redis
    Service1->>Redis: SET lock:user:123 token1 PX 30000 NX
    Redis->>Service1: OK (Lock Acquired)
    Service2->>Redis: SET lock:user:123 token2 PX 30000 NX
    Redis->>Service2: NULL (Lock Failed)
    Note over Service2: Wait or handle failure
    Service1->>Redis: EVAL releaseScript 1 lock:user:123 token1
    Redis->>Service1: 1 (Lock Released)
    Service2->>Redis: SET lock:user:123 token2 PX 30000 NX
    Redis->>Service2: OK (Lock Acquired)

Trade-offs:

Lock 2: PostgreSQL Transactional Locking

This is analogous to a vault door, with a guard verifying credentials.

// Begin a transaction
await client.query('BEGIN');
// Lock the relevant row
await client.query('SELECT * FROM users WHERE id = $1 FOR UPDATE', [userId]);
// Apply changes...
// Commit the transaction
await client.query('COMMIT');

How it works:

sequenceDiagram
    participant Transaction1 as Transaction 1
    participant Transaction2 as Transaction 2
    participant Postgres as PostgreSQL
    Transaction1->>Postgres: BEGIN
    Transaction1->>Postgres: SELECT * FROM users WHERE id=123 FOR UPDATE
    Note over Postgres: Row locked for Transaction 1
    Transaction2->>Postgres: BEGIN
    Transaction2->>Postgres: SELECT * FROM users WHERE id=123 FOR UPDATE
    Note over Transaction2: Waits for Transaction 1 to complete
    Transaction1->>Postgres: UPDATE users SET email='new@example.com', version=version+1 WHERE id=123
    Transaction1->>Postgres: COMMIT
    Note over Postgres: Row lock released
    Postgres->>Transaction2: Returns row data
    Transaction2->>Postgres: UPDATE users SET name='New Name', version=version+1 WHERE id=123
    Transaction2->>Postgres: COMMIT

Trade-offs:

Key Design Decisions

1. Why Use Both Redis and PostgreSQL Locks?

Decision: Employ both types of locks, rather than relying on a single mechanism.

graph TD
    Redis[Redis Lock] -->|Pros| RedisPros["Fast Distributed Simple"]
    Redis -->|Cons| RedisCons["Less Reliable Potential Starvation"]
    Postgres[PostgreSQL Lock] -->|Pros| PostgresPros["Reliable Data Integrity ACID Compliant"]
    Postgres -->|Cons| PostgresCons["Slower Resource Intensive Potential Deadlock"]
    Combined[Combined Approach] -->|Takes| Best[Best of Both]
    Redis -->|First Layer| Combined
    Postgres -->|Second Layer| Combined
    classDef redis fill:#fff0f0,stroke:#c00,stroke-width:2px,color:#111,font-weight:bold;
    classDef postgres fill:#e0f0ff,stroke:#06c,stroke-width:2px,color:#111,font-weight:bold;
    classDef combined fill:#eaffea,stroke:#090,stroke-width:2.5px,color:#111,font-weight:bold;
    class Redis,RedisPros,RedisCons redis;
    class Postgres,PostgresPros,PostgresCons postgres;
    class Combined,Best combined;
    style RedisPros color:#111,fill:#fff,stroke:#c00,stroke-width:2px;
    style RedisCons color:#111,fill:#fff,stroke:#c00,stroke-width:2px;
    style PostgresPros color:#111,fill:#fff,stroke:#06c,stroke-width:2px;
    style PostgresCons color:#111,fill:#fff,stroke:#06c,stroke-width:2px;
    style Best color:#111,fill:#fff,stroke:#090,stroke-width:2.5px;

Rationale:

Trade-offs:

2. Why Use Unique Tokens for Redis Locks?

Decision: Generate a unique token (UUID) for each lock acquisition.

sequenceDiagram
    participant Service as Service
    participant Redis as Redis
    Note over Service,Redis: Correct Release with Token
    Service->>+Redis: SET lock:user:123 token123 PX 30000 NX
    Redis-->>-Service: OK
    Note over Service: Process Data
    Service->>+Redis: EVAL "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end" 1 lock:user:123 token123
    Redis-->>-Service: 1 (Success)
    Note over Service,Redis: Without Token (Dangerous!)
    Service->>+Redis: SET lock:user:123 true PX 30000 NX
    Redis-->>-Service: OK
    Note over Service: Process takes too long...
    Note over Redis: Lock expires after timeout
    Note over Service: Another service acquires the lock
    Service->>+Redis: DEL lock:user:123
    Redis-->>-Service: 1 (Deleted someone else's lock!)

Rationale:

Trade-offs:

3. Why Use Optimistic Locking with Versions?

Decision: Add a version column to database records, incremented with each update.

sequenceDiagram
    participant Service1 as Service 1
    participant Service2 as Service 2
    participant DB as Database
    Service1->>+DB: SELECT * FROM users WHERE id=123
    DB-->>-Service1: {id: 123, email: "old@example.com", version: 1}
    Service2->>+DB: SELECT * FROM users WHERE id=123
    DB-->>-Service2: {id: 123, email: "old@example.com", version: 1}
    Service1->>+DB: UPDATE users SET email="new1@example.com", version=2 WHERE id=123 AND version=1
    DB-->>-Service1: 1 row updated
    Service2->>+DB: UPDATE users SET email="new2@example.com", version=2 WHERE id=123 AND version=1
    DB-->>-Service2: 0 rows updated (Version mismatch!)
    Note over Service2: Detects concurrency issue and can retry with latest data

Rationale:

Trade-offs:

4. Why Use FOR UPDATE in PostgreSQL?

Decision: Use FOR UPDATE in SELECT statements within transactions.

sequenceDiagram
    participant T1 as Transaction 1
    participant T2 as Transaction 2
    participant DB as Database
    T1->>DB: BEGIN
    T2->>DB: BEGIN
    T1->>DB: SELECT * FROM users WHERE id=123 FOR UPDATE
    Note over DB: Row locked for T1
    T2->>DB: SELECT * FROM users WHERE id=123 FOR UPDATE
    Note over T2: Waits here until T1 completes
    T1->>DB: UPDATE users SET balance=50 WHERE id=123
    T1->>DB: COMMIT
    Note over DB: Row unlocked
    DB->>T2: Returns row data (with updated balance=50)
    T2->>DB: UPDATE users SET balance=25 WHERE id=123
    T2->>DB: COMMIT

Rationale:

Trade-offs:

5. Why Set Timeouts on Redis Locks?

Decision: All Redis locks automatically expire after a set period.

sequenceDiagram
    participant P1 as Process 1
    participant Redis as Redis
    participant P2 as Process 2
    Note over P1,P2: Scenario 1: Normal Operation
    P1->>Redis: Acquire lock with 10s timeout
    Redis-->>P1: Lock granted
    Note over P1: Works for 5s
    P1->>Redis: Release lock
    Redis-->>P1: Lock released
    P2->>Redis: Acquire lock
    Redis-->>P2: Lock granted
    Note over P1,P2: Scenario 2: Process Crashes
    P1->>Redis: Acquire lock with 10s timeout
    Redis-->>P1: Lock granted
    Note over P1: Process crashes!
    Note over Redis: After 10s
    Note over Redis: Lock automatically expires
    P2->>Redis: Acquire lock
    Redis-->>P2: Lock granted

Rationale:

Trade-offs:

Real-World Scenario: Money Transfer Between Accounts

sequenceDiagram
    participant App as Application
    participant Redis as Redis
    participant DB as PostgreSQL
    App->>Redis: Acquire lock for accounts A & B
    Redis-->>App: Lock granted
    App->>DB: BEGIN TRANSACTION
    App->>DB: SELECT * FROM accounts WHERE id=A FOR UPDATE
    DB-->>App: Account A data
    App->>DB: SELECT * FROM accounts WHERE id=B FOR UPDATE
    DB-->>App: Account B data
    Note over App: Verify A has sufficient funds
    App->>DB: UPDATE accounts SET balance=balance-50, version=version+1 WHERE id=A AND version=current_version
    App->>DB: UPDATE accounts SET balance=balance+50, version=version+1 WHERE id=B AND version=current_version
    App->>DB: COMMIT
    App->>Redis: Release lock for accounts A & B
    Note over App,DB: If any step fails:
    App->>DB: ROLLBACK
    App->>Redis: Release lock
// First, acquire a Redis lock for both accounts
const lockKey = `lock:transfer:${fromAccount}-${toAccount}`;
await acquireLock(lockKey);
try {
  // Begin a PostgreSQL transaction
  await client.query('BEGIN');
  // Lock both rows with FOR UPDATE
  const fromUser = await client.query(
    'SELECT * FROM accounts WHERE id = $1 FOR UPDATE',
    [fromAccount]
  );
  const toUser = await client.query(
    'SELECT * FROM accounts WHERE id = $1 FOR UPDATE',
    [toAccount]
  );
  // Check balance and versions
  if (fromUser.balance < amount) throw new Error('Insufficient funds');
  // Perform updates with version checks
  await client.query(
    'UPDATE accounts SET balance = balance - $1, version = version + 1 WHERE id = $2 AND version = $3',
    [amount, fromAccount, fromUser.version]
  );
  await client.query(
    'UPDATE accounts SET balance = balance + $1, version = version + 1 WHERE id = $2 AND version = $3',
    [amount, toAccount, toUser.version]
  );
  // Commit the transaction
  await client.query('COMMIT');
} catch (error) {
  // Rollback on any error
  await client.query('ROLLBACK');
  throw error;
} finally {
  // Always release the Redis lock
  await releaseLock(lockKey);
}

This ensures:

Conclusion

The dual Redis-PostgreSQL locking approach offers a robust solution for distributed systems requiring both performance and reliability. While more complex than a single locking mechanism, it provides the strongest guarantees against race conditions and data corruption in high-concurrency environments.

flowchart TD
    A[Client Request] --> B{Redis Lock Available?}
    B -->|No| C[Wait or Fail Fast]
    B -->|Yes| D[Acquire Redis Lock]
    D --> E[Start PostgreSQL Transaction]
    E --> F[SELECT FOR UPDATE]
    F --> G[Perform Updates with Version Check]
    G --> H{Success?}
    H -->|Yes| I[Commit Transaction]
    H -->|No| J[Rollback Transaction]
    I --> K[Release Redis Lock]
    J --> K
    K --> L[Return Result]
    classDef redis fill:#fff0f0,stroke:#c00,stroke-width:2px,color:#111,font-weight:bold;
    classDef postgres fill:#e0f0ff,stroke:#06c,stroke-width:2px,color:#111,font-weight:bold;
    classDef process fill:#eaffea,stroke:#090,stroke-width:2px,color:#111,font-weight:bold;
    class B,C,D,K redis;
    class E,F,G,H,I,J postgres;
    class A,L process;
    style A fill:#fff,stroke:#222,stroke-width:2px,color:#111;
    style L fill:#fff,stroke:#222,stroke-width:2px,color:#111;
    style C fill:#fff,stroke:#c00,stroke-width:2px,color:#111;
    style K fill:#fff,stroke:#c00,stroke-width:2px,color:#111;

Redis provides speed and distributed coordination, whilst PostgreSQL ensures strong data integrity. Together, they enable a system capable of handling high load without sacrificing consistency.

distributed systems redis postgresql