跳到主要内容

设计缓存系统

问题

如何用 Go 设计缓存系统?如何解决缓存穿透、击穿、雪崩问题?

答案

多级缓存架构

本地缓存(ristretto)

import "github.com/dgraph-io/ristretto"

func NewLocalCache() *ristretto.Cache {
cache, _ := ristretto.NewCache(&ristretto.Config{
NumCounters: 1e7, // 跟踪频率的 key 数量
MaxCost: 1 << 30, // 最大内存 1GB
BufferItems: 64, // 写入缓冲
})
return cache
}

// 使用
func GetUser(cache *ristretto.Cache, rdb *redis.Client, db *gorm.DB, id string) (*User, error) {
// L1: 本地缓存
if val, ok := cache.Get(id); ok {
return val.(*User), nil
}

// L2: Redis
data, err := rdb.Get(context.Background(), "user:"+id).Bytes()
if err == nil {
var user User
json.Unmarshal(data, &user)
cache.Set(id, &user, 1) // 回填本地缓存
return &user, nil
}

// L3: 数据库
var user User
if err := db.First(&user, "id = ?", id).Error; err != nil {
return nil, err
}

// 回填两级缓存
bytes, _ := json.Marshal(&user)
rdb.Set(context.Background(), "user:"+id, bytes, 30*time.Minute)
cache.SetWithTTL(id, &user, 1, 5*time.Minute)
return &user, nil
}

三大缓存问题及解决

1. 缓存穿透(查不存在的数据)

// 方案一:布隆过滤器
type BloomFilter struct {
bits []bool
size uint
}

// 方案二:缓存空值
func GetWithNullCache(rdb *redis.Client, db *gorm.DB, key string) (*User, error) {
val, err := rdb.Get(ctx, key).Result()
if err == nil {
if val == "NULL" {
return nil, nil // 缓存了空值
}
// 解析返回...
}

var user User
if err := db.First(&user, "id = ?", key).Error; err != nil {
// 不存在,缓存空值(短过期)
rdb.Set(ctx, key, "NULL", 2*time.Minute)
return nil, nil
}

// 存在,正常缓存
data, _ := json.Marshal(&user)
rdb.Set(ctx, key, data, 30*time.Minute)
return &user, nil
}

2. 缓存击穿(热点 key 过期)

import "golang.org/x/sync/singleflight"

var sf singleflight.Group

// singleflight: 同一 key 的并发请求只查一次 DB
func GetWithSingleFlight(rdb *redis.Client, db *gorm.DB, id string) (*User, error) {
cacheKey := "user:" + id

// 先查缓存
if data, err := rdb.Get(ctx, cacheKey).Bytes(); err == nil {
var user User
json.Unmarshal(data, &user)
return &user, nil
}

// singleflight 合并并发请求
val, err, _ := sf.Do(cacheKey, func() (interface{}, error) {
var user User
if err := db.First(&user, "id = ?", id).Error; err != nil {
return nil, err
}
data, _ := json.Marshal(&user)
rdb.Set(ctx, cacheKey, data, 30*time.Minute)
return &user, nil
})

if err != nil {
return nil, err
}
user := val.(*User)
return user, nil
}

3. 缓存雪崩(大量 key 同时过期)

// 过期时间加随机值,避免同时失效
func SetWithJitter(rdb *redis.Client, key string, value interface{}, baseTTL time.Duration) {
// 基础 30 分钟 + 随机 0~5 分钟
jitter := time.Duration(rand.Int63n(int64(5 * time.Minute)))
ttl := baseTTL + jitter

data, _ := json.Marshal(value)
rdb.Set(context.Background(), key, data, ttl)
}

缓存一致性

策略做法一致性适用场景
Cache Aside读: 先缓存后 DB;写: 先更新 DB 后删缓存最终一致大多数场景(推荐)
延迟双删删缓存 → 更新 DB → 延迟再删缓存更强读写并发高
订阅 BinlogCanal 监听 DB 变更,异步更新缓存异步一致数据库驱动
Cache Aside 模式

写操作:先更新数据库,再删除缓存(不是更新缓存)。删除比更新更安全,因为更新可能导致旧值覆盖新值。


常见面试问题

Q1: 为什么是删缓存而不是更新缓存?

答案:并发写入时,两个请求同时更新数据库和缓存,由于网络延迟,可能出现缓存最终保存了旧值。删缓存让下一次读自动重建,不会出现不一致。

Q2: singleflight 和分布式锁的区别?

答案

  • singleflight:合并同一进程内的并发请求,轻量高效
  • 分布式锁:跨进程互斥,更重但覆盖面更广
  • 通常先用 singleflight,极端热点再考虑分布式锁

Q3: 本地缓存和 Redis 的过期时间怎么设?

答案:本地缓存 TTL < Redis TTL。如本地 5 分钟、Redis 30 分钟。本地缓存过期后从 Redis 取,减少 DB 压力。

相关链接