设计缓存系统
问题
如何用 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 → 延迟再删缓存 | 更强 | 读写并发高 |
| 订阅 Binlog | Canal 监听 DB 变更,异步更新缓存 | 异步一致 | 数据库驱动 |
Cache Aside 模式
写操作:先更新数据库,再删除缓存(不是更新缓存)。删除比更新更安全,因为更新可能导致旧值覆盖新值。
常见面试问题
Q1: 为什么是删缓存而不是更新缓存?
答案:并发写入时,两个请求同时更新数据库和缓存,由于网络延迟,可能出现缓存最终保存了旧值。删缓存让下一次读自动重建,不会出现不一致。
Q2: singleflight 和分布式锁的区别?
答案:
- singleflight:合并同一进程内的并发请求,轻量高效
- 分布式锁:跨进程互斥,更重但覆盖面更广
- 通常先用 singleflight,极端热点再考虑分布式锁
Q3: 本地缓存和 Redis 的过期时间怎么设?
答案:本地缓存 TTL < Redis TTL。如本地 5 分钟、Redis 30 分钟。本地缓存过期后从 Redis 取,减少 DB 压力。