目录
思考
- 如何控制缓存过期时间
- 如何控制缓存内存大小
- 如何提高内存的利用率
1.前言
缓存在业务中是不可避免的一个模块,大体上分成两类
- 本地缓存,如前端的localstorage
- 分布式缓存,如redise/memcache
同时,通常我们为了避免业务层直接操作redis,会引入一个缓存模块,即再次封装,最典型的例子就是go-cache
2.go-cache走读
2.1API概览与快速上手
go-cache的API设计主要分成两部分
- 单个操作:Set/Get
- 针对数字的加减: Increment
func Test_go_cache(t *testing.T) {
c := cache.New(time.Minute, 10*time.Minute)
c.Set("name", "zjb", cache.DefaultExpiration)
res, ok := c.Get("name")
if !ok {
t.Fatal("not found")
}
name := res.(string)
t.Log(name)
}
2.2主要数据结构
New方法返回Cache结构体
func New(defaultExpiration, cleanupInterval time.Duration) *Cache {
items := make(map[string]Item)
return newCacheWithJanitor(defaultExpiration, cleanupInterval, items)
}
type Item struct {
Object interface{}
Expiration int64 // 过期时间:设置时间+缓存时长
}
我们可以大致猜到这个Item就是实际存储KV的本地缓存,实际上就是如此,后续的Set/get等就是并发操作map;同时我们也意识到map属于hash一类的数据结构,内存利用率并不高(但从这角度讲,替换成数组是否更好?)
那问题来了,为什么我们不直接使用map还要套一层娃呢?因为map并发会冲突,扩展性也差
type Cache struct {
*cache
// If this is confusing, see the comment at the bottom of New()
}
type cache struct {
defaultExpiration time.Duration
items map[string]Item
mu sync.RWMutex
onEvicted func(string, interface{})
janitor *janitor
}
type janitor struct {
Interval time.Duration // 多长时间扫描一次缓存
stop chan bool // 是否需要停止
}
- onEvicted 是删除Key时执行的回调函数
- mu 内部使用锁来处理并发
- defaultxxxx 默认的超时时间New函数传入
- janitor 负责定时清空缓存
2.3清空缓存操作
newCacheWithJanitor方法初始化janitor
C := &Cache{c}
if ci > 0 {
runJanitor(c, ci)
runtime.SetFinalizer(C, stopJanitor)
}
内部启动一个协程,监听计时器和信号通道(cache的删除操作是 for range 遍历全部)
func runJanitor(c *cache, ci time.Duration) {
j := &janitor{
Interval: ci,
stop: make(chan bool),
}
c.janitor = j
go j.Run(c)
}
func (j *janitor) Run(c *cache) {
ticker := time.NewTicker(j.Interval)
for {
select {
case <-ticker.C:
c.DeleteExpired()
case <-j.stop:
ticker.Stop()
return
}
}
}
那么问题来了这个终止的信号量由谁输入?runtime.SetFinalizer
runtime.SetFinalizer 是Go提供对象被GC回收时的一个注册函数,可以在对象被回收的时候回掉函数
2.4扩展思考
- go-cache是锁住全部map,锁的粒度是否可以更小?
- 使用sync.Map代替map?
- SetFinalizer函数使用有什么需要注意点?
其他缓存库参考:
- freecache
- groupcache
- bigcache
3.尝试自己实现一个高可用的缓存
假设我们自己的redis缓存API设计如下
type Cache interface {
Get(ctx context.Context, key string) (any, error)
Set(ctx context.Context, key string, value any, t time.Duration) error
Delete(ctx context.Context, key string) error
}
实现结构体为
type RedisCache struct {
t time.Duration
cmd redis.Cmdable
}
set和get就不贴出了,具体就是操作cmd
3.1缓存的过期机制如何设计
缓存如何处理过期Key?
- 定期删除:学go-cache开一个协程定时轮询,time.Ticker时间一到就检测一遍。那么时间间隔多久合适,或者直接让用户传入?遍历数量是全部还是100个,1000个?(时间不精准,对象过期只有等到下一个甚至更久的循环才能被删除)
- 定时删除:每个key开一个协程盯着(time.Afterfunc)?虽然可行但很离谱,这些协程大部分时间在阻塞,浪费资源(定时器本身开销就很大,时间重置要先取消原本定时器,再重启)
- 懒惰删除:学mysql懒删除,即用户调用Get访问key的时候检查过期,通常会与定期删除一起使用,比如redis(对象过期后究竟什么时候删除不确定,内存浪费)
- 延迟队列:把对象扔到一个延迟队列里面,当从队列里取出时表示已经过期(需要额外的内存开销,而且过期时间被重置时需要调整在队列中的位置,计算的时间复杂度增加)
3.1.1 参考redis的过期删除策略
redis是追求高性能的中间件,因此不会选择使用延迟队列和定时删除的方式,选择懒加载和定期的方式
同时redis的定期删除会在每一个循环中遍历DB,如果当次定期没有遍历全部DB,那么下一次循环从下一个DB开始遍历,对于每个DB:
- 如果DB的key都没设置过期时间就遍历下一个DB
- 设置了就抽一批调查,默认25个,检查过期,执行删除
- 每遍历16个key就检测执行时间,如果超过阈值就中断本次循环
- 如果这批过期的key比例超过一个阈值,就继续抽取下一批(比例太低就直接去遍历下一个DB)
redis通过hz和dynamic-hz的值来控制定期删除的频率
特变注意redis还有一个主从分布的问题,3.2之前若key过期主库会执行删除操作,从库不会,因此仍可以从从库中获取key
补充一点redis的持久化文件RDB在生成时会忽略已经过期的key,AOF是无论是定期删除还是懒删除都会记录DEL命令
3.1.2 过期时间如何确定和优化
最完美的情况就是容量无限大,设置永不过期,但实际怎么可能,基本都是通过缓存命中率来确定过期时间
- 从缓存命中率角度出发(命中缓存的次数/过期时间),可以选择调大过期时间,但代价就是缓存了更多key,占用更多内存,类似的业务就是登录的状态(缓存命中率越高,就越需要更多的缓存容量,越长的过期时间)
- 减少过期时间,这是根据业务特征出发的,如果有些业务根本用不了一定的时间,可以选择降低过期时间,比如当某个数据被查出来后,大概率在三十分钟内再次使用这个对象,就可以减少过期时间到30分钟
问题又来了如何确定缓存命中率,这个实际要看用户体验确定,或者公司规定的平均响应时间来推算,比如要求平均响应时间是300ms,实际命中缓存时间100ms,没命中1000ms,假设命中率=p 则p需要满足
命中的时间p+没命中的时间(1-p)=300
100 x p + 1000 x (1-p) = 300
p=0.78
- 从数据特征出发,热点数据越热,过期时间越长,这也就是动态确定过期时间,比如根据请求特征、计算时间、重要性、优先级等
3.1.3 扩展-缓存的预加载和超短过期时间
简单来说就是预料用户的行为--空间换响应时间,在访问a数据时,大概率会访问B,获取A数据时提前缓存B数据,此时这个用户不管看不看B数据,别人都用不上,所以可以把B数据的过期时间设置很短,典型的就是B站的视频下方的视频推荐列表,朋友圈的九宫格图片,点开第一张后半预加载第二张等,总结就是预期用户行为
3.2缓存的内存如何控制
为了防止内存无止尽的扩展通常需要我们做缓存控制,go-cache里没实现改功能
- 控制键值对数量
- 控制内存大小
思路都是在set时检测是否超过阈值,在删除时恢复当前余量
3.2.1扩展阅读-淘汰策略
- LRU最近最少策略,缓存容量不足时从所有key挑一个最近最长未使用的key淘汰(经典的实现方式是额外使用一个队列,最近使用过的放到队首,队尾就是长时间未使用过的)
- LFU最不经常使用,根据使用频率删除(可以在队列实现的基础上,增加一个表示访问次数的字段,每次访问+1)
redis支持多种淘汰算法,可以根据maxmemory设置内存用量和maxmemory_policy设置淘汰算法
- 还有从业务角度出发,区分VIP用户和普通用户的数据,优先淘汰普通用户
- 从数据角度出发,可以选择先淘汰大对象或小对象,热点数据或冷点数据
3.3缓存模式
缓存常与数据库DB一起使用(最经典的场景就是redis查不到,再去DB里面查),这也涉及到我们常用的一些缓存模式
3.3.1 Cache Aside
就是Cache和DB的更新策略全部由开发者自己决定(把缓存看作是一个独立的数据源),即上面的业务层(写在业务代码里)查cache查不到,去db里查再去更新cache
cache-aside常在并发场景下的数据不一致问题
扩展阅读
3.3.2 读穿透read-through/写穿透write-through
cache可以做决策
read-through:
- 业务代码只需要从cache中读取数据,cache会在缓存不命中的时候读取数据(我们可以主动给结构体设置一个接收读DB的方法成员)
- 写数据的时候,业务代码需要自己写DB和cache
write-through反之,当然这两者也不能解决并发情况下,对同一数据使用的数据一致性问题
3.3.3 write back
- 写操作直接写缓存,不会更新DB,读也是一样
-
缓存过期了再写回数据库
(使用redis的Setnx回写命令能解决)
3.3.4 refresh ahead
即在DB和Redis中间再加一层,监听DB变更自动更新cache这种模式也有缓存一致性问题,出现在缓存未命中时(使用redis的Setnx回写命令能解决)
3.3.5删除缓存
顾名思义,在写操作时,直接写数据库删除缓存,类似的就是延迟双删(在第一次删除后设置一个定时器再次删除,为防止删除时再来一个写操作,实际就是二次检查)
3.3.6 缓存一致性问题
实际就是并发下的数据一致性问题(操作部分失败也会造成不一致我问题),
业务层面:常用的解决思路就是二次检查double-check:在获取数据时加锁做一次检查,在执行操作前再加锁做一次检查
使用消息队列,确保同一时刻只有一个线程在更新数据
引入数据版本号,每一次更新版本号+1,低版本数据不能覆盖高版本数据,缺点就是难以维护
多级缓存:本地+redis+mysql结合
分布式锁方案
3.4缓存异常
3.4.1缓存穿透
请求的数据根本不在(DB和Cache都没有),在cache-aside模式下就会造成多次请求全部打到数据库上(典型如黑客非法攻击)
那么我们是否可以在数据库都没有的情况下,往缓存里回写一个特殊值,标记数据不存在,那么下次查询过来不就防止了(但是如果攻击者每次都用不同key就会造成资源浪费)
3.4.2 缓存击穿
缓存没有对应的key,但是在某个时间该key并发访问量巨大,请求就会都落在数据库上
3.4.3 缓存雪崩
同一时刻大量key过期
3.4.4 解决思路-singleflight
- 穿透:同一ip的大量请求落在数据库上
- 击穿:不同ip的大量请求落在数据库上
- 雪崩:大量key同时过期导致大量请求落在数据库上
综上,解决思路就是如何在缓存出问题时如何让请求不落在数据库上?或者说我们在规定时间内,多个协程访问同一个key时,只允许一个通过,
singleflight就是上面的思路,击穿都能解决,但是穿透效果不好,因为数据库里面也是没有,那么我们可以使用布隆过滤器或者bit array,cache未命中时再问一下这些数据结构
最后别忘了最关键的限流