Redis 实用小技巧—— bitmap 应用之「缓存穿透」问题的处理
简介
在上一篇文章《Redis 实用小技巧—— bitmap 应用之「布隆过滤器」》中,我们了解到了「布隆过滤器」的原理和简单的实现。在接下来的文章中,我们再来介绍一个「布隆过滤器」比较典型的应用 —— 「缓存穿透」问题的处理。
其实在介绍「布隆过滤器」的应用场景中,我们已经提到了「缓存穿透」问题,这里我们之所以单独拎出来介绍这个话题,主要是因为「缓存穿透」问题在生产环境中比较常见,而且使用「布隆过滤器」也是比较典型的一种解决方案。
废话不多说,准备发车~
缓存穿透
概念
首先,什么是「缓存穿透」呢?
相信有一部分刚刚步入职场的小伙伴可能还没听到过这个概念。或者说在网上或者面试题中刷到过,但是实际工作中并没遇到过。
其实这也没什么,毕竟只有当数据量达到一定程度以后,缓存成为程序不可或缺的一部分的时候,我们才有机会接触到这些东西。在实际环境中,很多中小型公司还是以数据库查询占据着主导地位(毕竟业务场景决定了技术架构和技术选型)。
当提到「缓存穿透」的时候,经常会一并提到的还有两个概念:「缓存击穿」和「缓存雪崩」。为了防止概念混淆,我们先来简单介绍下这几个概念。
缓存击穿:某个热点 key 过期,导致大量请求该 key 的流量转向数据库,数据库压力过大,造成卡顿或宕机
缓存穿透:大量请求不存在的 key ,导致数据库压力过大,造成卡顿或宕机
缓存雪崩:Redis 中大量 key 集中过期,导致数据库压力过大,造成卡顿或宕机
出现这三种情况的根本原因是 Redis 命中率下降,没有扛住「第一波」压力,导致数据库承受的压力过大,进而造成服务延迟甚至不可用的状况。
接下来,我们就来围绕今天的主角 ——「缓存穿透」,具体分析一下,看看问题究竟是怎么发生的。
追根溯源
我们都知道,当我们的数据量越来越大时,为了处理服务的并发访问,缓解数据库的压力,我们一般会在访问访问数据库之前,先判断缓存中有没有数据,如果缓存有数据,则直接从缓存返回数据,如果没有的话,则从数据库查询并将数据存到缓存中,以方便下一次的调用。流程如下图所示:
这样设计看似没有什么问题,但是如果现在请求过来的数据,都无法命中缓存的话,那压力就来到数据库这边了。当这种请求量还很大的时候,可能会造成数据库压力过大而影响到正常的访问。
这种现象就叫做「缓存穿透」。
那么应该如何处理这种问题呢?
既然前边已经提到了「布隆过滤器」可以处理缓存穿透,那自然就是往「布隆过滤器」上靠了。
首先,我们需要清楚一点:「缓存穿透」这种现象正常吗?
其实,多数情况下,我们的数据都是会命中缓存的,但是不排除一些别有用心的「恶意请求」:比如,我们提供了一个根据用户 ID 查询用户信息的接口,正常我们用户 ID 都是不超过 1000 万的整数,但是用户偏偏就传超过 1000 万大小的 ID ,这就会导致请求直接到了数据库这边,如果这种请求还很多的话,势必造成数据库拥堵的状况。
那可不可以简单暴力点:如果缓存拿不到数据的话,就认为数据不存在,直接返回不存在的结果呢?
这样也是不合理的。因为一般我们都会给缓存设置过期时间,可能在缓存过期的空档,我们是无法命中缓存的,如果直接拿不到缓存就认为数据不存在的话,就会导致「误判」了。
那可不可以把不存在的数据也存到缓存里,请求过来的时候,直接从缓存拿数据呢?
乍一听好像没什么毛病,但是仔细一想,懵逼了:如何定义不存在的数据?边界有多大呢?答案是:无限大!!!
所以这种方案也是不合适的。
既然,无法定义不存在的数据,那可不可以把存在的数据的关键信息(比如 ID )放到缓存呢,请求过来的时候,先判断关键信息是否存在,然后再查询具体的缓存信息呢?
做是肯定可以做的,用 String 类型,Set 类型 或者 Hash 类型都可以做。但是这种场景,用普通的数据类型存储的话会造成内存的浪费(毕竟 Redis 内存还是很宝贵的),因为我们的数据量会很大,而且我们要的也只是一个「存在不存在」的结果……
等等,存在不存在?数据量还很大?这不就是 bitmap 的用武之地么。
这样看来,使用 bitmap 场景没什么问题,现在还需要考虑下如何「使用」的问题。
我们知道,在布隆过滤器中,不存在的肯定不存在,存在的可能不存在(不管存不存在,真理永远存在)。
我们把所有存在的数据的缓存 key 存到 bitmap 中,当请求过来时,先判断缓存 key 是否存在于 bitmap 中,如果不存在,则直接返回空的结果。如果存在,则查询缓存。流程如下图所示:
完美,搞定。
这里你或许会有疑问,bitmap 可以直接存储用户的主键 ID 吗?存 ID 的话这样不更简单么?而且因为主键 ID 本身就有唯一性,都不需要经过哈希映射,还不会有冲突的问题。
是的,看似无恙,但是当我们分配一张 bitmap 表的时候,就要考虑到可能的最大内存开销的问题 —— 最大 512 MB 。这样的话,bit 位表达的含义应该尽量丰富,毕竟我们有 2^32 - 1 个位置呢。
使用布隆过滤器的思想的话,用缓存 key 作为哈希的序列串,同 Redis 数据库中所有涉及到「缓存穿透」场景的都可以使用,而如果存用户的主键 ID 的话,则这张 bitmap 表的作用域就被限制了,对内存也是一种浪费。
可能你还要问了,布隆过滤器不是无法删除元素吗,那如果一条记录删除了以后,对应的缓存也清除了,但是布隆过滤器中该元素的「标记」依然存在,这样一来不就会误判了吗?
其实这种情况并不会产生太大的影响。因为当我们判断完布隆过滤器存在某元素的缓存 key 以后,我们下一步还是会去缓存查数据,如果缓存中没有的话,再去数据库查询。相对于「穿透力更强」的非法请求而言,这样的误判「杀伤力」微乎其微。
毕竟,在这种场景下,布隆过滤器的主要目的就是把那些「不怀好意」的请求给拦下来——把「典型」抓住,问题就解决了一大半。
最后,我们就来看看在代码中如何实现吧~(还是以 Laravel 为例)。
代码实现
本示例中的「布隆过滤器」的实现逻辑继承自上一篇文章 《Redis 实用小技巧—— bitmap 应用之「布隆过滤器」》 。
添加逻辑
首先,我们需要在创建模型的时候将缓存 key 加入到「布隆过滤器」。如下所示:
...
// 1. 创建模型
$user = new User();
$user->name = 'Tom';
$user->save();
// 2. 添加缓存
$cacheKey = "USER:{$user->id}";
Cache::add($cacheKey, $user, 86400);
// 3. 添加布隆过滤器
$bloomFilter = new BloomFilter();
$bloomFilter
->setBucket('BLOOM_FILTER:CACHE_PENETRATION')
->setHashFunctions(['JSHash', 'DJBHash', 'DEKHash']);
$bloomFilter->add($cacheKey);
...
查询逻辑
然后,在查询模型的地方,我们需要加一层先查询「布隆过滤器」的判断逻辑,如下所示:
...
$cacheKey = "USER:{$userId}";
// 1. 查询布隆过滤器
$bloomFilter = new BloomFilter();
if(!$bloomFilter->exist($cacheKey)){
throw new Exception('用户不存在');
}
// 2. 查询缓存或者数据库
$user = Cache::remember($cacheKey, 86400, function ($userId) {
return User::find($userId);
});
return $user;
...
有了「布隆过滤器」这一层防护以后,就不用担心那些「不怀好意」的访问了~
当然,涉及到「缓存穿透」还有其他一些细节需要注意,这里我们暂时就先不做讨论了。
总结
本篇文章还是围绕「布隆过滤器」这个话题在讨论,不过我们又接触到了另一个比较典型的场景 ——「缓存穿透」。用一句话来总结就是:
借助「布隆过滤器」的存储和计算优势,提高缓存命中率,从而达到缓冲数据库压力的目的。
在下一篇文章 《Redis 实用小技巧—— bitmap 应用之「签到统计」》中,我将为大家介绍 bitmap 另外一个比较常见的应用场景,那就是「签到统计」。
感谢大家的持续关注~
本作品采用《CC 协议》,转载必须注明作者和本文链接
推荐文章: