Architecture¶
This document explains the key architectural decisions in redlock4j.
Wait Strategy¶
When a lock is contended (another client holds it), redlock4j needs to wait for the lock to be released. The wait strategy determines how this waiting is implemented.
Available Strategies¶
| Strategy | Description | Default |
|---|---|---|
KEYSPACE_NOTIFICATIONS |
Uses Redis pub/sub to get instant notification when lock is released | Yes |
POLLING |
Polls Redis at the configured retryDelay interval (default 200ms) to check if lock is available |
No |
Keyspace Notifications (Default)¶
Redis Keyspace Notifications allow clients to subscribe to events when keys are modified. When a lock is released (via DEL or expiration), Redis automatically publishes an event.
sequenceDiagram
participant A as Client A<br/>(holds lock)
participant R as Redis
participant B as Client B<br/>(waiting)
A->>R: DEL lock:resource
R-->>B: __keyspace@0__:lock:resource<br/>event: "del"
B->>R: SET NX lock:resource
R-->>B: OK
Advantages:
- Instant notification (1-5ms latency vs 50-100ms polling)
- Lower CPU usage (no busy-waiting)
- Efficient under high contention
- Automatic TTL expiry detection
Requirements:
- RESP3 protocol (required)
- Redis 6.0+ (recommended for best RESP3 support)
Polling (Fallback)¶
The polling strategy checks lock availability at regular intervals. Use this only if:
- Your Redis deployment restricts
CONFIGcommands - You're using a Redis-compatible service that doesn't support keyspace notifications
- You need to support legacy RESP2 clients (not recommended)
sequenceDiagram
participant A as Client A<br/>(holds lock)
participant R as Redis
participant B as Client B<br/>(waiting)
B->>R: SET NX lock:resource
R-->>B: nil
Note over B: sleep retryDelay<br/>(default 200ms)
B->>R: SET NX lock:resource
R-->>B: nil
Note over B: sleep retryDelay<br/>(default 200ms)
A->>R: DEL lock:resource
B->>R: SET NX lock:resource
R-->>B: OK
Configuration¶
// Default: Keyspace notifications (recommended)
RedlockConfiguration config = RedlockConfiguration.builder()
.addRedisNode("localhost", 6379)
.build();
// Explicit: Polling fallback
RedlockConfiguration config = RedlockConfiguration.builder()
.addRedisNode("localhost", 6379)
.waitStrategy(WaitStrategy.POLLING)
.build();
Performance Comparison¶
The two strategies optimize for different things:
| Metric | Keyspace Notifications | Polling |
|---|---|---|
| Wake-up latency | ~6ms | ~18-50ms (poll interval) |
| Throughput under contention | Lower (thundering herd) | Higher (staggered retries) |
| CPU usage | Lower (event-driven) | Higher (busy-wait) |
Latency benchmark (time from lock release to waiter acquiring):
| Strategy | Wake-up Latency | Speedup |
|---|---|---|
| Keyspace Notifications | 6.78ms | 2.7x faster |
| Polling (50ms benchmark interval) | 18.02ms | baseline |
Throughput benchmark (10 waiters, 20ms lock hold):
| Lock Type | Mode | Keyspace (ops/s) | Polling (ops/s) | Notes |
|---|---|---|---|---|
| Distributed Lock | Single | 39.00 | 41.00 | ~Equal (throughput bounded by lock hold time) |
| ReadWriteLock | Single | 151.80 | 137.10 | Keyspace 10% better (readers benefit) |
| Semaphore | Single | 400.35 | 402.55 | ~Equal |
| FairLock | Single | 0.50 | 30.15 | Polling required (see note) |
Understanding the Results
When lock hold times are short (20ms), throughput is bounded by hold time, not wait time. Both strategies achieve similar throughput (~40 ops/s for a single lock with 20ms hold).
The latency benefit of keyspace notifications becomes important when:
- Lock hold times are longer
- Latency sensitivity is high
- You need instant lock expiry detection
FairLock Recommendation
FairLock should always use polling. Keyspace notifications cause severe performance degradation due to queue management interactions.
Thundering Herd Tradeoff¶
When using keyspace notifications, a "thundering herd" problem can occur: when a lock is released, ALL waiting threads wake up simultaneously, even though only ONE can acquire the lock.
flowchart TB
A[Lock Released] --> B[All 10 threads wake up simultaneously]
B --> C[All 10 threads attempt to acquire lock]
C --> D[1 thread succeeds, 9 threads fail]
D --> E[9 threads go back to waiting]
E --> A
Why this happens: The implementation uses a shared CountDownLatch per lock key. When countDown() is called, all waiting threads wake up at once.
The tradeoff:
| Aspect | Keyspace Notifications | Polling |
|---|---|---|
| Wakeup behavior | All waiters wake simultaneously | Waiters check independently (staggered) |
| Wakeup overhead (10 waiters) | 10 wakeups per release | 1 wakeup per release |
| Latency | ~1-10ms | Up to poll interval |
| CPU usage | Low (event-driven) | Higher (busy-wait) |
This design was chosen for simplicity, correctness (no missed wakeups), and memory efficiency (O(keys) not O(keys × waiters)).
When thundering herd matters:
- High contention (> 20 concurrent waiters) - significant overhead
- Short lock hold times (< 10ms) - frequent releases cause frequent herds
- Resource-constrained environments - extra context switches add up
When to Use Each Strategy¶
Based on comprehensive benchmarks:
| Lock Type | Recommended Strategy | Reason |
|---|---|---|
| FairLock | Polling (required) | Queue management conflicts with keyspace |
| Distributed Lock | Either | Similar throughput; keyspace has lower latency |
| ReadWriteLock | Keyspace | Readers benefit from instant wake-up |
| Semaphore | Either | Similar performance |
Choose Keyspace Notifications (default) when:
- Wake-up latency matters (~3x faster than polling)
- Using ReadWriteLock (readers wake up together instantly)
- Lock expiry detection must be instant
- Long lock hold times (latency benefit more noticeable)
- Low CPU usage is important (event-driven, not busy-wait)
Choose Polling when:
- Using FairLock (required - keyspace has severe issues)
- Redis doesn't support keyspace notifications (some managed services)
- Network reliability concerns (polling is more resilient)
- Consistent retry timing is needed
Decision flowchart:
flowchart TB
A{Which lock type?}
A -->|FairLock| B[Use Polling<br/>Required]
A -->|ReadWriteLock| C[Use Keyspace<br/>Readers benefit]
A -->|Distributed Lock| D{Latency critical?}
A -->|Semaphore| D
D -->|Yes| E[Use Keyspace<br/>3x faster wake-up]
D -->|No| F[Either works]
Configuration examples:
// Polling (recommended for FairLock and most use cases)
RedlockConfiguration config = RedlockConfiguration.builder()
.addRedisNode("localhost", 6379)
.defaultLockTimeout(Duration.ofSeconds(30))
.retryDelay(Duration.ofMillis(10)) // Poll every 10ms
.usePolling() // Better throughput for most lock types
.build();
// Keyspace Notifications (for ReadWriteLock in single-node mode)
RedlockConfiguration config = RedlockConfiguration.builder()
.addRedisNode("localhost", 6379)
.defaultLockTimeout(Duration.ofSeconds(30))
.retryDelay(Duration.ofMillis(50)) // Fallback delay
.build();
// Keyspace notifications are enabled by default
RESP3 Protocol Requirement¶
redlock4j requires RESP3 (Redis Serialization Protocol 3) for the keyspace notifications strategy. RESP3 is not a Redis version—it's a protocol version.
Why RESP3?¶
In RESP2, subscribing to a pub/sub channel puts the connection into "subscription mode," making it unusable for regular commands. This forces applications to maintain separate connection pools for pub/sub and commands.
RESP3 introduces push notifications, allowing pub/sub messages to arrive on the same connection used for commands:
// RESP2: Connection blocked after SUBSCRIBE
connection.subscribe(channel); // Connection now in subscription mode
connection.get("key"); // ERROR: Cannot run commands!
// RESP3: Push notifications on same connection
connection.subscribe(channel); // Push messages arrive asynchronously
connection.get("key"); // WORKS: Same connection handles both!
Client Library Support¶
| Driver | RESP3 Support | Minimum Version |
|---|---|---|
| Lettuce | Yes | 6.0+ |
| Jedis | Yes | 5.0+ |
Error Handling¶
If a RESP2 connection is detected, redlock4j fails fast with a clear error:
// This will throw Resp2NotSupportedException
RedlockManager manager = RedlockManager.withLettuce(config);
// Exception: "RESP3 required for keyspace notifications.
// Connection is using RESP2 which is not supported."
Solutions:
1. Upgrade your Redis client library to a version supporting RESP3
2. Configure your client to use RESP3 protocol
3. Use WaitStrategy.POLLING as a fallback (not recommended)
Redis Server Auto-Configuration¶
When using the keyspace notifications strategy (default), redlock4j automatically configures the Redis server:
Configuration Flags¶
| Flag | Meaning | Why We Need It |
|---|---|---|
K |
Keyspace events | Subscribe to specific key channels |
g |
Generic commands | Detect DEL, RENAME, MOVE |
x |
Expired events | Detect TTL expiration |
Flag Merging¶
If your Redis server already has keyspace notifications configured, redlock4j merges flags rather than overwriting:
Existing: "Ex" (expired keyevents)
Required: "Kgx" (keyspace + generic + expired)
Result: "EKgx" (merged - preserves existing config)
Permission Errors¶
If your Redis deployment restricts CONFIG commands (common in managed services), you'll see:
RedlockException: Cannot configure keyspace notifications.
Please run: CONFIG SET notify-keyspace-events "Kgx"
In this case, either:
1. Configure Redis manually via redis.conf or admin console
2. Use WaitStrategy.POLLING to avoid the CONFIG requirement
Connection Architecture¶
Single Connection per Lock Operation¶
redlock4j uses a single connection for both commands and pub/sub subscriptions (thanks to RESP3):
graph TB
subgraph RedlockManager
subgraph pool["Connection Pool (Lettuce/Jedis)"]
conn1["Connection 1 (RESP3)<br/>• Regular commands (SET, DEL)<br/>• Pub/sub subscriptions<br/>• Push notifications"]
conn2["Connection 2 (RESP3)<br/>• ..."]
end
end
Multi-Node Architecture (Redlock Algorithm)¶
For true distributed locking, redlock4j connects to multiple independent Redis nodes:
graph TB
subgraph RedlockManager
subgraph node1["Redis #1 (master)"]
pool1["Connection Pool #1"]
end
subgraph node2["Redis #2 (master)"]
pool2["Connection Pool #2"]
end
subgraph node3["Redis #3 (master)"]
pool3["Connection Pool #3"]
end
end
note["Lock acquired when: majority (N/2 + 1) nodes grant the lock<br/>With 3 nodes: need 2 to agree<br/>With 5 nodes: need 3 to agree"]
CAS/CAD Operations¶
Distributed locks require atomic operations to safely release locks. redlock4j uses Compare-And-Set (CAS) and Compare-And-Delete (CAD) operations to ensure only the lock owner can modify or release the lock.
The Problem¶
Without atomic operations, releasing a lock is unsafe:
sequenceDiagram
participant A as Client A
participant R as Redis
participant B as Client B
A->>R: GET lock:resource
R-->>A: "token-A"
Note over A: network delay
Note over R: Lock expires (TTL)
B->>R: SET lock:resource "token-B"
R-->>B: OK
A->>R: DEL lock:resource
Note over R: DELETES CLIENT B's LOCK!
Result: Client A accidentally deletes Client B's lock!
The Solution: Atomic CAD¶
Compare-And-Delete ensures the lock is only deleted if the value matches:
sequenceDiagram
participant A as Client A
participant R as Redis
participant B as Client B
A->>R: DELEX lock:resource IFEQ "token-A"
Note over R: Lock value is "token-B" ≠ "token-A"
R-->>A: 0 (not deleted)
Note over A: Client B's lock safe!
Implementation Strategies¶
redlock4j automatically detects and uses the best available method:
| Redis Version | Strategy | Command |
|---|---|---|
| 8.4+ | Native | DELEX key IFEQ value |
| < 8.4 | Lua Script | EVAL "if get==expected then del" |
// Automatic detection at driver initialization
private CADStrategy detectCADStrategy() {
try {
// Try native DELEX command
redis.dispatch(CommandType.DELEX, testKey, "IFEQ", "test");
return CADStrategy.NATIVE; // Redis 8.4+
} catch (Exception e) {
return CADStrategy.SCRIPT; // Fallback to Lua
}
}
Performance Comparison¶
| Operation | Native (Redis 8.4+) | Lua Script |
|---|---|---|
| Commands | 1 | 1 (EVAL) |
| Network round-trips | 1 | 1 |
| Server-side parsing | Optimized C | Lua interpreter |
| Latency | ~0.1ms | ~0.2-0.3ms |
Both approaches have identical network characteristics, but native commands have lower server-side overhead.
CAS for Lock Extension¶
When extending a lock's TTL, CAS ensures only the current owner can extend:
// Extend lock only if we still own it
boolean extended = driver.setIfValueMatches(
lockKey,
currentToken, // Expected current value
currentToken, // New value (same token)
newExpireTimeMs // New TTL
);
Redis 8.4+ command:
Lua script fallback:
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('set', KEYS[1], ARGV[2], 'PX', ARGV[3])
else
return nil
end
Why This Matters¶
| Scenario | Without CAS/CAD | With CAS/CAD |
|---|---|---|
| Lock expires during operation | May delete wrong lock | Safely fails |
| Network partition during unlock | Unpredictable | Atomic check |
| Concurrent lock extension | Race condition | Only owner extends |
| Lock ownership verification | Separate GET+DEL | Single atomic op |
Configuration¶
CAS/CAD strategy is automatically detected—no configuration needed:
// Driver auto-detects Redis version capabilities
RedlockManager manager = RedlockManager.withJedis(config);
// Native commands used if Redis 8.4+ detected
// Lua scripts used otherwise
To verify which strategy is active, enable debug logging:
or
DEBUG o.c.r.driver.JedisRedisDriver - Native CAS/CAD not available for redis://localhost:6379, using Lua scripts
Performance Analysis¶
This section summarizes the optimization work driven by the redlock4j-benchmark suite and reports the current cross-primitive standings against Redisson and other Redis-based locking libraries.
Methodology¶
All numbers below come from the same harness (redlock4j-benchmark/) running against a fresh 3-node Redis 7 Testcontainers cluster, 50 ms simulated work per critical section, 30 s warm-up, 60 s measurement. Lock-acquisition latency is captured per attempt; throughput is aggregated across all clients. Full raw data lives in redlock4j-benchmark/benchmark-analysis.md and the per-suite *-benchmark-results.json files.
Key Optimizations¶
Two architectural changes deliver the bulk of the recent performance improvements:
1. Parallel multi-node I/O — MultiNodeStrategy.acquireLock / releaseLock / extendLock previously iterated nodes sequentially. They now fan out via CompletableFuture and wait for the quorum on the join. Effect: the per-attempt RTT on a 3-node cluster collapses from 3 × RTT to max(RTT).
2. Exponential backoff with jitter — PollingWaitStrategy and the Redlock.tryLock fallback path now grow the inter-attempt delay geometrically (configurable multiplier, cap, and jitter ratio). The previous fixed retry (50 ms in the benchmark config) produced retry storms under contention. Backoff reduces wasted RTTs and stabilizes throughput. FairLock is intentionally excluded because head-of-queue waiters need tight polling to claim the lock the instant the holder releases.
p99 vs Throughput¶
These two metrics measure opposite ends of the same latency distribution and trade off against each other:
- Throughput (Ops/s) is system capacity — how many acquire+release cycles complete per second. Driven by RTTs per attempt, inter-attempt delay, and hold time.
- p99 latency is the worst-case experience for 99 % of callers. Driven by contention bursts, retry chains, and any single stuck attempt.
Aggressive polling raises throughput but inflates p99 (more attempts compete for the same release window). Backoff lowers p99 by reducing wasted attempts but can cap throughput when waiters sleep through release windows. Pub/sub-on-release (planned, see benchmark-analysis.md §8 A3) is the rare lever that improves both.
Which metric matters depends on the workload:
| Workload | Primary metric |
|---|---|
| Leader election, failover | p99 / max |
| API request serialization (per-user) | p99 |
| Cache-stampede prevention | p99 of waiters |
| Batch / background jobs | Throughput |
| Rate limiting, semaphores | Throughput |
| Critical section on a hot business path | Both |
Consolidated Results¶
All measurements taken on the same 3-node Redis cluster. redlock4j-singlenode is redlock4j in single-node mode (SingleNodeStrategy); redlock4j (or redlock4j-3node) uses full quorum Redlock across all 3 nodes.
Throughput (Ops/s — higher is better)¶
| Primitive | Redisson | redlock4j-singlenode | redlock4j-3node | Field leader |
|---|---|---|---|---|
| DistributedLock | 18.24 | 18.33 | 0.81 | redpulsar (21.63) |
| FairLock | 16.99 | 12.42 | 2.95 | redisson |
| MultiLock | 17.05 | 16.97 | 17.72 | redlock4j-3node |
| ReadWriteLock (reader) | 151.65 | 74.66 | 95.41 | redisson |
| ReadWriteLock (writer) | 0.21 | 15.63 | 16.42 | redlock4j-3node |
| Semaphore | 54.71 | 91.09 | 87.50 | redlock4j |
| CountDownLatch | 59.02 | 58.19 | 59.91 | redlock4j-3node |
p50 Latency (ms — lower is better)¶
| Primitive | Redisson | redlock4j-singlenode | redlock4j-3node |
|---|---|---|---|
| DistributedLock | 122.9 | 67.1 | 191.3 |
| FairLock | 221.0 | 332.0 | 1666.0 |
| MultiLock | 190.2 | 202.9 | 171.0 |
| ReadWriteLock (reader) | 0.79 | 12.54 | 2.72 |
| ReadWriteLock (writer) | 8649.0 | 57.8 | 64.1 |
| Semaphore | 1.00 | 0.73 | 1.77 |
| CountDownLatch | 17.2 | 16.2 | 15.3 |
p99 Latency (ms — lower is better)¶
| Primitive | Redisson | redlock4j-singlenode | redlock4j-3node |
|---|---|---|---|
| DistributedLock | 1286.7 | 1977.5 | 858.0 |
| FairLock | 229.8 | 434.8 | 2457.1 |
| MultiLock | 1192.2 | 1709.5 | 1057.6 |
| ReadWriteLock (reader) | 5.82 | 340.6 | 348.4 |
| ReadWriteLock (writer) | 16820.7 | 755.6 | 104.2 |
| Semaphore | 386.5 | 2.21 | 4.20 |
| CountDownLatch | 22.6 | 19.9 | 19.9 |
Standings Summary¶
redlock4j leads on throughput in 4 / 7 categories (MultiLock, RWLock writer, Semaphore, CountDownLatch — plus single-node DistributedLock parity with the field), and leads on p99 in 5 / 7 (DistributedLock 3-node, RWLock writer, MultiLock, Semaphore, CountDownLatch).
Remaining gaps under active investigation (benchmark-analysis.md §8):
- DistributedLock 3-node throughput is the largest residual gap. The 50 ms polling floor is the bottleneck; A3 (pub/sub-on-release wait strategy) targets this directly.
- FairLock 3-node throughput suffers from the per-attempt quorum head-check round-trip. P2-8 (single-shot atomic head-check + acquire) is the structural fix.
- RWLock readers trail Redisson because Redisson decrements readers in-process via semaphore counting while redlock4j hits Redis on every read acquire. This is an architectural choice, not a regression.
What This Means for Users¶
- Pick
redlock4j-singlenodewhen you have a single Redis instance — you get parity with single-node libraries on most primitives and a clear win on Semaphore. - Pick
redlock4j-3node(full Redlock quorum) when you need fault tolerance across independent Redis masters. Expect lower throughput on the basicDistributedLockandFairLockprimitives until A3 / P2-8 land; everything else (MultiLock, RWLock writers, Semaphore, CountDownLatch) is already at or above field-leader performance. - For latency-sensitive paths, the recent backoff change generally improves p99 (best-in-class on 5 / 7 primitives). The exception is
FairLock, where polling is required for correctness reasons (see FairLock Recommendation above).
Summary¶
| Decision | Choice | Rationale |
|---|---|---|
| Default wait strategy | Keyspace Notifications | 20-50x faster under contention |
| Protocol requirement | RESP3 | Single connection for commands + pub/sub |
| Server configuration | Auto-configure | Zero manual setup required |
| Fallback option | Polling | For restricted environments |
| CAS/CAD operations | Auto-detect | Native commands on Redis 8.4+, Lua fallback |
| Multi-node I/O | Parallel fan-out | max(RTT) per attempt instead of N × RTT |
| Retry backoff | Exponential + jitter | Reduces retry storms under contention |