[Redis] 03-缓存异常问题
引入了缓存层,就会有缓存异常的三个问题:分别是缓存雪崩、缓存击穿、缓存穿透。
一、缓存穿透
访问一个缓存和数据库都不存在的key,此时请求会直接访问到数据库,并且查不到数据,没法写缓存,所以下次请求同样会访问到数据库上。
此时,缓存起不到作用,请求每次都会走到数据库,流量大时数据库可能会被打垮,此时缓存就好像被"穿透"了一样,成为一个摆设起不到任何作用。
二、解决缓存穿透的几种方案
参数校验
在API入口处我们要判断请求参数是否合理,请求参数是否含有非法值、请求字段是否存在等,如果判断出恶意请求就直接返回错误,避免进一步访问缓存和数据库。
缓存空值
当发生缓存穿透现象时,可以针对查询的数据,在缓存中设置一个空值或默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询到数据库。
使用缓存空值方案来解决Redis缓存穿透问题时,主要的缺点包括:
- 数据不一致性:在某些情况下,如果数据库中的数据更新了,但是缓存中存储的是空值,这将导致用户在缓存过期之前始终无法获取到最新的数据。
- 资源浪费:缓存空值意味着即便是不存在的数据也会被存储在缓存中,这会占用额外的缓存空间。虽然这有助于防止缓存穿透,但对于存储大量无效查询的场景,这可能会导致不必要的资源浪费。
布隆过滤器
我们可以在写入数据的时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在。
这样即使发生了缓存穿透,大量请求只会查询Redis和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis自身也是支持布隆过滤器的。
布隆过滤器由一个固定大小的二进制数组(即初始值都为0的位图数组)和一系列哈希函数组成的。
当我们在写入数据库数据时,在布隆过滤器里做个标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中。
布隆过滤器会通过 3 个操作完成标记:
- 第一步,使用 N 个哈希函数分别对数据做哈希计算,得到 N 个哈希值;
- 第二步,将第一步得到的 N 个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置。
- 第三步,将每个哈希值在位图数组的对应位置的值设置为 1;
在数据库写入数据 x 后,把数据 x 标记在布隆过滤器时,数据 x 会被 3 个哈希函数分别计算出 3 个哈希值,然后在对这 3 个哈希值对 8 取模,假设取模的结果为 1、4、6,然后把位图数组的第 1、4、6 位置的值设置为 1。当应用要查询数据 x 是否数据库时,通过布隆过滤器只要查到位图数组的第 1、4、6 位置的值是否全为 1,只要有一个为 0,就认为数据 x 不在数据库中。
布隆过滤器是一种空间效率很高的数据结构,它用来测试一个元素是否在一个集合中。
布隆过滤器的两个关键特征:假阳性和零假阳性。
- 假阳性:当布隆过滤器说一个数据存在时,并不一定意味着这个数据是真的在数据库中存在,这是因为布隆过滤器是通过多个哈希函数来检测元素是否存在的,这些哈希函数将元素映射到一个位数组中的多个位置。如果一个元素的所有哈希函数位置都被设置了,布隆过滤器会认为该元素可能存在。由于不同元素的哈希位置可能会发生冲突(即不同元素的哈希函数指向同一位置),所以即使这个位置被设置了,也不能100%保证该元素确实存在于集合中,这种情况就是所谓的“假阳性”。
- 零假阴性(Zero False Negatives):相对地,当布隆过滤器说一个数据不存在时,可以肯定这个数据在数据库中一定不存在。这是因为如果数据确实存在,它的所有哈希函数映射的位置应该都已经被设置了。如果布隆过滤器检查这些位置中有任何一个没有被设置,它就会判断数据不存在。由于布隆过滤器在插入时不会产生错误,所以如果它说数据不存在,那么这个判断是准确的,不存在假阴性的情况。
简而言之,布隆过滤器能够确保如果它判断某数据不存在,那么这个数据确实不在集合中;但如果它判断某数据存在,这个判断可能是错误的,即可能出现假阳性错误。缓存空值可以作为布隆过滤器判断存在的兜底方案。
布隆过滤器的基本实现只支持添加(插入)和查询操作,不支持删除操作。(删除一个元素可能错误地影响到其他元素的安全性检查)。
使用guava中的布隆过滤器
- 添加Guava依赖:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>30.0-jre</version> <!-- 使用最新的版本 -->
</dependency>
- 创建布隆过滤器:
使用BloomFilter.create()静态方法创建一个布隆过滤器实例。你需要指定一个Funnel,它负责将你的元素类型映射到布隆过滤器的位数组中,以及预期插入的元素数量(n)和可接受的误报率(p)。
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
// 创建一个布隆过滤器,元素类型为String
int expectedInsertions = 1000; // 预期要插入多少元素
double falsePositiveProbability = 0.01; // 误报率
BloomFilter<String> filter = BloomFilter.create(
Funnels.stringFunnel(Charsets.UTF_8),
expectedInsertions,
falsePositiveProbability);
- 向过滤器中添加元素:
filter.put("element1");
filter.put("element2");
- 检查元素是否存在:
使用mightContain方法检查一个元素是否可能存在于布隆过滤器中。记住,布隆过滤器可能会产生假阳性。
if (filter.mightContain("someElement")) {
// 元素可能存在
} else {
// 元素绝对不存在
}
缺点:Guava提供的布隆过滤器是在单个JVM实例中使用的,这意味着它默认情况下并不支持分布式环境下使用。在单机环境中,布隆过滤器是存储在内存中的,其状态不会被自动同步到其他服务器或应用是利用。
使用Redisson中的布隆过滤器——底层是BitMap(在客户端实现的布隆过滤器)
- 添加Redisson依赖:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>最新版本号</version>
</dependency>
- 配置Redisson客户端:
首先,需要配置Redisson客户端以连接到Redis服务器
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);
- 获取布隆过滤器的实例:
然后,可以通过Redisson客户端获取布隆过滤器的实例:
import org.redisson.api.RBloomFilter;
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("sampleFilter");
- 初始化布隆过滤器:
在使用布隆过滤器之前,需要初始化它,指定预期插入的元素数量和误判率:
// 初始化布隆过滤器,预期插入1000个元素,误判率为0.03
bloomFilter.tryInit(1000L, 0.03);
- 添加元素:
bloomFilter.add("someElement");
- 检查元素是否存在:
boolean result = bloomFilter.contains("someElement");
if (result) {
System.out.println("元素可能存在");
} else {
System.out.println("元素不存在");
}
Redis服务端实现布隆过滤器
在Redis服务端实现布隆过滤器通常是**_通过使用Redis的模块,如RedisBloom,来实现的_**。这种方式允许直接在Redis服务端扩展新的数据结构和功能,包括布隆过滤器。与在客户端实现(例如Redisson)相比,服务端实现(如RedisBloom)可以更高效地利用Redis的性能,因为它减少了网络传输的开销,并直接在服务端利用Redis的内部机制。
使用RedisBloom模块实现布隆过滤器
- 安装RedisBloom
要在Redis服务端使用布隆过滤器,首先需要确保你的Redis服务器安装了RedisBloom模块。安装方法依赖于你的操作系统和Redis的配置。通常,你可以从RedisBloom的GitHub仓库下载预编译的模块或源码进行编译。
- 加载RedisBloom模块
安装RedisBloom模块后,需要在Redis配置文件中指定模块的加载。这可以通过向redis.conf文件添加如下行实现:
loadmodule /path/to/redisbloom.so
这里/path/to/redisbloom.so是RedisBloom模块文件的路径。
- 使用RedisBloom提供的命令
一旦安装并加载了RedisBloom模块,你就可以使用它提供的一系列命令来创建和管理布隆过滤器了。以下是一些基本命令:
- 创建布隆过滤器:使用BF.RESERVE命令创建一个新的布隆过滤器
BF.RESERVE {filterName} {errorRate} {capacity}
- 添加元素:使用使用BF.ADD命令向布隆过滤器中添加一个元素。
BF.ADD {filterName} {element}
- 判断元素是否存在:使用BF.EXISTS命令检查元素是否可能存在于布隆过滤器中。
BF.EXISTS {filterName} {element}
使用RedisBloom模块实现的服务端布隆过滤器有几个优点:
- 效率:在Redis服务端直接处理布隆过滤器相关的操作可以减少网络延迟,提高处理速度。
- 简化客户端:客户端不需要实现布隆过滤器的逻辑,只需通过简单的Redis命令与服务端交互。
- 可扩展性:利用Redis的分布式特性,可以轻松扩展布隆过滤器的规模和处理能力。
如何在RedisTemplate中使用java代码来操作服务端的布隆过滤器
在RedisTemplate中使用Java代码操作服务端的布隆过滤器,如RedisBloom,直接通过Redis命令可能不够直观,因为RedisTemplate和StringRedisTemplate并不直接支持RedisBloom的自定义命令。不过,你可以通过execute方法执行自定义的Redis脚本或命令,这样可以间接地使用RedisBloom的功能。(使用Lua脚本)
三、缓存雪崩
当大量缓存在同一时间过期(失效)或者Redi故障宕机时,如果此时有大量的用户请求,都无法在Redis中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统的崩溃。
可以看到,发生缓存雪崩有两个原因:
- 大量数据同时过期;
- Redis故障宕机;
不同的诱因,应对的策略也会不同。
四、解决缓存雪崩的接种方案
大量数据同时过期的解决方案
- 给不同key的TTL设置随机值
如果要给缓存设置过期时间,应该避免将大量的数据设置成同一个过期时间。我们可以在对缓存数据设置过期时间时,给这些数据的过期时间加上一个随机数,这样就保证数据不会在同一时间过期。
Redis故障宕机的解决方案
利用Redis集群,提高Redis的高可用性。
五、缓存击穿
如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,而是直接访问了数据库,数据库就很容易被高并发的请求冲垮,这就是缓存击穿的问题。
缓存击穿跟缓存雪崩很相似,可以认为缓存击穿是缓存雪崩的一个子集。
补充:
- 热点key:经常被大量用户访问的key。
- 数据预热:这一类数据,我们一般会提前将它们放到Redis中,这一步叫数据的预热。
六、缓存击穿的几种解决方案
互斥锁方案
互斥锁方案:保证同一时间只有一个线程重建缓存,此时该线程获得一把互斥锁,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或默认值;
Redis实现的互斥锁和分布式锁在技术实现上基本相同,主要区别在于它们被应用的上下文和目的。
逻辑过期方案
对于每个key,除了在缓存中设置一个物理过期时间,还可以额外设置一个逻辑过期时间。当访问一个key时,即使物理过期时间到了,只要逻辑过期时间没到,就先返回旧值,并异步更新新值到缓存中。这样既保证了缓存的数据最终一致性,又避免了数据库的压力。