redis-kit

module
v1.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 12, 2026 License: Apache-2.0

README

redis-kit

Go Reference Go Report Card License codecov

中文文档

A unified Redis utility library for Go projects. This package provides common Redis operations including client management, distributed locking, rate limiting, and caching.

Features

  • Client Management: Unified Redis client initialization and configuration
  • Distributed Locking: Redis-based distributed locks, with an opt-in fallback to local locks
  • Rate Limiting: Flexible rate limiting with support for user/IP/destination-based limits
  • Caching: Generic cache interface with Redis implementation
  • Health Checks: Built-in health check functionality

Installation

go get github.com/soulteary/redis-kit

Usage

Client Management
import (
    "github.com/soulteary/redis-kit/client"
    "github.com/redis/go-redis/v9"
)

// Create a client with default configuration
client, err := client.NewClientWithDefaults("localhost:6379")
if err != nil {
    log.Fatal(err)
}
defer client.Close(client)

// Or use custom configuration
cfg := client.DefaultConfig().
    WithAddr("localhost:6379").
    WithPassword("mypassword").
    WithDB(0).
    WithPoolSize(20)

client, err := client.NewClient(cfg)
Distributed Locking
import "github.com/soulteary/redis-kit/lock"

// Create a Redis locker
locker := lock.NewRedisLocker(client)

// Acquire a lock
success, err := locker.Lock("my-lock-key")
if err != nil {
    log.Fatal(err)
}
if !success {
    log.Println("Lock already held")
    return
}

// Do work...

// Release the lock
defer locker.Unlock("my-lock-key")

// Or use hybrid locker. It uses Redis when a client is given and a
// process-local lock when client is nil. When Redis is merely FAILING it
// returns lock.ErrRedisUnavailable rather than degrading, so the caller can
// fail closed:
hybridLocker := lock.NewHybridLocker(client)

// Opt back in to the old degrade-to-local behaviour only where a second
// concurrent holder is acceptable -- a cache warmer, never a payment:
hybridLocker = lock.NewHybridLockerWithLocalFallback(client)
success, err := hybridLocker.Lock("my-lock-key")

Notes

  • Unlock requires the same process to hold the lock value; unlocking a key without a local lock value returns an error to avoid deleting someone else's lock.
  • HybridLocker does not fall back to a local lock when Redis fails. A process-local lock provides no mutual exclusion between instances, so degrading to it during an outage drops the guarantee at exactly the moment it matters, and the caller cannot tell the difference from the return value. NewHybridLocker returns lock.ErrRedisUnavailable instead.
  • NewHybridLockerWithLocalFallback restores the degrading behaviour explicitly. Mutual exclusion across instances is lost while the fallback is in effect, so use it only where a second concurrent holder is acceptable. A key held through the fallback is not handed out via Redis again until it is released, and its unlock is routed back to the local lock.
Handle API: a lock you can renew

Lock/Unlock identify a lock by key alone, which is why Unlock has to check that this process still holds the lease. Acquire hands you the token instead, so Release and Extend act on that specific acquisition:

locker := lock.NewRedisLocker(client)

handle, err := locker.Acquire(ctx, "my-lock-key", 30*time.Second)
if err != nil {
    return err // includes "already held" — check errors.Is below
}
defer handle.Release(ctx)

log.Printf("holding %s until %s", handle.Key(), handle.ExpiresAt())

// Renew before the lease elapses, for work that outlives it
if err := handle.Extend(ctx, 30*time.Second); err != nil {
    return err // the lease is gone; stop doing the work it protected
}

Without Extend the TTL is fixed at lock.DefaultLockTime (15s) and work that outlives the lease simply loses the lock, with no error anywhere.

Lock errors
Sentinel Meaning
ErrRedisUnavailable Redis failed. NewHybridLocker returns this instead of degrading to a local lock — fail closed.
ErrLockExpired The lease elapsed before Unlock/Release. No delete was issued, so a later holder's lock is untouched.
ErrLockNotHeld This process holds no lock value for the key.
ErrLockValueMismatch The stored value is not this process's token.
ErrLockValueType The stored value is not the expected type.
ErrLockTrackingLimit The process-wide lock map is full. Prefer Acquire/Handle, which needs no map.

