FairLock Implementation Comparison: redlock4j vs Redisson¶
This document provides a detailed technical comparison of the FairLock implementations in redlock4j and Redisson.
Overview¶
Both libraries implement fair locks to ensure FIFO (First-In-First-Out) ordering for lock acquisition, but they use different approaches and data structures.
Architecture & Data Structures¶
redlock4j¶
- Data Structure: Redis Sorted Sets (ZSET) exclusively
- Keys Used:
- Lock key:
{lockKey} - Queue key:
{lockKey}:queue - Queue Management: Tokens stored with timestamps as scores
- Approach: Simpler two-key design
Redisson¶
- Data Structures: Redis List (LIST) + Sorted Set (ZSET)
- Keys Used:
- Lock key:
{lockName} - Queue key:
redisson_lock_queue:{lockName} - Timeout key:
redisson_lock_timeout:{lockName} - Queue Management: List for ordering, sorted set for timeout tracking
- Approach: Three-key design with separate timeout management
Queue Management Operations¶
redlock4j¶
// Add to queue
driver.zAdd(queueKey, timestamp, token)
// Check position
List<String> first = driver.zRange(queueKey, 0, 0)
boolean atFront = token.equals(first.get(0))
// Remove from queue
driver.zRem(queueKey, token)
// Cleanup expired
driver.zRemRangeByScore(queueKey, 0, expirationThreshold)
Characteristics: - Position determined by timestamp score - Natural ordering by insertion time - Single operation for position check
Redisson¶
-- Add to queue
redis.call('rpush', KEYS[2], ARGV[2])
redis.call('zadd', KEYS[3], timeout, ARGV[2])
-- Check position
local firstThreadId = redis.call('lindex', KEYS[2], 0)
-- Remove from queue
redis.call('lpop', KEYS[2])
redis.call('zrem', KEYS[3], ARGV[2])
Characteristics: - Position determined by list insertion order - Separate timeout tracking in sorted set - Two operations required for queue management
Fairness Guarantee¶
redlock4j¶
- Ordering: Based on timestamp when added to queue
- Quorum: Requires majority agreement on queue position
- Verification:
isAtFrontOfQueue()checks across multiple nodes - Clock Dependency: Relies on reasonably synchronized clocks
// The execution strategy fans out to the appropriate nodes and applies the
// quorum rule; a node "votes" for the token if it is at the front of that
// node's queue.
int votesForFront = executionStrategy.executeOnNodes(driver -> {
List<String> firstElements = driver.zRange(queueKey, 0, 0);
return !firstElements.isEmpty() && token.equals(firstElements.get(0));
});
return executionStrategy.isSuccessful(votesForFront);
Redisson¶
- Ordering: Based on list insertion order (FIFO)
- Stale Cleanup: Removes expired threads before each operation
- Timeout Calculation: Dynamic based on queue position
- Clock Dependency: Uses timeouts but less sensitive to clock skew
-- Cleanup stale threads first
while true do
local firstThreadId = redis.call('lindex', KEYS[2], 0)
if firstThreadId == false then break end
local timeout = redis.call('zscore', KEYS[3], firstThreadId)
if timeout ~= false and tonumber(timeout) <= tonumber(ARGV[4]) then
redis.call('zrem', KEYS[3], firstThreadId)
redis.call('lpop', KEYS[2])
else break end
end
Stale Entry Cleanup¶
redlock4j¶
Strategy: Periodic cleanup during queue operations
private void addToQueue(String token, long timestamp) {
// Add to queue on the appropriate nodes via the execution strategy
executionStrategy.executeOnNodes(driver -> {
driver.zAdd(queueKey, timestamp, token);
return true;
});
// Cleanup expired entries (older than 2x the lock timeout)
Duration expirationAge = config.getDefaultLockTimeout().multipliedBy(2);
long expirationThreshold = System.currentTimeMillis() - expirationAge.toMillis();
cleanupExpiredQueueEntries(expirationThreshold);
}
Characteristics:
- Cleanup triggered on addToQueue()
- Removes entries older than 2x lock timeout
- Separate cleanup operation
Redisson¶
Strategy: Cleanup before every lock operation
-- Embedded in every Lua script
while true do
local firstThreadId = redis.call('lindex', KEYS[2], 0)
if firstThreadId == false then break end
local timeout = redis.call('zscore', KEYS[3], firstThreadId)
if timeout ~= false and tonumber(timeout) <= currentTime then
redis.call('zrem', KEYS[3], firstThreadId)
redis.call('lpop', KEYS[2])
else break end
end
Characteristics: - State stored in Redis - Requires Redis call for reentrant check - Survives JVM restart - Consistent across instances
Lock Acquisition Flow¶
redlock4j¶
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
// 1. Check for reentrancy (thread-local)
LockState currentState = lockState.get();
if (currentState != null && currentState.isValid()) {
currentState.incrementHoldCount();
return true;
}
// 2. Add to queue with timestamp
String queueToken = generateToken();
long timestamp = System.currentTimeMillis();
addToQueue(queueToken, timestamp);
// 3. Retry loop
for (int attempt = 0; attempt <= maxRetryAttempts; attempt++) {
// 4. Check if at front (quorum-based)
if (isAtFrontOfQueue(queueToken)) {
// 5. Try standard Redlock acquisition
LockResult result = attemptLock();
if (result.isAcquired()) {
lockState.set(new LockState(...));
return true;
}
}
// 6. Check timeout
if (timeoutExceeded) {
removeFromQueue(queueToken);
break;
}
// 7. Wait and retry
Thread.sleep(retryDelayMs);
}
removeFromQueue(queueToken);
return false;
}
Flow: 1. Check thread-local reentrancy 2. Add to queue with current timestamp 3. Poll until at front of queue (quorum check) 4. Attempt standard Redlock acquisition 5. Remove from queue on success/failure
Redisson¶
-- 1. Clean stale threads
while true do
-- Remove expired threads from front
end
-- 2. Check if lock can be acquired
if (redis.call('exists', KEYS[1]) == 0)
and ((redis.call('exists', KEYS[2]) == 0)
or (redis.call('lindex', KEYS[2], 0) == ARGV[2])) then
-- 3. Remove from queue
redis.call('lpop', KEYS[2])
redis.call('zrem', KEYS[3], ARGV[2])
-- 4. Decrease timeouts for remaining waiters
local keys = redis.call('zrange', KEYS[3], 0, -1)
for i = 1, #keys, 1 do
redis.call('zincrby', KEYS[3], -tonumber(ARGV[3]), keys[i])
end
-- 5. Acquire lock
redis.call('hset', KEYS[1], ARGV[2], 1)
redis.call('pexpire', KEYS[1], ARGV[1])
return nil
end
-- 6. Check for reentrancy
if redis.call('hexists', KEYS[1], ARGV[2]) == 1 then
redis.call('hincrby', KEYS[1], ARGV[2], 1)
return nil
end
-- 7. Add to queue if not present
local timeout = redis.call('zscore', KEYS[3], ARGV[2])
if timeout == false then
-- Calculate timeout based on queue position
local lastThreadId = redis.call('lindex', KEYS[2], -1)
local ttl = redis.call('pttl', KEYS[1])
local timeout = ttl + threadWaitTime + currentTime
redis.call('zadd', KEYS[3], timeout, ARGV[2])
redis.call('rpush', KEYS[2], ARGV[2])
end
return ttl
Flow: 1. Clean stale threads from queue 2. Check if lock is free AND (queue empty OR at front) 3. Remove self from queue 4. Adjust timeouts for remaining threads 5. Acquire lock or return TTL
Timeout Handling¶
redlock4j¶
Strategy: Fixed timeout with retry
long timeoutMs = unit.toMillis(time);
long startTime = System.currentTimeMillis();
for (int attempt = 0; attempt <= maxRetryAttempts; attempt++) {
// Try to acquire
if (timeoutMs > 0 && (System.currentTimeMillis() - startTime) >= timeoutMs) {
removeFromQueue(queueToken);
break;
}
Thread.sleep(retryDelayMs);
}
Characteristics: - Simple timeout check - Fixed retry delay - Client-side timeout management - No timeout estimation
Redisson¶
Strategy: Dynamic timeout calculation
-- Calculate timeout based on queue position
local lastThreadId = redis.call('lindex', KEYS[2], -1)
local ttl
if lastThreadId ~= false and lastThreadId ~= ARGV[2] then
ttl = tonumber(redis.call('zscore', KEYS[3], lastThreadId)) - currentTime
else
ttl = redis.call('pttl', KEYS[1])
end
local timeout = ttl + threadWaitTime + currentTime
Characteristics: - Returns estimated wait time (TTL) - Timeout = previous thread timeout + threadWaitTime - Server-side calculation - More accurate wait estimation - Default threadWaitTime: 5 seconds
Unlock & Notification¶
redlock4j¶
Strategy: Simple unlock without notification
public void unlock() {
LockState state = lockState.get();
// Handle reentrancy
int remainingHolds = state.decrementHoldCount();
if (remainingHolds > 0) {
return;
}
// Release lock
releaseLock(state.lockValue);
removeFromQueue(state.queueToken);
lockState.remove();
}
Characteristics: - No explicit notification to next waiter - Next thread discovers via polling - Simpler implementation - Higher latency for next waiter
Redisson¶
Strategy: Pub/Sub notification to next waiter
-- After releasing lock
redis.call('del', KEYS[1])
local nextThreadId = redis.call('lindex', KEYS[2], 0)
if nextThreadId ~= false then
redis.call('publish', KEYS[4] .. ':' .. nextThreadId, ARGV[1])
end
Characteristics: - Explicit wake-up via Redis pub/sub - Next thread notified immediately - Lower latency for next waiter - More complex implementation - Requires pub/sub subscription per thread
Complexity Analysis¶
redlock4j¶
Lines of Code: ~390 lines
Pros: - Simpler to understand - Single data structure (sorted set) - Fewer Redis operations - Less state to manage - Timestamp-based ordering is intuitive
Cons: - Clock skew sensitivity - Polling-based (no notifications) - Cleanup only on addToQueue - Less sophisticated timeout handling
Redisson¶
Lines of Code: ~350 lines (but denser Lua scripts)
Pros: - Robust stale thread handling - Better timeout estimation - Explicit thread notification (pub/sub) - Less clock-dependent (list ordering) - Production-hardened
Cons: - More complex implementation - Two data structures to maintain - More Redis operations per attempt - Cleanup overhead on every operation - Requires pub/sub infrastructure
Performance Comparison¶
redlock4j¶
Lock Acquisition:
- 1x ZADD per node (add to queue)
- 1x ZRANGE per node per attempt (check position)
- Nx SET NX per node (standard Redlock)
- 1x ZREM per node (remove from queue)
Unlock:
- 1x Lua script per node (delete if matches)
- 1x ZREM per node (remove from queue)
Total: Moderate Redis operations, polling overhead
Redisson¶
Lock Acquisition: - 1x Lua script (all operations atomic) - Stale cleanup (variable) - Queue check - Lock acquisition - Timeout updates
Unlock: - 1x Lua script - Stale cleanup - Lock release - Pub/sub notification
Total: Fewer round trips, but heavier Lua scripts
Measured Performance¶
Benchmark: 5 clients, 50 ms work per cycle, 3-node Redis 7 cluster, 60 s measurement (full methodology in Architecture › Performance Analysis).
| Implementation | Ops/s | p50 (ms) | p99 (ms) | mean (ms) | success |
|---|---|---|---|---|---|
| Redisson | 16.99 | 221 | 230 | 248 | 100 % |
| redlock4j-singlenode | 12.42 | 332 | 435 | 349 | 100 % |
| redlock4j-3node (Jedis) | 2.95 | 1,666 | 2,457 | 1,642 | 100 % |
| redlock4j-3node (Lettuce) | 2.87 | 1,667 | 2,549 | 1,685 | 100 % |
Reading the numbers: Redisson leads on both throughput and p99 in single-node mode because its atomic Lua scripts collapse the queue-check + acquire into one round trip. redlock4j's 3-node mode is throughput-limited by the per-attempt quorum head-check (ZRANGE on every node). The structural fix is tracked as P2-8 (single-shot atomic head-check + acquire) in redlock4j-benchmark/benchmark-analysis.md §8. Note: FairLock deliberately uses fixed-interval polling — exponential backoff hurts FairLock because head-of-queue waiters must claim the lock the instant the holder releases.
Edge Cases & Robustness¶
redlock4j¶
Clock Skew: - Timestamps used for ordering - Significant clock skew could affect fairness - Mitigated by clock drift factor in Redlock
Stale Entries:
- Cleaned up on addToQueue()
- Could accumulate between additions
- Threshold: 2x lock timeout
Network Partitions: - Quorum-based queue position check - Handles minority node failures - Consistent with Redlock guarantees
Redisson¶
Clock Skew: - List ordering independent of clocks - Timeouts use timestamps but less critical - More resilient to clock issues
Stale Entries: - Cleaned up on every operation - Only from front of queue - More consistent cleanup
Network Partitions: - Single instance by default - No quorum mechanism - Less resilient than redlock4j
Recommendations¶
For redlock4j¶
Consider Adopting: 1. Pub/Sub Notifications: Reduce polling latency 2. Dynamic Timeout Estimation: Return TTL to caller 3. More Frequent Cleanup: Clean on every operation 4. List-Based Ordering: Reduce clock dependency
Keep: 1. Quorum-based queue position check 2. Thread-local state for performance 3. Simple two-key design 4. Standard Redlock integration
For Production Use¶
Choose redlock4j when: - True distributed locking is required - Simplicity and maintainability matter - Quorum-based safety is essential - Minimal dependencies preferred
Choose Redisson when: - Single instance is acceptable - Need full Redis framework - Pub/Sub infrastructure available - Production-hardened solution needed
Conclusion¶
Both implementations provide fair locking with different trade-offs:
- redlock4j: Simpler, quorum-based, timestamp-ordered, polling-based
- Redisson: Complex, single-instance, list-ordered, notification-based
The choice depends on your specific requirements for safety, performance, and operational complexity.
Characteristics: - Cleanup on every lock attempt - Only removes from front of queue - Integrated into lock acquisition logic - Higher overhead but more consistent
Reentrancy Handling¶
redlock4j¶
Storage: Thread-local state
private final ThreadLocal<LockState> lockState = new ThreadLocal<>();
private static class LockState {
final String lockValue;
final String queueToken;
final Instant acquisitionTime;
final Duration validityDuration;
int holdCount;
}
// On reentrant lock
LockState currentState = lockState.get();
if (currentState != null && currentState.isValid()) {
currentState.incrementHoldCount();
return true;
}
Characteristics: - State stored in JVM memory - Fast access (no Redis call) - Per-thread tracking - Lost on JVM restart
Redisson¶
Storage: Redis hash field