A distributed lock implementation in Go backed by Redis, supporting both single-instance locks and quorum-based multi-instance locks via the Redlock algorithm.
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/2node failures) vs. Lower performance (multiple network hops). - Best for: Critical distributed coordination where safety and consensus are paramount.
- Trade-off: High availability and safety (survives
Waiter(Retry Strategies)- Controls backoff behavior (
JitterWaitvsExponentialWait) to prevent thundering herd scenarios across clients trying to claim the same resource.
- Controls backoff behavior (
- 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).
- Partial Extensions on Quorum Failure: When using
DistributedLock, theExtendandTryExtendmethods (and by extension theWatch,WatchWithInterval, andWatchDogutilities) 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.
go get github.com/trviph/redlockimport (
"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...| Method | Description |
|---|---|
Acquire | Acquires lock with retry, returns fencing token |
TryAcquire | Single attempt, no retry; returns ErrLockAlreadyHeld if held |
Extend | Extends TTL with retry if fencing token matches |
TryExtend | Single extend attempt; returns ErrLockNotHeld on failure |
AcquireOrExtend | Extends if held, otherwise acquires (with retry) |
Release | Atomically releases lock if fencing token matches |
ReleaseWithCount | Releases 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.
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.
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.
Implement your own retry logic by satisfying the Waiter interface:
typeWaiterinterface {
Wait(ctx context.Context, timesint) <-chanWaitInfo
}Implementation Nuances:
- 0-indexed
times: Thetimesargument starts at0. Your implementation must return immediately whentimes == 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 returnWaitInfo{Err: ctx.Err()}immediately if cancelled.
The package provides sentinel errors for reliable error checking:
| Error | Description |
|---|---|
ErrLockAlreadyHeld | Lock is held by another client |
ErrLockNotHeld | Attempting to extend or release an unowned lock |
ErrMaxRetryExceeded | Maximum retry attempts exhausted |
ErrValidityExpired | Lock acquired but validity expired due to clock drift (DistributedLock only) |
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.
This project uses Docker Compose for integration testing:
# Start Redis instances
docker compose up -d
# Run tests
go test -v ./...
# Cleanup
docker compose downMIT