Match them with errors.Is.

An Unlock whose lease has already elapsed reports ErrLockExpired without issuing the delete, so it cannot release a key a later holder has since acquired. Tracked entries are swept periodically, so a process that acquires and never releases — panic, early return, expiry — does not grow the map without bound.

Rate Limiting
import (
    "github.com/soulteary/redis-kit/ratelimit"
    "time"
)

// Create a rate limiter
limiter := ratelimit.NewRateLimiter(client)

// Check rate limit
allowed, remaining, resetTime, err := limiter.CheckLimit(
    ctx,
    "user:123",
    10,                    // limit: 10 requests
    1 * time.Hour,         // window: 1 hour
)

// Check cooldown
allowed, resetTime, err := limiter.CheckCooldown(
    ctx,
    "challenge:abc",
    60 * time.Second,      // cooldown: 60 seconds
)

// Convenience methods
allowed, remaining, resetTime, err := limiter.CheckUserLimit(ctx, "user123", 10, time.Hour)
allowed, remaining, resetTime, err := limiter.CheckIPLimit(ctx, "192.168.1.1", 5, time.Minute)
allowed, remaining, resetTime, err := limiter.CheckDestinationLimit(ctx, "user@example.com", 10, time.Hour)

Notes

  • Rate limiting and cooldown checks use Redis Lua scripts (EVAL) to ensure atomicity; make sure scripts are allowed in your Redis deployment.

CheckLimit validates limit: a limit of 0 is rejected rather than treated as "no counter yet", which used to admit the first request of every window — so "allow nothing" let traffic through.

Caching
import "github.com/soulteary/redis-kit/cache"

// Create a cache with key prefix
c := cache.NewCache(client, "myapp:")

// Set a value
type User struct {
    ID   string
    Name string
}
user := User{ID: "123", Name: "Alice"}
err := c.Set(ctx, "user:123", user, 1*time.Hour)

// Get a value. A miss is ErrKeyNotFound, which also matches redis.Nil.
var retrievedUser User
if err := c.Get(ctx, "user:123", &retrievedUser); err != nil {
    if errors.Is(err, cache.ErrKeyNotFound) || errors.Is(err, redis.Nil) {
        // not cached
    }
    return err
}

// Check existence
exists, err := c.Exists(ctx, "user:123")

// Delete
err := c.Del(ctx, "user:123")

// Get TTL
ttl, err := c.TTL(ctx, "user:123")

// Set expiration
err := c.Expire(ctx, "user:123", 2*time.Hour)
Health Checks
import "github.com/soulteary/redis-kit/client"

// Simple health check
healthy := client.HealthCheck(ctx, client)

// Detailed health status
status := client.CheckHealth(ctx, client)
if !status.Healthy {
    log.Printf("Redis unhealthy: %v (latency: %v)", status.Error, status.Latency)
}

Project Structure

redis-kit/
├── client/          # Client initialization and management
├── lock/            # Distributed locking
├── ratelimit/       # Rate limiting
├── cache/           # Generic caching interface
├── utils/           # Utility functions
└── testutil/        # Testing utilities (mock Redis)

Requirements

  • Go 1.27+ (go.mod declares go 1.27.0)
  • Redis server (optional for testing, mock Redis is provided)

Test Coverage

The library includes comprehensive tests with mock Redis support, so you can run tests without a real Redis instance:

# Run all tests
go test ./... -v

# Run tests with coverage
go test ./... -coverprofile=coverage.out -covermode=atomic

# Generate HTML coverage report
go tool cover -html=coverage.out -o coverage.html

# View coverage summary
go tool cover -func=coverage.out

Examples

Complete Example: Rate-Limited Cache with Locking
package main

import (
    "context"
    "fmt"
    "time"
    
    "github.com/soulteary/redis-kit/cache"
    "github.com/soulteary/redis-kit/client"
    "github.com/soulteary/redis-kit/lock"
    "github.com/soulteary/redis-kit/ratelimit"
)

