二、缓存 API、缓存模式与缓存异常

2021-02-18T14:21:02+08:00 | 39分钟阅读 | 更新于 2021-02-18T14:21:02+08:00

@

学习目标

学完本章你应该能够:

  1. 解释缓存统一接口 Cache 的设计(Get/Set/Delete/GetMulti/LoadOrStore 等)为何以 context 作为公开方法的首个参数,并说明为何选用 any + 类型断言 而非泛型。
  2. 实现本地缓存的过期策略(定时轮询 + 懒惰删除)与并发安全的读写,并能讲清为何用 RWMutex 而非 sync.Map
  3. 区分五种缓存模式(Cache Aside / Read Through / Write Through / Write Back / Refresh Ahead)的读写职责,并针对一致性要求做选型。
  4. 辨析缓存穿透、击穿、雪崩的成因差异,并分别给出布隆过滤器、singleflight、随机过期偏移等工程解法。
  5. 用装饰器模式把布隆过滤器、singleflight、Read Through 层层组合,形成防穿透/击穿/雪崩的统一缓存。

前置知识

  • 已读完本模块上一篇《一、并发编程》,理解 goroutine、sync.Mutex/RWMutex、sync.Once、double-check 模式;
  • 了解 Go 接口与嵌入(embedding)机制;
  • 对 Redis 基本命令(GET/SET/MGET/SETNX/EVAL)有初步认知。

本章你会动手做的事

  1. 基于 Cache 接口实现一个最小本地缓存,并写测试验证 Set/Get 的过期与 LoadOrStore 原子语义。
  2. golang.org/x/sync/singleflight 改造一次 Cache Aside 读流程,观察并发回源是否被合并为一次 DB 查询。
  3. 用装饰器把 BloomFilterCacheSingleFlightReadThroughCacheV2 套在一起,写一次 Get 调用验证三层防护同时生效。

前言:为什么需要缓存

想象你去图书馆借书:

  • 没有缓存的世界:每次借书都要去仓库翻找,仓库管理员(数据库)要花很久才能找到
  • 有缓存的世界:图书馆大厅(缓存)放了一些热门书,你直接拿走就行,不用去仓库

缓存的核心思想就是:把经常访问的数据放在更快的地方,减少对慢速数据源(如数据库)的访问

业务但凡对性能有点要求,几乎都会考虑使用缓存。缓存大体上分成两类:

  • 本地缓存:数据存在应用进程内存中,速度极快,但只在单个实例内有效
  • 分布式缓存:如 Redis、Memcached,数据存在独立进程中,多个实例共享

一般在公司内部,我们都会再次封装不同缓存的 API,并且在这些 API 之上解决一些缓存问题。本教程将带你从零开始,理解缓存 API 设计、本地缓存实现、Redis 接入、缓存模式以及缓存异常的处理。


一、缓存 API 设计

1.1 API 设计参考

API 设计是缓存模块最关键的一步。我们可以参考不同的缓存中间件的 API 设计。

Beego 的缓存 API

// Beego 缓存 API 设计(简化版)
// 主要 API 分成三类:

// ==================== 1. 单个操作 ====================
// Get:根据 key 获取值
Get(key string) (any, error)
// Set:设置 key-value,带过期时间
Set(key string, val any, timeout time.Duration) error
// Delete:删除 key
Delete(key string) error

// ==================== 2. 批量操作 ====================
// GetMulti:批量获取
GetMulti(keys []string) ([]any, error)
// SetMulti:批量设置
SetMulti(values map[string]any, timeout time.Duration) error

// ==================== 3. 针对数字的自增自减 ====================
// Increment:将 key 对应的数字值 +1
Incr(key string) error
// Decrement:将 key 对应的数字值 -1
Decr(key string) error

go-cache 的 API 设计

// go-cache 的 API 分成两部分:

// ==================== 1. 单个操作 ====================
// Set:设置 key-value,带过期时间
Set(key string, x any, d time.Duration)
// Get:获取值
Get(key string) (any, bool)
// Delete:删除 key
Delete(key string)

// ==================== 2. 针对数字的加减操作 ====================
// Increment:将 key 对应的值增加 n
Increment(key string, n int64) error
// Decrement:将 key 对应的值减少 n
Decrement(key string, n int64) error

1.2 我们的 API 设计

综合参考各种缓存中间件,我们设计如下 API。注意我们依旧保持在公开方法里面接收一个 context 参数——这个参数在本地缓存实现里面可能没用,但是在接入 Redis 的时候就很有用(可以控制超时、传递链路信息等)。

package cache

import (
    "context"
    "time"
)

// ==================== 缓存接口设计 ====================
// Cache 是所有缓存实现的统一接口
// 无论是本地缓存还是 Redis 缓存,都实现这个接口
// 这样业务代码可以无缝切换缓存实现
type Cache interface {
    // ==================== 单个操作 ====================

    // Get:根据 key 获取值
    // 如果 key 不存在或已过期,返回 errKeyNotFound
    Get(ctx context.Context, key string) (any, error)

    // Set:设置 key-value,带过期时间
    // expiration <= 0 表示永不过期
    Set(ctx context.Context, key string, val any, expiration time.Duration) error

    // Delete:删除 key
    // 如果 key 不存在也不报错(幂等操作)
    Delete(ctx context.Context, key string) error

    // ==================== 批量操作 ====================

    // GetMulti:批量获取
    // keys 中的每个 key 如果不存在,对应位置返回 nil
    GetMulti(ctx context.Context, keys []string) (map[string]any, error)

    // SetMulti:批量设置
    SetMulti(ctx context.Context, values map[string]any, expiration time.Duration) error

    // ==================== 组合式操作 ====================

    // LoadOrStore:如果 key 存在就返回已有值,不存在就存入新值
    // 这是一个原子操作,防止并发场景下的竞态条件
    // loaded=true 表示是加载的已有值,false 表示是新存入的
    LoadOrStore(ctx context.Context, key string, val any, expiration time.Duration) (actual any, loaded bool, err error)

    // LoadAndDelete:取出值并删除
    // 如果 key 不存在,返回 nil, false
    LoadAndDelete(ctx context.Context, key string) (val any, loaded bool, err error)
}

// ==================== 错误定义 ====================

var (
    // errKeyNotFound:key 不存在
    // 对于用户来说,他不应该关心究竟是 key 不存在还是 key 已过期
    // 反正对于他来说,值都没有
    errKeyNotFound = errors.New("cache: key not found")

    // errKeyExpired:key 已过期但尚未被删除
    // 这个错误主要是因为我们采用了"懒惰删除"策略才产生的
    // 实际上更倾向于只用 errKeyNotFound,但教学中保留这个错误
    errKeyExpired = errors.New("cache: key expired")

    // errOverCapacity:缓存超过容量上限
    // 在有容量限制的缓存中,Set 超过容量时返回此错误
    errOverCapacity = errors.New("cache: over capacity")
)

1.3 为什么不用泛型

Go 1.18 之后我们可以考虑用泛型,但泛型本身是有限制的:

package cache

import (
    "context"
    "time"
)

// ==================== 泛型版本的缓存(V2)====================

// CacheV2 是使用泛型的缓存接口
// T 是类型参数,代表缓存值的类型
type CacheV2[T any] interface {
    Get(ctx context.Context, key string) (T, error)
    Set(ctx context.Context, key string, val T, expiration time.Duration) error
}

// ==================== 泛型设计的优缺点 ====================
// 优点:用户不需要做类型断言
//   val, err := cache.Get(ctx, "user:1")  // val 直接是 User 类型
//   不需要:val, err := cache.Get(ctx, "user:1").(User)

// 缺点:一个 CacheV2 的实例,只能存储 T 类型的数据
//   userCache := NewCacheV2[User]()   // 只能存 User
//   orderCache := NewCacheV2[Order]() // 只能存 Order
//   // 如果你想在一个 Cache 里同时存 User 和 Order,就做不到

// ==================== 理想版本(V3)但 Go 不支持 ====================

// 理想版本:CacheV3 本身没有类型参数,而是在具体方法里面定义
// 这样同一个 CacheV3 就可以被用于缓存不同类型
//
// type CacheV3 interface {
//     Get[T any](ctx context.Context, key string) (T, error)  // 方法级别的泛型
//     Set[T any](ctx context.Context, key string, val T, expiration time.Duration) error
// }
//
// 很可惜,Go 泛型不支持方法级别的类型参数
// 类型参数只能在结构体或接口定义时声明

// ==================== 结论 ====================
// 所有类似的"客户端类"中间件(ORM、缓存、消息队列等)
// 都会面临这个限制:要么用 any + 类型断言,要么用泛型但每个实例只能存一种类型
// 在缓存场景下,我们选择用 any + 类型断言,因为一个缓存实例通常需要存多种类型的数据

新手理解:想象缓存是一个大仓库。如果用泛型,你得为每种货物(User、Order)各建一个仓库。用 any 的话,一个仓库什么都能存,但取东西时你得自己确认是什么类型的(类型断言)。


二、本地缓存设计与实现

2.1 过期时间控制——三种策略

本地缓存要解决的核心问题是:如何处理 key 的过期时间?

// ==================== 三种过期处理策略 ====================

// 策略 1:每个 key 开一个 goroutine 盯着
// 利用 time.AfterFunc 实现,到期自动执行删除
//
// 优点:精确,到期立即删除
// 缺点:
//   - key 多了,goroutine 也多了(每个 key 一个 goroutine)
//   - 这些 goroutine 大部分时候都被阻塞,浪费资源
//
// 示例:
//   func (c *LocalCache) Set(key string, val any, ttl time.Duration) {
//       c.data[key] = val
//       if ttl > 0 {
//           // 为每个 key 开一个定时器,到期执行删除
//           time.AfterFunc(ttl, func() {
//               delete(c.data, key)
//           })
//       }
//   }

