Skip to content

模型缓存与缓存穿透防护

本篇深入讲解 go-zero 的缓存设计。我们将剖析 CachedConn 的工作原理,理解自动缓存的读写流程;然后系统介绍缓存三大经典问题(穿透、击穿、雪崩)以及 go-zero 的防护机制;最后通过一个高并发查询的实战对比,展示缓存方案的实际效果。go-zero 的缓存机制经过大量生产验证,理解其原理有助于在高并发场景下做出正确决策。

一、go-zero 缓存设计

go-zero 的缓存抽象集中在 core/stores/sqlc(DB 缓存)和 core/stores/cachex(通用缓存)两个包。核心接口是 CachedConn

1. CachedConn 接口

CachedConn 是一个「带缓存的 DB 连接」抽象,定义在 core/stores/sqlc/cachedconn.go

go
type (
    // CachedConn 把 DB 操作和缓存操作组合在一起
    CachedConn interface {
        // 带缓存的查询(单行)
        QueryRow(ctx context.Context, v any, key string, query QueryFn) error
        // 带缓存的查询(多行,不缓存,仅统一接口)
        QueryRows(ctx context.Context, v any, query QueryFn) error
        // 带缓存的查询(无版本号)
        QueryRowNoCache(ctx context.Context, v any, query QueryFn) error
        // 执行写操作(自动删除缓存)
        Exec(ctx context.Context, exec ExecFn, keys ...string) (sql.Result, error)
        // 不带缓存的执行
        ExecNoCache(ctx context.Context, exec ExecFn) (sql.Result, error)
        // 主动删除缓存
        DelCache(keys ...string) error
        // 带 ctx 的版本
        QueryRowCtx(ctx context.Context, v any, key string, query QueryCtxFn) error
        QueryRowsCtx(ctx context.Context, v any, query QueryCtxFn) error
        ExecCtx(ctx context.Context, exec ExecCtxFn, keys ...string) (sql.Result, error)
        // ...
    }

    // 查询函数:传入实际查 DB 的逻辑
    QueryCtxFn func(ctx context.Context, conn sqlx.SqlConn, v any) error
    // 执行函数:传入实际写 DB 的逻辑
    ExecCtxFn  func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error)
)

2. 创建 CachedConn

go
import (
    "github.com/zeromicro/go-zero/core/stores/cache"
    "github.com/zeromicro/go-zero/core/stores/sqlc"
    "github.com/zeromicro/go-zero/core/stores/sqlx"
)

// 缓存配置
cacheConf := cache.CacheConf{
    cache.NodeConf{
        RedisConf: redis.RedisConf{
            Host: "127.0.0.1:6379",
            Type: "node",
        },
        Weight: 100,
    },
}

// 创建带缓存的连接
conn := sqlc.NewConn(sqlx.NewMysql(dsn), cacheConf)

sqlc.NewConn 内部创建一个 cachedConn 实例,它持有:

  • sqlx.SqlConn:底层 DB 连接
  • cache.Cache:缓存客户端(封装了 Redis)
  • sync.WaitGroup:用于异步删缓存的等待

3. 自动缓存读写流程

查询流程(QueryRowCtx)

go
err := conn.QueryRowCtx(ctx, &user, "user:1001", func(ctx context.Context, conn sqlx.SqlConn, v any) error {
    // 这里写实际查 DB 的逻辑
    return conn.QueryRowCtx(ctx, v, "SELECT * FROM users WHERE id=?", 1001)
})

执行流程:

1. 根据缓存 key "user:1001" 查 Redis
   ├─ 命中(非空值标记)→ 反序列化 → 写入 v → 返回 nil
   ├─ 命中(空值标记 notFound)→ 返回 sqlc.ErrNotFound
   └─ 未命中 → 进入步骤 2

2. 用 singleflight 合并并发请求
   ├─ 同一 key 的并发查询只会有一组真正查 DB
   └─ 其他 goroutine 等待结果复用

