缓存是系统不可缺少的一部分。大多数系统都建立了多级缓存的架构,从客户端浏览器缓存,到 CDN,到 Nginx 缓存,再到后端的分布式缓存,再到节点的本地缓存…… 各级缓存相互协调,给用户带来流畅的体验,也保护后端系统免受大流量的冲击。

然而设计不当的缓存系统,也会引入其他问题。本文针对后端常用的分布式缓存系统,说明一下常见的几种问题场景。

缓存穿透 Cache penetration

缓存系统的工作流程都类似,请求到达后台后,先从缓存中查找,命中缓存后直接返回。如果缓存没有命中,就从下一层数据系统(一般是数据库)查找,并将对应的值放到缓存中返回。

而缓存穿透的场景是,请求对应的数据在缓存中不存在,数据库中也查找不到,因此无法更新缓存,导致请求直接打到了数据库上,好像缓存不存在一样,因此叫“穿透”。这种场景可能是代码缺陷导致,也可能是恶意用户故意构建错误的请求攻击后台。如果大流量的请求穿透缓存打到数据库,就可能导致服务不可用。

解决方案

  1. 业务层面过滤非法请求。比如查询年龄为 -1 的用户。这种方式最为常用,通常在 controller 层加上 Hibernate Validation ,配合 Spring 框架的注解实现对参数的校验。
  2. 如果是业务层面合法的请求,但后台 暂时 没有数据,可以考虑将 null 缓存下来。但这时候需要注意,这个缓存是临时性的,因为后续可能该数据就不为空了。所以缓存的时候可以给个稍微短一些的过期时间,比如 5 分钟。并且当数据库写入或更新数据时,需要同时刷新缓存以避免数据不一致。
  3. 使用布隆过滤器判断对应的 key 是否存在。布隆过滤器是一种特殊的数据结构,它用来判断 key 是否存在。原理很简单,布隆过滤器底层是一个位图(bitmap),可以理解为一个数组,数组元素就是二进制位(1 bit)。当 key 被加入到过滤器中,首先会经过一系列 hash 运算,每次 hash 运算都会计算出一个位图的坐标,标记该位置位 1。经过多次 hash 后,位图的某些位置就更新为 1 了。当需要判断 key 是否存在时,用同样的规则计算 hash,并查看对应坐标的值是否为 1,如果不为 1,那说明这个 key 肯定没有出现过。但如果对应的坐标值都为 1,却不能确定该值一定出现过,这是因为存在 hash 冲突,不同的 key 可能对应了同一个 hash 值,并且位图的长度也是有限的。因此如果要提高布隆过滤器的精度,需要增加位图的长度,并增加 hash 运算的次数,把冲突分散开。
  4. 上面的措施都是事前预防。也要注意监控线上的分布式缓存状态,如果在一段时间内发生大量未命中的请求,需要及时关注是否出现缓存穿透的问题并尽快处理。

缓存雪崩 Cache avalanche

缓存雪崩的场景是分布式缓存大面积失效,有可能是大量的缓存同时过期,或者缓存系统不可用,导致原本缓存系统承担的流量都涌入后台数据库,因为后台没有考虑承载如此巨大的流量,导致直接崩溃,进而导致整个系统不可用,引发连锁反应。

解决方案

  1. 如果是热点数据,可以考虑去掉过期时间,让该热点缓存一直有效,使用后台线程更新缓存。但这种方案需要忍受一段时间的数据不一致。
  2. 如果缓存对过期时间敏感,则可以尽量将过期时间分散开,比如一批缓存都是一分钟过期,可以给某些 key 设置 59 秒,某些 key 设置 61 秒,避免大量缓存同时过期引起雪崩。
  3. 使用双缓存(Double caching)。添加两份相同数据的缓存,一个设置过期时间,另一个不设置过期时间。如果一个缓存过期,就从另一个缓存获取数据。更新缓存的时候同时更新两个缓存。
  4. 给数据库添加限流措施,如锁或者队列,防止大量请求同时到达数据库。
  5. 给缓存添加高可用机制,如主从切换,分布式等措施,减少缓存系统发生故障的概率。

缓存击穿 Cache breakdown

缓存击穿容易和缓存雪崩混淆,这个翻译我觉得有点问题,叫缓存失效可能更好理解。它是指某个热点数据过期,导致大量请求打到数据库,给数据库造成很大的压力。

缓存击穿是缓存雪崩的一个子集。两者都是缓存失效导致的,缓存击穿的问题规模较小,是某个热点数据突然过期导致的,而缓存雪崩通常伴随的是大量的缓存失效甚至缓存系统都不可用。缓存击穿有可能是系统能自己恢复,如果数据库能扛住击穿后的瞬时流量,并且业务系统有缓存更新的方案,如果没有的话,缓存击穿就像是千里之堤溃于蚁穴,会演变成缓存雪崩。

解决方案

  1. 如果是热点数据,意味着该数据一直承载着大量的访问请求,这种情况不要设置过期时间,而是使用异步线程更新或重写缓存。这会带来一段时间的数据不一致。
  2. 使用互斥的方式更新缓存。这本质上是给数据库限流。当缓存失效时,一个请求开始查询数据库更新缓存,其他请求阻塞等待,直到缓存更新完成之后,其他请求再从缓存中获取数据。这会带来一段时间的系统吞吐量降低。
  3. 给数据库添加限流措施,防止大量请求到达数据库。

数据不一致

数据不一致是所有缓存系统都可能面临的问题。当数据发生变化时,数据库和缓存这两个数据源都需要更新,但这两个更新的操作一般不是原子性的,在高并发下就会出现不一致的情况。下面具体分析一下出现问题的几个场景。

TODO