缓存
缓存是我们开发过程中必不可少的一项提供接口性能的方式,但是,对项目引入缓存也会带来问题,比如缓存穿透,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中没有的数据则直接去数据库访问。那么为什么还需要多级缓存呢?总的来说有两点(参考:如何优雅的设计和使用缓存?)
- Redis如果挂了或者使用老版本的Redis,其会进行全量同步,此时Redis是不可用的,这个时候我们只能访问数据库,很容易造成雪崩。
- 访问Redis会有一定的网络I/O以及序列化反序列化,虽然性能很高但是其终究没有本地方法快,可以将最热的数据存放在本地,以便进一步加快访问速度。这个思路并不是我们做互联网架构独有的,在计算机系统中使用L1,L2,L3多级缓存,用来减少对内存的直接访问,从而加快访问速度。
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
HotKey 的问题在这里并没有解决,但是我看了下一些解决方案。在这里可以参考一些
- 热点key问题的发现与解决
- 有赞透明多级缓存解决方案(TMC)
- 在单机内存中使用 hashmap 统计每个 key 的访问频次,这里可以使用滑动窗口统计,即每个窗口中,维护一个 hashmap,之后统计所有未过去的 bucket,汇总所有 key 的数据。之后使用小堆计算 TopK 的数据,自动进行热点识别。
缓存穿透
缓存穿透存在的原因是请求不存在的数据。这里有两个解决方案:Bloomfilter 和 设置空值
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在同一时间过期。
- 随机过期
- 在过期时间上加上5%的标准偏差,5%是假设检验里P值的经验值