3. 在 singleflight 内调用 query 函数查 DB
   ├─ 查到数据 → 序列化 → 写入 Redis(带过期)→ 写入 v → 返回 nil
   └─ 未查到 → 写入空值标记(短期过期)→ 返回 sqlc.ErrNotFound

写流程(ExecCtx)

go
result, err := conn.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
    return conn.ExecCtx(ctx, "UPDATE users SET name=? WHERE id=?", newName, 1001)
}, "user:1001")  // 最后是要删除的缓存 key

执行流程:

1. 执行 SQL(更新 DB)
2. 执行成功后,删除缓存 key "user:1001"
   └─ 异步删除(不阻塞主流程)

4. 缓存一致性:先写 DB 再删缓存

go-zero 采用「先写 DB,再删缓存」策略,这是工程上最常用的方案。为什么是「删」而不是「更新」?

  • 删比更新简单:更新需要计算新值,删只需要让下次查询重新加载
  • 避免并发更新冲突:多个写操作同时更新缓存可能产生脏数据
  • 避免缓存和 DB 不一致:删缓存后,下次读会从 DB 加载最新值

为什么是「先写 DB,再删缓存」而不是「先删缓存,再写 DB」?

  • 先删缓存再写 DB 的窗口期:删缓存后、写 DB 前,另一个读请求会把旧数据加载到缓存
  • 先写 DB 再删缓存的窗口期:写 DB 后、删缓存前,读请求会返回旧缓存(短暂不一致)

后者窗口期更短,且可以通过「延迟双删」进一步消除。

5. 空值缓存机制

go-zero 在查询 DB 未命中时,会写入一个特殊的「空值标记」到 Redis,短期过期(默认 1 分钟)。下次同样查询会直接命中空值标记,返回 ErrNotFound,避免反复打 DB。

空值标记的实现:在 Redis 中存储一个特殊字符串(如 *),查询时识别这个标记就返回 not found。

二、缓存穿透防护

缓存穿透:查询一个根本不存在的数据,由于缓存不会命中,每次请求都会打到 DB。常见场景是恶意攻击(用大量不存在的 ID 查询)。

1. 空值缓存

go-zero 的 CachedConn 已经实现了空值缓存。我们看一个具体场景:

go
// 查询 id=99999 的用户(不存在)
user, err := l.svcCtx.UserModel.FindOne(ctx, 99999)
// 返回 ErrNotFound

第一次查询:

  1. 查 Redis:未命中
  2. singleflight 合并
  3. 查 DB:未找到
  4. 写入空值标记到 Redis(key: user:99999,value: *,过期 1 分钟)
  5. 返回 ErrNotFound

后续 1 分钟内查询同样 id:

  1. 查 Redis:命中空值标记
  2. 直接返回 ErrNotFound
  3. 不会打 DB

2. 布隆过滤器

对于 ID 空间巨大且大量不存在的场景(如订单号、UUID),空值缓存会占用过多 Redis 内存。这时可以用布隆过滤器:

go
import "github.com/zeromicro/go-zero/core/bloom"

// 在 ServiceContext 中初始化
filter := bloom.New(rds, "bloom:user_id", 64*1024*1024) // 64MB 容量

// 启动时加载所有存在的 ID
func (m *UserModel) WarmupBloomFilter(ctx context.Context, filter *bloom.Filter) error {
    // 分批查 DB,把所有 ID 加入过滤器
    var lastID int64
    for {
        ids, err := m.GetIDsAfter(ctx, lastID, 1000)
        if err != nil || len(ids) == 0 {
            break
        }
        for _, id := range ids {
            filter.Add([]byte(strconv.FormatInt(id, 10)))
        }
        lastID = ids[len(ids)-1]
    }
    return nil
}

// 查询时先过布隆过滤器
func (l *GetUserLogic) GetUser(req *types.GetUserRequest) (*types.GetUserResponse, error) {
    idStr := strconv.FormatInt(req.Id, 10)
    exists, _ := l.svcCtx.BloomFilter.Exists([]byte(idStr))
    if !exists {
        // 布隆过滤器说不存在,肯定不存在
        return nil, fmt.Errorf("user not found")
    }
    // 布隆过滤器说可能存在,继续走缓存和 DB
    user, err := l.svcCtx.UserModel.FindOne(l.ctx, req.Id)
    // ...
}

