Mastering Redis Distributed Locks: From SETNX+Lua to Redisson with Go Examples
This article examines the evolution of Redis distributed lock implementations—from the basic SETNX command to atomic SET with Lua scripts and the feature‑rich Redisson framework—detailing core requirements, common pitfalls, Go code samples, and production‑grade recommendations.
1 Distributed Lock Core Requirements
A qualified distributed lock must satisfy four essential properties:
Mutual exclusion : only one client can hold the lock at any moment.
Safety : only the lock owner can release it, preventing accidental deletion.
Dead‑lock avoidance : the lock should auto‑expire if the client crashes.
Fault tolerance : the lock must continue working when Redis nodes fail.
2 Correct Implementation of SETNX+Lua
2.1 Basic version: SETNX + EXPIRE (deprecated)
# non‑atomic operation, serious issue
SETNX lock:order1
EXPIRE lock:order 30Problem analysis : the two commands are not atomic; if the client crashes after SETNX, EXPIRE never runs, leaving the lock forever and causing a deadlock.
2.2 Improved version: atomic SET command
Since Redis 2.6.12, the SET command supports NX and EX/PX options, enabling an atomic lock acquisition.
# single‑command atomic operation
SET lock:order client_001 NX PX 30000Parameter explanation: lock:order: the lock key identifying the business resource. client_001: lock value, a unique client identifier (UUID + thread ID is recommended). NX: set only if the key does not exist (Not eXists). PX 30000: set expiration to 30 000 ms (30 s).
2.3 Ultimate version: SET + Lua script for unlocking
While lock acquisition can use the atomic SET command, unlocking still requires a Lua script to guarantee atomicity. A common mistake is:
// non‑atomic unlock, race window
val := redisClient.Get(ctx, lockKey).Val()
if val == myToken {
redisClient.Del(ctx, lockKey) // may delete another client's lock
}Problem description : GET and DEL are separate operations, creating a concurrency window where another client could acquire the lock before DEL executes.
Thread A GETs the token and finds it matches.
The lock expires just then.
Thread B acquires a new lock (value = tokenB).
Thread A executes DEL and unintentionally deletes B's lock.
Correct unlock Lua script :
-- KEYS[1] = lockKey
-- ARGV[1] = token
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end3 Common Pitfalls Deep Dive
3.1 Expiration and accidental deletion
Scenario : Service A obtains the lock and stalls; the lock expires, Service B acquires it; when Service A finally finishes it deletes the lock, inadvertently removing B's lock.
Solution : Use a unique token (e.g., UUID) as the lock value and verify it with a Lua script during unlock.
3.2 Business execution exceeds lock TTL
Problem : TTL is set to 10 s but the business operation needs 15 s, causing the lock to expire and another client to acquire it, leading to concurrent execution.
Solutions :
Set TTL based on the maximum expected business time plus a safety margin (e.g., 2×).
Implement a watchdog that periodically extends the TTL while the holder is alive.
3.3 Non‑reentrancy
SETNX cannot provide a re‑entrant lock because a second acquisition by the same thread returns 0 (key already exists).
Solution : Store lock information in a Redis hash, using a field for the owner identifier and a value for the re‑entry count.
3.4 Master‑slave switch causing lock loss
Scenario : Client A locks the master node; the master crashes before replication; the slave becomes the new master; Client B locks the new master; both A and B now hold the same logical lock, breaking mutual exclusion.
Solution : Use the RedLock algorithm or wait for cluster data synchronization before acquiring the lock.
4 Redisson Implementation Deep Dive
4.1 Data structure design
Redisson stores lock information in a Redis hash:
Key: lock:{resource} Field: UUID:ThreadID (client ID + thread ID)
Value: re‑entry count
This design supports re‑entrancy, owner identification, and lock renewal.
4.2 Lock acquisition Lua script
-- KEYS[1] = lockKey, ARGV[1] = ttl(ms), ARGV[2] = threadId
if redis.call('exists', KEYS[1]) == 0 then
redis.call('hset', KEYS[1], ARGV[2], 1)
redis.call('pexpire', KEYS[1], ARGV[1])
return nil
end
if redis.call('hexists', KEYS[1], ARGV[2]) == 1 then
redis.call('hincrby', KEYS[1], ARGV[2], 1)
redis.call('pexpire', KEYS[1], ARGV[1])
return nil
end
return redis.call('pttl', KEYS[1])Script logic:
If the lock does not exist, create a hash with a re‑entry count of 1.
If the lock exists and the current thread already holds it, increment the count and refresh the TTL.
If the lock is held by another thread, return the remaining TTL.
4.3 WatchDog mechanism
When lock() is called without an explicit TTL, Redisson assigns a default 30 s lease and starts a background task that runs every TTL/3 (≈10 s). If the lock holder is still alive, the task renews the TTL via Lua; if the process crashes, the task stops and the lock expires automatically, preventing deadlocks.
4.4 Pub/Sub waiting queue
Redisson leverages Redis Pub/Sub: a client that fails to acquire the lock subscribes to a release channel; the lock holder publishes a message on unlock, waking waiting clients without busy‑polling.
5 Comparison: SETNX+Lua vs Redisson
Implementation complexity : manual handling vs ready‑made library.
Re‑entrancy : not supported vs native support.
Lock renewal : custom watchdog vs built‑in mechanism.
Waiting mechanism : polling or custom Pub/Sub vs built‑in queue.
Fair lock : unavailable vs supported.
Timeout handling : application‑level vs automatic strategies.
Master‑slave consistency : developer responsibility vs RedLock algorithm.
Performance : lightweight fastest vs richer feature set with slight overhead.
Suitable scenarios : simple high‑performance cases vs production environments needing full feature set.
6 Golang Implementation Examples
6.1 Basic SETNX+Lua
package main
import (
"context"
"fmt"
"github.com/go-redis/redis/v8"
"github.com/google/uuid"
"time"
)
type RedisDistributedLock struct {
client *redis.Client
lockKey string
token string
timeout time.Duration
}
func NewRedisDistributedLock(client *redis.Client, lockKey string, timeout time.Duration) *RedisDistributedLock {
return &RedisDistributedLock{client: client, lockKey: lockKey, token: uuid.New().String(), timeout: timeout}
}
// Acquire lock using atomic SETNX+PX
func (l *RedisDistributedLock) Lock(ctx context.Context) (bool, error) {
result, err := l.client.SetNX(ctx, l.lockKey, l.token, l.timeout).Result()
if err != nil {
return false, err
}
return result, nil
}
// Unlock Lua script (atomic)
var unlockScript = redis.NewScript(`
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end`)
// Release lock
func (l *RedisDistributedLock) Unlock(ctx context.Context) (bool, error) {
result, err := unlockScript.Run(ctx, l.client, []string{l.lockKey}, l.token).Int()
if err != nil {
return false, err
}
return result == 1, nil
}
// Lock with retry logic
func (l *RedisDistributedLock) LockWithRetry(ctx context.Context, maxRetries int, retryInterval time.Duration) (bool, error) {
for i := 0; i < maxRetries; i++ {
locked, err := l.Lock(ctx)
if err != nil {
return false, err
}
if locked {
return true, nil
}
time.Sleep(retryInterval)
}
return false, nil
}6.2 Enhanced version with re‑entrancy and watchdog
package main
import (
"context"
"fmt"
"github.com/go-redis/redis/v8"
"sync"
"time"
)
type EnhancedRedisLock struct {
client *redis.Client
lockKey string
clientID string
timeout time.Duration
renewTicker *time.Ticker
mu sync.Mutex
reentrant int
}
// Lua scripts (lockScript, unlockScriptEnhanced, renewScript) implement hash‑based lock acquisition,
// re‑entrant count handling, atomic unlock, and TTL renewal. They are omitted here for brevity.
func (l *EnhancedRedisLock) Lock(ctx context.Context) (bool, error) {
l.mu.Lock()
defer l.mu.Unlock()
if l.reentrant > 0 {
l.reentrant++
return true, nil
}
result, err := lockScript.Run(ctx, l.client, []string{l.lockKey}, l.clientID, l.timeout.Milliseconds()).Int()
if err != nil {
return false, err
}
if result == 1 {
l.reentrant = 1
l.startWatchDog(ctx)
return true, nil
}
return false, nil
}
func (l *EnhancedRedisLock) Unlock(ctx context.Context) (bool, error) {
l.mu.Lock()
defer l.mu.Unlock()
if l.reentrant == 0 {
return false, fmt.Errorf("lock not held by current client")
}
l.reentrant--
if l.reentrant > 0 {
return true, nil
}
if l.renewTicker != nil {
l.renewTicker.Stop()
l.renewTicker = nil
}
result, err := unlockScriptEnhanced.Run(ctx, l.client, []string{l.lockKey}, l.clientID, l.timeout.Milliseconds()).Int()
if err != nil {
return false, err
}
return result > 0, nil
}
// Watchdog renews the lock every timeout/3.
func (l *EnhancedRedisLock) startWatchDog(ctx context.Context) {
l.renewTicker = time.NewTicker(l.timeout / 3)
go func() {
for range l.renewTicker.C {
l.renewLock(ctx)
}
}()
}
func (l *EnhancedRedisLock) renewLock(ctx context.Context) {
l.mu.Lock()
defer l.mu.Unlock()
if l.reentrant == 0 {
return
}
_, err := renewScript.Run(ctx, l.client, []string{l.lockKey}, l.clientID, l.timeout.Milliseconds()).Int()
if err != nil {
fmt.Printf("lock renewal failed: %v
", err)
}
}6.3 Usage example
func main() {
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379", Password: "", DB: 0})
ctx := context.Background()
lock := NewEnhancedRedisLock(rdb, "order:lock:1001", 30*time.Second)
locked, err := lock.Lock(ctx)
if err != nil {
fmt.Printf("lock error: %v
", err)
return
}
if !locked {
fmt.Println("lock already held by another client")
return
}
fmt.Println("lock acquired, processing...")
// Simulate business work
time.Sleep(10 * time.Second)
// Re‑enter the lock
if lock.Lock(ctx) {
fmt.Println("re‑enter successful")
lock.Unlock(ctx)
}
// Final unlock
if ok, err := lock.Unlock(ctx); err != nil {
fmt.Printf("unlock error: %v
", err)
} else if ok {
fmt.Println("lock released")
} else {
fmt.Println("lock already released or owned by another client")
}
}7 Production‑grade Recommendations
7.1 Choosing a strategy
Simple scenarios : use the lightweight SETNX+Lua approach for maximum performance.
Production environments : adopt Redisson or a comparable mature framework to avoid reinventing complex features.
Golang projects : consider libraries such as github.com/go-redsync/redsync or adapt the examples above.
7.2 Key configurations
Lock timeout – set to 2‑3 × the estimated maximum business execution time.
Retry strategy – implement exponential back‑off to prevent thundering‑herd effects.
Monitoring – track lock wait time, acquisition failure rate, and other critical metrics.
Fallback – design a degradation path (local lock or pass‑through) when the distributed lock cannot be obtained.
7.3 Performance tuning
Connection pooling to reduce connection overhead.
Command pipelining to minimize network round‑trips.
Local caching for hot locks to lower Redis traffic.
Fine‑grained lock granularity to reduce contention.
Architecture & Thinking
🍭 Frontline tech director and chief architect at top-tier companies 🥝 Years of deep experience in internet, e‑commerce, social, and finance sectors 🌾 Committed to publishing high‑quality articles covering core technologies of leading internet firms, application architecture, and AI breakthroughs.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