// 策略 2:用一个 goroutine 定时轮询
// 创建 cache 时同时创建一个 goroutine,定时检查每个 key 的过期时间
//
// 优点:只需要一个 goroutine
// 缺点:
//   - 要控制检查间隔。间隔太短 → 资源消耗大;间隔太长 → 过期 key 清理不及时
//   - 要控制遍历开销。如果全部 key 遍历一遍,可能耗时极长
//   - 可以控制遍历的时长(如只遍历 1ms)或数量(如只遍历 100 个)
//
// 示例:
//   func (c *LocalCache) cleanupLoop() {
//       ticker := time.NewTicker(time.Second)
//       for range ticker.C {
//           c.deleteExpiredKeys() // 只遍历部分 key
//       }
//   }

// 策略 3:Get 时检查过期时间(懒惰删除)
// 什么都不主动做,用户访问 key 的时候才检查是否过期
//
// 优点:零额外开销
// 缺点:过期但未被访问的 key 会一直占用内存
//
// 实际使用中,策略 2 和策略 3 配合使用:
//   - 定时轮询清理大部分过期 key(策略 2)
//   - Get 时再检查一次,防止轮询还没轮到的过期 key 被返回(策略 3)

和 Redis 对比:Redis 的过期处理也是类似的套路——get 的时候检查是否过期 + 遍历 key 找出过期的删掉。本质上过期处理没有特别好的方案,就是这三种。

和 sql.DB 对比sql.DB 中空闲连接的关闭也是类似。正常来说我们期望空闲连接一旦空闲时间够长(如 30 秒),连接池就帮我们关掉。但 sql.DB 最终采用的也是懒惰关闭——在 Get 连接的时候才检查有没有过期。

2.2 本地缓存完整实现

package cache

import (
    "context"
    "errors"
    "sync"
    "time"
)

// ==================== 本地缓存实现 ====================

// LocalCache 本地缓存
// 使用 sync.RWMutex 保护 map 的并发读写
type LocalCache struct {
    mu sync.RWMutex

    // data 存储所有的 key-value 数据
    // value 用 any 类型,可以存储任意类型的数据
    // 但取出来的时候需要做类型断言
    data map[string]*item

    // onEvicted 是 key 被删除时的回调函数
    // 可以有多个,都放在切片里
    // 类似于 Redis 的 keyspace notification
    onEvicted []func(key string, val any)
}

// item 缓存项
// 除了值本身,还需要记录过期时间
type item struct {
    val       any       // 缓存的值
    expireAt  time.Time // 过期时间点,零值表示永不过期
}

// NewLocalCache 创建本地缓存
func NewLocalCache() *LocalCache {
    return &LocalCache{
        data: make(map[string]*item),
    }
}

// ==================== Get 方法 ====================
// 获取 key 对应的值
// 使用 double-check 模式检查过期时间
func (c *LocalCache) Get(ctx context.Context, key string) (any, error) {
    // ==================== 第一次检查:读锁(快路径)====================
    // 先用读锁快速检查 key 是否存在
    c.mu.RLock()
    it, ok := c.data[key]
    c.mu.RUnlock()

    if !ok {
        // key 不存在
        return nil, errKeyNotFound
    }

    // 检查是否过期
    // 零值 expireAt 表示永不过期
    if !it.expireAt.IsZero() && time.Now().After(it.expireAt) {
        // ==================== 已过期:需要删除(慢路径)====================
        // 这里不能只加读锁了,需要加写锁来删除过期的 key
        // 使用 double-check 模式:加写锁后再检查一次
        c.mu.Lock()
        // 再次检查,因为在释放读锁到获取写锁之间,可能有其他 goroutine 已经删除了
        it, ok = c.data[key]
        if !ok {
            // 其他 goroutine 已经删除了
            c.mu.Unlock()
            return nil, errKeyNotFound
        }
        // 再次检查是否过期(可能其他 goroutine 已经更新了这个 key)
        if !it.expireAt.IsZero() && time.Now().After(it.expireAt) {
            // 确实过期了,删除它
            delete(c.data, key)
            c.mu.Unlock()

            // 执行 evict 回调(在锁外面执行,避免回调耗时导致锁占用太久)
            c.onEvictedTriggered(key, it.val)
            return nil, errKeyNotFound
        }
        // 没过期(被其他 goroutine 更新了),返回值
        val := it.val
        c.mu.Unlock()
        return val, nil
    }

    // ==================== 未过期:直接返回(快路径)====================
    return it.val, nil
}

// ==================== Set 方法 ====================
func (c *LocalCache) Set(ctx context.Context, key string, val any, expiration time.Duration) error {
    c.mu.Lock()
    defer c.mu.Unlock()

    var expireAt time.Time
    if expiration > 0 {
        // 计算过期时间点 = 当前时间 + 过期时长
        expireAt = time.Now().Add(expiration)
    }
    // expiration <= 0 表示永不过期,expireAt 保持零值

    c.data[key] = &item{
        val:      val,
        expireAt: expireAt,
    }
    return nil
}

// ==================== Delete 方法 ====================
func (c *LocalCache) Delete(ctx context.Context, key string) error {
    c.mu.Lock()
    // 先取出值,用于触发 evict 回调
    it, ok := c.data[key]
    if ok {
        delete(c.data, key)
    }
    c.mu.Unlock()

    // 在锁外面执行回调
    if ok {
        c.onEvictedTriggered(key, it.val)
    }
    return nil
}

// ==================== GetMulti 方法 ====================
func (c *LocalCache) GetMulti(ctx context.Context, keys []string) (map[string]any, error) {
    result := make(map[string]any, len(keys))
    for _, key := range keys {
        val, err := c.Get(ctx, key)
        if err == nil {
            result[key] = val
        }
        // key 不存在就跳过,不报错
    }
    return result, nil
}

// ==================== SetMulti 方法 ====================
func (c *LocalCache) SetMulti(ctx context.Context, values map[string]any, expiration time.Duration) error {
    for key, val := range values {
        if err := c.Set(ctx, key, val, expiration); err != nil {
            return err
        }
    }
    return nil
}

// ==================== LoadOrStore 方法 ====================
// 如果 key 存在就返回已有值,不存在就存入新值
// 这是一个原子操作
func (c *LocalCache) LoadOrStore(ctx context.Context, key string, val any, expiration time.Duration) (actual any, loaded bool, err error) {
    c.mu.Lock()
    defer c.mu.Unlock()

    // 检查 key 是否已存在
    it, ok := c.data[key]
    if ok && (it.expireAt.IsZero() || time.Now().Before(it.expireAt)) {
        // key 存在且未过期,返回已有值
        return it.val, true, nil
    }

    // key 不存在或已过期,存入新值
    var expireAt time.Time
    if expiration > 0 {
        expireAt = time.Now().Add(expiration)
    }
    c.data[key] = &item{
        val:      val,
        expireAt: expireAt,
    }
    return val, false, nil
}

// ==================== LoadAndDelete 方法 ====================
// 取出值并删除
func (c *LocalCache) LoadAndDelete(ctx context.Context, key string) (val any, loaded bool, err error) {
    c.mu.Lock()
    it, ok := c.data[key]
    if ok {
        delete(c.data, key)
    }
    c.mu.Unlock()

    if !ok {
        return nil, false, nil
    }

    // 检查是否过期
    if !it.expireAt.IsZero() && time.Now().After(it.expireAt) {
        c.onEvictedTriggered(key, it.val)
        return nil, false, nil
    }

    c.onEvictedTriggered(key, it.val)
    return it.val, true, nil
}

// ==================== evict 回调 ====================
// OnEvicted 注册 evict 回调
// 当 key 被删除(包括过期删除、手动删除)时,会执行回调
func (c *LocalCache) OnEvicted(fn func(key string, val any)) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.onEvicted = append(c.onEvicted, fn)
}

// onEvictedTriggered 执行所有 evict 回调
// 在锁外面调用,避免回调耗时导致锁占用太久
func (c *LocalCache) onEvictedTriggered(key string, val any) {
    // 先复制一份回调列表(在锁内),然后在锁外执行
    c.mu.RLock()
    callbacks := make([]func(string, any), len(c.onEvicted))
    copy(callbacks, c.onEvicted)
    c.mu.RUnlock()

    for _, fn := range callbacks {
        fn(key, val)
    }
}

// ==================== 定时清理过期 key ====================
// Cleanup 启动一个后台 goroutine 定时清理过期 key
// 应该在创建缓存后调用
func (c *LocalCache) Cleanup(interval time.Duration) {
    go func() {
        ticker := time.NewTicker(interval)
        defer ticker.Stop()

        for range ticker.C {
            c.cleanupExpired()
        }
    }()
}

// cleanupExpired 清理过期的 key
// 注意:要控制遍历的资源开销
func (c *LocalCache) cleanupExpired() {
    now := time.Now()

    c.mu.Lock()
    // 这里简化了,实际中应该控制遍历的数量或时长
    // 例如只遍历 100 个 key,或者只遍历 1ms
    var expiredKeys []string
    for key, it := range c.data {
        if !it.expireAt.IsZero() && now.After(it.expireAt) {
            expiredKeys = append(expiredKeys, key)
            delete(c.data, key)
        }
    }
    c.mu.Unlock()

    // 在锁外面执行回调
    for _, key := range expiredKeys {
        // 注意:这里 it.val 已经拿不到了,因为已经 delete 了
        // 实际实现中应该在 delete 之前先记录 val
        c.onEvictedTriggered(key, nil)
    }
}

