Files
team-api/internal/logic/common/cache.go
T

308 lines
9.7 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 common
import (
"context"
"encoding/json"
"math"
"math/rand/v2"
"reflect"
"time"
"github.com/gogf/gf/v2/database/gredis"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcache"
"github.com/gogf/gf/v2/util/gconv"
"golang.org/x/sync/singleflight"
)
// Cache provides a two-level caching mechanism:
// L1: In-memory cache (GoFrame gcache, handles Go structs natively)
// L2: Redis cache (stores JSON-serialized values, deserializes on read)
//
// Write: Set L1 (native) + Set L2 (JSON)
// Read: Get L1 → miss → Get L2 (JSON → struct) → miss → call fn → Set L1+L2
type Cache struct {
prefix string
ttl time.Duration
group singleflight.Group
}
func jitterTTL(ttl time.Duration) time.Duration {
if ttl <= 0 {
return ttl
}
spread := ttl / 10
if spread <= 0 {
return ttl
}
delta := time.Duration(rand.Int64N(int64(spread)*2+1)) - spread
return ttl + delta
}
// NewCache creates a new Cache instance with the given prefix and default TTL.
func NewCache(prefix string, ttl time.Duration) *Cache {
return &Cache{
prefix: prefix,
ttl: ttl,
}
}
// fullKey returns the full cache key with prefix.
func (c *Cache) fullKey(key string) string {
return c.prefix + ":" + key
}
// Set sets a value in both L1 (memory) and L2 (Redis) caches.
// L1 stores the value natively (Go struct pointers work).
// L2 stores a JSON-serialized copy for cross-process compatibility.
func (c *Cache) Set(ctx context.Context, key string, value any, ttl ...time.Duration) {
expire := c.ttl
if len(ttl) > 0 && ttl[0] > 0 {
expire = ttl[0]
}
expire = jitterTTL(expire)
fullKey := c.fullKey(key)
// L1: memory cache (native Go struct storage)
gcache.Set(ctx, fullKey, value, expire)
// L2: Redis cache (JSON serialized)
ttlSeconds := int64(math.Ceil(expire.Seconds()))
jsonBytes, err := json.Marshal(value)
if err != nil {
g.Log().Warningf(ctx, "[Cache] JSON marshal failed key=%s: %v", fullKey, err)
return
}
if ttlSeconds > 0 {
_, _ = g.Redis().Do(ctx, "SETEX", fullKey, ttlSeconds, string(jsonBytes))
} else {
// 兜底:ttl ≤ 0 时使用 24h 兜底 TTL,避免产生永久 key
g.Log().Warningf(ctx, "[Cache] TTL <= 0 for key=%s, using fallback 24h TTL", fullKey)
_, _ = g.Redis().Do(ctx, "SETEX", fullKey, 86400, string(jsonBytes))
}
}
// Get retrieves a value: L1 → L2 → miss.
// L1 returns native Go types. L2 deserializes JSON back to the original type.
func (c *Cache) Get(ctx context.Context, key string) (any, bool) {
fullKey := c.fullKey(key)
// Try L1: memory (native Go struct)
val, err := gcache.Get(ctx, fullKey)
if err == nil && val != nil {
return val.Interface(), true
}
// Try L2: Redis (JSON → deserialize)
redisVal, err := g.Redis().Do(ctx, "GET", fullKey)
if err == nil && !redisVal.IsNil() {
jsonStr := redisVal.String()
if jsonStr != "" {
// Unmarshal JSON to get the original Go value
var raw any
if unmarshalErr := json.Unmarshal([]byte(jsonStr), &raw); unmarshalErr == nil {
gcache.Set(ctx, fullKey, raw, jitterTTL(c.ttl))
return raw, true
}
// Fallback: treat as plain string (backward compat)
gcache.Set(ctx, fullKey, jsonStr, jitterTTL(c.ttl))
return jsonStr, true
}
}
return nil, false
}
// GetJSON retrieves a JSON-serialized value from L2 and unmarshals it into target.
// Use this when caching Go struct pointers to avoid type assertion panics.
func (c *Cache) GetJSON(ctx context.Context, key string, target any) bool {
fullKey := c.fullKey(key)
// Try L1: memory (native Go struct — correct type already)
val, err := gcache.Get(ctx, fullKey)
if err == nil && val != nil {
// L1 has native value; copy via JSON round-trip to populate target
if jsonBytes, err := json.Marshal(val.Interface()); err == nil {
return json.Unmarshal(jsonBytes, target) == nil
}
}
// Try L2: Redis (JSON string)
redisVal, err := g.Redis().Do(ctx, "GET", fullKey)
if err == nil && !redisVal.IsNil() {
jsonStr := redisVal.String()
if jsonStr != "" {
if unmarshalErr := json.Unmarshal([]byte(jsonStr), target); unmarshalErr != nil {
g.Log().Warningf(ctx, "[Cache] JSON unmarshal failed key=%s: %v", fullKey, unmarshalErr)
return false
}
// Backfill L1:存反序列化副本而非 target 本身。target 指针归调用方所有,
// 若调用方在 GetJSON 返回后就地改写(如定价缓存按时段重评乘数),
// 存储别名会与并发 L1 读者的 json.Marshal 构成数据竞争。
// 副本失败时退回存 target(此时调用方须自律不改写,与历史行为一致)
if cp, cpErr := unmarshalCopy(jsonStr, target); cpErr == nil {
gcache.Set(ctx, fullKey, cp, jitterTTL(c.ttl))
} else {
gcache.Set(ctx, fullKey, target, jitterTTL(c.ttl))
}
return true
}
}
return false
}
// unmarshalCopy 按 target 的具体类型从 JSON 生成一个独立副本(L1 回填用,避免
// L1 持有调用方 target 指针的别名)。target 必须是非 nil 指针(调用前已成功
// unmarshal 到 target,天然满足)。
func unmarshalCopy(jsonStr string, target any) (any, error) {
t := reflect.TypeOf(target)
if t == nil || t.Kind() != reflect.Pointer {
return nil, gerror.New("cache: unmarshalCopy target must be a non-nil pointer")
}
cp := reflect.New(t.Elem()).Interface()
if err := json.Unmarshal([]byte(jsonStr), cp); err != nil {
return nil, err
}
return cp, nil
}
// GetOrSet retrieves a value or calls fn to set it if missing.
func (c *Cache) GetOrSet(ctx context.Context, key string, fn func(ctx context.Context) (any, error)) (any, error) {
if val, ok := c.Get(ctx, key); ok {
return val, nil
}
val, err, _ := c.group.Do(c.fullKey(key), func() (any, error) {
if cached, ok := c.Get(ctx, key); ok {
return cached, nil
}
loaded, loadErr := fn(ctx)
if loadErr != nil {
return nil, loadErr
}
c.Set(ctx, key, loaded)
return loaded, nil
})
return val, err
}
// Delete removes a value from both L1 and L2.
func (c *Cache) Delete(ctx context.Context, key string) {
fullKey := c.fullKey(key)
// L1
gcache.Remove(ctx, fullKey)
// L2
_, _ = g.Redis().Do(ctx, "DEL", fullKey)
// Publish invalidation for other instances
_, _ = g.Redis().Do(ctx, "PUBLISH", "cache:invalidate", fullKey)
}
// DeleteByPattern removes all cache entries matching the pattern.
// Uses SCAN + DEL to properly handle wildcard patterns (Redis DEL does not support wildcards).
// 同时清除 L1(gcache)并发布跨实例失效通知,与 Delete 行为对齐。
func (c *Cache) DeleteByPattern(ctx context.Context, pattern string) {
fullPattern := c.fullKey(pattern)
// 先收集所有匹配的 key,再统一处理 L1/L2/pub/sub
var allKeys []string
cursor := int64(0)
for {
result, err := g.Redis().Do(ctx, "SCAN", cursor, "MATCH", fullPattern, "COUNT", 100)
if err != nil {
g.Log().Warningf(ctx, "[Cache] SCAN failed pattern=%s: %v", fullPattern, err)
break
}
slice := result.Slice()
if len(slice) < 2 {
break
}
cursor = gconv.Int64(slice[0])
allKeys = append(allKeys, gconv.Strings(slice[1])...)
if cursor == 0 {
break
}
}
if len(allKeys) == 0 {
return
}
// L2: 批量删除 Redis(比逐条快)
delArgs := make([]any, len(allKeys))
for i, k := range allKeys {
delArgs[i] = k
}
_, _ = g.Redis().Do(ctx, "DEL", delArgs...)
// L1 + 跨实例失效:逐 key 清本进程内存缓存并发布失效通知
// (gcache 不支持通配符删除,只能逐 key;PUBLISH 触发其他实例的订阅者清理 L1)
for _, key := range allKeys {
gcache.Remove(ctx, key)
_, _ = g.Redis().Do(ctx, "PUBLISH", "cache:invalidate", key)
}
}
// PublishInvalidation publishes a cache invalidation message via Redis Pub/Sub.
func PublishInvalidation(ctx context.Context, fullKey string) {
_, _ = g.Redis().Do(ctx, "PUBLISH", "cache:invalidate", fullKey)
}
// StartCacheInvalidationSubscriber 订阅 Redis "cache:invalidate" 频道,收到失效通知时移除本进程 L1(gcache)中对应 key。
// C1 修复:此前 Cache.Delete / PublishInvalidation 只向 cache:invalidate 频道 PUBLISH,全仓库无人 SUBSCRIBE,
// 多实例部署下某实例删除 key 后,其他实例的 L1 gcache 会一直陈旧到自然 TTL。此订阅方补齐跨实例 L1 失效。
// 在 cmd.go 启动时调用一次;断线自动重连,语义与 ConfigService.StartSubscriber 一致。
func StartCacheInvalidationSubscriber(ctx context.Context) {
go func() {
var reconnectCount int
for {
conn, _, err := g.Redis().Subscribe(ctx, "cache:invalidate")
if err != nil {
reconnectCount++
g.Log().Warningf(ctx, "[PubSub:cache] 连接失败 (第%d次): %v", reconnectCount, err)
time.Sleep(5 * time.Second)
continue
}
if reconnectCount > 0 {
g.Log().Infof(ctx, "[PubSub:cache] 重连成功 (此前失败%d次)", reconnectCount)
reconnectCount = 0
} else {
g.Log().Info(ctx, "[PubSub:cache] 订阅已启动")
}
for {
v, err := conn.Receive(ctx)
if err != nil {
reconnectCount++
g.Log().Warningf(ctx, "[PubSub:cache] 接收错误 (第%d次): %v", reconnectCount, err)
time.Sleep(5 * time.Second)
break // reconnect
}
msg, ok := v.Val().(*gredis.Message)
if !ok {
continue // skip Subscription/Pong 等
}
// 仅移除本进程 L1;L2(Redis) 由发布方已 DEL,无需重复处理
gcache.Remove(ctx, msg.Payload)
}
conn.Close(ctx)
}
}()
}
// TenantGroupModelCache 缓存租户通过分组可访问的模型集合,TTL 300s
var TenantGroupModelCache = NewCache("tenant_group_models", 300*time.Second)
// TenantModelAccessCache 缓存租户模型访问权限(enabled + channel_scope),TTL 300s
// 缓存键格式:{tenantID}:{modelName}
// 缓存值:{"enabled": true/false, "channel_scope": [1,2,3]}
var TenantModelAccessCache = NewCache("tenant_model_access", 300*time.Second)