布隆过滤器的特点:

  • 可能有误判(说不存在一定不存在,说存在可能不存在)
  • 占用内存小(1 亿 ID 约 100MB)
  • 不能删除(要支持删除需用布谷鸟过滤器)

3. 限流

对查询接口加限流,防止恶意攻击打爆 DB:

go
limiter := limit.NewPeriodLimit(1, 100, rds, "user_query_rate")
code, _ := limiter.Take(clientIP)
if code == limit.OverQuota {
    return nil, fmt.Errorf("rate limited")
}

4. 参数校验

在 Logic 层做参数校验,过滤明显不合法的查询:

go
func (l *GetUserLogic) GetUser(req *types.GetUserRequest) (*types.GetUserResponse, error) {
    if req.Id <= 0 || req.Id > 1e12 {
        return nil, fmt.Errorf("invalid user id")
    }
    // ...
}

三、缓存击穿防护

缓存击穿:一个热点 key 突然过期,瞬间大量请求同时打到 DB,可能导致 DB 压力骤增甚至崩溃。

1. singleflight

singleflight 是 Go 标准库提供的原语,用于合并并发请求:对同一个 key 的多个查询,只有一个 goroutine 真正执行,其他 goroutine 等待并复用结果。

go-zero 的 CachedConn 内部已经集成了 singleflight。我们看 core/stores/sqlc/cachedconn.go 的核心逻辑(简化版):

go
func (cc *cachedConn) QueryRowCtx(ctx context.Context, v any, key string, query QueryCtxFn) error {
    // 1. 查缓存
    err := cc.getCache(key, v)
    if err == nil {
        return nil // 缓存命中
    }
    if err == errPlaceholder {
        return ErrNotFound // 空值标记
    }

    // 2. 缓存未命中,用 singleflight 合并
    val, err := cc.doLoad(ctx, key, v, query)
    return err
}

func (cc *cachedConn) doLoad(ctx context.Context, key string, v any, query QueryCtxFn) (any, error) {
    // singleflight.Do:同一 key 的并发调用只会执行一次
    return cc.do.Do(key, func() (any, error) {
        // 真正查 DB
        if err := query(ctx, cc.db, v); err != nil {
            if err == sqlx.ErrNotFound {
                // 写入空值标记
                cc.setCacheWithExpire(key, placeholder, notFoundExpire)
                return nil, ErrNotFound
            }
            return nil, err
        }
        // 写入缓存
        cc.setCache(key, v)
        return v, nil
    })
}

这样即使 1000 个请求同时查同一个 key,也只会有 1 个请求真正打 DB。

2. 手动使用 singleflight

如果不用 CachedConn,也可以手动使用 go-zero 的 Collection 包里的 singleflight:

go
import "github.com/zeromicro/go-zero/core/syncx"

sf := syncx.NewSingleFlight()

func (l *GetProductLogic) GetProduct(req *types.GetProductRequest) (*types.Product, error) {
    key := fmt.Sprintf("product:%d", req.Id)
    
    // 先查缓存
    if val, _ := l.svcCtx.Redis.GetCtx(l.ctx, key); val != "" {
        var p Product
        json.Unmarshal([]byte(val), &p)
        return &p, nil
    }
    
    // singleflight 合并
    result, err := sf.Do(key, func() (any, error) {
        // 只有第一个请求会执行这里
        p, err := l.svcCtx.ProductModel.FindOne(l.ctx, req.Id)
        if err != nil {
            return nil, err
        }
        // 写入缓存
        if data, _ := json.Marshal(p); data != nil {
            l.svcCtx.Redis.SetexCtx(l.ctx, key, 3600, string(data))
        }
        return p, nil
    })
    if err != nil {
        return nil, err
    }
    return result.(*Product), nil
}

