缓存模式与分布式锁

Redis 最广泛的应用是缓存。用得好能扛住高并发,用不好会引发数据不一致与雪崩。本章讲清模式与陷阱。

缓存旁路模式(Cache Aside)

最常用的模式,读时先查缓存、未命中查库并回填;写时更新数据库并删除缓存。

读: 查缓存 ──命中──► 返回
        │未命中

     查数据库 ──► 回填缓存 ──► 返回

写: 更新数据库 ──► 删除缓存
func GetUser(ctx context.Context, id int64) (*User, error) {
    key := fmt.Sprintf("user:%d", id)

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

    // 2. 未命中,查库
    u, err := db.GetUser(ctx, id)
    if err != nil {
        return nil, err
    }

    // 3. 回填缓存,并设置过期(防雪崩 + 兜底一致性)
    data, _ := json.Marshal(u)
    rdb.Set(ctx, key, data, time.Minute+time.Duration(rand.Intn(60))*time.Second)
    return u, nil
}

func UpdateUser(ctx context.Context, u *User) error {
    if err := db.UpdateUser(ctx, u); err != nil {
        return err
    }
    // 先更新库、后删缓存;删除失败可重试或走消息队列补偿
    return rdb.Del(ctx, fmt.Sprintf("user:%d", u.ID)).Err()
}
Warning

为什么写操作是删除缓存而不是更新缓存?因为并发写时「写库顺序」与「更新缓存顺序」可能交错,导致缓存留下旧值。删除 + 下次读回填更简单可靠。先操作数据库、再删缓存(Cache Aside 的推荐顺序),并配合过期时间兜底。

延迟双删

在强一致要求下,删缓存后延迟一段时间再删一次,用来清理「读请求把旧值回填」的窗口:

更新数据库 → 删除缓存 → sleep(几百毫秒) → 再次删除缓存

三大缓存问题

缓存穿透

查询不存在的数据,缓存与数据库都未命中,请求每次打到数据库。

对策

  • 缓存空值(SET key "" EX 60),短过期。
  • 布隆过滤器(Bloom Filter)前置拦截不存在的 key。
  • 参数校验,拦截非法请求。

缓存击穿

某个热点 key 过期瞬间,大量并发请求同时打到数据库。

对策

  • 互斥锁:只让一个线程回源,其余等待重试。
  • 逻辑过期:value 内带过期时间,异步刷新,读永远命中。
  • 热点 key 永不过期 + 后台更新。
func GetWithMutex(ctx context.Context, key string) (string, error) {
    if v, err := rdb.Get(ctx, key).Result(); err == nil {
        return v, nil
    }
    lockKey := key + ":lock"
    // 只有一个请求能拿到锁去回源
    ok, _ := rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Result()
    if !ok {
        time.Sleep(50 * time.Millisecond)
        return rdb.Get(ctx, key).Result() // 稍后重试
    }
    defer rdb.Del(ctx, lockKey)

    v, err := loadFromDB(ctx, key)
    if err != nil {
        return "", err
    }
    rdb.Set(ctx, key, v, 5*time.Minute)
    return v, nil
}

缓存雪崩

大量 key 同一时刻集中过期,或 Redis 宕机,导致请求全部涌向数据库。

对策

  • 过期时间加随机抖动,避免集中失效。
  • 多级缓存(本地缓存 + Redis)。
  • Redis 高可用(哨兵/Cluster)避免单点。
  • 限流与熔断,保护数据库。

分布式锁

多实例环境下,用 Redis 实现互斥。

加锁:SET NX EX

func Acquire(ctx context.Context, key, token string, ttl time.Duration) (bool, error) {
    return rdb.SetNX(ctx, key, token, ttl).Result()
}

要点:必须设置过期(防死锁),value 用唯一 token(防误删他人锁)。

解锁:Lua 保证原子

var unlockScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end
`)

func Release(ctx context.Context, key, token string) error {
    return unlockScript.Run(ctx, rdb, []string{key}, token).Err()
}
Warning

不要把「判断持有者」与「删除」分成两条命令GET 判断后、DEL 执行前锁可能已过期并被他人获取,从而误删他人锁。Lua 脚本在 Redis 中原子执行,是标准做法。

续期与 Redlock

  • 长任务需要看门狗(watchdog)定期续期,如 Redisson 的实现。
  • Redlock 在多个独立 Redis 节点上多数派加锁,提升容错;但其正确性在分布式系统理论下存在争议,对强一致要求极高的场景,优先使用 etcd/ZooKeeper 等基于共识的协调服务。

小结

  • Cache Aside 是默认模式:读缓存回填、写库后删缓存,并设置随机过期。
  • 穿透用空值/布隆过滤器,击穿用互斥锁/逻辑过期,雪崩用随机过期 + 高可用 + 限流。
  • 分布式锁:SET NX EX 加锁、唯一 token、Lua 原子解锁、必要时看门狗续期。