为什么不使用 sync.Map? 从上面的代码可以看出,Get 方法在检查到过期时需要加写锁删除。sync.Map 不支持这种"读时可能写"的操作模式,所以我们用 RWMutex + map 来实现。

2.3 errKeyNotFound 和 errKeyExpired 的争议

// ==================== 两个错误的争议 ====================

// 在我们的实现中,有两个错误:
//   errKeyNotFound  → key 不存在
//   errKeyExpired   → key 已过期
//
// 实际上,更倾向于只用 errKeyNotFound。原因:
//
// 1. 对于用户来说,他不应该关心究竟是 key 不存在还是 key 已过期
//    反正对他来说,值都没有,处理方式是一样的(去数据库查)
//
// 2. 从封装的角度来说,key 过期就应该被删除
//    errKeyExpired 实质上是因为我们采用了"懒惰删除"策略才搞出来的
//    相当于:因为我的实现是这样的,所以我会有这个错误
//    这暴露了实现细节给用户,不是好的封装
//
// 所以在我们的实现中,过期也直接返回 errKeyNotFound

2.4 控制本地缓存内存

缓存,尤其是本地缓存都会面临一个问题:缓存会不会使用了太多内存?

package cache

import (
    "context"
    "time"
)

// ==================== 控制内存的两种策略 ====================

// 策略 1:控制键值对数量
// 比如说只允许十万个键值对
// 简单直接,但不同大小的 value 占用的内存可能差很多

// 策略 2:控制整体内存
// 需要计算每个对象的大小,然后累加
// 更精确,但计算对象大小本身有开销

// ==================== 用装饰器模式实现容量控制 ====================

// MaxCntCache 在原有 Cache 的基础上增加容量限制
// 这是一个装饰器模式:不修改原有实现,在外面套一层
type MaxCntCache struct {
    Cache       // 嵌入 Cache 接口(装饰器模式)
    cnt   int   // 当前键值对数量
    max   int   // 最大键值对数量
}

// NewMaxCntCache 创建带容量限制的缓存
func NewMaxCntCache(cache Cache, max int) *MaxCntCache {
    return &MaxCntCache{
        Cache: cache,
        max:   max,
    }
}

// Set 在设置值之前先检查容量
func (c *MaxCntCache) Set(ctx context.Context, key string, val any, expiration time.Duration) error {
    // 先检查 key 是否已存在
    // 如果已存在,Set 不会增加数量,不需要检查容量
    _, err := c.Cache.Get(ctx, key)
    if err == nil {
        // key 已存在,直接更新
        return c.Cache.Set(ctx, key, val, expiration)
    }

    // key 不存在,需要检查容量
    if c.cnt >= c.max {
        // ==================== 容量已满,需要淘汰 ====================
        // 最简单的做法:返回 errOverCapacity,让调用方处理
        // 但更好的做法是执行 LRU/LFU 算法淘汰一个 key
        //
        // 注意:简单的 LRU 实现有问题!
        // 如果在 Set 返回 errOverCapacity 的地方淘汰 key
        // 然后再 Set,这两个操作不是原子的
        // 并发场景下可能多个 goroutine 同时淘汰,导致淘汰过多
        return errOverCapacity
    }

    c.cnt++
    return c.Cache.Set(ctx, key, val, expiration)
}

// ==================== LRU(最近最少使用)算法概念 ====================
//
// LRU 的核心思想:最近被访问的数据,将来也更可能被访问
// 所以淘汰最久没被访问的数据
//
// 实现:用一个双向链表 + hashmap
//   - hashmap:O(1) 查找 key 对应的节点
//   - 双向链表:O(1) 移动节点到头部
//   - 访问 key → 把节点移到链表头部
//   - 淘汰 → 删除链表尾部的节点
//
// 数据结构:
//   head <-> [A] <-> [B] <-> [C] <-> tail
//   最近访问                        最久没访问
//   ↑ 新数据放这里                   ↑ 淘汰从这里删

// ==================== LFU(最不经常使用)算法概念 ====================
//
// LFU 的核心思想:被访问次数少的数据,将来也更少被访问
// 所以淘汰访问次数最少的数据
//
// 与 LRU 的区别:
//   LRU 关注"最近",LFU 关注"频率"
//   LRU 简单但可能淘汰热点数据(如果热点数据刚好有一阵没被访问)
//   LFU 更公平但实现更复杂

2.5 evict 回调与 CDC 接口

// ==================== evict 回调的概念 ====================
// 部分缓存中间件可以提供 CDC(Change Data Capture)接口
// 例如 key 被更新的时候打印一些数据,类似于 Redis 的 subscribe
//
// 在本地缓存实现中,这种接口主要就是缓存过期被删除的回调
//
// 有三个地方需要暴露执行回调:
//   1. Delete 方法 → 用户主动删除 key
//   2. Get 方法 → 检查到 key 过期时删除
//   3. 轮询删除 → 后台 goroutine 清理过期 key 时
//
// OnEvicted 可以设计成切片,即允许多个回调

三、Redis 缓存实现

3.1 Redis 实现基础

我们使用 github.com/go-redis/redis/v9 来实现 Redis 版本的缓存。

package cache

import (
    "context"
    "encoding/json"
    "time"

    redis "github.com/go-redis/redis/v9"
)

// ==================== Redis 缓存实现 ====================

// RedisCache 基于 Redis 的缓存实现
// 实现 Cache 接口,和 LocalCache 可以无缝替换
type RedisCache struct {
    client redis.Cmdable // Redis 客户端接口
}

// NewRedisCache 创建 Redis 缓存
// client 可以是 *redis.Client(单节点)或 *redis.ClusterClient(集群)
func NewRedisCache(client redis.Cmdable) *RedisCache {
    return &RedisCache{
        client: client,
    }
}

// ==================== Get 方法 ====================
func (r *RedisCache) Get(ctx context.Context, key string) (any, error) {
    // 使用 Redis 的 GET 命令
    // context 在这里就有用了:可以控制 Redis 操作的超时
    val, err := r.client.Get(ctx, key).Result()
    if err != nil {
        if err == redis.Nil {
            // Redis 返回 nil 表示 key 不存在
            return nil, errKeyNotFound
        }
        // 其他错误(网络错误、Redis 宕机等)
        return nil, err
    }

    // Redis 存储的是字符串,需要反序列化
    // 这里用 JSON 反序列化,实际中可以根据需要选择更高效的序列化方式
    var result any
    if err := json.Unmarshal([]byte(val), &result); err != nil {
        return nil, err
    }
    return result, nil
}

// ==================== Set 方法 ====================
func (r *RedisCache) Set(ctx context.Context, key string, val any, expiration time.Duration) error {
    // 先序列化值
    data, err := json.Marshal(val)
    if err != nil {
        return err
    }

    // 使用 Redis 的 SET 命令,带过期时间
    // expiration <= 0 表示永不过期(Redis 中传 0 表示永不过期)
    return r.client.Set(ctx, key, string(data), expiration).Err()
}

// ==================== Delete 方法 ====================
func (r *RedisCache) Delete(ctx context.Context, key string) error {
    // 使用 Redis 的 DEL 命令
    // DEL 是幂等的:key 不存在也不报错
    return r.client.Del(ctx, key).Err()
}

// ==================== GetMulti 方法 ====================
func (r *RedisCache) GetMulti(ctx context.Context, keys []string) (map[string]any, error) {
    // 使用 Redis 的 MGET 命令批量获取
    // MGET 比 循环 GET 高效得多,只需要一次网络往返
    results, err := r.client.MGet(ctx, keys...).Result()
    if err != nil {
        return nil, err
    }

    m := make(map[string]any, len(keys))
    for i, key := range keys {
        if results[i] != nil {
            // MGET 返回的是字符串,需要反序列化
            if str, ok := results[i].(string); ok {
                var val any
                if err := json.Unmarshal([]byte(str), &val); err == nil {
                    m[key] = val
                }
            }
        }
    }
    return m, nil
}

// ==================== SetMulti 方法 ====================
func (r *RedisCache) SetMulti(ctx context.Context, values map[string]any, expiration time.Duration) error {
    // Redis 没有 MSET 带 TTL 的命令
    // 可以用 Pipeline 批量执行 SET 命令
    // Pipeline 将多个命令打包发送,减少网络往返
    pipe := r.client.Pipeline()
    for key, val := range values {
        data, err := json.Marshal(val)
        if err != nil {
            return err
        }
        pipe.Set(ctx, key, string(data), expiration)
    }
    // 一次性执行所有命令
    _, err := pipe.Exec(ctx)
    return err
}

// ==================== LoadOrStore 方法 ====================
// 在 Redis 中实现 LoadOrStore 需要保证原子性
// 可以用 SET NX(不存在才设置)来实现
func (r *RedisCache) LoadOrStore(ctx context.Context, key string, val any, expiration time.Duration) (actual any, loaded bool, err error) {
    data, err := json.Marshal(val)
    if err != nil {
        return nil, false, err
    }

    // SET key value NX:只有 key 不存在时才设置
    // 返回 true 表示设置成功(key 之前不存在)
    // 返回 false 表示 key 已存在,没有设置
    ok, err := r.client.SetNX(ctx, key, string(data), expiration).Result()
    if err != nil {
        return nil, false, err
    }

    if ok {
        // 设置成功,说明 key 之前不存在,是新存入的
        return val, false, nil
    }

    // key 已存在,获取已有值
    existing, err := r.Get(ctx, key)
    if err != nil {
        return nil, false, err
    }
    return existing, true, nil
}