3. 互斥锁

替代 singleflight,可以用 redislock 实现分布式互斥锁:

go
import "github.com/zeromicro/go-zero/core/stores/redis"

lock := redis.NewRedisLock(rds, "lock:product:1001")
lock.SetExpire(5)
acquired, _ := lock.Acquire()
if !acquired {
    // 等待一会儿重试,或者返回稍后再试
    time.Sleep(100 * time.Millisecond)
    // 重新查缓存
    // ...
}
defer lock.Release()

// 查 DB,写缓存

singleflight 和互斥锁的区别:

维度singleflight互斥锁
粒度按 key 合并按 key 互斥
等待行为等待结果复用等待锁释放后重试
适合场景读多写少,结果可复用严格互斥
复杂度低(自动)中(需处理重试)

推荐优先用 singleflight。

4. 热点 key 永不过期

对极热点的 key(如首页 banner),可以设为永不过期,通过后台任务定期更新:

go
// 启动时加载,并启动后台刷新
func (m *BannerModel) StartRefreshJob(ctx context.Context) {
    ticker := time.NewTicker(5 * time.Minute)
    go func() {
        for {
            select {
            case <-ticker.C:
                banners, _ := m.findAllFromDB(ctx)
                data, _ := json.Marshal(banners)
                m.rds.Set("banner:all", string(data)) // 永不过期
            case <-ctx.Done():
                return
            }
        }
    }()
}

四、缓存雪崩防护

缓存雪崩:大量 key 同时过期,导致请求全部打到 DB。

1. 随机过期时间

最简单的防护:在过期时间上加随机抖动。go-zero 的 CachedConn 默认就会加随机抖动(基于 cache.WithExpire 配置)。

手动管理缓存时,可以这样加抖动:

go
expire := 3600 + rand.Intn(600) // 1 小时 + 0~10 分钟随机
l.svcCtx.Redis.SetexCtx(ctx, key, expire, string(data))

2. 多级缓存

本地缓存 + Redis 缓存的组合,减少 Redis 故障的影响:

go
import "github.com/zeromicro/go-zero/core/collection"

type CacheService struct {
    localCache *collection.Cache  // 本地 LRU 缓存
    redis      *redis.Redis
    model      ProductModel
}

func NewCacheService(rds *redis.Redis, model ProductModel) *CacheService {
    // 本地缓存,5 分钟过期,最多 1 万条
    localCache, _ := collection.NewCache(time.Minute*5, collection.WithLimit(10000))
    return &CacheService{
        localCache: localCache,
        redis:      rds,
        model:      model,
    }
}

func (s *CacheService) GetProduct(ctx context.Context, id int64) (*Product, error) {
    key := fmt.Sprintf("product:%d", id)
    
    // L1: 本地缓存
    if val, ok := s.localCache.Get(key); ok {
        return val.(*Product), nil
    }
    
    // L2: Redis 缓存
    if val, _ := s.redis.GetCtx(ctx, key); val != "" {
        var p Product
        if err := json.Unmarshal([]byte(val), &p); err == nil {
            s.localCache.Set(key, &p)
            return &p, nil
        }
    }
    
    // L3: DB
    p, err := s.model.FindOne(ctx, id)
    if err != nil {
        return nil, err
    }
    
    // 写回 L2 和 L1
    if data, _ := json.Marshal(p); data != nil {
        s.redis.SetexCtx(ctx, key, 3600+rand.Intn(600), string(data))
    }
    s.localCache.Set(key, p)
    return p, nil
}

多级缓存策略:

  • L1(本地缓存):速度最快,但容量有限、不共享
  • L2(Redis):共享、容量大,但有网络开销
  • L3(DB):最慢,但数据最准确

读顺序:L1 → L2 → L3,任一层命中即返回。写顺序:L3 → 删 L2 → 删 L1。

3. 缓存预热

在低峰期或服务启动时,主动把热点数据加载到缓存:

