缓存是我们开发过程中必不可少的一项提供接口性能的方式,但是,对项目引入缓存也会带来问题,比如缓存穿透,HotKey,缓存雪崩,缓存击穿,缓存一致性的问腿。所以,我们可能在缓存库中加入一些解决方案。
我们的目标是设计一个通用的缓存库。设计的目标如下
提供基础操作,创建和删除缓存。
// Cache ...
type Cache interface {
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) (err error)
Get(ctx context.Context, key string, fetch fetchFunc) (result []byte,err error)
Del(ctx context.Context, key string) (err error)
AddPlugin(p Plugin)
}
多级缓存指的是本地缓存+分布式缓存。
通常来说,Redis用来存储热点数据,Redis中没有的数据则直接去数据库访问。那么为什么还需要多级缓存呢?总的来说有两点(参考:如何优雅的设计和使用缓存?)
type RedisCacheClient struct {
client *redis.Client
prefix string
plugins []Plugin
status *cacheStat
unstableExpiry mathx.Unstable
loadGroup flightGroup
DefaultExpire time.Duration
localCache *freecache.Cache // 本地缓存,实际用的是freecache.Cache
}
并发控制指的是在并发的情况下请求同一个key保证只会有一个请求落到数据库中,这样做可以减少对数据库的压力
// 并发控制,定义了一个接口,可以自己实现这个接口做并发的控制
type flightGroup interface {
// Done is called when Do is done.
Do(key string, fn func() (interface{}, error)) (interface{}, error)
}
数据统计指的是缓存库中会统计当前缓存的Hit和Miss率,用于观测缓存的使用情况
type cacheStat struct {
hit uint64 // local cahe + remote cahe
miss uint64 // local cahe + remote cahe
localCacheHit uint64
localCacheMiss uint64
}
提供接口,对缓存获取的一些日志解析记录或者信息上报到监控系统,实现这个接口就做一些自定义在请求开始和结束时的工作。在
type Plugin interface {
OnSetRequestEnd(ctx context.Context, cmd string, elapsed int64, fullKey string, err error)
OnGetRequestEnd(ctx context.Context, cmd string, elapsed int64, fullKey string, err error)
}
HotKey 的问题在这里并没有解决,但是我看了下一些解决方案。在这里可以参考一些
缓存穿透存在的原因是请求不存在的数据。这里有两个解决方案:Bloomfilter 和 设置空值
在原始数据源中查询不到数据或者查询返回错误时。设置一个约定的空值到缓存中,应用程在发现是这个约定的空值的时候,再做对应的处理。
NoneValue = []byte("NoneValue")
// not found key
if err == redis.Nil {
r.status.IncrementMiss()
if fetch!=nil {
var b []byte
_, err = r.loadGroup.Do(fullKey, func() (interface{}, error) {
var fetchResult interface{}
if val, err := r.localCache.Get(fullKeyByte); err == nil {
return val, nil
}
v, e := fetch()
// if fetch data return err, will set NoneValue to cache
if e != nil {
logger.Error("get redis key: %v, from fetch error: %v", fullKey, e)
// set none value
expiration := r.unstableExpiry.AroundDuration(r.DefaultExpire)
_ = r.localCache.Set(fullKeyByte, NoneValue, int(expiration.Seconds()))
return NoneValue, nil
}
expiration := r.unstableExpiry.AroundDuration(r.DefaultExpire)
b, _ = json.Marshal(fetchResult)
_ = r.localCache.Set(fullKeyByte, b, int(expiration.Seconds()))
return v, nil
})
if err != nil {
return nil, err
}
return b, nil
}
}
缓存击穿的原因是热点数据的过期,因为是热点数据,所以一旦过期可能就会有大量对该热点数据的请求同时过来,这时如果所有请求在缓存里都找不到数据,如果同时落到DB去的话,那么DB就会瞬间承受巨大的压力,甚至直接卡死。
防止缓存击穿可以使用 singleflight.go, 这个库可以保证对同一个Key的请求只会有一个到达数据源
缓存雪崩的原因是大量同时加载的缓存有相同的过期时间,在过期时间到达的时候出现短时间内大量缓存过期,这样就会让很多请求同时落到DB去,从而使DB压力激增,甚至卡死。用了go-zero 中的 unstable 会返回一个与设置的时间有偏差的过期时间。避免大量Key在同一时间过期。