缓存系统的三个常见概念: 缓存穿透、缓存击穿、缓存雪崩, 有区别又有联系, 十分容易混淆, 值得用一篇文章去学习记录一下;

缓存穿透

定义: 指查询一个根本不存在的数据, 缓存层和持久层都不会命中;
在日常工作中出于容错的考虑, 如果从持久层查不到数据则不写入缓存层, 缓存穿透将导致不存在的数据每次请求都要到持久层去查询, 失去了缓存保护后端的意义;

缓存穿透
缓存穿透

解决方案:

  1. bloomfilter 拦截: 先验证是否存在, 不存在则拦截请求;
    缺点: 存在假阳性的可能, 且对于已存在的值删除困难;
  2. 缓存空对象:
    缺点: 缓存层和存储层的数据可能存在不一致的窗口期;
    缓解方案: 建议对空值设置较短的过期时间, 或利用消息系统等异步方式清除掉缓存层中不一致的空对象;

缓存击穿

定义: 当缓存失效过期后, 相关的请求直接访问存储层, 以试图重建缓存;
缓存击穿本是预期内现象, 因为缓存的 key 一定需要设置过期时间, 所以一定有过期的时刻; 但是如果一个缓存 key 存在如下情况时则需要考虑其击穿可能造成的潜在风险:

  • 当前 key 是一个热点 key (例如一个秒杀活动), 并发量非常大;
  • 重建缓存不能在短时间完成, 比如可能是一个复杂计算, 例如复杂的 SQL、多次 IO、多个下游依赖等;

这种 key 在缓存失效的瞬间, 会有大量线程来重建缓存, 造成后端负载加大, 甚至可能会让进程崩溃;

解决方案:

  1. 在为缓存 key 设置物理过期时间之外, 额外设置逻辑过期时间, 使用异步线程检测缓存 key 是否逻辑过期, 并对逻辑过期的 key 异步重建缓存;
    缺点: 代码复杂度增加, 逻辑时间的维护成本高, 逻辑过期删除不及时将导致策略失效;
  2. 互斥重建缓存: 使用分布式互斥锁, 只允许一个线程重建缓存;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fun get(key: string): string = {
val value = getFromCache(key)
if (value not null) {
return value
}
return globalSync(key -> laodCache(key))
}

fun laodCache(key: string): string = {
val value = getFromCache(key)
if (value not null) {
return value
}
val newValue = calcValue(key)
wirteToCache(key, newValue)
return newValue
}

缺点: 大量线程竞争同一把锁, 吞吐量会骤降, 即便第一个线程已经完成了缓存重建, 其他已经排队等待锁的线程也需要继续排队依次执行;
解决方案: 设置加锁超时时间, 一旦超时再次优先尝试直接获取:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fun globalSync(key: string,
loadCacheProcedure: Func<string, string>,
getFromCacheProcedure: Func<string, string>): string = {
while (currTryTimes < threshold) {
// 尝试加锁
val result = syncWithTimeout(loadCacheProcedure, timeout)
if (result.success) {
return result.data
}
// 加锁超时, 尝试直接获取
val value = getFromCacheProcedure.apply(key)
if (value not null) {
return value
}
}
// 达到重试阈值, 失败返回
throw exception
}

缓存雪崩

定义: 如果大量缓存 key 集中在一段时间内失效, 发生大量的缓存击穿, 所有的查询都落在数据库上, 造成了进程崩溃;

缓存雪崩
缓存雪崩

缓存雪崩的原因:

  • 情况 1: 大量缓存 key 过期, 系缓存击穿问题的放大;
    解决该问题的核心是让缓存 key 失效时间点错开分布, 即避免大量请求同时到达, 比如使用队列实现请求削峰;
  • 情况 2: 缓存服务器故障, 导致请求全部转向存储层, 造成压力突增;
    解决该问题的方式是引入熔断、降级和限流, 增强系统的稳定性;

参考资料