redislock

repository·main·Indexed 23 days ago

https://github.com/bsm/redislock

A simplified distributed locking implementation for Go using Redis. It provides mechanisms for obtaining and releasing single or multiple locks, refreshing lock TTLs, and using monotonically increasing fencing tokens to prevent stale writes caused by process pauses. The library includes customizable retry strategies such as Linear, Exponential, and Limited backoff, and integrates with the go-redis/v9 client.

Tokens
2.6K
Snippets
3
Records
13
Agent score
81%

What's inside redislock

  1. How fencing tokens work to prevent stale writes

    main

    A fencing token is a strictly increasing value used to protect resources from clients that lose their lock (e.g., due to a long GC pause) but attempt to perform writes anyway.

    To use them:

    1. Set Options.FenceKey in locker.Obtain. This key is used to store the monotonic counter in Redis.
    2. Retrieve the token using lock.FenceToken().
    3. Include this token in every write operation to your protected resource.
    4. The resource must atomically check that the incoming token is greater than or equal to the highest token it has already processed. If the token is older, the write must be rejected.

    Important Notes:

    • Redis Cluster Compatibility: The FenceKey must hash to the same slot as the lock key. Use Redis hash tags (e.g., lock key {job}:lock and fence key {job}:fence) to ensure they reside on the same node.
    • Monotonicity: On a single Redis instance, the token is strictly monotonic. In a Sentinel or Cluster failover scenario, the token might regress if the INCR operation is lost during failover. For absolute cross-failover monotonicity, use a linearizable store.
    • Persistence: The counter persists across lock releases and continues to increment.
    // 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)
    }
  2. Obtain and release a distributed lock

    main

    To use redislock, create a new locker using redislock.New(client) where client is a *redis.Client. Use locker.Obtain(ctx, key, ttl, options) to attempt to acquire a lock. If the lock cannot be acquired, it returns redislock.ErrNotObtained. Once obtained, you must ensure the lock is released by calling lock.Release(ctx), typically via defer. You can also check the remaining time on the lock using lock.TTL(ctx) and extend the lock duration using lock.Refresh(ctx, ttl, options).

    import (
    	"context"
    	"fmt"
    	"log"
    	"time"
    
    	"github.com/bsm/redislock"
    	"github.com/redis/go-redis/v9"
    )
    
    func main() {
    	client := redis.NewClient(&redis.Options{
    		Network: "tcp",
    		Addr:    "127.0.0.1:6379",
    	})
    	defer client.Close()
    
    	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!")
    
    	// Extend my lock.
    	if err := lock.Refresh(ctx, 100*time.Millisecond, nil); err != nil {
    		log.Fatalln(err)
    	}
    }
  3. Implement an external watchdog to refresh locks

    main

    redislock does not provide an internal background goroutine for refreshing locks. If you need a lock to outlive its initial TTL, you must implement your own watchdog pattern.

    Best Practices:

    • Refresh Interval: Set the refresh interval to approximately ttl/3. This provides enough buffer to handle transient Redis connectivity issues before the lock actually expires.
    • Error Handling: If lock.Refresh returns redislock.ErrNotObtained, it means the lock was lost or stolen. In this case, you should immediately cancel the context used for the protected work to prevent further operations with an invalid lock.
    • Ownership: The caller remains responsible for calling Release.
    // 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.
    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() // Stop the work if refresh fails
    				return
    			}
    		}
    	}
    }()
    
    // ... do work using workCtx ...
  4. Use Fencing Tokens to prevent race conditions

    main

    When Options.FenceKey is provided, redislock implements a fencing mechanism. A fencing token is a monotonically increasing integer minted at the FenceKey every time a lock is successfully acquired.

    By calling lock.FenceToken(), you can obtain this integer. This token can be passed to downstream services (like a database) to ensure that if a process loses its lock (e.g., due to a long GC pause) and a new process acquires the lock, the old process's late-arriving requests are rejected because they carry an older (smaller) fencing token.

  5. Configure lock retry strategies with RetryStrategy

    main

    The RetryStrategy interface allows you to customize how the library waits between attempts to acquire a lock. By implementing or using the provided factory functions, you can control the backoff duration returned by NextBackoff() for each subsequent retry attempt.

    Available strategies include:

    • LinearBackoff(backoff time.Duration): Retries at a constant interval.
    • NoRetry(): Attempts to acquire the lock only once without retrying.
    • LimitRetry(s RetryStrategy, max int): A decorator that wraps an existing strategy and limits the total number of retry attempts to max.
    • ExponentialBackoff(min, max time.Duration): Doubles the wait time between retries (starting at 4ms), clamped between min and max bounds.
  6. Configure lock acquisition with Options

    main

    The Options struct allows you to customize how a lock is acquired via Obtain or ObtainMulti:

    • RetryStrategy: Customizes how the client retries acquisition if the lock is held. Defaults to no retry.
    • Metadata: A string that is stored alongside the lock value.
    • Token: A custom unique value used to identify the lock. If empty, a random token is generated.
    • FenceKey: Enables fencing. A FenceKey is used to mint a monotonically increasing fencing token. On Redis Cluster, this key must hash to the same slot as the lock key(s).
  7. Use LimitRetry to cap the number of attempts

    main
    LimitRetry is a decorator that wraps another RetryStrategy. It tracks the number of attempts and returns 0 (signaling no more retries) once the max number of attempts has been exceeded.
  8. Initialize a redislock Client

    main

    To use redislock, you must first create a Client by wrapping an existing RedisClient. The RedisClient interface is a minimal wrapper around redis.Scripter (from github.com/redis/go-redis/v9).

    You can use the New function to create a client instance.

  9. Manage an acquired Lock

    main

    Once a Lock is obtained, you can interact with it using the following methods:

    • Key(): Returns the first Redis key used by the lock.
    • Keys(): Returns all Redis keys used by the lock.
    • Token(): Returns the unique identifier (token) for the lock.
    • Metadata(): Returns any additional metadata string provided during acquisition.
    • TTL(ctx): Returns the remaining time-to-live for the lock. Returns 0 if the lock has expired or if the lock is not held.
    • Refresh(ctx, ttl, opt): Extends the lock's TTL. Returns ErrNotObtained if the refresh fails (e.g., the lock was lost).
    • Release(ctx): Manually releases the lock. Returns ErrLockNotHeld if the lock is no longer active.
  10. Obtain a single lock with Obtain()

    main

    The Obtain method attempts to acquire a lock for a specific key with a given Time-To-Live (TTL). If the lock is already held by another process, it will return ErrNotObtained (unless a RetryStrategy is provided in the Options).

    Obtain is a convenience wrapper around New(client).Obtain(...).

  11. Use LinearBackoff for constant retry intervals

    main
    Use LinearBackoff when you want to retry lock acquisition at a fixed, regular interval. You can also use NoRetry() to achieve a single attempt with zero backoff.
  12. Obtain multiple locks with ObtainMulti()

    main

    The ObtainMulti method attempts to acquire locks for a slice of keys atomically. If any of the requested keys are already locked, no keys are locked, and the method returns ErrNotObtained. This ensures an all-or-nothing acquisition pattern.

    ObtainMulti is a convenience wrapper around New(client).ObtainMulti(...).