Redis 缓存层
Vistack 内置了一个通用 Cache-Aside 缓存组件(internal/core/cache),一次性解决缓存三大经典问题:穿透、击穿、雪崩。接入点:视频详情、推荐列表等读多写少场景。
设计目标
text
读请求 ──▶ 本地/Redis 缓存命中?── 是 ──▶ 直接返回(快路径)
│
否(缓存未命中)
▼
防穿透:布隆过滤器 + 空值缓存
▼
防击穿:singleflight 合并 + Redis 互斥锁
▼
回源 DB → 写缓存(随机 TTL 防雪崩)三大问题与对策
1. 缓存穿透(查询不存在的数据)
问题:请求的数据在缓存和 DB 中都不存在,每次请求都打到 DB。
对策(两层):
| 层 | 机制 | 说明 |
|---|---|---|
| 第一层 | 布隆过滤器 | 启动时异步全量构建视频 ID 布隆过滤器,Exists(key)==false 直接短路返回,不再查缓存/DB |
| 第二层 | 空值缓存 | DB 未命中时写入空值哨兵 \x00NULL\x00(null_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。
对策(双保险):
- singleflight 合并(进程内):同一 key 的并发回源只执行一次,其余等待结果;
- 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
end3. 缓存雪崩(大量 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 持久化与重启重建。
