# redislock: a small Go lock library that hands the watchdog decision back to you

> A single dependency Go package providing Redis-backed distributed locks with TTL, refresh, and optional fencing tokens, deliberately shipping no watchdog goroutine of its own. Suited to teams that want to own lock renewal policy, and a poor fit for anyone who wants a lock that renews itself.

**bsm/redislock** — Simplified distributed locking implementation using Redis

- Repository: https://github.com/bsm/redislock
- Stars: 1,769 · Forks: 165
- Language: Go
- License: NOASSERTION
- Published: 2026-10-16 · Updated: 2026-10-16 · Language: en
- Canonical page: https://hysenlabs.com/projects/bsm-redislock

## A whole lock library in four Lua scripts and one Go file

The repository is unusually small for a distributed systems package, and the file listing explains why. The implementation is redislock.go, and the atomic operations are Lua scripts shipped alongside it: obtain.lua, refresh.lua, release.lua and pttl.lua. Retry logic lives in retry.go, and there are tests for both the lock itself and the retry behaviour.

Because each operation is a Lua script rather than a client side read-then-write, the acquire, refresh and release steps execute atomically inside Redis. That is the whole correctness argument of the library, and it is why there are four scripts rather than a set of helpers assembled from pipelines.

The module is github.com/bsm/redislock and it declares Go 1.25 as its minimum. Its one direct dependency is github.com/redis/go-redis/v9 at v9.19.0, with cespare/xxhash/v2 and go.uber.org/atomic as indirect dependencies. That is the entire dependency surface.

## Obtain takes a TTL and returns ErrNotObtained rather than blocking

The API is built around one call. You construct a locker with redislock.New(client), then ask for a lock on a key with a time to live.

```go
client := redis.NewClient(&redis.Options{
  Network: "tcp",
  Addr:    "127.0.0.1:6379",
})
defer client.Close()

locker := redislock.New(client)
ctx := context.Background()

lock, err := locker.Obtain(ctx, "my-key", 100*time.Millisecond, nil)
if err == redislock.ErrNotObtained {
  fmt.Println("Could not obtain lock!")
  return
} else if err != nil {
  log.Fatalln(err)
  return
}

defer lock.Release(ctx)
```

The nil at the end is an *redislock.Options, which is how per-lock settings are passed rather than configured on the locker. The two error cases are distinct on purpose: ErrNotObtained means somebody else holds the lock, which is an expected outcome, while any other error means Redis was unreachable or the script failed, which is not.

The critical detail is the TTL. This is a lease with an expiry, not a lock held until released, and every later decision in the API follows from that.

## TTL and Refresh, which is how you find out you lost the lock

Once you hold the lock you can ask for its remaining lifetime and you can extend it. Both take the context and return an error.

```go
time.Sleep(50 * time.Millisecond)
if ttl, err := lock.TTL(ctx); err != nil {
  log.Fatalln(err)
} else if ttl > 0 {
  fmt.Println("Yay, I still have my lock!")
}

if err := lock.Refresh(ctx, 100*time.Millisecond, nil); err != nil {
  log.Fatalln(err)
}
```

The example in the README sleeps past the refreshed TTL and then checks that the TTL has dropped to zero, which is the demonstration that expiry is real and not advisory.

What the API does not give you is a signal that your work is still safe. TTL tells you the Redis side state, and Refresh tells you whether you could extend it, but whether a failed refresh should abort your job, retry it, or page someone is not a question the library answers.

## No watchdog: the README hands you the ticker and the policy

This is the design decision that most affects whether redislock fits your codebase. The README is explicit that redislock deliberately does not bundle a built-in watchdog goroutine, and that refresh cadence and error handling are application concerns: log and continue, retry, cancel the protected work, or page someone.

The package therefore shows you the pattern rather than shipping it. The documented watchdog refreshes every ttl/3 and cancels the work context when a refresh fails:

```go
const ttl = 30 * time.Second
lock, err := locker.Obtain(ctx, "my-key", ttl, nil)
if err != nil {
  log.Fatalln(err)
}
defer lock.Release(context.Background())

workCtx, cancel := context.WithCancel(ctx)
defer cancel()

go func() {
  t := time.NewTicker(ttl / 3)
  defer t.Stop()
  for {
    select {
    case <-workCtx.Done():
      return
    case <-t.C:
      if err := lock.Refresh(workCtx, ttl, nil); err != nil {
        log.Printf("lock refresh failed: %v", err)
        cancel()
        return
      }
    }
  }
}()
```

The interval is not arbitrary. The README suggests ttl/3 so a single transient Redis blip still leaves room to retry before the lock expires, and it notes that a Refresh error of redislock.ErrNotObtained means the lock was lost through expiry or theft, which is usually fatal for the work in progress. Release stays with the caller, so ownership is explicit.

If your team would rather not write this loop, that is a legitimate reason to pick a different package.

