Skip to content

一文理解穿透、击穿、雪崩

2263字约8分钟

distributedcache

2024-10-20

前言

一般来说,我们在使用 Redis 缓存时不可避免的会遇到穿透、击穿、雪崩等问题,这不仅仅是一个八股,也是我们在享受缓存带来的性能提升时所需要额外考虑的问题。

所以,这篇文章我们就来详细聊聊。

雪崩

雪崩是指在某个时间点,缓存中大量数据在极短的时间段内同时失效,或者缓存节点不可用,那么大量的请求就会绕过缓存直接打到数据库,数据库的压力就会明显上升,并且由于数据库的性能一般比缓存要差得多,这些请求很可能会击垮数据库,导致系统崩溃。

雪崩一般有两种情况可能出现,大量数据同时失效以及缓存节点不可用。

我们知道,在分布式系统中,数据库和缓存中的数据是很难保证强一致性的,只能尽可能保证最终一致性,所以我们需要给缓存 key 设置一个过期时间,这主要是为了避免缓存和数据库数据的长期不一致,做一个兜底的策略。

在一般情况下,由于缓存数据是离散时间写入的,所以也是离散时间过期并淘汰的。

而在某些特定情况下,我们可能会提前预热一些数据,这批数据会在很短的时间内被系统主动从数据库加载到缓存中,所以,这批数据也会在某个很短的时间段内同时过期,这就会导致缓存的数据大量失效。

那么造成这种情况的原因是由于缓存预热时的过期时间一致,所以我们的解决方案就是将过期时间离散化。

具体来说,我们可以对每个 key 在基础过期时间之上加上一个随机时间偏移量,这样就可以避免这批数据在极短的时间内同时过期,对数据库造成过大的压力。

其次就是缓存节点不可用导致的雪崩问题,这就相当于缓存中的所有 key 都同时失效了。

一旦缓存节点宕机,我们首先就是进行服务熔断,暂停应用对缓存服务的访问,直接返回错误,同时需要对请求进行限流,只放过少量请求到数据库,避免大流量直接击垮数据库。

而更有效的方案其实是搭建缓存高可用集群,比如可以通过主从节点的方式构建 Redis 缓存主从集群,master 节点一旦故障宕机,slave 节点可以立即切换为 master 节点,继续提供缓存服务,避免了由于 Redis 故障宕机而导致的缓存雪崩问题。

击穿

击穿一般出现在某个热 key 的场景。

也就是当某个热 key 失效后,大量的请求也会直接打到数据库,增加数据库的负担,进而可能导致数据库崩溃。

而要解决击穿的问题,本质上就是将查询数据库重建缓存这个操作当做临界资源,不能放过太多请求直接访问数据库。

所以,我们可以通过加锁保证线程对数据库的互斥访问。

这个加锁的粒度可以是 JVM 锁,也可以是分布式锁,具体要看业务数据库能否支撑应用集群的查询量,如果重建缓存的查询是一个重量级的慢 SQL,并且应用集群数量比较多的话,就可以加分布式锁,保证只有单个线程查询数据库。

引入锁之后,又会引入一个额外的问题,获取锁失败的线程怎么办?这里又有两种方案。

其中一种是可以让获取锁失败的线程阻塞一段时间后重新查询缓存数据,但我其实是不建议这么做的,首先阻塞等待的时间其实是不好确定的,其次既然已经用上了缓存,那么一定是在并发请求量比较高的情况,所以可能会导致大量的请求阻塞导致 Tomcat 线程池或者 RPC 连接池打满。

所以我们更推荐使用逻辑过期的方式,也就是不对热 key 设置实际的过期时间,而是设置一个逻辑过期时间,我们在应用代码根据这个逻辑过期时间来判断缓存是否过期,而我们上面所说的获取锁失败的线程就可以返回缓存的旧值,加锁成功的线程采取重建缓存。

或者我们可以让加锁成功的线程也返回旧值,只是在返回之前开一个异步线程去重建缓存,但是如果使用异步重建缓存,那么加锁、解锁又会变得更加复杂,真的是不好搞。

下面我们给出一个简单示例

public Optional<Object> getCache(String key) {
    String value = cache.get(key);
    if (StrUtil.isEmpty(value)) {
        return Optional.empty();
    }
    Cache data = JSON.parseObject(value, Cache.class);
    Long expire = data.getExpire();
    // 未过期
    if (System.currentTimeMillis() <= expire) {
        return Optional.of(data.getData());
    }
    // 过期,加锁,缓存重建
    RLock lock = getLock(LOCK_KEY);
    if (lock.tryLock()) {
        try {
            rebuildCache();
        } catch (Exception e) {
            // log
        } finally {
            lock.unlock();
        }
    }
    // 返回旧数据
    return Optional.of(data.getData());
}

但是也要说明,逻辑过期只适用于那些对数据一致性要求不高的业务场景,因为会存在较长时间的数据不一致,只是这种方案能够保证缓存和数据库的最终一致性。

穿透

穿透是指客户端请求的数据在缓存和数据库中都不存在,这样,缓存永远不会生效,所有的请求都会直接打到数据库。

如果存在一些“特殊访客”,持续访问系统中不存在的 key,就会对数据库产生很大的压力,影响正常服务。

穿透存在的原因是,我们在进行系统设计时,更多考虑的是正常情况下的访问,而对于一些灰色流量未加限制。

所以最根本的解决方案是依赖系统的风控处理,比如在网关使用 nginx 和 lua 脚本对请求 ip 进行限流。

但是我们后端还是要做一些兜底策略。

一些业界常用的方案包括缓存 null 值,布隆过滤器等,我们这里也详细说说这些方案的优缺点。

针对缓存 null 值,其实就是当数据库和缓存查询结果都为空时,我们向缓存中加入一个 null 值,并且设置一个很短的过期时间,这样接下来的请求就可以避免再次请求数据库,就可以很好的减轻数据库的压力。

但这种方案的缺点在于,如果真的有大量的灰色流量,那么缓存系统就会加入大量的 null 值,占用大量内存。

进而又引出了布隆过滤器的方案,也就是说,我们在应用启动时,可以将全量的数据加载到布隆过滤器中,当有新的请求到来时,先查询布隆过滤器,如果布隆过滤器返回不存在,则直接返回空或者默认值,否则继续查询缓存数据,如果缓存数据不存在,再进一步查询数据库,同时,当我们后续从数据库加载增量数据到缓存时,也要更新布隆过滤器。

我们知道布隆过滤器有两个缺点,存在误判以及无法删除数据,怎么解决呢?

其实,如果布隆过滤器误判了也无所谓,不过就是多查询一次缓存或者数据库而已,而且我们知道布隆过滤器的误判率其实是很低的,所以这种误判也是可以接受的。

另外,对于业务数据的删除,虽然我们不能删除布隆过滤器的数据,但是我们可以在数据变化达到一定的量级之后重建布隆过滤器,这个也可以用定时任务来做。

总结

最后,我们在记忆击穿、雪崩、穿透时,不要死记硬背,你可以尝试这样记忆:

  • 首先,雪崩的概念比较容易,就是很多个 key 同时过期才会雪崩,“缓存雪崩的时候没有一个 key 是无辜的”。
  • 至于穿透和击穿,区别在于穿透是“透”,什么叫透呢,就是请求不仅击穿缓存,数据库也被击穿了,这种才叫透。所以,这种缓存和数据库中都没有的情况叫做“穿透”。