深入剖析 go-zero 分布式缓存

前言

一个好的缓存框架,需要考虑下面几个特性:

  • 缓存的正确性 :主要是db和缓存的一致性

  • 缓存的稳定性:

    • 缓存穿透:当请求不存在的数据时,因为数据不存在,所以缓存里肯定没有,那么就落到DB去了。这样当大量请求不存在的数据时DB压力就会特别大,尤其是可能被恶意请求打垮
    • 缓存击穿:缓存击穿的原因是热点数据的过期,因为是热点数据,所以一旦过期可能就会有大量对该热点数据的请求同时过来,这时如果所有请求在缓存里都找不到数据,如果同时落到DB去的话,那么DB就会瞬间承受巨大的压力,甚至直接打垮
    • 缓存雪崩:大量同时加载的缓存有相同的过期时间,在过期时间到达的时候出现短时间内大量缓存过期,这样就会让很多请求同时落到DB去,从而使DB压力激增,甚至打垮
  • 可观测性 :需要统计缓存的命中率

那么,go-zero是怎么实现这些关键特性 的呢?

关于缓存db缓存的一致性,go-zero使用cache aside模式:

  • 读:先读缓存,缓存没有从db读,然后写入缓存
  • 写:更新完db后,删除缓存

这种方式在下面的时序中会发生db和缓存不一致的问题:

但这种场景发生概率非常小 ,需要读时缓存恰好失效,且正好有个并发的更新mysql请求,且回源读db+写缓存,比更新mysql+删除缓存 慢

且db和缓存是两个数据源,需要保证其强一致的成本非常大

go-zero选择不处理。作者认为这是一种权衡,没必要用非常复杂的方案,解决极小概率发生的异常情况

关于缓存的稳定性,go-zero的解决方案为:

  • 缓存穿透 :对于不存在的数据的请求我们也会在缓存里短暂 (比如一分钟)存放一个占位符,这样对同一个不存在数据的DB请求数就会跟实际请求数解耦了

  • 缓存击穿 :对于相同的数据我们可以借助于 core/syncx/SharedCalls (机制类似singleflight)来确保对同一个key,同一时间只有一个请求落到DB。其它请求等待第一个请求返回并共享结果或错误

  • 缓存雪崩 :在原有过期时间上加上5% (推荐值)的随机扰动,分散大量缓存项同时失效的可能性,平滑数据库压力

关于可观测性,go-zero提供了stat组件,定时将缓存命中率的统计信息打印到日志。可以采集这些信息,在可视化平台展示

下面将深入源码,分析go-zero的分布式缓存是怎么实现的

本文基于的源码地址:github.com/zeromicro/g...,版本为v1.8.3

源码分析

缓存实现

核心方法是doTake,参数有:

  • key:缓存的key

  • v:接收返回结果

  • query:缓存查不到,应该怎么查db

  • cacheVal:缓存查到后,怎么set到缓存

核心流程如下:

  1. 根据key从缓存查value

  2. 如果缓存里是Placeholder,返回没查到

  3. 执行query,从db查

    1. (防止缓存穿透)如果没查到,往缓存里设置Placeholder,过期时间较短

    2. 如果查到了,将其设置到缓存中

  • 实现可观测性:整个过程中都会更新统计数据,包括缓存hit,缓存miss,查db失败的次数
  • 防止缓存击穿:整个过程在singleflight的控制下,保证一个进程内对同一个key,只有一个往redis或db的出流量
go 复制代码
func (c cacheNode) doTake(ctx context.Context, v any, key string,
    query func(v any) error, cacheVal func(v any) error) error {
    logger := logx.WithContext(ctx)
    val, fresh, err := c.barrier.DoEx(key, func() (any, error) {
       if err := c.doGetCache(ctx, key, v); err != nil {
          // 如果缓存里是Placeholder,返回没查到
          if errors.Is(err, errPlaceholder) {
             return nil, c.errNotFound
          } else if !errors.Is(err, c.errNotFound) {
return nil, err
          }

          // 查db
          if err = query(v); errors.Is(err, c.errNotFound) {
             // db没查到,往缓存设置占位符
             if err = c.setCacheWithNotFound(ctx, key); err != nil {
                logger.Error(err)
             }

             return nil, c.errNotFound
          } else if err != nil {
             c.stat.IncrementDbFails()
             return nil, err
          }

          if err = cacheVal(v); err != nil {
             logger.Error(err)
          }
       }

       return jsonx.Marshal(v)
    })
    if err != nil {
       return err
    }
    if fresh {
       return nil
    }

c.stat.IncrementTotal()
    c.stat.IncrementHit()

    return jsonx.Unmarshal(val.([]byte), v)
}

