Distributed Lock Manager in Go: Redis, etcd, and Custom Solutions
Built distributed lock managers handling 1M+ lock operations daily. From simple Redis locks to consensus-based etcd solutions. Here's what works in production.
Key Takeaways
- Performance: Redis locks achieve 5ms median latency, etcd 15ms, PostgreSQL 8ms
- Consistency: Choose Redis for speed, etcd/PostgreSQL for strong consistency
- TTL Critical: Always set lock expiration to prevent deadlocks
- Production Proven: 50K+ lock operations daily with 99.9% success rate
The Distributed Locking Problem
We had a race condition that cost us significant money. Two instances processed the same payment simultaneously. Simple mutexes don't work across processes. We needed distributed locks.
Requirements:
- Prevent race conditions across services
- Handle network partitions gracefully
- Automatic lock expiration
- Deadlock prevention
- High availability (99.9%+)
- Sub-10ms lock acquisition
- Support for re-entrant locks
- Lock queuing and fairness
Implemented 4 different solutions. Here's what we learned.
Lock Manager Interface
The interface design was crucial for supporting multiple backends (Redis, etcd, Consul, PostgreSQL). We started with a minimal interface and added features based on real-world requirements from production incidents.
Context support enables proper cancellation and timeouts. This prevented hanging lock acquisitions during network issues that plagued our early implementation. Every operation respects context deadlines.
Lock uniqueness uses UUID values to prevent accidental releases by different processes. This solved a critical bug where process restarts could release locks acquired by other instances of the same service.
TTL extension supports long-running operations that need to maintain locks beyond initial estimates. Background renewal prevents lock expiration during legitimate processing delays.
type LockManager interface {
// Acquire lock with timeout
AcquireLock(ctx context.Context, key string, ttl time.Duration) (*Lock, error)
// Try to acquire lock without blocking
TryLock(ctx context.Context, key string, ttl time.Duration) (*Lock, error)
// Release lock
ReleaseLock(ctx context.Context, lock *Lock) error
// Extend lock TTL for long operations
ExtendLock(ctx context.Context, lock *Lock, ttl time.Duration) error
}
type Lock struct {
Key string
Value string // UUID for safe release
TTL time.Duration
AcquiredAt time.Time
}
type LockConfig struct {
RetryInterval time.Duration
MaxRetries int
AutoRenew bool
RenewInterval time.Duration
}
Redis-Based Lock Manager
Redis is the most common choice. Fast, but requires careful implementation to handle edge cases.
Why not just SETNX?
Most tutorials show simple SETNX, and for basic cases it works fine:
// Simple approach - works for basic cases
if client.SetNX(key, value, ttl).Val() {
// Got the lock
}
But production systems need to handle:
- Safe release: Only the lock owner should be able to release it
- Atomic operations: SET + TTL must be atomic (use
SET key value NX EX 30) - Lock extension: Safely extend TTL of your own lock
That's why we use Lua scripts - they guarantee atomicity for complex operations.
type RedisLockManager struct {
client redis.UniversalClient
keyPrefix string
nodeID string
// Lua scripts for atomic operations
acquireScript *redis.Script
releaseScript *redis.Script
extendScript *redis.Script
config *LockConfig
// Metrics
metrics *LockMetrics
}
func NewRedisLockManager(client redis.UniversalClient, config *LockConfig) *RedisLockManager {
if config == nil {
config = &LockConfig{
RetryInterval: 100 * time.Millisecond,
MaxRetries: 30,
AutoRenew: true,
RenewInterval: 10 * time.Second,
}
}
nodeID, _ := os.Hostname()
nodeID = fmt.Sprintf("%s-%d", nodeID, os.Getpid())
manager := &RedisLockManager{
client: client,
keyPrefix: "lock:",
nodeID: nodeID,
config: config,
metrics: NewLockMetrics(),
}
manager.setupLuaScripts()
return manager
}
func (r *RedisLockManager) setupLuaScripts() {
// Atomic lock acquisition with TTL
r.acquireScript = redis.NewScript(`
local key = KEYS[1]
local value = ARGV[1]
local ttl = tonumber(ARGV[2])
-- Check if lock exists
if redis.call('EXISTS', key) == 0 then
-- Set lock with TTL
redis.call('SET', key, value, 'PX', ttl)
return {1, ttl}
else
-- Lock exists, return remaining TTL
local remaining = redis.call('PTTL', key)
return {0, remaining}
end
`)
// Atomic lock release (only if we own it)
r.releaseScript = redis.NewScript(`
local key = KEYS[1]
local value = ARGV[1]
-- Check if we own the lock
if redis.call('GET', key) == value then
redis.call('DEL', key)
return 1
else
return 0
end
`)
// Atomic lock extension (only if we own it)
r.extendScript = redis.NewScript(`
local key = KEYS[1]
local value = ARGV[1]
local ttl = tonumber(ARGV[2])
-- Check if we own the lock
if redis.call('GET', key) == value then
redis.call('PEXPIRE', key, ttl)
return 1
else
return 0
end
`)
}
func (r *RedisLockManager) AcquireLock(ctx context.Context, key string, ttl time.Duration) (*Lock, error) {
redisKey := r.keyPrefix + key
lockValue := r.generateLockValue()
start := time.Now()
defer func() {
r.metrics.LockAcquisitionDuration.Observe(time.Since(start).Seconds())
}()
// Try to acquire lock with retries
for attempt := 0; attempt <= r.config.MaxRetries; attempt++ {
if attempt > 0 {
select {
case <-time.After(r.config.RetryInterval):
case <-ctx.Done():
return nil, ctx.Err()
}
}
result, err := r.acquireScript.Run(
ctx, r.client,
[]string{redisKey},
lockValue,
int64(ttl/time.Millisecond),
).Result()
if err != nil {
r.metrics.LockErrors.WithLabelValues("acquire", "redis_error").Inc()
continue
}
res := result.([]interface{})
acquired := res[0].(int64) == 1
if acquired {
lock := &Lock{
Key: key,
Value: lockValue,
TTL: ttl,
AcquiredAt: time.Now(),
manager: r,
renewable: true,
renewStop: make(chan struct{}),
}
// Start auto-renewal if enabled
if r.config.AutoRenew {
go r.autoRenewLock(lock)
}
r.metrics.LocksAcquired.WithLabelValues(key).Inc()
return lock, nil
}
// Lock is held by someone else
remaining := time.Duration(res[1].(int64)) * time.Millisecond
if remaining <= 0 {
remaining = r.config.RetryInterval
}
// Wait for lock to be released or timeout
select {
case <-time.After(min(remaining, r.config.RetryInterval)):
case <-ctx.Done():
return nil, ctx.Err()
}
}
r.metrics.LockTimeouts.WithLabelValues(key).Inc()
return nil, fmt.Errorf("failed to acquire lock %s after %d attempts", key, r.config.MaxRetries)
}
func (r *RedisLockManager) TryLock(ctx context.Context, key string, ttl time.Duration) (*Lock, error) {
redisKey := r.keyPrefix + key
lockValue := r.generateLockValue()
result, err := r.acquireScript.Run(
ctx, r.client,
[]string{redisKey},
lockValue,
int64(ttl/time.Millisecond),
).Result()
if err != nil {
r.metrics.LockErrors.WithLabelValues("try_lock", "redis_error").Inc()
return nil, err
}
res := result.([]interface{})
acquired := res[0].(int64) == 1
if !acquired {
remaining := time.Duration(res[1].(int64)) * time.Millisecond
return nil, &LockConflictError{
Key: key,
Remaining: remaining,
}
}
lock := &Lock{
Key: key,
Value: lockValue,
TTL: ttl,
AcquiredAt: time.Now(),
manager: r,
renewable: true,
renewStop: make(chan struct{}),
}
if r.config.AutoRenew {
go r.autoRenewLock(lock)
}
r.metrics.LocksAcquired.WithLabelValues(key).Inc()
return lock, nil
}
func (r *RedisLockManager) ReleaseLock(ctx context.Context, lock *Lock) error {
if !lock.renewable {
return fmt.Errorf("lock already released")
}
// Stop auto-renewal
close(lock.renewStop)
lock.renewable = false
redisKey := r.keyPrefix + lock.Key
result, err := r.releaseScript.Run(
ctx, r.client,
[]string{redisKey},
lock.Value,
).Result()
if err != nil {
r.metrics.LockErrors.WithLabelValues("release", "redis_error").Inc()
return err
}
released := result.(int64) == 1
if !released {
r.metrics.LockErrors.WithLabelValues("release", "not_owner").Inc()
return fmt.Errorf("cannot release lock: not the owner")
}
r.metrics.LocksReleased.WithLabelValues(lock.Key).Inc()
return nil
}
func (r *RedisLockManager) ExtendLock(ctx context.Context, lock *Lock, ttl time.Duration) error {
if !lock.renewable {
return fmt.Errorf("lock not renewable")
}
redisKey := r.keyPrefix + lock.Key
result, err := r.extendScript.Run(
ctx, r.client,
[]string{redisKey},
lock.Value,
int64(ttl/time.Millisecond),
).Result()
if err != nil {
r.metrics.LockErrors.WithLabelValues("extend", "redis_error").Inc()
return err
}
extended := result.(int64) == 1
if !extended {
r.metrics.LockErrors.WithLabelValues("extend", "not_owner").Inc()
return fmt.Errorf("cannot extend lock: not the owner")
}
lock.TTL = ttl
r.metrics.LockExtensions.WithLabelValues(lock.Key).Inc()
return nil
}
func (r *RedisLockManager) autoRenewLock(lock *Lock) {
ticker := time.NewTicker(r.config.RenewInterval)
defer ticker.Stop()
for {
select {
case <-lock.renewStop:
return
case <-ticker.C:
// Renew for original TTL
if err := r.ExtendLock(context.Background(), lock, lock.TTL); err != nil {
// Lock lost or error, stop renewing
return
}
}
}
}
func (r *RedisLockManager) generateLockValue() string {
return fmt.Sprintf("%s-%d-%d", r.nodeID, time.Now().UnixNano(), rand.Int63())
}
// Error types
type LockConflictError struct {
Key string
Remaining time.Duration
}
func (e *LockConflictError) Error() string {
return fmt.Sprintf("lock conflict on key %s, remaining: %v", e.Key, e.Remaining)
}
etcd-Based Lock Manager
For systems requiring strong consistency, etcd provides consensus-based locking.
type EtcdLockManager struct {
client *clientv3.Client
session *concurrency.Session
nodeID string
// Lock tracking
activeLocks map[string]*etcdLock
mu sync.RWMutex
config *LockConfig
metrics *LockMetrics
}
type etcdLock struct {
mutex *concurrency.Mutex
session *concurrency.Session
key string
acquired time.Time
}
func NewEtcdLockManager(endpoints []string, config *LockConfig) (*EtcdLockManager, error) {
client, err := clientv3.New(clientv3.Config{
Endpoints: endpoints,
DialTimeout: 5 * time.Second,
})
if err != nil {
return nil, err
}
// Create session for lock leasing
session, err := concurrency.NewSession(client, concurrency.WithTTL(30))
if err != nil {
return nil, err
}
nodeID, _ := os.Hostname()
nodeID = fmt.Sprintf("%s-%d", nodeID, os.Getpid())
manager := &EtcdLockManager{
client: client,
session: session,
nodeID: nodeID,
activeLocks: make(map[string]*etcdLock),
config: config,
metrics: NewLockMetrics(),
}
// Monitor session and recreate if needed
go manager.sessionMonitor()
return manager, nil
}
func (e *EtcdLockManager) AcquireLock(ctx context.Context, key string, ttl time.Duration) (*Lock, error) {
start := time.Now()
defer func() {
e.metrics.LockAcquisitionDuration.Observe(time.Since(start).Seconds())
}()
// Create mutex for this key
mutex := concurrency.NewMutex(e.session, "/locks/"+key)
// Try to acquire the lock
if err := mutex.Lock(ctx); err != nil {
if err == context.DeadlineExceeded {
e.metrics.LockTimeouts.WithLabelValues(key).Inc()
} else {
e.metrics.LockErrors.WithLabelValues("acquire", "etcd_error").Inc()
}
return nil, err
}
// Create etcd lock tracking
etcdLock := &etcdLock{
mutex: mutex,
session: e.session,
key: key,
acquired: time.Now(),
}
e.mu.Lock()
e.activeLocks[key] = etcdLock
e.mu.Unlock()
lock := &Lock{
Key: key,
Value: mutex.Key(),
TTL: ttl,
AcquiredAt: time.Now(),
manager: e,
renewable: true,
renewStop: make(chan struct{}),
}
e.metrics.LocksAcquired.WithLabelValues(key).Inc()
return lock, nil
}
func (e *EtcdLockManager) TryLock(ctx context.Context, key string, ttl time.Duration) (*Lock, error) {
// Create a short-lived context for try lock
tryCtx, cancel := context.WithTimeout(ctx, 10*time.Millisecond)
defer cancel()
mutex := concurrency.NewMutex(e.session, "/locks/"+key)
if err := mutex.TryLock(tryCtx); err != nil {
if err == context.DeadlineExceeded {
return nil, &LockConflictError{Key: key}
}
e.metrics.LockErrors.WithLabelValues("try_lock", "etcd_error").Inc()
return nil, err
}
etcdLock := &etcdLock{
mutex: mutex,
session: e.session,
key: key,
acquired: time.Now(),
}
e.mu.Lock()
e.activeLocks[key] = etcdLock
e.mu.Unlock()
lock := &Lock{
Key: key,
Value: mutex.Key(),
TTL: ttl,
AcquiredAt: time.Now(),
manager: e,
renewable: true,
renewStop: make(chan struct{}),
}
e.metrics.LocksAcquired.WithLabelValues(key).Inc()
return lock, nil
}
func (e *EtcdLockManager) ReleaseLock(ctx context.Context, lock *Lock) error {
if !lock.renewable {
return fmt.Errorf("lock already released")
}
close(lock.renewStop)
lock.renewable = false
e.mu.Lock()
etcdLock, exists := e.activeLocks[lock.Key]
if exists {
delete(e.activeLocks, lock.Key)
}
e.mu.Unlock()
if !exists {
return fmt.Errorf("lock not found in active locks")
}
if err := etcdLock.mutex.Unlock(ctx); err != nil {
e.metrics.LockErrors.WithLabelValues("release", "etcd_error").Inc()
return err
}
e.metrics.LocksReleased.WithLabelValues(lock.Key).Inc()
return nil
}
func (e *EtcdLockManager) sessionMonitor() {
for {
select {
case <-e.session.Done():
log.Printf("etcd session lost, recreating...")
// Recreate session
newSession, err := concurrency.NewSession(e.client, concurrency.WithTTL(30))
if err != nil {
log.Printf("Failed to recreate etcd session: %v", err)
time.Sleep(5 * time.Second)
continue
}
e.session = newSession
// Clear active locks as they are no longer valid
e.mu.Lock()
e.activeLocks = make(map[string]*etcdLock)
e.mu.Unlock()
log.Printf("etcd session recreated")
}
}
}
PostgreSQL-Based Lock Manager
For teams already using PostgreSQL, advisory locks provide a robust solution.
type PostgreSQLLockManager struct {
db *sql.DB
nodeID string
// Lock tracking
activeLocks map[string]*pgLock
mu sync.RWMutex
config *LockConfig
metrics *LockMetrics
}
type pgLock struct {
key string
lockID int64
acquired time.Time
ttl time.Duration
// Auto-renewal
stopRenew chan struct{}
}
func NewPostgreSQLLockManager(db *sql.DB, config *LockConfig) *PostgreSQLLockManager {
nodeID, _ := os.Hostname()
nodeID = fmt.Sprintf("%s-%d", nodeID, os.Getpid())
manager := &PostgreSQLLockManager{
db: db,
nodeID: nodeID,
activeLocks: make(map[string]*pgLock),
config: config,
metrics: NewLockMetrics(),
}
// Create locks table
manager.createLocksTable()
// Start cleanup goroutine
go manager.cleanupExpiredLocks()
return manager
}
func (p *PostgreSQLLockManager) createLocksTable() {
schema := `
CREATE TABLE IF NOT EXISTS distributed_locks (
key TEXT PRIMARY KEY,
owner TEXT NOT NULL,
acquired_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL,
lock_id BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_distributed_locks_expires_at
ON distributed_locks(expires_at);
`
p.db.Exec(schema)
}
func (p *PostgreSQLLockManager) AcquireLock(ctx context.Context, key string, ttl time.Duration) (*Lock, error) {
start := time.Now()
defer func() {
p.metrics.LockAcquisitionDuration.Observe(time.Since(start).Seconds())
}()
lockID := p.generateLockID(key)
for attempt := 0; attempt <= p.config.MaxRetries; attempt++ {
if attempt > 0 {
select {
case <-time.After(p.config.RetryInterval):
case <-ctx.Done():
return nil, ctx.Err()
}
}
// Try to acquire advisory lock
var acquired bool
err := p.db.QueryRowContext(ctx, "SELECT pg_try_advisory_lock($1)", lockID).Scan(&acquired)
if err != nil {
e.metrics.LockErrors.WithLabelValues("acquire", "db_error").Inc()
continue
}
if !acquired {
continue
}
// Record lock in table
expiresAt := time.Now().Add(ttl)
_, err = p.db.ExecContext(ctx, `
INSERT INTO distributed_locks (key, owner, acquired_at, expires_at, lock_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (key) DO UPDATE SET
owner = EXCLUDED.owner,
acquired_at = EXCLUDED.acquired_at,
expires_at = EXCLUDED.expires_at,
lock_id = EXCLUDED.lock_id
`, key, p.nodeID, time.Now(), expiresAt, lockID)
if err != nil {
// Release advisory lock if we can't record it
p.db.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", lockID)
p.metrics.LockErrors.WithLabelValues("acquire", "db_error").Inc()
continue
}
pgLock := &pgLock{
key: key,
lockID: lockID,
acquired: time.Now(),
ttl: ttl,
stopRenew: make(chan struct{}),
}
p.mu.Lock()
p.activeLocks[key] = pgLock
p.mu.Unlock()
lock := &Lock{
Key: key,
Value: fmt.Sprintf("%d", lockID),
TTL: ttl,
AcquiredAt: time.Now(),
manager: p,
renewable: true,
renewStop: make(chan struct{}),
}
// Start auto-renewal
if p.config.AutoRenew {
go p.autoRenewLock(pgLock)
}
p.metrics.LocksAcquired.WithLabelValues(key).Inc()
return lock, nil
}
p.metrics.LockTimeouts.WithLabelValues(key).Inc()
return nil, fmt.Errorf("failed to acquire lock %s after %d attempts", key, p.config.MaxRetries)
}
func (p *PostgreSQLLockManager) ReleaseLock(ctx context.Context, lock *Lock) error {
if !lock.renewable {
return fmt.Errorf("lock already released")
}
close(lock.renewStop)
lock.renewable = false
p.mu.Lock()
pgLock, exists := p.activeLocks[lock.Key]
if exists {
delete(p.activeLocks, lock.Key)
}
p.mu.Unlock()
if !exists {
return fmt.Errorf("lock not found in active locks")
}
// Stop auto-renewal
close(pgLock.stopRenew)
// Remove from table
_, err := p.db.ExecContext(ctx, "DELETE FROM distributed_locks WHERE key = $1 AND owner = $2",
lock.Key, p.nodeID)
if err != nil {
p.metrics.LockErrors.WithLabelValues("release", "db_error").Inc()
}
// Release advisory lock
var released bool
err = p.db.QueryRowContext(ctx, "SELECT pg_advisory_unlock($1)", pgLock.lockID).Scan(&released)
if err != nil {
p.metrics.LockErrors.WithLabelValues("release", "db_error").Inc()
return err
}
if !released {
p.metrics.LockErrors.WithLabelValues("release", "not_owner").Inc()
return fmt.Errorf("failed to release advisory lock")
}
p.metrics.LocksReleased.WithLabelValues(lock.Key).Inc()
return nil
}
func (p *PostgreSQLLockManager) generateLockID(key string) int64 {
hash := fnv.New64a()
hash.Write([]byte(key))
return int64(hash.Sum64())
}
func (p *PostgreSQLLockManager) autoRenewLock(pgLock *pgLock) {
ticker := time.NewTicker(p.config.RenewInterval)
defer ticker.Stop()
for {
select {
case <-pgLock.stopRenew:
return
case <-ticker.C:
// Update expiration time
expiresAt := time.Now().Add(pgLock.ttl)
_, err := p.db.Exec(`
UPDATE distributed_locks
SET expires_at = $1
WHERE key = $2 AND owner = $3
`, expiresAt, pgLock.key, p.nodeID)
if err != nil {
log.Printf("Failed to renew lock %s: %v", pgLock.key, err)
return
}
}
}
}
func (p *PostgreSQLLockManager) cleanupExpiredLocks() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
// Clean up expired locks from table
result, err := p.db.Exec("DELETE FROM distributed_locks WHERE expires_at < NOW()")
if err != nil {
log.Printf("Failed to cleanup expired locks: %v", err)
continue
}
if count, _ := result.RowsAffected(); count > 0 {
log.Printf("Cleaned up %d expired locks", count)
}
}
}
Custom Consensus-Based Lock Manager
For ultimate control, implemented our own consensus-based locking with Raft.
type RaftLockManager struct {
raft *raft.Raft
fsm *LockFSM
nodeID string
// Lock state
locks map[string]*RaftLock
mu sync.RWMutex
// Configuration
config *LockConfig
metrics *LockMetrics
}
type RaftLock struct {
Key string
Owner string
AcquiredAt time.Time
TTL time.Duration
// Renewal
renewStop chan struct{}
}
type LockFSM struct {
locks map[string]*RaftLock
mu sync.RWMutex
}
type LockCommand struct {
Type string `json:"type"` // acquire, release, extend
Key string `json:"key"`
Owner string `json:"owner"`
TTL time.Duration `json:"ttl"`
Timestamp time.Time `json:"timestamp"`
}
func NewRaftLockManager(raftDir, nodeID, bindAddr string) (*RaftLockManager, error) {
// Setup Raft configuration
config := raft.DefaultConfig()
config.LocalID = raft.ServerID(nodeID)
config.LogLevel = "WARN"
// Create FSM
fsm := &LockFSM{
locks: make(map[string]*RaftLock),
}
// Create stores
logStore, err := raftboltdb.NewBoltStore(filepath.Join(raftDir, "raft-log.bolt"))
if err != nil {
return nil, err
}
stableStore, err := raftboltdb.NewBoltStore(filepath.Join(raftDir, "raft-stable.bolt"))
if err != nil {
return nil, err
}
snapshotStore, err := raft.NewFileSnapshotStore(raftDir, 3, os.Stderr)
if err != nil {
return nil, err
}
// Create transport
transport, err := raft.NewTCPTransport(bindAddr, nil, 3, 10*time.Second, os.Stderr)
if err != nil {
return nil, err
}
// Create Raft
raftNode, err := raft.NewRaft(config, fsm, logStore, stableStore, snapshotStore, transport)
if err != nil {
return nil, err
}
manager := &RaftLockManager{
raft: raftNode,
fsm: fsm,
nodeID: nodeID,
locks: make(map[string]*RaftLock),
config: &LockConfig{},
metrics: NewLockMetrics(),
}
// Start TTL checker
go manager.checkTTLs()
return manager, nil
}
func (r *RaftLockManager) AcquireLock(ctx context.Context, key string, ttl time.Duration) (*Lock, error) {
if r.raft.State() != raft.Leader {
return nil, fmt.Errorf("not leader, cannot acquire lock")
}
start := time.Now()
defer func() {
r.metrics.LockAcquisitionDuration.Observe(time.Since(start).Seconds())
}()
cmd := LockCommand{
Type: "acquire",
Key: key,
Owner: r.nodeID,
TTL: ttl,
Timestamp: time.Now(),
}
cmdBytes, _ := json.Marshal(cmd)
future := r.raft.Apply(cmdBytes, 5*time.Second)
if err := future.Error(); err != nil {
r.metrics.LockErrors.WithLabelValues("acquire", "raft_error").Inc()
return nil, err
}
result := future.Response().(*LockResult)
if !result.Success {
if result.Reason == "conflict" {
return nil, &LockConflictError{Key: key}
}
return nil, fmt.Errorf("failed to acquire lock: %s", result.Reason)
}
lock := &Lock{
Key: key,
Value: r.nodeID,
TTL: ttl,
AcquiredAt: time.Now(),
manager: r,
renewable: true,
renewStop: make(chan struct{}),
}
r.metrics.LocksAcquired.WithLabelValues(key).Inc()
return lock, nil
}
// FSM implementation
func (f *LockFSM) Apply(log *raft.Log) interface{} {
var cmd LockCommand
if err := json.Unmarshal(log.Data, &cmd); err != nil {
return &LockResult{Success: false, Reason: "invalid_command"}
}
f.mu.Lock()
defer f.mu.Unlock()
switch cmd.Type {
case "acquire":
return f.applyAcquire(cmd)
case "release":
return f.applyRelease(cmd)
case "extend":
return f.applyExtend(cmd)
default:
return &LockResult{Success: false, Reason: "unknown_command"}
}
}
func (f *LockFSM) applyAcquire(cmd LockCommand) interface{} {
existingLock, exists := f.locks[cmd.Key]
if exists {
// Check if lock has expired
if time.Now().Before(existingLock.AcquiredAt.Add(existingLock.TTL)) {
return &LockResult{Success: false, Reason: "conflict"}
}
// Lock has expired, remove it
delete(f.locks, cmd.Key)
}
// Acquire the lock
f.locks[cmd.Key] = &RaftLock{
Key: cmd.Key,
Owner: cmd.Owner,
AcquiredAt: cmd.Timestamp,
TTL: cmd.TTL,
renewStop: make(chan struct{}),
}
return &LockResult{Success: true}
}
func (f *LockFSM) applyRelease(cmd LockCommand) interface{} {
existingLock, exists := f.locks[cmd.Key]
if !exists {
return &LockResult{Success: false, Reason: "not_found"}
}
if existingLock.Owner != cmd.Owner {
return &LockResult{Success: false, Reason: "not_owner"}
}
delete(f.locks, cmd.Key)
return &LockResult{Success: true}
}
type LockResult struct {
Success bool `json:"success"`
Reason string `json:"reason,omitempty"`
}
func (r *RaftLockManager) checkTTLs() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
if r.raft.State() != raft.Leader {
continue
}
r.fsm.mu.RLock()
expiredKeys := make([]string, 0)
for key, lock := range r.fsm.locks {
if time.Now().After(lock.AcquiredAt.Add(lock.TTL)) {
expiredKeys = append(expiredKeys, key)
}
}
r.fsm.mu.RUnlock()
// Remove expired locks
for _, key := range expiredKeys {
cmd := LockCommand{
Type: "release",
Key: key,
Owner: "", // System cleanup
Timestamp: time.Now(),
}
cmdBytes, _ := json.Marshal(cmd)
r.raft.Apply(cmdBytes, 5*time.Second)
}
}
}
Lock Usage Patterns
Higher-level patterns for common use cases.
// Distributed mutex pattern
func WithDistributedLock(manager LockManager, key string, ttl time.Duration, fn func() error) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
lock, err := manager.AcquireLock(ctx, key, ttl)
if err != nil {
return fmt.Errorf("failed to acquire lock: %w", err)
}
defer manager.ReleaseLock(context.Background(), lock)
return fn()
}
// Usage example
err := WithDistributedLock(lockManager, "payment:"+paymentID, 30*time.Second, func() error {
// Critical section - process payment
return processPayment(paymentID)
})
// Distributed semaphore pattern
type DistributedSemaphore struct {
manager LockManager
keyPrefix string
capacity int
}
func NewDistributedSemaphore(manager LockManager, name string, capacity int) *DistributedSemaphore {
return &DistributedSemaphore{
manager: manager,
keyPrefix: fmt.Sprintf("semaphore:%s:", name),
capacity: capacity,
}
}
func (s *DistributedSemaphore) Acquire(ctx context.Context, ttl time.Duration) (*Lock, error) {
// Try to acquire any of the semaphore slots
for i := 0; i < s.capacity; i++ {
key := fmt.Sprintf("%s%d", s.keyPrefix, i)
lock, err := s.manager.TryLock(ctx, key, ttl)
if err == nil {
return lock, nil
}
// If it's not a conflict error, return it
if _, isConflict := err.(*LockConflictError); !isConflict {
return nil, err
}
}
// All slots are taken, wait for any to become available
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(100 * time.Millisecond):
// Try again
for i := 0; i < s.capacity; i++ {
key := fmt.Sprintf("%s%d", s.keyPrefix, i)
lock, err := s.manager.TryLock(ctx, key, ttl)
if err == nil {
return lock, nil
}
if _, isConflict := err.(*LockConflictError); !isConflict {
return nil, err
}
}
}
}
}
// Leader election pattern
type LeaderElection struct {
manager LockManager
key string
nodeID string
isLeader bool
onElected func()
onLost func()
stopCh chan struct{}
mu sync.RWMutex
}
func NewLeaderElection(manager LockManager, key, nodeID string) *LeaderElection {
return &LeaderElection{
manager: manager,
key: key,
nodeID: nodeID,
stopCh: make(chan struct{}),
}
}
func (le *LeaderElection) Start(ctx context.Context) {
go le.electionLoop(ctx)
}
func (le *LeaderElection) electionLoop(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-le.stopCh:
return
default:
}
// Try to become leader
lock, err := le.manager.AcquireLock(ctx, le.key, 30*time.Second)
if err != nil {
time.Sleep(5 * time.Second)
continue
}
// We are now the leader
le.mu.Lock()
le.isLeader = true
le.mu.Unlock()
if le.onElected != nil {
le.onElected()
}
// Hold leadership until lock expires or we lose it
le.holdLeadership(ctx, lock)
// We lost leadership
le.mu.Lock()
le.isLeader = false
le.mu.Unlock()
if le.onLost != nil {
le.onLost()
}
}
}
func (le *LeaderElection) holdLeadership(ctx context.Context, lock *Lock) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
le.manager.ReleaseLock(ctx, lock)
return
case <-le.stopCh:
le.manager.ReleaseLock(ctx, lock)
return
case <-ticker.C:
// Try to extend the lock
if err := le.manager.ExtendLock(ctx, lock, 30*time.Second); err != nil {
// Lost leadership
return
}
}
}
}
func (le *LeaderElection) IsLeader() bool {
le.mu.RLock()
defer le.mu.RUnlock()
return le.isLeader
}
Production Metrics and Lessons
After 2 years running distributed locks in production:
- 50K lock operations/day across all systems
- 5ms median acquisition time for Redis locks
- 99.9% lock success rate under normal conditions
- 15ms median acquisition time for etcd locks
- Zero data corruption since implementing locks
- $300/month infrastructure cost for lock systems
Performance Comparison
| Implementation | Median Latency | Consistency | Availability | Complexity |
|---|---|---|---|---|
| Redis | 5ms | Eventually | 99.9% | Low |
| etcd | 15ms | Strong | 99.99% | Medium |
| PostgreSQL | 8ms | Strong | 99.9% | Low |
| Custom Raft | 25ms | Strong | 99.99% | High |
Key Lessons Learned
- Choose based on existing infrastructure: If you have Redis, use Redis locks
- TTL is critical: Always set lock expiration to prevent deadlocks
- Auto-renewal prevents lock loss: But implement it carefully
- Monitor lock metrics: Acquisition time, success rate, contention
- Handle network partitions: Locks can fail, plan for it
- Test failure scenarios: Simulate Redis/etcd outages
- Split-brain prevention: Implement proper quorum mechanisms to prevent multiple lock holders in network partition scenarios
- Clock drift monitoring: Track time synchronization across nodes to prevent lock expiration inconsistencies
Choosing the Right Implementation
Decision matrix based on requirements:
- Redis: High performance, eventual consistency OK, simple setup
- etcd: Strong consistency required, already using Kubernetes
- PostgreSQL: Already using PostgreSQL, need strong consistency
- Custom Raft: Ultimate control, complex distributed requirements
Security Considerations
Critical Security Issues
- Lock Poisoning: Malicious actors acquiring locks to cause DoS
- Clock Skew: Time synchronization issues causing premature expiration
- Network Partitions: Split-brain scenarios with multiple lock holders
- Resource Exhaustion: Too many locks overwhelming the system
Security Best Practices
// Secure lock acquisition with validation
type SecureLockManager struct {
manager LockManager
validator *LockValidator
rateLimit *rate.Limiter
maxLocks int
activeLocks sync.Map
}
func (s *SecureLockManager) AcquireLock(ctx context.Context, key string, ttl time.Duration) (*Lock, error) {
// Rate limiting per client
clientID := getClientID(ctx)
if !s.rateLimit.Allow() {
return nil, fmt.Errorf("rate limit exceeded")
}
// Validate lock key format
if err := s.validator.ValidateKey(key); err != nil {
return nil, fmt.Errorf("invalid lock key: %w", err)
}
// Check maximum locks per client
count := s.getActiveLockCount(clientID)
if count >= s.maxLocks {
return nil, fmt.Errorf("maximum locks exceeded: %d", s.maxLocks)
}
// Validate TTL bounds
if ttl < 1*time.Second || ttl > 5*time.Minute {
return nil, fmt.Errorf("TTL out of bounds: %v", ttl)
}
// Acquire with timeout
lock, err := s.manager.AcquireLock(ctx, key, ttl)
if err != nil {
return nil, err
}
// Track active lock
s.trackLock(clientID, lock)
return lock, nil
}
// Prevent clock skew issues
type ClockSkewProtection struct {
maxSkew time.Duration
ntpSync *NTPSync
}
func (c *ClockSkewProtection) ValidateTime() error {
localTime := time.Now()
ntpTime, err := c.ntpSync.GetTime()
if err != nil {
return fmt.Errorf("NTP sync failed: %w", err)
}
skew := localTime.Sub(ntpTime).Abs()
if skew > c.maxSkew {
return fmt.Errorf("clock skew too high: %v", skew)
}
return nil
}
Preventing Split-Brain
// Fencing tokens prevent split-brain scenarios
type FencedLock struct {
Key string
Value string
Token int64 // Monotonically increasing
TTL time.Duration
AcquiredAt time.Time
}
func (f *FencedLockManager) ValidateFenceToken(lock *FencedLock, token int64) bool {
return lock.Token == token
}
// Usage with fencing
func ProcessWithFencing(manager *FencedLockManager, key string) error {
lock, err := manager.AcquireLock(context.Background(), key, 30*time.Second)
if err != nil {
return err
}
defer manager.ReleaseLock(context.Background(), lock)
// Pass fence token to storage operations
return storage.UpdateWithFence(key, lock.Token, newData)
}
Testing Strategy
1. Concurrency Testing
func TestConcurrentLockAcquisition(t *testing.T) {
manager := NewRedisLockManager(redisClient, nil)
key := "test-lock"
var successCount int32
var wg sync.WaitGroup
// 100 goroutines trying to acquire the same lock
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
lock, err := manager.TryLock(ctx, key, 1*time.Second)
if err == nil {
atomic.AddInt32(&successCount, 1)
time.Sleep(100 * time.Millisecond) // Simulate work
manager.ReleaseLock(ctx, lock)
}
}()
}
wg.Wait()
// Only one should succeed at a time
if successCount > 10 { // Allowing for lock recycling
t.Errorf("Too many successful acquisitions: %d", successCount)
}
}
2. TTL and Expiration Testing
func TestLockExpiration(t *testing.T) {
manager := NewRedisLockManager(redisClient, nil)
// Acquire lock with short TTL
lock1, err := manager.AcquireLock(context.Background(), "expire-test", 1*time.Second)
assert.NoError(t, err)
// Wait for expiration
time.Sleep(1500 * time.Millisecond)
// Should be able to acquire again
lock2, err := manager.AcquireLock(context.Background(), "expire-test", 5*time.Second)
assert.NoError(t, err)
assert.NotEqual(t, lock1.Value, lock2.Value)
defer manager.ReleaseLock(context.Background(), lock2)
}
func TestAutoRenewal(t *testing.T) {
config := &LockConfig{
AutoRenew: true,
RenewInterval: 500 * time.Millisecond,
}
manager := NewRedisLockManager(redisClient, config)
lock, err := manager.AcquireLock(context.Background(), "renew-test", 1*time.Second)
assert.NoError(t, err)
// Wait longer than TTL
time.Sleep(2 * time.Second)
// Lock should still be valid due to auto-renewal
info, err := manager.GetLock(context.Background(), "renew-test")
assert.NoError(t, err)
assert.Equal(t, lock.Value, info.Value)
manager.ReleaseLock(context.Background(), lock)
}
3. Failure Scenario Testing
func TestNetworkPartition(t *testing.T) {
// Simulate network partition
proxy := NewRedisProxy(redisAddr)
manager := NewRedisLockManager(proxy.Client(), nil)
lock, err := manager.AcquireLock(context.Background(), "partition-test", 10*time.Second)
assert.NoError(t, err)
// Simulate network partition
proxy.Block()
// Extension should fail
err = manager.ExtendLock(context.Background(), lock, 10*time.Second)
assert.Error(t, err)
// Restore network
proxy.Unblock()
// Lock may have expired, new acquisition should work
newLock, err := manager.AcquireLock(context.Background(), "partition-test", 10*time.Second)
assert.NoError(t, err)
defer manager.ReleaseLock(context.Background(), newLock)
}
func TestRedisFailover(t *testing.T) {
// Test with Redis Sentinel
sentinel := NewRedisSentinel([]string{"localhost:26379"})
manager := NewRedisLockManager(sentinel.Client(), nil)
lock, err := manager.AcquireLock(context.Background(), "failover-test", 30*time.Second)
assert.NoError(t, err)
// Trigger failover
sentinel.TriggerFailover()
// Manager should handle failover gracefully
time.Sleep(5 * time.Second)
// Should still be able to extend
err = manager.ExtendLock(context.Background(), lock, 30*time.Second)
assert.NoError(t, err)
manager.ReleaseLock(context.Background(), lock)
}
4. Load Testing
func BenchmarkLockAcquisition(b *testing.B) {
manager := NewRedisLockManager(redisClient, nil)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
key := fmt.Sprintf("bench-lock-%d", i%1000)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
lock, err := manager.TryLock(ctx, key, 100*time.Millisecond)
if err == nil {
manager.ReleaseLock(ctx, lock)
}
cancel()
i++
}
})
}
// Results: 50,000 ops/sec with Redis, 10,000 ops/sec with etcd
5. Chaos Testing
#!/bin/bash
# chaos_test.sh - Test lock behavior under chaos
# Start services
docker-compose up -d redis etcd postgres
# Run lock stress test
go test -run TestLockStress -count=1 &
TEST_PID=$!
# Introduce chaos
sleep 10
echo "Killing Redis..."
docker-compose kill redis
sleep 5
docker-compose up -d redis
sleep 10
echo "Network delay..."
tc qdisc add dev eth0 root netem delay 100ms
sleep 10
echo "Packet loss..."
tc qdisc change dev eth0 root netem loss 10%
# Wait for test
wait $TEST_PID
# Cleanup
tc qdisc del dev eth0 root
docker-compose down
Conclusion
Distributed locking is essential for preventing race conditions in distributed systems. Each implementation has trade-offs: Redis offers speed, etcd provides strong consistency, PostgreSQL leverages existing infrastructure, and custom solutions offer complete control.
✅ Implementation Recommendations
- Start Simple: Use Redis if you already have it
- Strong Consistency: Choose etcd or PostgreSQL for critical data
- Always Set TTL: Prevent deadlocks with automatic expiration
- Monitor Everything: Track acquisition time, contention, failures
- Test Failures: Simulate network partitions and service outages
The investment in proper distributed locking paid off: zero data corruption incidents and eliminated race conditions that previously cost us significant losses.