// ==================== LoadAndDelete 方法 ====================
// 在 Redis 中,Get + Del 不是原子的
// 要保证原子性,需要用 Lua 脚本
func (r *RedisCache) LoadAndDelete(ctx context.Context, key string) (val any, loaded bool, err error) {
    // Lua 脚本:先 GET 再 DEL,保证原子性
    // 如果 key 存在,返回值并删除;不存在返回 nil
    //
    // Lua 脚本说明:
    //   KEYS[1] = key
    //   redis.call('GET', KEYS[1]) 获取值
    //   redis.call('DEL', KEYS[1]) 删除 key
    //   如果值不为 false(nil),说明 key 存在
    const loadAndDeleteScript = `
        local val = redis.call('GET', KEYS[1])
        if val then
            redis.call('DEL', KEYS[1])
            return val
        end
        return nil
    `

    result, err := r.client.Eval(ctx, loadAndDeleteScript, []string{key}).Result()
    if err != nil {
        return nil, false, err
    }

    if result == nil {
        // key 不存在
        return nil, false, nil
    }

    // 反序列化
    str, ok := result.(string)
    if !ok {
        return nil, false, nil
    }

    var v any
    if err := json.Unmarshal([]byte(str), &v); err != nil {
        return nil, false, err
    }
    return v, true, nil
}

组合式 API 与线程安全:组合式 API(如 LoadOrStore、LoadAndDelete)要注意线程安全。在本地实现里面,加锁就可以保证;在 Redis 里面,需要使用 Lua 脚本保证原子性,否则多个命令之间可能被其他客户端插入。

3.2 Redis 实现的测试

测试 Redis 实现需要用到 gomock 来 mock Redis 客户端。

package cache

import (
    "context"
    "testing"
    "time"

    redis "github.com/go-redis/redis/v9"
    "github.com/golang/mock/gomock"
    "github.com/stretchr/testify/assert"
)

// ==================== 单元测试(使用 gomock)====================
// 要生成 mock 文件,在根目录执行:
// mockgen -destination=cache/mocks/mock_redis_cmdable.gen.go \
//         -package=mocks \
//         github.com/go-redis/redis/v9 Cmdable

func TestRedisCache_Get(t *testing.T) {
    // 创建 gomock controller
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    // 创建 mock 的 Redis 客户端
    mockClient := mocks.NewMockCmdable(ctrl)

    // 创建 RedisCache 实例
    cache := NewRedisCache(mockClient)

    // ==================== 测试 1:key 存在 ====================
    // 设置 mock 期望:当调用 Get 时,返回 "test_value"
    mockClient.EXPECT().
        Get(gomock.Any(), "test_key").
        Return(redis.NewStringResult(`"test_value"`, nil)).
        Times(1)

    val, err := cache.Get(context.Background(), "test_key")
    assert.NoError(t, err)
    assert.Equal(t, "test_value", val)

    // ==================== 测试 2:key 不存在 ====================
    mockClient.EXPECT().
        Get(gomock.Any(), "missing_key").
        Return(redis.NewStringResult("", redis.Nil)).
        Times(1)

    _, err = cache.Get(context.Background(), "missing_key")
    assert.Equal(t, errKeyNotFound, err)
}

func TestRedisCache_Set(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockClient := mocks.NewMockCmdable(ctrl)
    cache := NewRedisCache(mockClient)

    // 设置 mock 期望
    mockClient.EXPECT().
        Set(gomock.Any(), "test_key", `"test_value"`, time.Minute).
        Return(redis.NewStatusResult("OK", nil)).
        Times(1)

    err := cache.Set(context.Background(), "test_key", "test_value", time.Minute)
    assert.NoError(t, err)
}

// ==================== 集成测试(连接真实 Redis)====================
// 集成测试需要启动一个真实的 Redis 实例
// 可以用 docker run -p 6379:6379 redis 启动

// func TestRedisCache_Integration(t *testing.T) {
//     // 连接真实 Redis(测试时跳过如果没有 Redis)
//     client := redis.NewClient(&redis.Options{
//         Addr: "localhost:6379",
//     })
//
//     cache := NewRedisCache(client)
//     ctx := context.Background()
//
//     // 测试 Set + Get
//     err := cache.Set(ctx, "integration_key", "hello", time.Minute)
//     assert.NoError(t, err)
//
//     val, err := cache.Get(ctx, "integration_key")
//     assert.NoError(t, err)
//     assert.Equal(t, "hello", val)
//
//     // 测试 Delete
//     err = cache.Delete(ctx, "integration_key")
//     assert.NoError(t, err)
//
//     _, err = cache.Get(ctx, "integration_key")
//     assert.Equal(t, errKeyNotFound, err)
// }

3.3 组合式 API

// ==================== 什么是组合式 API ====================
// 组合式 API 就是多个动作组合在一起,作为一个 API 提供出去:
//   - LoadOrStore:加载或存储(不存在就存,存在就返回已有值)
//   - LoadAndDelete:加载并删除(取出值的同时删除 key)
//   - 自增、自减 API
//   - ...
//
// 为什么要组合?
//   因为分开调用多个 API 无法保证原子性
//   例如先 Get 再 Set,中间可能有其他 goroutine 修改了数据
//   组合成一个原子操作,就可以避免竞态条件

// ==================== 自增自减 API ====================
// 在 Redis 中,INCR 和 DECR 是原子的
// 在本地缓存中,需要用锁保证原子性

// Incr 将 key 对应的数字值增加 n
func (c *LocalCache) Incr(ctx context.Context, key string, n int64) (int64, error) {
    c.mu.Lock()
    defer c.mu.Unlock()

    it, ok := c.data[key]
    if !ok {
        // key 不存在,从 0 开始
        c.data[key] = &item{val: n}
        return n, nil
    }

    // 取出当前值,转换为 int64
    current, ok := it.val.(int64)
    if !ok {
        return 0, errors.New("cache: value is not int64")
    }

    current += n
    it.val = current
    return current, nil
}

// 在 Redis 中,可以直接用 INCRBY 命令
func (r *RedisCache) Incr(ctx context.Context, key string, n int64) (int64, error) {
    // INCRBY 是原子操作,Redis 内部保证
    result, err := r.client.IncrBy(ctx, key, n).Result()
    if err != nil {
        return 0, err
    }
    return result, nil
}

四、缓存模式

缓存模式描述的是如何组织缓存和数据源(如数据库)之间的读写关系。常用的缓存模式有五种:

┌───────────────────────────────────────────────────┐
│                五种缓存模式                         │
│                                                   │
│  1. Cache Aside    → 业务代码自己管理缓存和DB       │
│  2. Read Through   → 读时缓存自动回源              │
│  3. Write Through  → 写时缓存自动同步到DB           │
│  4. Write Back     → 写缓存不写DB,过期时回写       │
│  5. Refresh Ahead  → 通过CDC主动刷新缓存           │
└───────────────────────────────────────────────────┘

4.1 Cache Aside(旁路缓存)

Cache Aside 是最常用的缓存模式。把 Cache 当成一个普通的数据源,更新 Cache 和 DB 都依赖于开发者自己写代码。

package cache

import (
    "context"
    "time"
)

// ==================== Cache Aside 模式 ====================
//
// 读流程:
//   1. 先查缓存
//   2. 缓存命中 → 直接返回
//   3. 缓存未命中 → 查数据库
//   4. 数据库有数据 → 回写缓存 + 返回
//   5. 数据库没数据 → 返回空或默认值
//
// 写流程:
//   1. 更新数据库
//   2. 删除(或更新)缓存
//
// 业务代码可以做决策:
//   - 未命中时是否要从 DB 取数据(可以不取,用默认值)
//   - 同步还是异步读取数据并写入缓存
//   - 是否使用 singleflight(防止并发重复查询)

// ==================== Cache Aside 代码示例 ====================

// UserService 使用 Cache Aside 模式的业务服务
type UserService struct {
    cache Cache
    db    DB // 数据库接口(假设已定义)
}

// GetUser 获取用户信息(Cache Aside 读流程)
func (s *UserService) GetUser(ctx context.Context, userID int) (*User, error) {
    key := fmt.Sprintf("user:%d", userID)

    // 步骤 1:先查缓存
    val, err := s.cache.Get(ctx, key)
    if err == nil {
        // 缓存命中,直接返回
        return val.(*User), nil
    }

    // 步骤 2:缓存未命中,查数据库
    user, err := s.db.FindUser(ctx, userID)
    if err != nil {
        return nil, err
    }
    if user == nil {
        // 数据库也没有,返回默认值或错误
        // 可以选择缓存一个空值,防止缓存穿透(后面会讲)
        return nil, errUserNotFound
    }

    // 步骤 3:回写缓存
    // 这里可以选择同步写入或异步写入
    // 同步写入:用户等待时间增加,但缓存一致性更好
    // 异步写入:用户等待时间短,但短时间内的请求可能还会打到数据库
    _ = s.cache.Set(ctx, key, user, 10*time.Minute)

    return user, nil
}

// UpdateUser 更新用户信息(Cache Aside 写流程)
func (s *UserService) UpdateUser(ctx context.Context, user *User) error {
    // 步骤 1:先更新数据库
    if err := s.db.UpdateUser(ctx, user); err != nil {
        return err
    }

    // 步骤 2:删除缓存(不是更新缓存)
    // 为什么删除而不是更新?
    //   1. 删除是幂等的,更新不是
    //   2. 避免并发更新导致的脏数据
    //   3. 如果缓存更新失败,删除比更新更容易重试
    key := fmt.Sprintf("user:%d", user.ID)
    _ = s.cache.Delete(ctx, key)

    return nil
}

