181 lines
5.4 KiB
Go
181 lines
5.4 KiB
Go
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)
|
||
}
|