Skip to content

Redis 缓存层

Vistack 内置了一个通用 Cache-Aside 缓存组件internal/core/cache),一次性解决缓存三大经典问题:穿透、击穿、雪崩。接入点:视频详情、推荐列表等读多写少场景。

设计目标

text
读请求 ──▶ 本地/Redis 缓存命中?── 是 ──▶ 直接返回(快路径)

              否(缓存未命中)

      防穿透:布隆过滤器 + 空值缓存

      防击穿:singleflight 合并 + Redis 互斥锁

      回源 DB → 写缓存(随机 TTL 防雪崩)

三大问题与对策

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

问题:请求的数据在缓存和 DB 中都不存在,每次请求都打到 DB。

对策(两层):

机制说明
第一层布隆过滤器启动时异步全量构建视频 ID 布隆过滤器,Exists(key)==false 直接短路返回,不再查缓存/DB
第二层空值缓存DB 未命中时写入空值哨兵 \x00NULL\x00null_ttl 默认 60s),后续相同 key 直接返回空
go
// GetOrLoad:布隆过滤 + 空值缓存
if co.useBloom && c.bloom != nil {
    if exists, err := c.bloom.Exists(ctx, key); err == nil && !exists {
        return false, nil   // 布隆过滤:key 一定不存在
    }
}
// 回源未命中 → 写空值哨兵
if !found {
    c.client.Set(ctx, key, nullSentinel, c.opts.NullTTL)
}

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

问题:热点 key 过期瞬间,大量并发请求同时回源 DB。

对策(双保险):

  1. singleflight 合并(进程内):同一 key 的并发回源只执行一次,其余等待结果;
  2. Redis 互斥锁(跨实例):拿到锁的实例回源写缓存,未拿到锁的实例自旋等待lock_wait_ms 默认 2s,每 50ms 重读缓存),超时后直接回源(降级)。
go
// singleflight:进程内合并
v, err, _ := c.sf.Do(key, func() (any, error) {
    return c.loadAndCache(ctx, key, loader, co)
})

// loadAndCache:跨实例互斥锁
token, acquired, err := c.acquireLock(ctx, key)  // SetNX key:lock
if acquired {
    defer c.releaseLock(ctx, key, token)          // Lua 校验持有者后删除
    return c.loadAndWrite(ctx, key, loader, co)   // 回源 + 写缓存
}
return c.waitAndRead(ctx, key, loader, c.opts.LockWait)  // 自旋等待

释放锁的细节:用 Lua 脚本校验 token 持有者后再 DEL,防止「A 的锁被 B 误删」。

lua
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

3. 缓存雪崩(大量 key 同时过期 / Redis 宕机)

问题:大量 key 同一时刻过期,或 Redis 不可用,请求全部打到 DB。

对策

场景对策
大量 key 同时过期随机 TTL[cache].default_ttl_min/max(默认 300–600s 随机),天然错峰
Redis 宕机fail-open 降级:所有 Redis 错误走 directLoad 直接回源,业务不中断
go
// 随机 TTL:避免批量过期
ttl := c.randomTTL(co.ttlMin, co.ttlMax)

// Redis 出错:记录日志并降级直连 DB
if err := c.acquireLock(ctx, key); err != nil {
    logger.Error("acquire cache lock failed, degrade to direct load", ...)
    return c.directLoad(ctx, loader)
}

组件 API

go
// 构造
c := cache.New(redisClient, cache.Options{
    DefaultTTL: [2]time.Duration{300 * time.Second, 600 * time.Second}, // 随机 TTL 范围
    NullTTL:    60 * time.Second,   // 空值缓存 TTL
    LockTTL:    5 * time.Second,    // 互斥锁 TTL
    LockWait:   2 * time.Second,    // 未抢到锁的最大等待
    Bloom:      &cache.BloomOptions{Key: "bloom:video", Bits: 10_000_000, Hashes: 7},
})

// 读取(未命中自动回源 + 写缓存)
found, err := c.GetOrLoad(ctx, key, &video, loader,
    cache.WithTTL(300*time.Second, 600*time.Second),
    cache.WithBloom(),          // 按 ID 查询时启用布隆过滤
)

// 写路径失效
c.Delete(ctx, key)

布隆过滤器

go
// internal/core/cache/bloom.go
type Bloom struct {
    client *redis.Client
    key    string
    bits   uint64   // bitmap 位数(默认 10,000,000)
    hashes uint64   // 哈希函数个数(默认 7)
}
  • 基于 Redis bitmap(SETBIT / GETBIT)+ 双重哈希实现;
  • api 启动时异步全量构建视频 ID(best-effort,失败仅记日志);
  • 误判率与 bits / hashes 相关,bloom_bits=10^7, bloom_hashes=7 下对 10 万级 ID 误判率可忽略。

配置

配置说明默认
[cache].enabled总开关true
[cache].default_ttl_min / max随机 TTL 范围(秒)300 / 600
[cache].null_ttl空值缓存 TTL(秒)60
[cache].lock_ttl互斥锁 TTL(秒)5
[cache].lock_wait_ms未抢到锁最大等待(毫秒)2000
[cache].recommend_ttl推荐列表 TTL(秒)300
[cache].bloom_enabled布隆过滤器开关true
[cache].bloom_bits / bloom_hashes布隆参数10^7 / 7

演进方向(roadmap)

  • 布隆过滤器定期重建(防 Add 失败导致的假阴性);
  • 点赞/播放计数 Redis 持久化与重启重建。

基于 MIT License 发布