181 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package cache
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/config"
"github.com/redis/go-redis/v9"
)
type Redis struct {
client *redis.Client
prefix string
}
type UnlockFunc func(context.Context) error
var ErrRedisUnavailable = errors.New("redis is unavailable")
type FixedWindowRule struct {
Key string
Limit uint64
}
type FixedWindowDecision struct {
Allowed bool
RetryAfter time.Duration
}
// fixedWindowScript consumes every rule in one Redis-side operation. All rate-limit
// keys use one Cluster hash tag at the caller, so EVAL remains atomic without CROSSSLOT.
// Rejected attempts still increment every layer; expiry is assigned only on the first
// increment and is never renewed, preserving true fixed-window behavior.
const fixedWindowScript = `
local allowed = 1
local retry_ms = 0
local window_ms = tonumber(ARGV[1])
for i = 1, #KEYS do
local count = redis.call("INCR", KEYS[i])
if count == 1 then
redis.call("PEXPIRE", KEYS[i], window_ms)
end
local limit = tonumber(ARGV[i + 1])
if count > limit then
allowed = 0
local ttl = redis.call("PTTL", KEYS[i])
if ttl > retry_ms then
retry_ms = ttl
end
end
end
if allowed == 0 and retry_ms <= 0 then
retry_ms = window_ms
end
return {allowed, retry_ms}`
func NewRedis(ctx context.Context, cfg config.RedisConfig) (*Redis, error) {
if !cfg.Enabled {
return nil, nil
}
client := redis.NewClient(&redis.Options{
Addr: cfg.Addr,
Password: cfg.Password,
DB: cfg.DB,
ContextTimeoutEnabled: true,
})
if err := client.Ping(ctx).Err(); err != nil {
_ = client.Close()
return nil, err
}
return &Redis{client: client, prefix: cfg.KeyPrefix}, nil
}
func (r *Redis) Close() error {
if r == nil || r.client == nil {
return nil
}
return r.client.Close()
}
func (r *Redis) Ping(ctx context.Context) error {
if r == nil || r.client == nil {
return nil
}
return r.client.Ping(ctx).Err()
}
func (r *Redis) TryLock(ctx context.Context, key string, owner string, ttl time.Duration) (bool, UnlockFunc, error) {
if r == nil || r.client == nil {
return true, func(context.Context) error { return nil }, nil
}
if strings.TrimSpace(owner) == "" {
return false, nil, errors.New("lock owner is required")
}
redisKey := r.key(key)
ok, err := r.client.SetNX(ctx, redisKey, owner, ttl).Result()
if err != nil || !ok {
return ok, nil, err
}
return true, func(unlockCtx context.Context) error {
const script = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0`
return r.client.Eval(unlockCtx, script, []string{redisKey}, owner).Err()
}, nil
}
// RefreshLock 只延长仍由当前 owner 持有的锁;任务 worker 续租 DB lease 时同步续租 Redis
// 防止大导出超过初始 TTL 后被另一实例重复执行并覆盖产物。
func (r *Redis) RefreshLock(ctx context.Context, key string, owner string, ttl time.Duration) (bool, error) {
if r == nil || r.client == nil {
return true, nil
}
if strings.TrimSpace(owner) == "" || ttl <= 0 {
return false, errors.New("lock owner and positive ttl are required")
}
const script = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
end
return 0`
result, err := r.client.Eval(ctx, script, []string{r.key(key)}, owner, ttl.Milliseconds()).Int64()
return result == 1, err
}
// ConsumeFixedWindow atomically increments all supplied counters and decides whether
// every limit still allows the request. A nil/disabled Redis is an availability error,
// not an implicit allow: security callers must fail closed instead of falling back to
// per-process memory that attackers could bypass by switching replicas.
func (r *Redis) ConsumeFixedWindow(ctx context.Context, window time.Duration, rules []FixedWindowRule) (FixedWindowDecision, error) {
if r == nil || r.client == nil {
return FixedWindowDecision{}, ErrRedisUnavailable
}
if window < time.Millisecond || len(rules) == 0 {
return FixedWindowDecision{}, errors.New("positive fixed window and at least one rule are required")
}
keys := make([]string, 0, len(rules))
args := make([]any, 0, len(rules)+1)
args = append(args, window.Milliseconds())
seen := make(map[string]struct{}, len(rules))
for _, rule := range rules {
key := strings.TrimSpace(rule.Key)
if key == "" || rule.Limit == 0 {
return FixedWindowDecision{}, errors.New("fixed-window keys and limits must be non-empty")
}
key = r.key(key)
if _, exists := seen[key]; exists {
return FixedWindowDecision{}, fmt.Errorf("duplicate fixed-window key %q", key)
}
seen[key] = struct{}{}
keys = append(keys, key)
args = append(args, strconv.FormatUint(rule.Limit, 10))
}
result, err := r.client.Eval(ctx, fixedWindowScript, keys, args...).Int64Slice()
if err != nil {
return FixedWindowDecision{}, fmt.Errorf("%w: %v", ErrRedisUnavailable, err)
}
if len(result) != 2 || (result[0] != 0 && result[0] != 1) {
return FixedWindowDecision{}, fmt.Errorf("%w: invalid fixed-window response", ErrRedisUnavailable)
}
retryAfter := time.Duration(result[1]) * time.Millisecond
if result[0] == 0 && retryAfter <= 0 {
retryAfter = window
}
return FixedWindowDecision{Allowed: result[0] == 1, RetryAfter: retryAfter}, nil
}
func (r *Redis) key(key string) string {
if r.prefix == "" {
return key
}
return r.prefix + strings.TrimPrefix(key, r.prefix)
}