README
¶
redis-kit
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
Unlockrequires 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.HybridLockerdoes 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.NewHybridLockerreturnslock.ErrRedisUnavailableinstead.NewHybridLockerWithLocalFallbackrestores 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.moddeclaresgo 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
ErrRedisUnavailableinstead of a local lock.HybridLocker.Locktried Redis and fell through toLocalLockeron 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. HandleErrRedisUnavailableand fail closed, or useNewHybridLockerWithLocalFallbackto 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
Unlockcompare-and-deleted with the second holder's token. The map now records the lease deadline too, and anUnlockpast its lease reportsErrLockExpiredwithout issuing the delete. AnUnlockthat 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
ErrLockTrackingLimitwhen full. Acquire/Handleis new, and is the better API: it hands the token to the caller, soReleaseandExtendact on a specific acquisition and need no shared map.Extendalso 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 matchesredis.Nil.cache.Getreturned a plainfmt.Errorf, soerrors.Is(err, redis.Nil)was false and callers had to match on the error text. The original message is preserved. ratelimit.CheckLimitvalidateslimit. Withlimit=0the 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
LICENSEfile is Apache 2.0. - Requirements said Go 1.26;
go.modrequires1.27.0.
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - 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 fmtandgo vetbefore committing - Update documentation as needed
License
Apache License 2.0 — see LICENSE for details.