Dual-Layer Locking: Redis for Speed, PostgreSQL for Reliability
02 Jun 2025Dual-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:
- Use Redis for fast, distributed coordination
- Use PostgreSQL for bulletproof data integrity
- Combine both for robust, scalable systems
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:
- 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.
- 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. - Perform your update—with a version check for extra safety.
- Commit the transaction. If anything fails, roll back.
- 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:
- Two individuals might withdraw funds at the same time, resulting in an overdraft
- Updates could be lost if two changes occur concurrently
- The system may become inconsistent regarding the true account balance
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:
- A process attempts to place a reservation on a resource
- Only one process may hold this reservation at any time
- The reservation expires automatically after a set period
- A unique token ensures only the owner can remove their reservation
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:
- Fast: Redis operates in-memory and is extremely quick
- Distributed: Functions across multiple servers or services
- Simple: Straightforward to implement and reason about
- Not entirely reliable: If Redis fails or network issues arise, locks may be lost
- Starvation possible: Poorly chosen timeouts may cause processes to wait excessively
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:
- Initiate an ACID-compliant transaction
- Use
FOR UPDATE
to lock specific rows - Employ version checks for additional safety
- All operations either succeed or fail as a unit
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:
- Reliable: Provides strong consistency guarantees
- Data integrity: Prevents partial or corrupted updates
- Automatic rollback: Any failure undoes all changes
- Slower: Database operations are inherently slower than Redis
- Potential for deadlocks: Complex transactions may deadlock
- Resource intensive: Holding database locks consumes resources
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:
- Redis offers speed and cross-service coordination
- PostgreSQL ensures reliability and data integrity
- The combination mitigates the weaknesses of each individual approach
Trade-offs:
- More robust: The system remains operational if either lock mechanism fails
- Improved performance: Redis absorbs most contention before it reaches the database
- Increased complexity: Two locking systems require more maintenance
- Slightly higher overhead: Running both locks consumes more resources
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:
- Prevents a process from inadvertently releasing another process's lock
- Addresses the "lock release" problem in distributed systems
Trade-offs:
- Safer: Only the lock owner can release it
- Slightly more complex: Requires tracking the token
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:
- Detects if data has changed between reading and writing
- Provides a history of record modifications
Trade-offs:
- Catches race conditions: Even subtle timing issues are detected
- Simple implementation: Just an integer column in the database
- Requires retry logic: Applications must handle version conflicts
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:
- Locks the selected rows, forcing other transactions to wait
- Guarantees no changes occur during processing
Trade-offs:
- Prevents dirty reads/writes: Data consistency is assured
- Can reduce concurrency: Locks block other operations
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:
- Prevents deadlocks if a process crashes while holding a lock
- Ensures processes complete their work within a reasonable timeframe
Trade-offs:
- System remains operational: No permanent blocking
- Timeout tuning required: Must balance between too short and too long
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:
- Only one transfer operation occurs at a time between these accounts
- Both account updates succeed or fail together
- Concurrent modifications are detected via versioning
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.