同步 vs 异步刷新缓存

  • 同步:业务代码同步从数据库读取数据,用 DB 数据执行业务,同时异步刷新缓存。用户等待时间长,但一致性更好。
  • 异步:业务代码发现缓存没有数据时直接返回响应(或使用默认值),而后异步从 DB 读取数据刷新缓存。用户等待时间短,但短时间不一致。

4.2 Read Through(读穿透)

Read Through:业务代码只需要从 cache 中读取数据,cache 会在缓存不命中时自动去读取数据源。

package cache

import (
    "context"
    "time"
)

// ==================== Read Through 模式 ====================
//
// 和 Cache Aside 的区别:
//   Cache Aside:业务代码负责"缓存未命中时查数据库 + 回写缓存"
//   Read Through:缓存自己负责"未命中时查数据库 + 回写缓存"
//
// 业务代码更简洁,只需要调 cache.Get 就行
// 但 cache 需要知道怎么查数据库(通过 LoadFunc)

// ==================== Read Through 实现 ====================

// ReadThroughCache 在 Cache 的基础上增加自动回源功能
// 装饰器模式:在已有 Cache 的基础上增加"读穿透"功能
type ReadThroughCache struct {
    Cache // 嵌入 Cache 接口

    // loadFunc:当缓存未命中时,调用此函数从数据源加载数据
    // 注意:这个函数通常是和具体业务绑定的
    // 比如加载 User 和加载 Order 是不同的函数
    loadFunc func(ctx context.Context, key string) (any, error)

    // expiration:回写缓存时的过期时间
    expiration time.Duration
}

// NewReadThroughCache 创建 Read Through 缓存
func NewReadThroughCache(cache Cache, loadFunc func(ctx context.Context, key string) (any, error), expiration time.Duration) *ReadThroughCache {
    return &ReadThroughCache{
        Cache:      cache,
        loadFunc:   loadFunc,
        expiration: expiration,
    }
}

// Get 重写 Get 方法,加入自动回源逻辑
func (r *ReadThroughCache) Get(ctx context.Context, key string) (any, error) {
    // 步骤 1:先查缓存
    val, err := r.Cache.Get(ctx, key)
    if err == nil {
        // 缓存命中,直接返回
        return val, nil
    }

    // 步骤 2:缓存未命中,调用 loadFunc 从数据源加载
    val, err = r.loadFunc(ctx, key)
    if err != nil {
        return nil, err
    }

    // 步骤 3:回写缓存
    // 这里可以选择同步写入或异步写入
    //
    // 同步写入(当前实现):
    //   用户需要等待回写完成才返回,但下一次请求就能命中缓存
    //
    // 异步写入:
    //   go func() { r.Cache.Set(ctx, key, val, r.expiration) }()
    //   用户不用等回写,但短时间内可能多次回源
    _ = r.Cache.Set(ctx, key, val, r.expiration)

    return val, nil
}

// ==================== 使用示例 ====================
// read-through 对用户来说,基本上只能是一个缓存类型一个实例
// 因为 LoadFunc 在大多数时候,没办法写成非常通用的
//
// userCache := &ReadThroughCache{
//     Cache:    localCache,
//     loadFunc: func(ctx, key) { return db.FindUser(ctx, parseUserID(key)) },
// }
//
// orderCache := &ReadThroughCache{
//     Cache:    localCache,
//     loadFunc: func(ctx, key) { return db.FindOrder(ctx, parseOrderID(key)) },
// }
// 从数据库中找 user,或者找 order,明显就是不一样的 loadFunc

// ==================== 使用泛型改进(V1)====================
// ReadThroughCacheV1 使用泛型,让类型更安全
// 但注意:ReadThroughCacheV1 并没有实现 Cache 接口(因为方法签名不同)
//
// type ReadThroughCacheV1[T any] struct {
//     Cache
//     loadFunc   func(ctx context.Context, key string) (T, error)
//     expiration time.Duration
// }
//
// func (r *ReadThroughCacheV1[T]) Get(ctx context.Context, key string) (T, error) {
//     val, err := r.Cache.Get(ctx, key)
//     if err == nil {
//         return val.(T), nil
//     }
//     t, err := r.loadFunc(ctx, key)
//     if err != nil {
//         var zero T
//         return zero, err
//     }
//     r.Cache.Set(ctx, key, t, r.expiration)
//     return t, nil
// }

4.3 Write Through(写穿透)

Write Through:开发者只需要写入 cache,cache 自己会更新数据库。

package cache

import (
    "context"
)

// ==================== Write Through 模式 ====================
//
// 写流程:
//   开发者写入 cache → cache 自己更新数据库
//
// 读流程(未命中时):
//   开发者需要自己去数据库捞数据,然后更新缓存
//   (此时缓存不需要更新 DB 了,因为是读操作)
//
// cache 可以做决策:
//   - 同步还是异步写数据到 DB
//   - 先写 DB 还是先写 cache(一般是先写 DB)

// ==================== Write Through 实现 ====================

// WriteThroughCache 在 Cache 的基础上增加自动写数据库功能
type WriteThroughCache struct {
    Cache // 嵌入 Cache 接口

    // storeFunc:当写入缓存时,调用此函数将数据同步到数据库
    storeFunc func(ctx context.Context, key string, val any) error
}

// NewWriteThroughCache 创建 Write Through 缓存
func NewWriteThroughCache(cache Cache, storeFunc func(ctx context.Context, key string, val any) error) *WriteThroughCache {
    return &WriteThroughCache{
        Cache:     cache,
        storeFunc: storeFunc,
    }
}

// Set 重写 Set 方法,加入自动写数据库逻辑
func (w *WriteThroughCache) Set(ctx context.Context, key string, val any, expiration time.Duration) error {
    // 步骤 1:先写数据库
    // 一般是先写 DB,因为如果 DB 写失败了,就不应该写缓存
    if err := w.storeFunc(ctx, key, val); err != nil {
        return err
    }

    // 步骤 2:再写缓存
    // 可以选择同步写入或异步写入
    //
    // 同步写入(当前实现):
    //   cache 会同步将数据刷新到 DB,而后返回响应
    //
    // 异步写入:
    //   go func() { w.Cache.Set(ctx, key, val, expiration) }()
    //   只具备理论意义,实际上几乎不会用
    return w.Cache.Set(ctx, key, val, expiration)
}

// ==================== WriteThroughCache 和 ReadThroughCache 的对比 ====================
// WriteThroughCache 和 ReadThroughCache 差不多
// 只是 LoadFunc 改成了 StoreFunc
// 类似地,WriteThroughCache 也可以尝试使用泛型
// 但也面临着 ReadThrough 的问题(一个实例只能处理一种类型)

4.4 Write Back(写回)

Write Back:在写操作的时候写了缓存直接返回,不会直接更新数据库。读也是直接读缓存。在缓存过期的时候,将缓存写回去数据库。

白话类比:Write Back 就像"先在小本子上记账,月底才统一报账"。业务每次改动只写进缓存(小本子)就立刻返回,不碰数据库;只有当这条记录在缓存里过期/被淘汰时,才通过 OnEvicted 回调把小本子上的内容誊写到数据库。好处是平时几乎不排队、极快;坏处是一旦小本子丢了(缓存宕机),这笔账就永久丢失。

flowchart LR
    A[业务写数据] --> B[只写入缓存
立即返回] B --> C{key 过期
或被淘汰?} C -- 否 --> D[继续服务读请求] C -- 是 --> E[OnEvicted 回调
写回数据库] E --> F[(数据库)]
package cache

import (
    "context"
    "time"
)

// ==================== Write Back 模式 ====================
//
// 优缺点:
// 优点:
//   - 所有 goroutine 都是读写缓存,不存在一致性问题
//     (如果是本地缓存依旧会有问题,因为多实例之间的本地缓存不共享)
//   - 性能极高,写操作不需要等数据库
//
// 缺点:
//   - 数据可能丢失:如果在缓存过期刷新到数据库之前,缓存宕机
//     那么会丢失数据(这是最大的缺点)
//
// 实现方式:
//   write-back 主要是利用 OnEvicted 回调
//   在里面将数据刷新到 DB 里

// ==================== Write Back 实现 ====================

// WriteBackCache 使用 Write Back 模式的缓存
type WriteBackCache struct {
    Cache // 嵌入 Cache 接口

    // storeFunc:当缓存 key 过期/被删除时,调用此函数将数据写回数据库
    storeFunc func(ctx context.Context, key string, val any) error
}

// NewWriteBackCache 创建 Write Back 缓存
func NewWriteBackCache(cache Cache, storeFunc func(ctx context.Context, key string, val any) error) *WriteBackCache {
    w := &WriteBackCache{
        Cache:     cache,
        storeFunc: storeFunc,
    }

    // 注册 evict 回调
    // 当 key 过期或被删除时,将数据写回数据库
    // 这就是 Write Back 的核心:在 key 被淘汰时才写回 DB
    //
    // 注意:这里假设 cache 是 *LocalCache,可以注册 OnEvicted 回调
    // 如果是 Redis,需要用 Redis 的 keyspace notification
    if lc, ok := cache.(*LocalCache); ok {
        lc.OnEvicted(func(key string, val any) {
            // 在 key 过期时,将数据写回数据库
            _ = w.storeFunc(context.Background(), key, val)
        })
    }

    return w
}

// Set 写操作:只写缓存,不写数据库
func (w *WriteBackCache) Set(ctx context.Context, key string, val any, expiration time.Duration) error {
    // 只写缓存,不写 DB
    // DB 的写入会在 key 过期时通过 OnEvicted 回调触发
    return w.Cache.Set(ctx, key, val, expiration)
}

// Get 读操作:只读缓存
func (w *WriteBackCache) Get(ctx context.Context, key string) (any, error) {
    return w.Cache.Get(ctx, key)
}

