深入剖析 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)
    }
}
相关推荐
tan180°8 小时前
MySQL表的操作(3)
linux·数据库·c++·vscode·后端·mysql
优创学社29 小时前
基于springboot的社区生鲜团购系统
java·spring boot·后端
why技术9 小时前
Stack Overflow,轰然倒下!
前端·人工智能·后端
幽络源小助理9 小时前
SpringBoot基于Mysql的商业辅助决策系统设计与实现
java·vue.js·spring boot·后端·mysql·spring
ai小鬼头10 小时前
AIStarter如何助力用户与创作者?Stable Diffusion一键管理教程!
后端·架构·github
简佐义的博客10 小时前
破解非模式物种GO/KEGG注释难题
开发语言·数据库·后端·oracle·golang
Code blocks11 小时前
使用Jenkins完成springboot项目快速更新
java·运维·spring boot·后端·jenkins
追逐时光者11 小时前
一款开源免费、通用的 WPF 主题控件包
后端·.net