缓存异常
穿透、击穿和雪崩
最常见的三种针对缓存的异常情况
缓存穿透:
- 读请求对应的数据根本不存在,因此每次都会发起数据库查询。数据库返回 NULL,所以下一次请求依旧会打到数据库。
- 关键点就是这个数据根本没有,所以不会回写缓存。
- 一般是黑客使用了一些非法的请求,比如说非法的邮箱、ID 等。
对同一个 key 访问两次,结果两次都查询了数据库。后面继续访问依旧是落到数据库上。
缓存击穿:
-
缓存中没有对应 key 的数据。
-
一般情况下,某个 key 缓存未命中并不会导致严重问题。但是如果该 key 的访问量非常大,大家都去数据库查询数据,那么就可能压垮数据库。
-
击穿和穿透比起来,关键在于击穿本身数据在 DB 里面是有的,只是缓存里面没有而已,所以只要回写到缓存,此一次访问就是命中缓存。
缓存雪崩:
-
同一时刻,大量 key 过期,查询都要回查数据库。
-
常见场景是在启动的时候加载缓存,因为所有 key 的过期时间都一样,所以会在同一时间全部过期。
异常解决思路
所有问题,落脚点都是:大量请求落到了数据库上。
所以思路就是,怎么在缓存出现问题的时候,依旧让这些请求不会落到数据库呢?
singleflight
singleflight 设计模式能够有效减轻对数据库的压力。
singleflight:在有多个 goroutine 试图去数据库加载同一个 key 对应数据的时候,只允许一个 goroutine 过去查询,其它都在原地等待结果。
对数据库的压力本来是跟 QPS 相当,变为跟同一时刻不同 key 的数量和实例数量相当。例如同一个时刻需要加载十个不同 key 的数据,应用部署了三个实例,那么对数据库的压力就是 10* 3。
热点越集中的应用,效果越好。
singleflight + cache aside
普通的 singleflight 是和 cache aside 一起使用的。
在右边代码块中,业务代码发现缓存返回了 KeyNotFound,于是开始利用 singleflight 设计模式去数据库加载数据,并且刷新缓存。
singleflight + read through
singleflight 也可以和 read through 结合,做成一个装饰器模式。
本身 read through 也是一个装饰器模式。
实现一:
这种实现是通过 singleflight 封装了 LoadFunc 方法,可以确保从数据库加载数据必然每个 key 一个 goroutine。
但是把数据回写缓存就是多个 goroutine 重复执行了。
实现二:
这种就是很简单的装饰器模式,在 Get 方法里面利用 singleflight 来完成加载数据和回写缓存两个步骤。
穿透解决方案
缓存穿透:读请求对应的数据根本不存在,因此每次都会发起数据库查询。数据库返回 NULL,所以下一次请求依旧旧会打到数据库。
解决方案:
- 使用 singleflight 能够缓解问题。但如果攻击者是构造了大量不同的不存在的 key,那么 singleflight 的效果并不是很好
- 知道数据库里面根本没有数据,缓存未命中就直接返回
- 缓存里面是全量数据,那么未命中就可以直接返回
- 使用布隆过滤器、bit array 等结构,末命中的时候再问一下这些结构
- 缓存没有,但是依旧不会去数据库查询,而是使用默认值
- 在缓存未命中回表查询的时候,加上限流器
综合 BloomFilter
BloomFilter 认为 key1 存在,才会最终去数据库中查询。
大部分不存在的 key1 会直接在 BloomFilter 这一层被拦下。
击穿解决方案
缓存击穿:缓存中没有对应 key 的数据。
解决方案:
- singleflight 就足以解决问题
- 缓存未命中的时候,使用默认值
- 在回查数据库的时候,加上限流器,不过这个是保护系统,而不是解决问题
雪崩解决方案
缓存雪崩:同一时刻,大量 key 过期,查询都要回查数据库
解决方案:
- 在设置 key 过期时间的时候,加上一个随机的偏移量
总结
- 缓存穿透、雪崩、击穿
- 缓存穿透:用户请求的数据不在缓存中,也不存在于数据库,导致每次请求都直达数据库,增加数据库压力。解决方案包括使用布隆过滤器、缓存空结果等。可以通过装饰器模式无侵入式解决。
- 缓存击穿:缓存中的某个热点数据失效,大量请求同时到达数据库,压力骤增。常见的解决方法是使用互斥锁(如 singleflight)来控制并发更新,或设置热点数据永不过期。
- 缓存雪崩:缓存大面积失效,大量请求直接打到数据库,导致数据库崩溃。可以通过给不同的缓存设置不同的过期时间、使用互斥锁等手段来避免。
- 缓存模式:
- read-through:当缓存未命中时,由缓存系统从数据库读取并写入缓存,适合读密集型场景。
- write-through:写操作直接写入缓存和数据库,确保缓存和数据库的一致性。
- cache-aside:应用程序直接与缓存和数据库交互,缓存未命中时从数据库加载,缓存过期策略更灵活。
- write-back:写操作只写入缓存,定期将数据同步到数据库,优势在于提高写操作性能,但风险是数据可能在宕机时丢失。
- 缓存穿透、雪崩、击穿与缓存模式的关系:两者并无直接关系。前者是缓存使用不当导致的问题,后者是如何组织缓存和数据的设计模式。
- singleflight:用于控制单进程内某个资源只有一个 goroutine 访问,避免多次重复的资源请求。不能用于分布式场景,因为那会涉及分布式锁,性能影响较大。
- 缓存一致性问题:缓存模式无法直接解决缓存一致性问题,特别是在 write-back 模式下,宕机可能导致永久性数据丢失。
- 这些要点在实践中可以通过装饰器模式进行无侵入式优化,提升代码的可维护性和灵活性。
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。