Skip to content

Repository files navigation

Redlock

Go TestGo Report CardGo Referencecodecov

A distributed lock implementation in Go backed by Redis, supporting both single-instance locks and quorum-based multi-instance locks via the Redlock algorithm.

Table of Contents

Architecture & Trade-offs

Core Components

  • Lock (Single Instance)
    • Trade-off: High performance (single network hop) vs. Lower availability (fails if the single Redis node goes down).
    • Best for: Non-critical background jobs where occasional failure isn't catastrophic.
  • DistributedLock (Multi-Instance)
    • Trade-off: High availability and safety (survives N/2 node failures) vs. Lower performance (multiple network hops).
    • Best for: Critical distributed coordination where safety and consensus are paramount.
  • Waiter (Retry Strategies)
    • Controls backoff behavior (JitterWait vs ExponentialWait) to prevent thundering herd scenarios across clients trying to claim the same resource.
  • Fencing Tokens
    • UUIDs generated upon lock acquisition. These are essential for pairing a lock owner with lock release/extension logic natively within the package.
    • Note: They are random UUIDs, not monotonically increasing counters, and cannot be used for external shielding (e.g., preventing split-brain writes in database storage).

Known Quirks & Limitations

  • Partial Extensions on Quorum Failure: When using DistributedLock, the Extend and TryExtend methods (and by extension the Watch, WatchWithInterval, and WatchDog utilities) suffer from a partial extension issue. If extending the lock fails to achieve quorum across the independent Redis instances, the successfully extended instances are not automatically rolled back. They will remain locked until their TTL naturally expires.

Installation

go get github.com/trviph/redlock

Usage

Single Instance

import (
"context""time""github.com/redis/go-redis/v9""github.com/trviph/redlock"
)
rdb:=redis.NewClient(&redis.Options{Addr: "localhost:6379"})
waiter:=redlock.NewJitterWait(
redlock.WithJitterMaxIteration(-1), // Default: -1 (infinite)redlock.WithJitterMinDelay(0), // Default: 0redlock.WithMaxJitterDuration(300*time.Millisecond), // Default: 300ms
)
lock:=redlock.NewLock(rdb, redlock.WithWaiter(waiter))
// Alternatively, use Exponential Backoff:// expWaiter := redlock.NewExponentialWait(// redlock.WithExpMinDelay(100*time.Millisecond), // Start wait time// redlock.WithExpMaxDelay(10*time.Second), // Max wait time cap// redlock.WithExpFactor(2.0), // Multiplier// redlock.WithExpMaxIteration(10), // Max retry attempts// )// lock := redlock.NewLock(rdb, redlock.WithWaiter(expWaiter))ctx:=context.Background()
key:="my-resource"ttl:=10*time.Second// Acquire lock (retries until success, context cancellation, or max retries)fencing, err:=lock.Acquire(ctx, key, ttl)
iferr!=nil {
panic(err)
}
deferlock.Release(ctx, key, fencing)
// Do work...

Key Methods

MethodDescription
AcquireAcquires lock with retry, returns fencing token
TryAcquireSingle attempt, no retry; returns ErrLockAlreadyHeld if held
ExtendExtends TTL with retry if fencing token matches
TryExtendSingle extend attempt; returns ErrLockNotHeld on failure
AcquireOrExtendExtends if held, otherwise acquires (with retry)
ReleaseAtomically releases lock if fencing token matches
ReleaseWithCountReleases lock and returns ReleaseStatus with detailed stats

Note

If you require strict monotonic fencing tokens for external shielding, you can generate them yourself (e.g., using a separate counter) and pass them to the AcquireWithFencing or TryAcquireWithFencing methods. However, if strong consistency is a strict requirement, it is recommended to consider systems designed for it, such as etcd or Zookeeper, instead of Redis.


Multi-Instance (Redlock Algorithm)

DistributedLock implements the Redlock algorithm for higher availability. It requires a quorum (N/2 + 1) to succeed.

redis1:=redis.NewClient(&redis.Options{Addr: "redis1:6379"})
redis2:=redis.NewClient(&redis.Options{Addr: "redis2:6379"})
redis3:=redis.NewClient(&redis.Options{Addr: "redis3:6379"})
locks:= []*redlock.Lock{
redlock.NewLock(redis1),
redlock.NewLock(redis2),
redlock.NewLock(redis3),
}
waiter:=redlock.NewJitterWait(
redlock.WithJitterMaxIteration(-1), // Default: -1 (infinite)redlock.WithJitterMinDelay(0), // Default: 0redlock.WithMaxJitterDuration(300*time.Millisecond), // Default: 300ms
)
dl:=redlock.NewDistributedLock(locks,
redlock.WithClockDriftFactor(0.01), // Default: 1%redlock.WithClockDriftBuffer(2*time.Millisecond), // Default: 2msredlock.WithReleaseTimeout(5*time.Second), // Default: 5sredlock.WithDistWaiter(waiter),
)
fencing, err:=dl.Acquire(ctx, "my-resource", 30*time.Second)
iferr!=nil {
panic(err)
}
deferdl.Release(ctx, "my-resource", fencing)

The API mirrors Lock for consistency (Acquire, TryAcquire, Extend, TryExtend, AcquireOrExtend, Release). It also provides ReleaseWithCount for detailed release statistics.

Tip

Use an odd number of instances (3, 5, 7) for optimal fault tolerance.


Watchdog Pattern (Auto-Renewal)

For long-running operations where duration is unknown, use a watchdog goroutine to periodically extend the lock. This pattern works with both Lock and DistributedLock:

fencing, _:=lock.Acquire(ctx, key, 10*time.Second)
watchCtx, watchCancel:=context.WithCancel(ctx)
deferwatchCancel()
redlock.Watch(watchCtx, lock, key, fencing, 10*time.Second)
// Do long-running work...watchCancel() // Stop the watchdog explicitlylock.Release(ctx, key, fencing)

You can customize the extension interval using WatchWithInterval or utilize the full WatchDog struct for advanced callback handling, such as early cancellation:

watchCtx, watchCancel:=context.WithCancel(ctx)
deferwatchCancel()
// Define a callback to handle errors and trigger early cancellation if the lock is losterrHandler:=func(ctx context.Context, item*redlock.WatchItem, errerror) {
iferr==context.Canceled {
log.Printf("WatchDog stopped for key %s", item.Key)
return
}
log.Printf("WatchDog error: %v", err)
// Stop the watchdog early if the lock no longer exists (e.g. expired)iferrors.Is(err, redlock.ErrLockNotHeld) {
log.Println("Lock lost! Triggering early cancellation...")
watchCancel()
}
}
wd:=redlock.NewWatchDog(locker,
redlock.WithErrorCallbacks(context.Background(), errHandler),
redlock.WithItem("resource-1", "token-1", 10*time.Second, 2*time.Second),
)
gowd.Run(watchCtx)

Warning

The isolated background watchdog logic will not stop automatically if the lock is lost or fails to extend. It will continue attempting to extend the lock indefinitely until the provided context is canceled. This intentional design prevents premature termination during transient network failures.


Custom Retry Strategies

Implement your own retry logic by satisfying the Waiter interface:

typeWaiterinterface {
Wait(ctx context.Context, timesint) <-chanWaitInfo
}

Implementation Nuances:

  • 0-indexed times: The times argument starts at 0. Your implementation must return immediately when times == 0.
  • Buffered Channel: Use a buffered channel (e.g., make(chan WaitInfo, 1)) to avoid goroutine leaks if the caller stops listening.
  • Context Handling: Respect ctx.Done() and return WaitInfo{Err: ctx.Err()} immediately if cancelled.

Error Handling

The package provides sentinel errors for reliable error checking:

ErrorDescription
ErrLockAlreadyHeldLock is held by another client
ErrLockNotHeldAttempting to extend or release an unowned lock
ErrMaxRetryExceededMaximum retry attempts exhausted
ErrValidityExpiredLock acquired but validity expired due to clock drift (DistributedLock only)

Unwrapping Joined Errors

DistributedLock operations may join errors from multiple instances using errors.Join(). You can unwrap these for granular inspection:

ifunwrapper, ok:=err.(interface{ Unwrap() []error }); ok {
for_, e:=rangeunwrapper.Unwrap() {
log.Printf("Instance error: %v", e)
}
}

Caution

Release Error Handling: Release for DistributedLock returns an error if any single Redis instance fails to release the lock. This ensures you are aware of potential cleanup issues, even if the release was successful on the majority of nodes (quorum). Use ReleaseWithCount if you need detailed success rates.


Testing

This project uses Docker Compose for integration testing:

# Start Redis instances
docker compose up -d
# Run tests
go test -v ./...
# Cleanup
docker compose down

License

MIT

About

Yet another Redis lock implementation

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages