深入剖析 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)
    }
}
相关推荐
爱勇宝4 小时前
第 1 章:别把“需求文档”当成真正的需求
前端·后端·程序员
IT_陈寒9 小时前
闭包陷阱让我加了两天班,JavaScript你真行
前端·人工智能·后端
易协同低代码10 小时前
通达OA核心类库TD类深度解析
后端
Gopher_HBo10 小时前
Go语言学习笔记(十八)Gin处理Session
后端
谭光志11 小时前
工具塞满上下文窗口怎么办?深度拆解 AI Agent Tool Search 按需加载实现原理
前端·后端·ai编程
她说..11 小时前
Java 默认值设置方式
java·开发语言·后端·springboot
foggyprojects11 小时前
从0开始,一句话启动AI DataAgent
后端·数据分析·ai编程
郡杰11 小时前
一些基础和问题解决
后端
陈随易11 小时前
前端项目部署只要30秒
前端·后端·程序员
YIAN11 小时前
从零手写文件读取 MCP 服务:一文吃透 Model Context Protocol 全链路通信原理
前端·后端·mcp