前言
一个好的缓存框架,需要考虑下面几个特性:
-
缓存的正确性 :主要是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到缓存
核心流程如下:

-
根据key从缓存查value
-
如果缓存里是
Placeholder
,返回没查到 -
执行query,从db查
-
(防止缓存穿透)如果没查到,往缓存里设置
Placeholder
,过期时间较短 -
如果查到了,将其设置到缓存中
-
- 实现可观测性:整个过程中都会更新统计数据,包括缓存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)
}
}