## Fencing tokens close the paused-GC hole, and the resource has to cooperate

Set Options.FenceKey and each acquisition mints a strictly increasing value, incremented atomically, returned by Lock.FenceToken.

```go
lock, err := locker.Obtain(ctx, "my-key", time.Second, &redislock.Options{FenceKey: "my-key:fence"})
if err != nil {
  log.Fatalln(err)
}
defer lock.Release(ctx)

if token := lock.FenceToken(); token != 0 {
  fmt.Printf("fenced write with token %d\n", token)
}
```

The scenario it addresses is a lock holder that pauses for a garbage collection or a scheduler stall long enough to lose the lock without noticing. Its writes then carry a stale token and the resource refuses them. The README names the source of this approach as Martin Kleppmann's writing on distributed locking.

The cost is that enforcement is not free and not local. redislock mints and returns the token, but the check, rejecting an incoming value lower than the highest applied one, must happen atomically at the resource, because that is the only place where the comparison and the write can be a single operation. FenceToken is 0 when no FenceKey is set, so the stamping code has to handle that case.

## Cluster slot placement and failover monotonicity are the two sharp edges

Two failure modes are documented and both are Redis deployment issues rather than code issues.

The first is slot placement. You supply the FenceKey, so you control where it lives. On Redis Cluster it must hash to the same slot as the lock keys, and the README gives the mechanism: share a hashtag, for example a lock at {job}:lock with a fence at {job}:fence. If they land in different slots the Lua script cannot touch both and fails with CROSSSLOT. This is a configuration mistake that shows up at the first acquisition in a clustered deployment, not at review time.

The second is monotonicity. The counter persists across a Release and keeps increasing; Release never resets it. But the token is only as monotonic as the Redis underneath it. On a single instance it is strict. On a Sentinel or Cluster failover that loses the INCR, the value can regress. For strict cross-failover monotonicity the README says to source the token from a linearizable store instead, which means this feature is not sufficient on its own for a system where failover is expected.

## Maintenance signals: active commits, no releases, and a generated README

The last push was on 2026-06-27 and the repository publishes no GitHub releases, so there is no version to pin from GitHub tags and no release note to read against. In practice the module is consumed by its import path, so the question is whether the go-redis v9 line keeps moving compatibly underneath it.

The repository layout tells you how the project is maintained. README.md is generated: the Makefile has a doc target that depends on README.md.tpl and the Go files, and runs becca to render it. That means the README you are reading is partly machine produced from the source, which is why the examples match the current function signatures so precisely.

The Makefile also gives you the contributor's three commands: go test ./... for the default target, go test ./... -race for test-race, and golangci-lint run for lint. A .golangci.yml at the root configures the linter, and CHANGELOG.md tracks changes. The README badge points at a test workflow in .github/.

On licensing, the repository badge and link state Apache 2.0, though GitHub itself classifies the LICENSE file as a custom licence it cannot categorise, so read the file before relying on the badge.

## Conclusion

Adopt redislock if your Go service needs a Redis lock with an explicit TTL, a Refresh you control, and fencing tokens for a resource that can reject stale writes. Do not adopt it expecting automatic renewal, because the README states the package deliberately does not bundle a watchdog goroutine and leaves refresh cadence and error handling to the application. Verify first that your lock key and FenceKey hash to the same slot if you run Redis Cluster, since a mismatch fails the script with CROSSSLOT, and decide whether a Sentinel or Cluster failover that loses the INCR is acceptable for your token monotonicity requirement.

## FAQ

### Does redislock renew a lock automatically?

No. The README states redislock deliberately does not bundle a built-in watchdog goroutine, and that refresh cadence and error handling are application concerns. The package documents a ticker at ttl/3 that calls Refresh and cancels the work context on failure.

### What happens when Obtain fails because someone else holds the lock?

Obtain returns redislock.ErrNotObtained, which is distinct from a transport or script error. The README's example treats it as an expected outcome rather than a failure.

### How do fencing tokens work in redislock?

Set Options.FenceKey on the Obtain call and each acquisition atomically increments a counter that Lock.FenceToken returns. You stamp writes with the token and the protected resource rejects any write carrying an older one, atomically at the resource.

### Why does my fencing token fail with CROSSSLOT on Redis Cluster?

The FenceKey and the lock key must hash to the same slot. The README advises sharing a hashtag, for example a lock at {job}:lock with a fence at {job}:fence.

### What Go version and dependencies does redislock need?

go.mod declares Go 1.25 and requires github.com/redis/go-redis/v9 at v9.19.0, with cespare/xxhash/v2 and go.uber.org/atomic as indirect dependencies.

## Sources

- [bsm/redislock on GitHub](https://github.com/bsm/redislock)
- [Issues](https://github.com/bsm/redislock/issues)
- [README](https://github.com/bsm/redislock/blob/main/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/bsm-redislock