Write Back 的一致性问题:对于 Redis 之类的缓存,一致性问题要轻微一点,因为用户读写都是访问缓存。虽然缓存数据和数据库数据不一致,但用户没有感知。最大的缺点就是可能永久丢失数据——突然宕机时来不及将缓存刷回去数据库。

4.5 Refresh Ahead(提前刷新)

白话类比:Refresh Ahead 就像"订阅了天气预报推送"——数据库那边一有数据变动(通过 CDC / binlog 等变更通知),缓存就提前把最新数据拉过来更新好。于是业务读请求几乎永远命中最新值,很少出现缓存未命中的卡顿;代价是背后得有一套 CDC 基础设施在运转。

flowchart LR
    DB[(数据库)] -->|数据变更 CDC/binlog| CDC[变更监听]
    CDC -->|主动刷新| Cache[缓存]
    U[业务读请求] --> Cache
    Cache -->|命中 几乎不 miss| U
// ==================== Refresh Ahead 模式 ====================
//
// refresh-ahead 依赖于 CDC(Change Data Capture)接口:
//   1. 数据库暴露数据变更接口
//   2. cache 或者第四方在监听到数据变更之后自动更新数据
//   3. 如果读 cache 未命中,依旧要刷新缓存的话,依然会出现并发问题
//
// 这种模式需要数据库或中间件支持数据变更通知
// 例如 MySQL 的 binlog、PostgreSQL 的 logical replication
// 或者通过 Debezium、Canal 等工具监听数据库变更
//
// refresh-ahead 的优势:
//   - 缓存数据"始终"是最新的(在 CDC 通知延迟范围内)
//   - 用户读请求几乎不会 miss
//
// 劣势:
//   - 实现复杂,需要 CDC 基础设施
//   - 如果读未命中时仍需刷新缓存,会有并发问题

五、缓存异常——穿透、击穿和雪崩

5.1 三种缓存异常

┌────────────────────────────────────────────────────────────────┐
│                    三种缓存异常对比                              │
├──────────┬──────────────────────────────────────────────────────┤
│          │  穿透          │  击穿          │  雪崩              │
├──────────┼────────────────┼────────────────┼────────────────────┤
│ 触发原因 │ 数据根本不存在 │ 热点key过期    │ 大量key同时过期    │
├──────────┼────────────────┼────────────────┼────────────────────┤
│ DB中数据 │ 没有           │ 有             │ 可能有             │
├──────────┼────────────────┼────────────────┼────────────────────┤
│ 请求量   │ 可大可小       │ 非常大         │ 非常大             │
├──────────┼────────────────┼────────────────┼────────────────────┤
│ 能否自愈 │ 不能(数据不存在│ 能(回写缓存后  │ 能(回写缓存后     │
│          │ 每次都穿透)    │ 下次就命中了)  │ 下次就命中了)     │
├──────────┼────────────────┼────────────────┼────────────────────┤
│ 解决方案 │ 布隆过滤器      │ singleflight   │ 过期时间加随机值   │
│          │ 缓存空值        │ 互斥锁          │                    │
│          │ 限流            │ 限流            │                    │
└──────────┴────────────────┴────────────────┴────────────────────┘

5.2 缓存穿透

缓存穿透:读请求对应的数据根本不存在,因此每次都会发起数据库查询。数据库返回 NULL,所以下一次请求依旧会打到数据库。

// ==================== 缓存穿透场景 ====================
//
// 用户请求 key1 → 缓存未命中 → 查数据库 → 数据库返回 NULL
// → 不回写缓存(因为没数据)
// → 下一次请求 key1 → 缓存还是未命中 → 又查数据库...
//
// 关键点:数据根本没有,所以不会回写缓存
// 一般是黑客使用了一些非法的请求,比如说非法的邮箱、ID 等

// ==================== 解决方案 ====================

// 方案 1:缓存空值
// 即使数据库返回 NULL,也缓存一个空值(带较短过期时间)
// 这样下次请求就不会打到数据库
func (s *UserService) GetUserWithNullCache(ctx context.Context, userID int) (*User, error) {
    key := fmt.Sprintf("user:%d", userID)

    val, err := s.cache.Get(ctx, key)
    if err == nil {
        if val == nil {
            // 缓存了空值,说明数据库里也没有
            return nil, errUserNotFound
        }
        return val.(*User), nil
    }

    user, err := s.db.FindUser(ctx, userID)
    if err != nil {
        return nil, err
    }

    if user == nil {
        // 数据库也没有,缓存一个空值
        // 过期时间设短一些,防止数据库后来有了这个数据
        _ = s.cache.Set(ctx, key, nil, 1*time.Minute)
        return nil, errUserNotFound
    }

    _ = s.cache.Set(ctx, key, user, 10*time.Minute)
    return user, nil
}

// 方案 2:布隆过滤器
// 布隆过滤器是一种空间效率很高的数据结构
// 可以快速判断一个元素"可能存在"或"一定不存在"
// 如果布隆过滤器说不存在,就一定不存在,直接返回
// 如果说可能存在,再去查数据库

// 方案 3:singleflight
// 可以缓解问题,但如果攻击者构造了大量不同的不存在的 key
// singleflight 的效果并不是很好

// 方案 4:限流
// 在缓存未命中回表查询的时候,加上限流器
// 但这是保护系统,而不是解决问题

5.3 缓存击穿

缓存击穿:缓存中没有对应 key 的数据(通常是热点 key 过期了),大量请求同时打到数据库。

// ==================== 缓存击穿场景 ====================
//
// 热点 key(如首页推荐数据)的缓存突然过期
// 此时有大量请求同时进来,都发现缓存未命中
// 全部去查数据库,可能压垮数据库
//
// 击穿和穿透的区别:
//   穿透:数据在 DB 中根本不存在
//   击穿:数据在 DB 中是有的,只是缓存里没有
//   所以击穿只要回写缓存,下一次访问就命中了

// ==================== 解决方案:singleflight ====================
// singleflight 是最有效的解决方案
// 详见下一节的 singleflight 详解

5.4 缓存雪崩

缓存雪崩:同一时刻,大量 key 过期,查询都要回查数据库。

// ==================== 缓存雪崩场景 ====================
//
// 常见场景:在启动的时候加载缓存,所有 key 的过期时间都一样
// 所以会在同一时间全部过期 → 所有请求都打到数据库
//
// ==================== 解决方案 ====================
// 在设置 key 过期时间的时候,加上一个随机的偏移量

func (s *UserService) SetUserCache(ctx context.Context, user *User) error {
    key := fmt.Sprintf("user:%d", user.ID)

    // 基础过期时间:10 分钟
    baseTTL := 10 * time.Minute

    // 加上随机偏移量:0~120 秒
    // 这样每个 key 的过期时间都不一样,不会同时过期
    randomOffset := time.Duration(rand.Intn(120)) * time.Second

    return s.cache.Set(ctx, key, user, baseTTL+randomOffset)
}

六、singleflight——防止并发重复查询

6.1 singleflight 原理

singleflight 是一种设计模式,能够有效减轻对数据库的压力。

没有 singleflight:                    有 singleflight:

  请求1 → 缓存未命中 → 查DB              请求1 → 缓存未命中 → 查DB
  请求2 → 缓存未命中 → 查DB              请求2 → 缓存未命中 → 等待请求1的结果
  请求3 → 缓存未命中 → 查DB              请求3 → 缓存未命中 → 等待请求1的结果
  请求4 → 缓存未命中 → 查DB              请求4 → 缓存未命中 → 等待请求1的结果
                                        → 请求1返回,所有等待者都拿到结果
  4次DB查询                              1次DB查询

原理:在有多个 goroutine 试图去数据库加载同一个 key 对应数据的时候,只允许一个 goroutine 过去查询,其它都在原地等待结果。对数据库的压力本来是跟 QPS 相当,变为跟同一时刻不同 key 的数量和实例数量相当。

6.2 singleflight 实现

Go 标准库 golang.org/x/sync/singleflight 提供了实现,但我们也可以自己实现来理解原理:

package cache

import (
    "context"
    "sync"
)

// ==================== singleflight 简化实现 ====================

// call 代表一次正在执行的函数调用
type call struct {
    wg  sync.WaitGroup // 用于等待结果
    val any            // 结果值
    err error          // 结果错误
}

// Group 是 singleflight 的核心结构
// 确保同一个 key 同时只有一个 goroutine 在执行加载
type Group struct {
    mu sync.Mutex         // 保护 calls map
    calls map[string]*call // 正在执行的调用
}

// Do 执行函数,如果同一个 key 已经有 goroutine 在执行,就等待结果
func (g *Group) Do(key string, fn func() (any, error)) (any, error) {
    g.mu.Lock()
    if g.calls == nil {
        g.calls = make(map[string]*call)
    }

    // 检查是否已有相同 key 的调用正在进行
    if c, ok := g.calls[key]; ok {
        // 已有调用正在进行,释放锁,等待结果
        g.mu.Unlock()
        c.wg.Wait() // 阻塞等待结果
        return c.val, c.err
    }

    // 没有正在进行的调用,创建新的 call
    c := &call{}
    c.wg.Add(1)  // 加 1,表示有一个 goroutine 正在执行
    g.calls[key] = c
    g.mu.Unlock()

    // 执行函数
    c.val, c.err = fn()
    c.wg.Done() // 标记完成,唤醒所有等待的 goroutine

    // 清理已完成的 call
    g.mu.Lock()
    delete(g.calls, key)
    g.mu.Unlock()

    return c.val, c.err
}

// ==================== 使用示例 ====================