func main() {
    ctx := context.Background()
    
    // Initialize Redis client
    redisClient, err := client.NewClientWithDefaults("localhost:6379")
    if err != nil {
        panic(err)
    }
    defer redisClient.Close()
    
    // Health check
    if !client.HealthCheck(ctx, redisClient) {
        panic("Redis is not healthy")
    }
    
    // Create cache
    userCache := cache.NewCache(redisClient, "user:")
    
    // Create locker
    locker := lock.NewHybridLocker(redisClient)
    
    // Create rate limiter
    limiter := ratelimit.NewRateLimiter(redisClient)
    
    // Example: Get user with caching and rate limiting
    userID := "user123"
    
    // Check rate limit
    allowed, remaining, resetTime, err := limiter.CheckUserLimit(ctx, userID, 10, time.Hour)
    if err != nil {
        panic(err)
    }
    if !allowed {
        fmt.Printf("Rate limit exceeded. Reset at: %v\n", resetTime)
        return
    }
    fmt.Printf("Rate limit OK. Remaining: %d\n", remaining)
    
    // Try to acquire lock
    lockKey := fmt.Sprintf("user:%s:lock", userID)
    acquired, err := locker.Lock(lockKey)
    if err != nil {
        panic(err)
    }
    if !acquired {
        fmt.Println("Could not acquire lock")
        return
    }
    defer locker.Unlock(lockKey)
    
    // Check cache first
    type User struct {
        ID   string
        Name string
    }
    var user User
    exists, err := userCache.Exists(ctx, userID)
    if err != nil {
        panic(err)
    }
    
    if exists {
        // Cache hit
        err = userCache.Get(ctx, userID, &user)
        if err != nil {
            panic(err)
        }
        fmt.Printf("Cache hit: %+v\n", user)
    } else {
        // Cache miss - fetch from database
        user = User{ID: userID, Name: "Alice"}
        
        // Store in cache
        err = userCache.Set(ctx, userID, user, 1*time.Hour)
        if err != nil {
            panic(err)
        }
        fmt.Printf("Cached: %+v\n", user)
    }
}

Upgrade Notes (v1.6.0)

HybridLocker no longer degrades to a local lock. That is the change most likely to surface in a deployment, and it is deliberate.

  • A Redis failure returns ErrRedisUnavailable instead of a local lock. HybridLocker.Lock tried Redis and fell through to LocalLocker on any error. A process-local lock provides no exclusion between instances, so during a Redis outage every instance took its own "lock" and believed it held the key — and the caller saw (true, nil), indistinguishable from a real distributed lock. The guarantee disappeared at exactly the moment it mattered. Handle ErrRedisUnavailable and fail closed, or use NewHybridLockerWithLocalFallback to keep the old behaviour where a second concurrent holder is genuinely acceptable — a cache warmer, never a payment.
  • A stale holder can no longer release someone else's lock. The token lived in a process-wide map keyed by the lock key, so a second acquisition of the same key overwrote the first holder's token and the first holder's Unlock compare-and-deleted with the second holder's token. The map now records the lease deadline too, and an Unlock past its lease reports ErrLockExpired without issuing the delete. An Unlock that used to succeed spuriously now returns an error — which is the correct answer.
  • The lock map is swept and bounded. A process that acquired and never released grew it without bound; it now reports ErrLockTrackingLimit when full.
  • Acquire/Handle is new, and is the better API: it hands the token to the caller, so Release and Extend act on a specific acquisition and need no shared map. Extend also fills in the missing renewal path — the TTL was fixed at 15s with no way to refresh it, so work outliving the lease lost the lock silently.
  • A cache miss carries ErrKeyNotFound, and matches redis.Nil. cache.Get returned a plain fmt.Errorf, so errors.Is(err, redis.Nil) was false and callers had to match on the error text. The original message is preserved.
  • ratelimit.CheckLimit validates limit. With limit=0 the Lua script took its "no counter yet" branch and admitted the first request of every window, so "allow nothing" let traffic through.
  • The License badge said MIT. The LICENSE file is Apache 2.0.
  • Requirements said Go 1.26; go.mod requires 1.27.0.

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add some amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request
Development Guidelines
  • Follow Go best practices and conventions
  • Add tests for new features
  • Ensure all tests pass (go test ./...)
  • Run go fmt and go vet before committing
  • Update documentation as needed

License

Apache License 2.0 — see LICENSE for details.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL