Files
backend/internal/cache/idempotency.go

123 lines
4.2 KiB
Go
Raw Permalink Normal View History

package cache
import (
"context"
"time"
redislib "github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// IdempotencyResult 描述幂等检查的结果。
type IdempotencyResult struct {
// Acquired 为 true 表示当前请求成功抢占到幂等键,调用方应执行业务逻辑。
// 为 false 表示检测到重复请求ExistingValue 为先前写入的值(可能为空,
// 表示前一个请求仍在进行中——即"进行中"占位)。
Acquired bool
// ExistingValue 命中已有幂等记录时的值(如已创建的 postID
// Acquired=true 时为空字符串。
ExistingValue string
}
// TryAcquireIdempotency 原子抢占幂等键。
//
// 流程:
// 1. 读取幂等键。若已有值,视为重复请求,返回 (Acquired=false, ExistingValue=...)。
// 2. 若无值,用 SetNX 写入空占位(标记"进行中")。成功则获得执行权 (Acquired=true)。
//
// 降级:当 Redis 不可用cache 非 *LayeredCache/*RedisCache或底层 client 为 nil
// 返回 (Acquired=true),即不阻断业务(与现有 call_service 的降级策略一致),
// 此时幂等性退化为依赖前端防抖。
//
// 调用方在业务成功后必须调用 CompleteIdempotency 写入真实结果,以便后续重试可返回同一资源。
func TryAcquireIdempotency(ctx context.Context, c Cache, key string, ttl time.Duration) IdempotencyResult {
if c == nil {
return IdempotencyResult{Acquired: true}
}
// 1) 先查命中:若已有"完成值",直接复用,避免重复创建。
if existing, ok := GetTyped[string](c, key); ok && existing != "" {
return IdempotencyResult{Acquired: false, ExistingValue: existing}
}
// 2) 用 SetNX 写入空占位,原子抢占执行权。
// 占位为空字符串:表示"进行中"。后续命中时若值为空,视为并发请求进行中,
// 直接拒绝Acquired=false 且 ExistingValue="")。
rdb := resolveRedisClient(c)
if rdb == nil {
// Redis 不可用:降级放行,依赖前端防抖。
return IdempotencyResult{Acquired: true}
}
ok, err := rdb.SetNX(ctx, normalizeKey(key), "", ttl).Result()
if err != nil {
zap.L().Warn("idempotency SetNX failed, falling back to non-idempotent create",
zap.String("key", key),
zap.Error(err),
)
return IdempotencyResult{Acquired: true}
}
if !ok {
// 已被其他请求抢占(进行中或已完成但值尚未回填)。
// 读取实际值以区分"进行中"与"已完成"。
if existing, ok := GetTyped[string](c, key); ok && existing != "" {
return IdempotencyResult{Acquired: false, ExistingValue: existing}
}
return IdempotencyResult{Acquired: false}
}
return IdempotencyResult{Acquired: true}
}
// CompleteIdempotency 在业务成功后回填幂等键的真实结果值(如 postID
// 使后续重复请求能返回同一资源。失败时仅记录日志,不影响业务结果。
func CompleteIdempotency(ctx context.Context, c Cache, key, value string, ttl time.Duration) {
if c == nil || value == "" {
return
}
// 直接覆盖占位为真实值,保留原 TTL。
rdb := resolveRedisClient(c)
if rdb == nil {
// 降级:用通用 Set 写入LayeredCache/RedisCache 都支持)。
c.Set(key, value, ttl)
return
}
if err := rdb.Set(ctx, normalizeKey(key), value, ttl).Err(); err != nil {
zap.L().Warn("idempotency complete Set failed",
zap.String("key", key),
zap.Error(err),
)
}
}
// ReleaseIdempotency 在业务失败时清除占位,允许重试。
func ReleaseIdempotency(ctx context.Context, c Cache, key string) {
if c == nil {
return
}
// 仅当当前值仍为空占位时才删除,避免误删已回填的真实值。
if existing, ok := GetTyped[string](c, key); ok && existing != "" {
return
}
c.Delete(key)
}
// resolveRedisClient 从 Cache 实现中解析出底层 *redis.Client用于 SetNX 原子操作。
// 返回 nil 表示 Redis 不可用(如纯内存 LRU 模式)。
func resolveRedisClient(c Cache) *redislib.Client {
switch v := c.(type) {
case *LayeredCache:
client := v.GetRedisClient()
if client == nil {
return nil
}
return client.GetClient()
case *RedisCache:
client := v.GetRedisClient()
if client == nil {
return nil
}
return client.GetClient()
}
return nil
}