func ExampleUsage() {
    var g Group
    var wg sync.WaitGroup

    // 模拟 10 个 goroutine 同时请求同一个 key
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            // 只有第一个 goroutine 会真正执行 fn
            // 其他 9 个 goroutine 会等待第一个的结果
            val, err := g.Do("hotkey", func() (any, error) {
                fmt.Printf("goroutine %d 正在查询数据库...\n", id)
                time.Sleep(100 * time.Millisecond) // 模拟耗时查询
                return "data_from_db", nil
            })
            fmt.Printf("goroutine %d 拿到结果: %v, err: %v\n", id, val, err)
        }(i)
    }
    wg.Wait()
    // 输出(只有一行"正在查询数据库"):
    // goroutine 3 正在查询数据库...
    // goroutine 0 拿到结果: data_from_db, err: <nil>
    // goroutine 1 拿到结果: data_from_db, err: <nil>
    // ...(所有 goroutine 拿到相同结果)
}

6.3 singleflight + Cache Aside

普通的 singleflight 是和 Cache Aside 一起使用的。业务代码发现缓存返回了 KeyNotFound,于是利用 singleflight 去数据库加载数据,并刷新缓存。

package cache

import (
    "context"
    "time"

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

// ==================== singleflight + Cache Aside ====================

// UserServiceWithSingleFlight 使用 singleflight 防止缓存击穿
type UserServiceWithSingleFlight struct {
    cache Cache
    db    DB
    g     singleflight.Group // singleflight 组
}

// GetUser 获取用户信息
func (s *UserServiceWithSingleFlight) GetUser(ctx context.Context, userID int) (*User, error) {
    key := fmt.Sprintf("user:%d", userID)

    // 步骤 1:先查缓存
    val, err := s.cache.Get(ctx, key)
    if err == nil {
        return val.(*User), nil
    }

    // 步骤 2:缓存未命中,使用 singleflight 查数据库
    // Do 方法确保同一个 key 同时只有一个 goroutine 在查数据库
    // 其他 goroutine 会等待这个 goroutine 的结果
    val, err, _ = s.g.Do(key, func() (any, error) {
        // 只有第一个请求会执行到这里
        user, err := s.db.FindUser(ctx, userID)
        if err != nil {
            return nil, err
        }
        if user == nil {
            return nil, errUserNotFound
        }

        // 回写缓存
        // 注意:这里只有一个 goroutine 执行回写
        _ = s.cache.Set(ctx, key, user, 10*time.Minute)

        return user, nil
    })

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

6.4 singleflight + Read Through

singleflight 也可以和 Read Through 结合,做成装饰器模式。本身 Read Through 也是一个装饰器模式。

package cache

import (
    "context"
    "time"

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

// ==================== singleflight + Read Through ====================

// 实现一:通过 singleflight 封装 LoadFunc
// 可以确保从数据库加载数据必然每个 key 一个 goroutine
// 但把数据回写缓存就是多个 goroutine 重复执行了

type SingleFlightReadThroughCacheV1 struct {
    ReadThroughCache // 嵌入 ReadThroughCache
    g               singleflight.Group
}

func NewSingleFlightReadThroughCacheV1(
    cache Cache,
    loadFunc func(ctx context.Context, key string) (any, error),
    expiration time.Duration,
) *SingleFlightReadThroughCacheV1 {
    return &SingleFlightReadThroughCacheV1{
        ReadThroughCache: ReadThroughCache{
            Cache:      cache,
            loadFunc:   loadFunc,
            expiration: expiration,
        },
    }
}

// 重写 Get 方法,用 singleflight 包裹 loadFunc
func (s *SingleFlightReadThroughCacheV1) Get(ctx context.Context, key string) (any, error) {
    // 先查缓存
    val, err := s.Cache.Get(ctx, key)
    if err == nil {
        return val, nil
    }

    // 用 singleflight 包裹 loadFunc
    // 这样同一个 key 只有一个 goroutine 在查数据库
    // 但是注意:回写缓存的代码在 loadFunc 里面没有
    // 所以多个 goroutine 都会执行回写缓存(重复执行)
    val, err, _ = s.g.Do(key, func() (any, error) {
        return s.loadFunc(ctx, key)
    })
    if err != nil {
        return nil, err
    }

    // 回写缓存(多个 goroutine 都会执行这里,重复写入)
    _ = s.Cache.Set(ctx, key, val, s.expiration)
    return val, nil
}

// ==================== 实现二:在 Get 方法中用 singleflight 完成全部 ====================
// 这种就是很简单的装饰器模式
// 在 Get 方法里面利用 singleflight 来完成加载数据和回写缓存两个步骤

type SingleFlightReadThroughCacheV2 struct {
    Cache
    loadFunc   func(ctx context.Context, key string) (any, error)
    expiration time.Duration
    g          singleflight.Group
}

func NewSingleFlightReadThroughCacheV2(
    cache Cache,
    loadFunc func(ctx context.Context, key string) (any, error),
    expiration time.Duration,
) *SingleFlightReadThroughCacheV2 {
    return &SingleFlightReadThroughCacheV2{
        Cache:      cache,
        loadFunc:   loadFunc,
        expiration: expiration,
        g:          singleflight.Group{},
    }
}

// Get 方法:用 singleflight 包裹整个"加载 + 回写"过程
func (s *SingleFlightReadThroughCacheV2) Get(ctx context.Context, key string) (any, error) {
    // 先查缓存
    val, err := s.Cache.Get(ctx, key)
    if err == nil {
        return val, nil
    }

    // 用 singleflight 包裹"加载 + 回写"两个步骤
    // 这样只有一个 goroutine 执行加载和回写
    // 其他 goroutine 直接拿到结果,不会重复回写
    val, err, _ = s.g.Do(key, func() (any, error) {
        // 加载数据
        data, err := s.loadFunc(ctx, key)
        if err != nil {
            return nil, err
        }
        // 回写缓存
        _ = s.Cache.Set(ctx, key, data, s.expiration)
        return data, nil
    })

    return val, err
}

两种实现的区别

  • V1:singleflight 只包裹 loadFunc,回写缓存是每个 goroutine 都会执行的(重复写入,但结果一样,无害)
  • V2:singleflight 包裹整个"加载 + 回写",只有一个 goroutine 执行,更优雅

七、布隆过滤器——解决缓存穿透

7.1 布隆过滤器原理

布隆过滤器(Bloom Filter)是一种空间效率很高的数据结构,用于判断一个元素是否在集合中。

布隆过滤器原理:

初始化:一个长度为 m 的位数组(全部为 0)+ k 个哈希函数

添加元素 "hello":
  hash1("hello") = 3  → bits[3] = 1
  hash2("hello") = 7  → bits[7] = 1
  hash3("hello") = 11 → bits[11] = 1

查询元素 "hello":
  hash1("hello") = 3  → bits[3] = 1 ✓
  hash2("hello") = 7  → bits[7] = 1 ✓
  hash3("hello") = 11 → bits[11] = 1 ✓
  → 所有的位都是 1,"可能存在"

查询元素 "world":
  hash1("world") = 2  → bits[2] = 0 ✗
  → 有位是 0,"一定不存在"

特点:
  - 说"不存在" → 一定不存在(100%准确)
  - 说"存在" → 可能存在(有误判率)
  - 误判率可以通过增加位数组大小和哈希函数数量来降低
  - 空间效率极高:1亿条数据只需约 100MB

7.2 布隆过滤器解决缓存穿透

package cache

import (
    "context"
    "time"

    "github.com/bits-and-blooms/bloom/v3"
)

// ==================== 布隆过滤器解决缓存穿透 ====================

// BloomFilterCache 在 ReadThroughCache 的基础上增加布隆过滤器
// 装饰器模式:在已有的缓存外面再套一层布隆过滤器
type BloomFilterCache struct {
    Cache
    filter *bloom.BloomFilter // 布隆过滤器
    loadFunc func(ctx context.Context, key string) (any, error)
    expiration time.Duration
}

// NewBloomFilterCache 创建带布隆过滤器的缓存
func NewBloomFilterCache(
    cache Cache,
    loadFunc func(ctx context.Context, key string) (any, error),
    expiration time.Duration,
    expectedElements uint, // 预期元素数量
    falsePositiveRate float64, // 可接受的误判率(如 0.01 表示 1%)
) *BloomFilterCache {
    // 创建布隆过滤器
    // expectedElements:预期要存储多少元素
    // falsePositiveRate:可接受的误判率
    filter := bloom.NewWithEstimates(expectedElements, falsePositiveRate)

    return &BloomFilterCache{
        Cache:      cache,
        filter:     filter,
        loadFunc:   loadFunc,
        expiration: expiration,
    }
}

// Get 重写 Get 方法,加入布隆过滤器检查
func (b *BloomFilterCache) Get(ctx context.Context, key string) (any, error) {
    // ==================== 步骤 1:先查缓存 ====================
    val, err := b.Cache.Get(ctx, key)
    if err == nil {
        return val, nil
    }

    // ==================== 步骤 2:缓存未命中,查布隆过滤器 ====================
    // 布隆过滤器说"不存在" → 一定不存在,直接返回
    // 大部分不存在的 key 会直接在布隆过滤器这一层被拦下
    if !b.filter.TestString(key) {
        // 布隆过滤器说 key 不存在,直接返回
        // 不需要查数据库,防止缓存穿透
        return nil, errKeyNotFound
    }

    // 布隆过滤器说"可能存在" → 查数据库
    // 注意:可能是误判,数据库里也可能没有
    val, err = b.loadFunc(ctx, key)
    if err != nil {
        return nil, err
    }

    // 回写缓存
    _ = b.Cache.Set(ctx, key, val, b.expiration)
    return val, nil
}

// Add 向布隆过滤器中添加 key
// 应该在数据库有新数据时调用
func (b *BloomFilterCache) Add(key string) {
    b.filter.AddString(key)
}

// ==================== 使用示例 ====================
//
// // 初始化
// bfCache := NewBloomFilterCache(
//     localCache,
//     loadUserFromDB,
//     10*time.Minute,
//     1_000_000,  // 预期 100 万用户
//     0.01,       // 1% 误判率
// )
//
// // 启动时加载所有用户 ID 到布隆过滤器
// userIDs := db.GetAllUserIDs()
// for _, id := range userIDs {
//     bfCache.Add(fmt.Sprintf("user:%d", id))
// }
//
// // 正常使用
// user, err := bfCache.Get(ctx, "user:12345")
// // 如果 user:12345 不在布隆过滤器中,直接返回 errKeyNotFound
// // 不会查数据库,防止缓存穿透

布隆过滤器的局限

  • 布隆过滤器有误判率(false positive),说"存在"时可能不存在
  • 布隆过滤器不支持删除元素(标准版本)
  • 需要预先知道大概有多少元素,以设置合适的位数组大小

八、缓存异常综合解决方案

8.1 装饰器模式组合解决

在实践中,大多数时候可以采用装饰器模式无侵入式地解决缓存异常问题:

package cache

import (
    "context"
    "math/rand"
    "time"

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

// ==================== 综合解决方案 ====================
// 通过装饰器模式,将多个功能层层包裹

// AllInOneCache 集成了多种缓存保护机制
// 使用装饰器模式层层包裹:
//   最外层:布隆过滤器(防穿透)
//   中间层:singleflight(防击穿)
//   最内层:ReadThroughCache(自动回源)
type AllInOneCache struct {
    Cache
    filter     *bloomFilter // 布隆过滤器(简化表示)
    g          singleflight.Group
    loadFunc   func(ctx context.Context, key string) (any, error)
    expiration time.Duration
}

// Get 综合防护的 Get 方法
func (a *AllInOneCache) Get(ctx context.Context, key string) (any, error) {
    // ==================== 第一层:缓存命中检查 ====================
    val, err := a.Cache.Get(ctx, key)
    if err == nil {
        return val, nil
    }

    // ==================== 第二层:布隆过滤器(防穿透)====================
    if a.filter != nil && !a.filter.Test(key) {
        // 布隆过滤器说不存在,直接返回
        return nil, errKeyNotFound
    }

    // ==================== 第三层:singleflight(防击穿)====================
    val, err, _ = a.g.Do(key, func() (any, error) {
        // 只有一个 goroutine 会执行到这里
        data, err := a.loadFunc(ctx, key)
        if err != nil {
            return nil, err
        }

        // ==================== 第四层:随机过期时间(防雪崩)====================
        // 在基础过期时间上加随机偏移,防止大量 key 同时过期
        ttl := a.expiration
        if ttl > 0 {
            // 加 0~10% 的随机偏移
            offset := time.Duration(rand.Int63n(int64(ttl) / 10))
            ttl += offset
        }

        _ = a.Cache.Set(ctx, key, data, ttl)
        return data, nil
    })

    return val, err
}

// ==================== 装饰器组合方式 ====================
//
// 组装过程(由外到内):
//
//   1. 创建底层缓存
//      localCache := NewLocalCache()
//
//   2. 包裹 Read Through(自动回源)
//      readThrough := NewReadThroughCache(localCache, loadFunc, 10*time.Minute)
//
//   3. 包裹 singleflight(防击穿)
//      withSF := NewSingleFlightReadThroughCacheV2(localCache, loadFunc, 10*time.Minute)
//
//   4. 包裹布隆过滤器(防穿透)
//      withBF := NewBloomFilterCache(withSF, loadFunc, 10*time.Minute, 1e6, 0.01)
//
//   最终得到的 withBF 就是集成了所有防护的缓存
//   业务代码只需要调用 withBF.Get(ctx, key) 就行

8.2 缓存异常与缓存模式的关系

// ==================== 重要澄清 ====================
//
// 面试中可能会被问到:"缓存穿透、雪崩、击穿和缓存模式的关系?"
//
// 答案:没有关系。
//
// 缓存穿透、雪崩、击穿是描述"缓存使用不当"导致的问题
//   → 是运维层面/防御层面的问题
//
// 缓存模式则是"如何组织缓存和数据源"的设计模式
//   → 是架构设计层面的选择
//
// 两者是正交的:
//   - 你可以用 Cache Aside 模式,同时遇到缓存穿透问题
//   - 你可以用 Read Through 模式,同时遇到缓存雪崩问题
//   - 任何缓存模式都可能遇到缓存异常
//   - 任何缓存异常都可以在任何缓存模式下解决

九、面试要点总结

9.1 缓存 API 与本地缓存

问题要点
缓存过期时间怎么控制定期删除 + 懒惰删除(Get 时检查)
定期删除有什么注意的控制CPU开销,防止定期删除占用太多资源(Redis 也是点到即止)
为什么无法做到过期即删除借助延迟队列可勉强做到毫秒级误差,更精确做不到。开 goroutine 盯着资源开销太大
本地缓存如何避免内存过多控制内存总量或控制键值对总数,配合 LRU/LFU 淘汰算法
如何提高缓存内存利用率不用 Hash 结构,存储序列化后的数据而非对象
为什么不用 sync.MapGet 检查到过期时需要加写锁删除,sync.Map 不支持这种模式
为什么不用泛型Go 不支持方法级别泛型,一个实例只能存一种类型

9.2 缓存模式

问题要点
缓存模式有哪些Cache Aside、Read Through、Write Through、Write Back、Refresh Ahead
Cache Aside 特点业务代码自己管理缓存和 DB 的读写
Read Through 特点缓存自动回源,业务代码只需 Get
Write Through 特点缓存自动同步到 DB,一般先写 DB
Write Back 优缺点优点:读写都走缓存,性能高;缺点:宕机可能丢数据
缓存模式能否解决一致性不能,缓存模式是设计模式,一致性问题需要额外处理
Write Back 最大缺点突然宕机时来不及将缓存刷回数据库,永久丢失数据

9.3 缓存异常

问题要点
什么是缓存穿透数据根本不存在,每次都打到数据库,不能自愈
什么是缓存击穿热点 key 过期,大量请求打到数据库,回写缓存后可自愈
什么是缓存雪崩大量 key 同时过期,全部回查数据库
穿透解决方案布隆过滤器、缓存空值、限流
击穿解决方案singleflight、互斥锁、限流
雪崩解决方案过期时间加随机偏移量
穿透击穿雪崩与缓存模式的关系没有关系,前者是使用问题,后者是设计模式

9.4 singleflight

问题要点
什么是 singleflight多个 goroutine 请求同一 key 时,只让一个去查 DB,其他等待结果
singleflight 好处将 DB 压力从 QPS 级别降到"不同 key 数量 × 实例数"级别
singleflight 缺点需要的内存和 key 数量成正比,只能控制单进程内的 goroutine
为什么不支持全局 singleflight全局的本质是分布式锁,性能影响太大;单机用了还扛不住说明 DB 该扩容了
singleflight 热点越集中效果越好因为合并的请求越多

自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. 我们的缓存接口 Cache 为何要在每个公开方法里接收 context.Context 参数?它在本地缓存实现和 Redis 实现里分别起到什么作用?
  2. 本地缓存的过期处理采用"定时轮询 + 懒惰删除"两策略配合,请说明各自解决的问题与仍存在的短板。
  3. Cache Aside 模式在写流程里为什么要"删缓存"而不是"更新缓存"?
  4. 缓存穿透、击穿、雪崩在"DB 中是否存在该数据"“能否自愈"两个维度上分别有什么不同?各自首选的工程解法是什么?
  5. singleflight 把对数据库的压力从"与 QPS 相当"降到了什么量级?它为什么只能控制单进程内的 goroutine,而无法做成"全局 singleflight”?

动手练习(建议真做一遍)

  1. 实现 LocalCache,写测试:设一个 1 秒过期的 key,分别在 0.5 秒与 1.5 秒后 Get,验证前者返回、后者返回 errKeyNotFound;再测 LoadOrStore 的原子语义。
  2. 在 Cache Aside 的 GetUser 中引入 singleflight.Group,用 go test -race 跑 100 个并发请求同一个 userID,观察 DB 只被查询一次(加日志或计数验证)。
  3. 用装饰器把 ReadThroughCacheSingleFlightReadThroughCacheV2BloomFilterCache 依次包裹成 AllInOneCache,写场景:先请求不存在的 key(被布隆过滤器拦截),再请求存在的 key(走回源并被合并),打印每步是否命中。

本章小结

  • 缓存 API 的核心是统一 Cache 接口 + context 首参;用 any + 类型断言 而非泛型,是为了让单个缓存实例能存多种类型。
  • 本地缓存靠"定时轮询清理 + Get 时懒惰删除"处理过期,并用 RWMutex(非 sync.Map)保证"读时可能写"的并发安全。
  • 五种缓存模式划分的是"缓存与数据源的读写职责",与缓存异常正交:模式是架构选择,异常是使用问题,任何模式都可能遇到任何异常。
  • 穿透(数据不存在)、击穿(热点 key 过期)、雪崩(大量 key 同时过期)成因不同,解法分别是布隆过滤器/空值、singleflight、随机过期偏移。
  • 装饰器模式能把布隆过滤器、singleflight、Read Through 无侵入地层层组合,形成统一防护。

缓存模块两篇到此收尾:从并发原语到缓存 API、模式与异常防护,已构建完整知识链。后续可继续深入 Redis 集群、分布式锁等进阶主题。

About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!