go
func (s *CacheService) Warmup(ctx context.Context) error {
    // 查出热点商品 ID
    hotIDs, _ := s.model.FindHotProductIDs(ctx, 100)
    for _, id := range hotIDs {
        // 直接走 DB 查询并写缓存
        s.GetProduct(ctx, id)
    }
    return nil
}

4. 熔断降级

当 DB 压力过大时,返回降级数据(默认值、缓存旧数据),保护 DB:

go
import "github.com/zeromicro/go-zero/core/breaker"

func (s *CacheService) GetProduct(ctx context.Context, id int64) (*Product, error) {
    // 用熔断器包裹 DB 查询
    var product *Product
    err := breaker.DoWithAcceptable(func() error {
        p, err := s.model.FindOne(ctx, id)
        if err != nil {
            return err
        }
        product = p
        return nil
    })
    if err != nil {
        // 熔断后返回降级数据
        return s.getFallbackProduct(id), nil
    }
    return product, nil
}

五、缓存更新策略对比

策略描述一致性复杂度适用场景
Cache-Aside先写 DB,再删缓存通用
Write-Through写 DB 同时写缓存一致性要求高
Write-Behind先写缓存,异步写 DB写多读少
双删先删缓存→写 DB→延迟再删强一致性

go-zero 默认采用 Cache-Aside,对大多数场景足够。强一致性需求可以用「延迟双删」:

go
func (m *UserModel) UpdateWithDoubleDelete(ctx context.Context, data *User) error {
    key := fmt.Sprintf("user:%d", data.Id)
    // 1. 先删缓存
    m.DelCache(key)
    // 2. 写 DB
    _, err := m.ExecNoCacheCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
        return conn.ExecCtx(ctx, "UPDATE users SET name=? WHERE id=?", data.Name, data.Id)
    })
    if err != nil {
        return err
    }
    // 3. 延迟再删(异步)
    go func() {
        time.Sleep(500 * time.Millisecond)
        m.DelCache(key)
    }()
    return nil
}

六、实战:高并发查询的缓存方案

我们通过一个压测对比,展示缓存方案的实际效果。

1. 场景

  • 商品详情接口:GET /api/v1/product/:id
  • 数据量:1 万个商品
  • 并发:1000 QPS
  • 数据分布:80% 请求集中在 20% 的热点商品(典型长尾分布)

2. 无缓存方案

go
func (l *GetProductLogic) GetProduct(req *types.GetProductRequest) (*types.ProductResponse, error) {
    product, err := l.svcCtx.ProductModel.FindOneFromDB(l.ctx, req.Id)
    if err != nil {
        return nil, err
    }
    // ...
}

每次请求都查 DB。

3. go-zero 自动缓存方案

go
func (l *GetProductLogic) GetProduct(req *types.GetProductRequest) (*types.ProductResponse, error) {
    // FindOne 自动走 CachedConn,含 singleflight 和空值缓存
    product, err := l.svcCtx.ProductModel.FindOne(l.ctx, req.Id)
    if err != nil {
        return nil, err
    }
    // ...
}

4. 多级缓存方案

go
type ProductCacheService struct {
    localCache *collection.Cache
    redis      *redis.Redis
    model      ProductModel
    sf         *syncx.SingleFlight
}

func (s *ProductCacheService) GetProduct(ctx context.Context, id int64) (*Product, error) {
    key := fmt.Sprintf("product:%d", id)
    
    // L1: 本地缓存
    if val, ok := s.localCache.Get(key); ok {
        return val.(*Product), nil
    }
    
    // L2: Redis + singleflight
    result, err := s.sf.Do(key, func() (any, error) {
        // 在 singleflight 内查 Redis
        if val, _ := s.redis.GetCtx(ctx, key); val != "" {
            var p Product
            if err := json.Unmarshal([]byte(val), &p); err == nil {
                s.localCache.SetWithExpire(key, &p, time.Minute*5)
                return &p, nil
            }
        }
        // L3: DB
        p, err := s.model.FindOne(ctx, id)
        if err != nil {
            return nil, err
        }
        // 写回 L2
        if data, _ := json.Marshal(p); data != nil {
            expire := 3600 + rand.Intn(600)
            s.redis.SetexCtx(ctx, key, expire, string(data))
        }
        // 写回 L1
        s.localCache.SetWithExpire(key, p, time.Minute*5)
        return p, nil
    })
    if err != nil {
        return nil, err
    }
    return result.(*Product), nil
}

