学习目标
学完本章你应该能够:
- 解释缓存统一接口
Cache的设计(Get/Set/Delete/GetMulti/LoadOrStore 等)为何以context作为公开方法的首个参数,并说明为何选用any + 类型断言而非泛型。 - 实现本地缓存的过期策略(定时轮询 + 懒惰删除)与并发安全的读写,并能讲清为何用
RWMutex而非sync.Map。 - 区分五种缓存模式(Cache Aside / Read Through / Write Through / Write Back / Refresh Ahead)的读写职责,并针对一致性要求做选型。
- 辨析缓存穿透、击穿、雪崩的成因差异,并分别给出布隆过滤器、singleflight、随机过期偏移等工程解法。
- 用装饰器模式把布隆过滤器、singleflight、Read Through 层层组合,形成防穿透/击穿/雪崩的统一缓存。
前置知识:
- 已读完本模块上一篇《一、并发编程》,理解 goroutine、sync.Mutex/RWMutex、sync.Once、double-check 模式;
- 了解 Go 接口与嵌入(embedding)机制;
- 对 Redis 基本命令(GET/SET/MGET/SETNX/EVAL)有初步认知。
本章你会动手做的事:
- 基于
Cache接口实现一个最小本地缓存,并写测试验证 Set/Get 的过期与LoadOrStore原子语义。 - 用
golang.org/x/sync/singleflight改造一次 Cache Aside 读流程,观察并发回源是否被合并为一次 DB 查询。 - 用装饰器把
BloomFilterCache与SingleFlightReadThroughCacheV2套在一起,写一次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.Map | Get 检查到过期时需要加写锁删除,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 热点越集中效果越好 | 因为合并的请求越多 |
自测题与动手练习
自测题(合上书能答出来,才算懂):
- 我们的缓存接口
Cache为何要在每个公开方法里接收context.Context参数?它在本地缓存实现和 Redis 实现里分别起到什么作用? - 本地缓存的过期处理采用"定时轮询 + 懒惰删除"两策略配合,请说明各自解决的问题与仍存在的短板。
- Cache Aside 模式在写流程里为什么要"删缓存"而不是"更新缓存"?
- 缓存穿透、击穿、雪崩在"DB 中是否存在该数据"“能否自愈"两个维度上分别有什么不同?各自首选的工程解法是什么?
singleflight把对数据库的压力从"与 QPS 相当"降到了什么量级?它为什么只能控制单进程内的 goroutine,而无法做成"全局 singleflight”?
动手练习(建议真做一遍):
- 实现
LocalCache,写测试:设一个 1 秒过期的 key,分别在 0.5 秒与 1.5 秒后Get,验证前者返回、后者返回errKeyNotFound;再测LoadOrStore的原子语义。 - 在 Cache Aside 的
GetUser中引入singleflight.Group,用go test -race跑 100 个并发请求同一个userID,观察 DB 只被查询一次(加日志或计数验证)。 - 用装饰器把
ReadThroughCache、SingleFlightReadThroughCacheV2、BloomFilterCache依次包裹成AllInOneCache,写场景:先请求不存在的 key(被布隆过滤器拦截),再请求存在的 key(走回源并被合并),打印每步是否命中。
本章小结
- 缓存 API 的核心是统一
Cache接口 +context首参;用any + 类型断言而非泛型,是为了让单个缓存实例能存多种类型。 - 本地缓存靠"定时轮询清理 + Get 时懒惰删除"处理过期,并用
RWMutex(非sync.Map)保证"读时可能写"的并发安全。 - 五种缓存模式划分的是"缓存与数据源的读写职责",与缓存异常正交:模式是架构选择,异常是使用问题,任何模式都可能遇到任何异常。
- 穿透(数据不存在)、击穿(热点 key 过期)、雪崩(大量 key 同时过期)成因不同,解法分别是布隆过滤器/空值、singleflight、随机过期偏移。
- 装饰器模式能把布隆过滤器、singleflight、Read Through 无侵入地层层组合,形成统一防护。
缓存模块两篇到此收尾:从并发原语到缓存 API、模式与异常防护,已构建完整知识链。后续可继续深入 Redis 集群、分布式锁等进阶主题。