对用户暴露的API如下:主要是在调doTake之前,对缓存的过期时间加上随机扰动,防止缓存雪崩问题

go 复制代码
func (c cacheNode) TakeWithExpireCtx(ctx context.Context, val any, key string,
    query func(val any, expire time.Duration) error) error {
    // 对过期时间加随机扰动
    expire := c.aroundDuration(c.expiry)
    return c.doTake(ctx, val, key, func(v any) error {
       return query(v, expire)
    }, func(v any) error {
       return c.SetWithExpireCtx(ctx, key, v, expire)
    })
}

其中

go 复制代码
func (c cacheNode) aroundDuration(duration time.Duration) time.Duration {
    return c.unstableExpiry.AroundDuration(duration)
}

然后调到AroundDuration方法:

例如当deviation为0.05时,返回的过期时间就是[0.95,1.05]之间的随机数 * base,也就是给用户提供的过期时间加上正负5% 的扰动

go 复制代码
func (u Unstable) AroundDuration(base time.Duration) time.Duration {
    u.lock.Lock()
    val := time.Duration((1 + u.deviation - 2*u.deviation*u.r.Float64()) * float64(base))
    u.lock.Unlock()
    return val
}

缓存统计

对于每个缓存key,gozero在进程内维护了total,hit,miss,用于统计缓存命中率:

go 复制代码
type Stat struct {
    name string
Total   uint64
    Hit     uint64
    Miss    uint64
    // ...
}

每次缓存hit或miss时,增加统计的值:

go 复制代码
func (s *Stat) IncrementTotal() {
    atomic.AddUint64(&s.Total, 1)
}

 // IncrementHit increments the hit count. 
func (s *Stat) IncrementHit() {
    atomic.AddUint64(&s.Hit, 1)
}

 // IncrementMiss increments the miss count. 
func (s *Stat) IncrementMiss() {
    atomic.AddUint64(&s.Miss, 1)
}

后台每隔1分钟,打印统计日志。这样外部系统可以采集该日志,在别的可视化平台展示

go 复制代码
func NewStat(name string) *Stat {
    ret := &Stat{
       name: name,
    }

    go func() {
       // statInterval固定为1分钟     
       ticker := timex.NewTicker(statInterval)
       defer ticker.Stop()

       ret.statLoop(ticker)
    }()

    return ret
}

func (s *Stat) statLoop(ticker timex.Ticker) {
    for range ticker.Chan() {
       total := atomic.SwapUint64(&s.Total, 0)
       if total == 0 {
          continue
       }

       hit := atomic.SwapUint64(&s.Hit, 0)
       percent := 100 * float32(hit) / float32(total)
       miss := atomic.SwapUint64(&s.Miss, 0)
       dbf := atomic.SwapUint64(&s.DbFails, 0)
       logx.Statf("dbcache(%s) - qpm: %d, hit_ratio: %.1f%%, hit: %d, miss: %d, db_fails: %d",
          s.name, total, percent, hit, miss, dbf)
    }
}
相关推荐
uzong3 小时前
技术故障复盘模版
后端
GetcharZp4 小时前
基于 Dify + 通义千问的多模态大模型 搭建发票识别 Agent
后端·llm·agent
桦说编程4 小时前
Java 中如何创建不可变类型
java·后端·函数式编程
IT毕设实战小研4 小时前
基于Spring Boot 4s店车辆管理系统 租车管理系统 停车位管理系统 智慧车辆管理系统
java·开发语言·spring boot·后端·spring·毕业设计·课程设计
wyiyiyi4 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
阿华的代码王国5 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
Jimmy5 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
AntBlack6 小时前
不当韭菜V1.1 :增强能力 ,辅助构建自己的交易规则
后端·python·pyqt
bobz9657 小时前
pip install 已经不再安全
后端
寻月隐君7 小时前
硬核实战:从零到一,用 Rust 和 Axum 构建高性能聊天服务后端
后端·rust·github