5. 性能对比(模拟数据)

方案平均延迟 (ms)P99 延迟 (ms)DB QPSRedis QPS
无缓存158010000
go-zero 自动缓存210501000
多级缓存0.5310100

分析:

  • 无缓存:DB QPS = 请求 QPS,DB 容易被打满
  • go-zero 自动缓存:DB QPS 大幅下降(缓存命中率 95%+),延迟主要来自 Redis 网络
  • 多级缓存:本地缓存命中后延迟极低,Redis QPS 也下降

6. 缓存命中率监控

在生产环境中,监控缓存命中率非常重要:

go
type Metrics struct {
    cacheHit  int64
    cacheMiss int64
}

func (m *Metrics) RecordHit()  { atomic.AddInt64(&m.cacheHit, 1) }
func (m *Metrics) RecordMiss() { atomic.AddInt64(&m.cacheMiss, 1) }

func (m *Metrics) HitRate() float64 {
    hit := atomic.LoadInt64(&m.cacheHit)
    miss := atomic.LoadInt64(&m.cacheMiss)
    total := hit + miss
    if total == 0 {
        return 0
    }
    return float64(hit) / float64(total)
}

更专业的做法是用 Prometheus:

go
import "github.com/prometheus/client_golang/prometheus"

var cacheCounter = prometheus.NewCounterVec(
    prometheus.CounterOpts{
        Name: "cache_operations_total",
    },
    []string{"name", "result"}, // result: hit / miss
)

func init() {
    prometheus.MustRegister(cacheCounter)
}

// 在缓存查询点记录
cacheCounter.WithLabelValues("product", "hit").Inc()
cacheCounter.WithLabelValues("product", "miss").Inc()

七、常见缓存问题排查

1. 缓存命中率低

可能原因:

  • 过期时间太短
  • key 设计不合理(每个请求都查不同 key)
  • 数据访问不集中(长尾分布)
  • 写操作频繁(频繁删缓存)

排查:监控命中率,分析 key 分布。

2. 缓存与 DB 不一致

可能原因:

  • 先删缓存再写 DB(窗口期问题)
  • 删缓存失败(Redis 故障)
  • 主从延迟(Redis 主从同步延迟)

排查:对账工具定期对比缓存和 DB 数据。

3. 缓存占用内存过大

可能原因:

  • key 没设过期
  • 空值缓存积累
  • 序列化数据冗余

排查:用 redis-cli --bigkeys 找大 key,定期清理。

八、小结

本篇深入讲解了 go-zero 的模型缓存设计与缓存防护机制。要点回顾:

  • CachedConn 是 go-zero 的核心缓存抽象,把 DB 和 Redis 组合在一起
  • 查询流程:查 Redis → singleflight 合并 → 查 DB → 写缓存/空值标记
  • 写流程:写 DB → 删缓存(Cache-Aside 策略)
  • 缓存穿透防护:空值缓存(go-zero 内置)+ 布隆过滤器 + 限流 + 参数校验
  • 缓存击穿防护:singleflight(go-zero 内置)+ 互斥锁 + 热点永不过期
  • 缓存雪崩防护:随机过期时间(go-zero 内置抖动)+ 多级缓存 + 预热 + 熔断降级
  • 强一致性场景可用「延迟双删」
  • 多级缓存(本地 + Redis + DB)能显著降低延迟和 DB 压力
  • 监控缓存命中率是运维缓存系统的关键指标

下一篇我们将进入服务治理,讲解 go-zero 内置的限流、熔断、负载均衡和服务发现。