缓存穿透、击穿与雪崩

缓存系统学习系列(一)

Posted by John Mactavish on August 4, 2021

文章转自“程序员囧辉”的知乎文章

略有修改与补充

缓存穿透

描述:

访问一个缓存和数据库都不存在的 key,此时会直接打到数据库上,并且因为查不到数据,没法回写缓存,所以下一次同样会打到数据库上。 此时,缓存起不到作用,请求每次都会走到数据库,流量大时数据库可能会被打挂。此时缓存就好像被“穿透”了一样,起不到任何作用。

解决方案:

  1. 接口校验。在正常业务流程中可能会存在少量访问不存在 key 的情况,但是一般不会出现大量的情况,所以这种场景最大的可能性是遭受了非法攻击。可以在最外层先做一层校验:用户鉴权、数据合法性校验等,例如商品查询中,商品的 ID 是正整数,则可以直接对非正整数直接过滤等等。
  2. 缓存空值。当访问缓存和 DB 都没有查询到值时,可以将空值写进缓存,但是设置较短的过期时间,该时间需要根据产品业务特性来设置。
  3. 布隆过滤器。使用布隆过滤器存储所有可能访问的 key,不存在的 key 直接被过滤,存在的 key 则再进一步查询缓存和数据库。

布隆过滤器(Bloom Filter)

布隆过滤器是 1970 年由布隆提出的。它实际上是一个 bitset 和一系列随机映射函数。 布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。

布隆过滤器的原理是,当一个元素被加入集合时,通过 K 个散列函数将这个元素映射成一个位数组中的 K 个点,把它们置为 1。检索时,我们只要看看这些点是不是都是 1 就(大约)知道集合中有没有它了:如果这些点有任何一个 0,则被检元素一定不在;如果都是 1,则被检元素很可能在(小概率这些点刚好是其他多个元素映射的并集)。简而言之,布隆过滤器可以回答元素“可能存在”或“一定不存在”。

contribution

布隆过滤器的两种回答都有应用。当我们利用它“可能存在”的回答时, 我们本质上是在用一个“可靠性换空间”的 bitset;当我们利用它“一定不存在”的回答时, 我们是在用它高效地拦截非法或无效请求。具体怎么用取决于实际场景的要求。常用场景有:

  • 网页爬虫对 URL 去重,避免爬取相同的 URL 地址;
  • 反垃圾邮件,从数十亿个垃圾邮件列表中判断某邮件是否为垃圾邮件;
  • Google Chrome 使用布隆过滤器识别恶意 URL;
  • Medium 使用布隆过滤器避免推荐给用户已经读过的文章;
  • Google BigTableApache HBbaseApache Cassandra 使用布隆过滤器减少对不存在的行和列的查找。

布隆过滤器的误判率可以被降低,思路主要有:

  • 加大 bitset 的长度,这样不同的值出现“冲突”的概率就降低了;
  • 提升 hash 函数的个数,hash 函数越多,每个值对应的 bit 越多。

缓存击穿

描述:

某一个热点 key,在缓存过期的一瞬间,同时有大量的请求打进来,由于此时缓存过期了, 所有请求最终都会走到数据库,造成瞬时数据库请求量大、压力骤增,甚至可能打垮数据库。

解决方案:

  1. 热点数据不过期。直接将缓存设置为不过期。这种方式适用于比较极端的场景,例如流量特别特别大的场景。
  2. 加互斥锁。在并发的多个请求中,只有第一个请求线程能拿到锁并执行数据库查询操作,其他的线程拿不到锁就等着,隔段时间再查一下缓存,等到第一个线程将数据写入缓存后,直接成功走缓存。

加互斥锁

关于互斥锁的选择,可以用 Redis 分布式锁,因为这个可以保证只有一个请求会走到数据库。

但是仔细想想的话,这边其实没有必要保证只有一个请求走到数据库,只要能让走到数据库的请求大大降低即可,所以还有另一个思路是 JVM 锁。 JVM 锁保证了在单台服务器上只有一个请求走到数据库,同时在性能上比分布式锁更好。

需要注意的是,无论是使用“分布式锁”,还是“JVM 锁”,加锁时要按 key 去加锁。 而不能共用一个锁,这样会导致不同的 key 之间也会互相阻塞,严重损耗性能。

下面是使用 redis 分布式锁的伪代码,仅供参考:

public Object getData(String key) throws InterruptedException {
    Object value = redis.get(key);
    // 缓存值过期
    if (value == null) {
        // lockRedis:专门用于加锁的 redis;
        // "empty":加锁的值随便设置都可以
        if (lockRedis.set(key, "empty", "PX", lockExpire, "NX")) {
            try {
                // 查询数据库,并写到缓存,让其他线程可以直接走缓存
                value = getDataFromDb(key);
                redis.set(key, value, "PX", expire);
            } catch (Exception e) {
                // 异常处理
            } finally {
                // 释放锁
                lockRedis.delete(key);
            }
        } else {
            // sleep 50ms后,进行重试
            Thread.sleep(50);
            return getData(key);
        }
    }
    return value;
}

缓存雪崩

描述:

大量的热点 key 设置了相同的过期时间,缓存在同一时刻全部失效,造成瞬时数据库请求量大、压力骤增,引起雪崩,甚至导致数据库被打挂。 缓存雪崩其实有点像“升级版的缓存击穿”,缓存击穿是一个热点 key,缓存雪崩是一组热点 key。

解决方案:

  1. 过期时间打散。既然是大量缓存集中失效,那最容易想到就是让他们不集中生效。可以给缓存的过期时间加上一个随机值,使得每个 key 的过期时间分布开来,不会集中在同一时刻失效。
  2. 热点数据不过期。该方式和缓存击穿一样。
  3. 加互斥锁。该方式和缓存击穿一样,按 key 加锁,对于同一个 key,只允许一个线程去查询,其他线程原地等待第一个线程完成查询与缓存回写,然后直接走缓存即可。

参考资料:

维基百科的“布隆过滤器”词条

如果你喜欢我的文章,请我吃根冰棒吧 (o゜▽゜)o ☆

contribution

最后附上 GitHub:https://github.com/gonearewe