1 Redis 应用问题解决
1.1 缓存穿透
1.1.1 问题描述
key 对应的数据在数据源并不存在
,每次针对此 key 的请求从缓存获取不到,请求都会压到数据源(数据库)
,从而可能压垮数据源。比如
用一个不存在的用户 id 获取用户信息,不论缓存还是数据库都没有,若黑客利用此漏洞进行攻击可能压垮数据库。
缓存穿透发生的条件:
- 应用服务器压力变大
- redis 命中率降低
- 一直查询数据库,使得数据库压力太大而压垮
其实 redis 在这个过程中一直平稳运行,崩溃的是我们的数据库(如 MySQL)
1.1.2 解决方案
对空值缓存
:如果一个查询返回的数据为空(不管是数据是否不存在),我们仍然把这个空结果(null)进行缓存,设置空结果的过期时间会很短。设置可访问的名单(白名单)
:使用 bitmaps 类型定义一个可以访问的名单,名单 id 作为 bitmaps 的偏移量,每次访问和 bitmap 里面的 id 进行比较,如果访问 id 不在 bitmaps 里面,进行拦截,不允许访问。采用布隆过滤器
:布隆过滤器(Bloom Filter)是 1970 年由布隆提出的。它实际上是一个很长的二进制向量 (位图) 和一系列随机映射函数(哈希函数)。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。进行实时监控
:当发现 Redis 的命中率开始急速降低,需要排查访问对象和访问的数据,和运维人员配合,可以设置黑名单限制服务。
1.2 缓存击穿
1.2.1 问题描述
key 对应的数据存在
,但在 redis 中过期
,此时若有大量并发请求
过来访问当前key,这些请求发现缓存过期一般都会从后端数据库加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端数据库压垮。属于热点key失效问题
1.2.2 解决方案
1、设置热点数据永不过期
对于某个需要频繁获取的信息,缓存在Redis中,并设置其永不过期。当然这种方式比较粗暴,对于某些业务场景是不适合的。
2、定时更新
比如这个热点数据的过期时间是1h,那么每到59minutes时,通过定时任务去更新这个热点key,并重新设置其过期时间。
3、互斥锁
这是解决缓存穿透比较常用的方法。
互斥锁简单来说就是在Redis中根据key获得的value值为空时,先锁上,然后从数据库加载,加载完毕,释放锁。若其他线程也在请求该key时,发现获取锁失败,则睡眠一段时间(比如100ms)后重试。
1.3 缓存雪崩
1.3.1 问题描述
Redis中缓存的数据大面积同时失效
,或者Redis宕机,从而会导致大量请求直接到数据库,压垮数据库。缓存雪崩与缓存击穿的区别在于这里针对很多 key
缓存,前者则是某一个 key
正常访问。
缓存失效瞬间:
1.3.2 解决方案
1、设置有效期均匀分布
:避免缓存设置相近的有效期,我们可以在设置有效期时增加随机值;或者统一规划有效期,使得过期时间均匀分布。
2、数据预热
:对于即将来临的大量请求,我们可以提前走一遍系统,将数据提前缓存在Redis中,并设置不同的过期时间。
3、构建多级缓存架构
:nginx 缓存 + redis 缓存 + 其他缓存(ehcache 等)
4、使用锁或队列
:用加锁或者队列的方式来保证不会有大量的线程对数据库一次性进行读写,从而避免失效时大量的并发请求落到底层存储系统上,该方法不适用高并发情况。
1.4 分布式锁
分布式锁本质上要实现的目标就是在 Redis 里面占一个“坑位”,当别的进程也要来占 时,发现已经有进程占用了,就只好放弃或者稍后再试
。 占坑一般是使用 setnx(set if not exists)
指令,只允许被一个客户端占坑。先来先占,用 完了,再调用 del 指令释放。
1.4.1 setnx
和del
命令实现
127.0.0.1:6379> setnx lock true # 获取锁
(integer) 1
# ... do biz ...
# ... do biz ...
127.0.0.1:6379> del lock # 释放锁
(integer) 1
127.0.0.1:6379>
但是有个问题,如果逻辑执行到中间出现异常了,可能会导致 del 指令没有被调用
,这样 就会陷入死锁,锁永远得不到释放。
于是我们在拿到锁之后,再给锁加上一个过期时间,比如 5s,这样即使中间出现异常也 可以保证 5 秒之后锁会自动释放。
1.4.2 setnx
、expire
和del
命令实现
127.0.0.1:6379> setnx lock true
(integer) 1
127.0.0.1:6379> expire lock 5
(integer) 1
# ... do biz ...
# ... do biz ...
127.0.0.1:6379> del lock
(integer) 0
但是以上逻辑还有问题。如果在 setnx 和 expire 之间服务器进程突然挂掉了,或者客户端由于某种原因掉线,就会导致 expire 得不到执行,也会造成死锁。
这种问题的根源就在于 setnx 和 expire 是两条指令而不是原子指令
。如果这两条指令可以一起执行就不会出现问题。也许会想到用 Redis 事务
来解决。但是这里不行,因为 expire
是依赖于 setnx
的执行结果的,如果 setnx 没抢到锁,expire 是不应该执行的。事务的特点是一口气执行,没有 ifelse 分支逻辑,要么全部执行要么一个都不执行。
为了解决这个疑难,Redis 开源社区涌现了一堆分布式锁的 library,专门用来解决这个问题,实现方法极为复杂。
1.4.3 set
扩展命令参数实现
幸运的是在redis 2.6.12 及之后版本
对set命令进行了扩展,使得可以原子性的实现 setnx + expire 功能:
SET key value [NX | XX] [GET] [EX seconds | PX milliseconds |
EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL]
127.0.0.1:6379> set lock true ex 5 nx
OK
127.0.0.1:6379> set lock true ex 5 nx
(nil)
经过上三步的推到,分布式锁依然存在一下问题:
1.4.4 超时问题
这种方式的Redis 的分布式锁不能解决超时问题
,A线程超时时间设为10s(为了解决死锁问题), 但代码执行时间可能需要30s, 然后redis服务端10s后将锁删除, 此时, B线程恰好申请锁, redis服务端不存在该锁, 可以申请, 也执行了代码, 那么问题来了, A、B线程都同时获取到锁并执行业务逻辑, 这与分布式锁最基本的性质相违背: 在任意一个时刻, 只有一个客户端持有锁, 即独享
,还会引发A线程把B线程的锁释放了,B把C释放了依次递推,造成分布式锁彻底失效
为了避免这个问题:
方案一:Redis 分布式锁不要用于较长时间的任务
方案二:异步定时进行lock锁的续期,实现比较复杂(例如:redisson中监控锁
的看门狗)
1.4.5 可重入性
可重入性是指线程在持有锁的情况下再次请求加锁
,如果一个锁支持同一个线程的多次加锁,那么这个锁就是可重入的。比如 Java 语言里有个 ReentrantLock
就是可重入锁。
Redis 分布式锁如果要支持可重入,需要对客户端的 set 方法进行包装,使用类似线程的Threadlocal变量存储当前持有锁的计数。对于redis的重入锁业界还是有很多解决方案的,目前最流行的就是采用Redisson
。
不推荐使用可重入锁,它加重了客户端的复杂性,在编写业务方法时注意在逻辑结构上进行调整完全可以不使用可重入锁
1.4.6 集群环境下的分布式锁
比如加锁操作在 Sentinel
集群中,主节点挂掉时,从节点会取而代之,客户端上却并没有明显感知。原先第一个客户端在主节点中申请成功了一把锁,但是这把锁还没有来得及同步到从节点
,主节点突然挂掉了。然后从节点变成了主节点,这个新的节点内部没有这个锁
,所以当 另一个客户端过来请求加锁时,立即就批准了。这样就会导致系统中同样一把锁被两个客户端同时持有
,不安全性由此产生。
不过这种不安全也仅仅是在主从发生 failover 的情况下才会产生,而且持续时间极短
, 业务系统多数情况下可以容忍。
1.4.7 Redlock 算法
Redlock 是一种算法,Redlock 也就是 Redis Distributed Lock
,它的流程比较复杂,解决集群环境下的分布式锁的问题,可用实现多节点 redis 的分布式锁。
RedLock 官方推荐,Redisson 完成了对 Redlock 算法封装。
此种方式具有以下特性:
互斥访问
:即永远只有一个 client 能拿到锁。避免死锁
:最终 client 都可能拿到锁,不会出现死锁的情况,即使锁定资源的服务崩溃或者分区,仍然能释放锁。容错性
:只要大部分 Redis 节点存活(一半以上),就可以正常提供服务
加锁时,它会向过半节点
发送 set(key, value, nx=True, ex=xxx)
指令,只要过半节点 set
成功,那就认为加锁成功。释放锁时,需要向所有节点发送 del 指令
。不过 Redlock 算法还需要考虑出错重试
、时钟漂移
等很多细节问题,同时因为 Redlock 需要向多个节点进行读、写,意味着相比单实例 Redis 性能会下降一些。
1.5 布隆过滤器
1.5.1 什么是 BloomFilter
布隆过滤器(英语:Bloom Filter)是 1970 年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。主要用于判断一个元素是否在一个集合中。它的优点是空间效率和查询时间都比一般的算法要好的多,缺点是有一定的误识别率和删除困难。
当布隆过滤器表示某个值存在时,该值可能不存在;当它说一个值不存在时,它就一定不存在。
通常我们会遇到很多要判断一个元素是否在某个集合中的业务场景,一般想到的是将集合中所有元素保存起来,然后通过比较确定。链表、树、散列表(又叫哈希表,Hash table)等等数据结构都是这种思路。但是随着集合中元素的增加,我们需要的存储空间也会呈现线性增长,最终达到瓶颈。同时检索速度也越来越慢,上述三种结构的检索时间复杂度分别为 O ( n ) O(n) O(n), O ( l o g n ) O(logn) O(logn), O ( 1 ) O(1) O(1)。
这个时候,布隆过滤器(Bloom Filter)就应运而生。
1.5.2 工作原理与设计思想
1.5.2.1 Hash 函数原理
了解布隆过滤器原理之前,先回顾下 Hash 函数原理。
哈希函数的概念是:将任意大小的输入数据转换成特定大小的输出数据
的函数,转换后的数据称为哈希值
或哈希编码
,也叫散列值
。下面是一幅示意图:
所有散列函数都有如下基本特性:
- 单向散列函数:如果两个
散列值
是不相同
的(根据同一函数),那么这两个散列值的原始输入也是不相同
的。这个特性是散列函数具有确定性的结果,具有这种性质的散列函数称为单向散列函数。 - 散列碰撞:如果两个
散列值相同
,两个原始输入值很大可能是相同的,但也可能不同
1.5.2.2 布隆过滤器数据结构
BloomFilter 是由一个固定大小
的二进制向量或者位图(bitmap)和一系列映射函数组成的。在初始状态时,对于长度为 m 的位数组
,它的所有位都被置为0
,如下图所示:
当有变量被加入集合时,通过 K 个映射函数
将这个变量映射
成位图中的 K 个点
,把它们置为 1
(假定有两个变量都通过 3 个映射函数)。
变量:obj1、obj2;函数:Fun1、Fun2、Fun3
查询某个变量的时候我们只要看看这些点是不是都是 1
就可以大概率知道集合中有没有它了
- 如果这些点有任何一个
0
,则被查询变量一定不在
; - 如果都是
1
,则被查询变量很可能存在
为什么说是可能存在,而不是一定存在呢?那是因为映射函数本身就是散列函数,会产生散列碰撞
。
1.5.2.3 布隆过滤器误判率和删除问题
布隆过滤器的误判是指多个输入经过K个哈希之后在相同的bit位,置为1
了,这样就无法判断究竟是哪个输入产生的,因此误判的根源在于相同的 bit 位被多次映射且置 1。
这种情况也造成了布隆过滤器的删除问题,因为布隆过滤器的每一个 bit 并不是独占的
,很有可能多个元素共享
了某一位。如果我们直接删除这一位的话,会影响其他的元素,导致误判率增加。例如上图中 3 位置的判断两个值obj1、obj2都映射在一个位置了。
1.5.2.4 添加与查询元素步骤
添加元素
- 将要添加的元素给 k 个哈希函数进行计算
- 得到对应于位数组上的 k 个位置
- 将这k个位置设为 1
查询元素
- 将要查询的元素给k个哈希函数进行计算
- 得到对应于位数组上的k个位置
- 如果k个位置有一个为 0,则肯定不在集合中
- 如果k个位置全部为 1,则可能在集合中
1.5.3 布隆过滤器优缺点
1.5.3.1 优点
- 相比于其它的数据结构,布隆过滤器在
空间
和时间
方面都有巨大的优势。布隆过滤器存储空间和插入/查询时间
都是常数O(K)
,另外,散列函数相互之间没有关系,方便由硬件并行实现。 - 布隆过滤器不需要存储元素本身,在某些对
保密
要求非常严格的场合有优势。 - 布隆过滤器可以表示全集,其它任何数据结构都不能。
1.5.3.2 缺点
-
布隆过滤器的缺点和优点一样明显。误算率是其中之一。随着存入的元素数量增加,误算率随之增加。但是如果元素数量太少,则使用散列表足矣。
-
布隆过滤器可以添加元素,但是不能删除元素。因为删掉元素会导致误判率增加。
1.5.4 布隆过滤器使用场景
布隆过滤器的典型应用有:
-
黑名单:垃圾邮件过滤功能,从数十亿个垃圾邮件列表(类似地,垃圾邮件)中判断电子邮件是否为垃圾邮件。
-
业务场景中判断用户是否阅读过某视频或文章,比如抖音或头条,当然会导致一定的误判,但不会让用户看到重复的内容。
-
缓存穿透,将已经存在的缓存放在布隆中,当黑客访问不存在的缓存时迅速返回避免缓存及DB挂掉
-
数据库防止穿库。 Google Bigtable,HBase 和 Cassandra 以及 Postgresql 使用BloomFilter来减少不存在的行或列的磁盘查找。避免代价高昂的磁盘查找会大大提高数据库查询操作的性能。
-
WEB拦截器,如果相同请求则拦截,防止重复被攻击。用户第一次请求,将请求参数放入布隆过滤器中,当第二次请求时,先判断请求参数是否被布隆过滤器命中。可以提高缓存命中率。
- Squid 网页代理缓存服务器在 cache digests 中就使用了布隆过滤器。
- Google Chrome浏览器使用了布隆过滤器加速安全浏览服务
1.5.5 Redis中使用布隆过滤器
布隆过滤器(Bloom Filter)是Redis 4.0
提供的新功能。它作为插件加载到Redis服务器中。
安装 RedisBloom
# 下载地址
https://github.com/RedisBloom/RedisBloom
# 解压文件
unzip RedisBloom-master.zip
# 进入目录
cd RedisBloom-master
# 执行编译命令,生成redisbloom.so 文件
make
# 拷贝至指定目录
cp redisbloom.so /path/to/redisbloom.so
# 在redis配置文件里加入以下配置
loadmodule /path/to/redisbloom.so
# 配置完成后重启redis服务
sudo redis-server restart
# 测试是否安装成功
127.0.0.1:6379> bf.add user 101
(integer) 1
127.0.0.1:6379> bf.exists user 101
(integer) 1
127.0.0.1:6379> bf.exists user 10
(integer) 0
常用命令
- bf.add:添加元素到布隆过滤器。
- bf.exists:判断某个元素是否在于布隆过滤器中。
- bf.madd:同时添加多个元素到布隆过滤器。
- bf.mexists:同时判断多个元素是否存在于布隆过滤器中。
- bf.reserve:以自定义的方式设置布隆过滤器参数值,共有 3 个参数分别是 key、error_rate(错误率)、initial_size(初始大小)。