Go猜想录
大道至简,悟者天成
手搓缓存层 -- #4 缓存异常

缓存异常

穿透、击穿和雪崩

最常见的三种针对缓存的异常情况

缓存穿透:

  • 读请求对应的数据根本不存在,因此每次都会发起数据库查询。数据库返回 NULL,所以下一次请求依旧会打到数据库。
  • 关键点就是这个数据根本没有,所以不会回写缓存。
  • 一般是黑客使用了一些非法的请求,比如说非法的邮箱、ID 等。

cache-24.png

对同一个 key 访问两次,结果两次都查询了数据库。后面继续访问依旧是落到数据库上。

缓存击穿:

  • 缓存中没有对应 key 的数据。

  • 一般情况下,某个 key 缓存未命中并不会导致严重问题。但是如果该 key 的访问量非常大,大家都去数据库查询数据,那么就可能压垮数据库。

  • 击穿和穿透比起来,关键在于击穿本身数据在 DB 里面是有的,只是缓存里面没有而已,所以只要回写到缓存,此一次访问就是命中缓存。

缓存雪崩:

  • 同一时刻,大量 key 过期,查询都要回查数据库。

  • 常见场景是在启动的时候加载缓存,因为所有 key 的过期时间都一样,所以会在同一时间全部过期。

异常解决思路

所有问题,落脚点都是:大量请求落到了数据库上。

所以思路就是,怎么在缓存出现问题的时候,依旧让这些请求不会落到数据库呢?

cache-27.png

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 等结构,末命中的时候再问一下这些结构
  • 缓存没有,但是依旧不会去数据库查询,而是使用默认值
  • 在缓存未命中回表查询的时候,加上限流器

cache-27.png

综合 BloomFilter

cache-29.png BloomFilter 认为 key1 存在,才会最终去数据库中查询。

cache-30.png 大部分不存在的 key1 会直接在 BloomFilter 这一层被拦下。

击穿解决方案

缓存击穿:缓存中没有对应 key 的数据。

解决方案:

  • singleflight 就足以解决问题
  • 缓存未命中的时候,使用默认值
  • 在回查数据库的时候,加上限流器,不过这个是保护系统,而不是解决问题

雪崩解决方案

缓存雪崩:同一时刻,大量 key 过期,查询都要回查数据库

解决方案:

  • 在设置 key 过期时间的时候,加上一个随机的偏移量

总结

  1. 缓存穿透、雪崩、击穿
    • 缓存穿透:用户请求的数据不在缓存中,也不存在于数据库,导致每次请求都直达数据库,增加数据库压力。解决方案包括使用布隆过滤器、缓存空结果等。可以通过装饰器模式无侵入式解决。
    • 缓存击穿:缓存中的某个热点数据失效,大量请求同时到达数据库,压力骤增。常见的解决方法是使用互斥锁(如 singleflight)来控制并发更新,或设置热点数据永不过期。
    • 缓存雪崩:缓存大面积失效,大量请求直接打到数据库,导致数据库崩溃。可以通过给不同的缓存设置不同的过期时间、使用互斥锁等手段来避免。
  2. 缓存模式
    • read-through:当缓存未命中时,由缓存系统从数据库读取并写入缓存,适合读密集型场景。
    • write-through:写操作直接写入缓存和数据库,确保缓存和数据库的一致性。
    • cache-aside:应用程序直接与缓存和数据库交互,缓存未命中时从数据库加载,缓存过期策略更灵活。
    • write-back:写操作只写入缓存,定期将数据同步到数据库,优势在于提高写操作性能,但风险是数据可能在宕机时丢失。
  3. 缓存穿透、雪崩、击穿与缓存模式的关系:两者并无直接关系。前者是缓存使用不当导致的问题,后者是如何组织缓存和数据的设计模式。
  4. singleflight:用于控制单进程内某个资源只有一个 goroutine 访问,避免多次重复的资源请求。不能用于分布式场景,因为那会涉及分布式锁,性能影响较大。
  5. 缓存一致性问题:缓存模式无法直接解决缓存一致性问题,特别是在 write-back 模式下,宕机可能导致永久性数据丢失。
  6. 这些要点在实践中可以通过装饰器模式进行无侵入式优化,提升代码的可维护性和灵活性。

知识共享许可协议

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。