The Principal Dev – Masterclass for Tech Leads

The Principal Dev – Masterclass for Tech Leads28-29 May

Join

redislock

Test GoDoc License

Simplified distributed locking implementation using Redis. For more information, please see examples.

Documentation

Full documentation is available on GoDoc

Examples

import (
  "context"
  "fmt"
  "log"
  "time"

  "github.com/bsm/redislock"
  "github.com/redis/go-redis/v9"
)

func main() {
	// Connect to redis.
	client := redis.NewClient(&redis.Options{
		Network:	"tcp",
		Addr:		"127.0.0.1:6379",
	})
	defer client.Close()

	// Create a new lock client.
	locker := redislock.New(client)

	ctx := context.Background()

	// Try to obtain lock.
	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
	}

	// Don't forget to defer Release.
	defer lock.Release(ctx)
	fmt.Println("I have a lock!")

	// Sleep and check the remaining TTL.
	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!")
	}

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

	// Sleep a little longer, then check.
	time.Sleep(100 * time.Millisecond)
	if ttl, err := lock.TTL(ctx); err != nil {
		log.Fatalln(err)
	} else if ttl == 0 {
		fmt.Println("Now, my lock has expired!")
	}

}

Fencing tokens

Set Options.FenceKey to mint a fencing token with the lock: a strictly increasing value, incremented atomically on each new acquisition and returned by Lock.FenceToken. Stamp every write to the protected resource with the token and have the resource reject any write carrying an older token. A lock holder that pauses (GC, scheduling) long enough to lose the lock without noticing is then fenced out — its writes carry a stale token and are refused. This is the mitigation described in Martin Kleppmann's How to do distributed locking.

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

	locker := redislock.New(client)

	ctx := context.Background()

	// Obtain a lock with a fencing token.
	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)

	// FenceToken is 0 without a FenceKey. Stamp writes with the token; reject older ones.
	if token := lock.FenceToken(); token != 0 {
		fmt.Printf("fenced write with token %d\n", token)
	}
}

A few notes:

External watchdog

redislock deliberately does not bundle a built-in watchdog goroutine: refresh cadence and error handling are application concerns (log and continue, retry, cancel the protected work, page someone, etc.). If you need a long-running lock that outlives its initial TTL, drop a small ticker next to your work and let it call Refresh for you:

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

	locker := redislock.New(client)

	ctx := context.Background()

	// Obtain a lock with a 30s TTL.
	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())

	// Start a watchdog that refreshes the lock every ttl/3. The work context
	// is cancelled if a refresh fails so the protected work can abort.
	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
				}
			}
		}
	}()

	// ... do work using workCtx ...
	fmt.Println("I have a lock!")
}

A few notes:

Join libs.tech

...and unlock some superpowers

GitHub

We won't share your data with anyone else.