Refresh Ahead
Refresh Ahead是指利用CDC(capture Data Change)接口来异步刷新缓存的模式,这种模式在实践中也很常见,比如利用Canal来监听数据库的binlog,然后Canal刷新Redis。这种模式也有缓存一致性的问题,也是出在缓存未命中的读请求和写请求上。
实际上,这个缓存一致性问题是可以解决的,也就是参考 Write Back 里面的策略。
如果读请求在回写缓存的时候,使用了 SETNX 命令,那么就没有什么大的不一致问题了。唯一的不一致就是数据写入到了数据库,但是还没刷新到缓存的那段时间。
SingleFlight
SingleFlight主要是为了控制住加载数据的并发量
先简单介绍SingleFlight的原理,再补充它的优缺点
Singleflight模式是指当缓存未命中的时候,访问同一个key的线程或者协程中只有一个会真的加载数据,其他都在原地等待。
这种模式最大的优点就是可以减轻访问数据库的并发量,比如如果同一时刻有100个线程要访问key1,那么最终也只有1个线程去数据库中加载数据。这个模式的缺点是如果并发量不高,那么基本没有效果。所以热点之类的数据就很适合使用这个模式。
删除缓存
这算是业务中比较常见的用法,也就是在更新数据的时候先更新数据库,然后将缓存删除。
删除缓存本身没有规定必须是业务代码来删除缓存,所以实际上也可以结合 Write Through 模式,让缓存去更新数据库,然后缓存自己删除自己的数据。
这个模式依旧没有解决数据一致性的问题,但是它的一致性问题不是源自两个线程同时更新数据,而是源自一个线程更新数据,一个线程缓存未命中回查数据库。
你在回答的时候要注意答出这一点。
删除是最常用的更新缓存的模式,它是指在更新数据库之后,直接删除缓存。这种做法可以是业务代码来控制删除操作,也可以结合 Write Through 使用。而且删除缓存之后会使缓存命中率下降,也算是一个隐患。如果偶尔出现写频繁的场景,导致缓存一直被删除,那么就会使性能显著下降。缓存未命中回查数据库叠加写操作,数据库压力会很大。
删除缓存和别的模式一样,也有一致性问题。但是它的一致性问题是出在读线程缓存未命中和写线程冲突的情况下。
然后你补充一句总结。
为了避免这种缓存不一致的问题,又有了延迟双删模式。
延迟双删
有两次删除操作
延迟双删类似于删除缓存的做法,它在第一次删除操作之后设定一个定时器,在一段时间之后再次执行删除。
第二次删除就是为了避开删除缓存中的读写导致数据不一致的场景。
那么是不是就不会有数据不一致的问题了?从理论上来说是可能的。第一个不一致出现在上图写入 a = 3 到第二次删缓存之间,还有一种不一致的可能如下图。
但是这种可能性只是存在理论中,因为两次删除的时间间隔很长,不至于出现图片里的这种情况。所以你补充说明一下就可以了。
在这种形态之下,只需要考虑在回写缓存和第二次删除之间,数据可能不一致的问题。
紧接着再次说明这种模式的缺点。
延迟双删因为存在两次删除,所以实际上缓存命中率下降的问题更加严重。
选用什么模式
我觉得到这一步你已经非常困惑了,万一面试官问你应该使用哪个模式要怎么回答呢?坦白说,任何一种缓存模式都有各自的缺陷,所以你实际上选哪个都有好处,也都有问题。面试的时候你就可以根据自己的偏好来选择,只要分析清楚优劣,并解释清楚数据一致性问题就可以了。
如果你确实需要一个标准答案,那么你就回答延迟双删。
这么多模式里面,我比较喜欢延迟双删,因为它的一致性问题不是很严重。虽然会降低缓存的命中率,但是我们的业务并发也没有特别高,写请求是很少的。命中率降低一点点是完全可以接受的。
亮点方案:用装饰器模式实现缓存模式
这个亮点你可以考虑是否要使用,我建议你在实践中落地之后再拿去面试。但是你不需要把所有的模式都实现一遍,实现一下你项目中用到的就可以。
前面的缓存模式中,除了 Refresh Ahead 和 Cache Aside,其他的模式都可以使用装饰器模式来实现。我举一个使用缓存模式中 Read Through 模式的例子。你可以参考我给出的伪代码。
type Cache interface {
Get(key string) any
Set(key string, val any)
}
type ReadThroughCache struct {
c Cache
fn func(key string) any
}
func (r *ReadThroughCache) Get(key string) any {
val := r.c.Get(key)
if val == nil {
val = r.fn(key)
r.c.Set(key, val)
}
return val
}
你抓住关键词装饰器模式来描述这个解决方案。
我在我们公司利用装饰器模式,无侵入式地实现了其中的大部分模式。以 Read Through 为例,装饰器模式只需要在已有的缓存实现的基础上,为 Get 方法添加一个缓存中没有找到就去加载数据的额外逻辑就可以。
而且,如果你平时在公司的项目经历比较平淡,那么你完全可以在公司内部定义一个统一的 Cache 接口,提供基于 Redis 和本地内存的实现,同时提供这些缓存模式的实现,那么也算是一个比较有特色的项目了。
你就可以这样介绍你的项目。我在公司里面因为经常用到缓存,也经常使用缓存模式,所以我抽象了一个缓存接口,提供了基于 Redis 和本地内存的实现。在这个基础上,我还用装饰器模式实现了大部分缓存模式。对于开发者来说,他们只要会初始化装饰器就可以应用这个缓存模式。
后续你就可以和面试官讨论每一个缓存模式的细节。Beego 的缓存模块中有类似的实现,你可以参考。