缓存雪崩
1.什么是缓存雪崩?
缓存在同一时间大面积失效或者Redis集群宕机,大量请求都会落到数据库上,造成数据库短时间内承受大量请求而崩掉。
2.解决方案
- 事前:尽量保证整个 redis 集群的高可用性,发现机器宕机尽快补上。选择合适的内存淘汰策略;
- 事中:本地ehcache缓存 + hystrix限流&降级,避免MySQL崩掉;
- 事后:利用 redis 持久化机制保存的数据尽快恢复缓存;
2.1 事前-Redis删除机制
定期删除+惰性删除
- 定期删除:redis默认是每隔 100ms 就随机抽取一些设置了过期时间的key,检查其是否过期,如果过期就删除。注意这里是随机抽取的。为什么要随机呢?你想一想假如 redis 存了几十万个 key ,每隔100ms就遍历所有的设置过期时间的 key 的话,就会给 CPU 带来很大的负载!
- 惰性删除 :定期删除可能会导致很多过期 key 到了时间并没有被删除掉。所以就有了惰性删除。假如你的过期 key,靠定期删除没有被删除掉,还停留在内存里,除非你的系统去查一下那个 key,才会被redis给删除掉。这就是所谓的惰性删除。
但是仅仅通过设置过期时间还是有问题的,我们想一下:如果定期删除漏掉了很多过期key,然后你也没及时去查,也就没走惰性删除,此时会怎么样?如果大量过期key堆积在内存里,导致redis内存快耗尽了,怎么解决这个问题呢?我们需要选择合适的 redis 内存淘汰机制。
2.2 事前-Redis内存淘汰机制
redis 配置文件 redis.conf 中有相关注释:http://download.redis.io/redis-stable/redis.conf
redis 提供 6种数据淘汰策略,4.0版本后增加了两种:
- volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰;
- volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰;
- volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰;
- allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key(常用);
- allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰;
- no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错 (谨慎使用);
- volatile-lfu:从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰;
- allkeys-lfu:当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的key;
2.3 事中-限流
2.4 事后-恢复缓存
1. 快照(snapshotting)持久化
Redis可以通过创建快照来获得存储在内存里面的数据在某个时间点上的副本。Redis创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis主从结构,主要用来提高Redis性能),还可以将快照留在原地以便重启服务器的时候使用。
快照持久化是Redis默认采用的持久化方式,在redis.conf配置文件中默认有此下配置:
save 900 1 #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
save 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
save 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
2. AOF(append-only file)持久化
与快照持久化相比,AOF持久化 的实时性更好,因此已成为主流的持久化方案。默认情况下Redis没有开启AOF(append only file)方式的持久化,可以通过appendonly
参数开启:
appendonly yes
开启AOF持久化后每执行一条会更改Redis中的数据的命令,Redis就会将该命令写入硬盘中的AOF文件。AOF文件的保存位置和RDB文件的位置相同,都是通过dir参数设置的,默认的文件名是appendonly.aof
。
在Redis的配置文件中存在三种不同的 AOF 持久化方式,它们分别是:
appendfsync always #每次有数据修改发生时都会写入AOF文件,这样会严重降低Redis的速度
appendfsync everysec #每秒钟同步一次,显示地将多个写命令同步到硬盘
appendfsync no #让操作系统决定何时进行同步
为了兼顾数据和写入性能,用户可以考虑 appendfsync everysec选项 ,让Redis每秒同步一次AOF文件,Redis性能几乎没受到任何影响。而且这样即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据。当硬盘忙于执行写入操作的时候,Redis还会优雅的放慢自己的速度以便适应硬盘的最大写入速度。
3. Redis 4.0 对于持久化机制的优化
Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble
开启)。
如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据。当然缺点也是有的, AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。
4. 补充内容:AOF 重写
AOF重写可以产生一个新的AOF文件,这个新的AOF文件和原有的AOF文件所保存的数据库状态一样,但体积更小。
AOF重写是一个有歧义的名字,该功能是通过读取数据库中的键值对来实现的,程序无须对现有AOF文件进行任何读入、分析或者写入操作。
在执行 BGREWRITEAOF 命令时,Redis 服务器会维护一个 AOF 重写缓冲区,该缓冲区会在子进程创建新AOF文件期间,记录服务器执行的所有写命令。当子进程完成创建新AOF文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新AOF文件的末尾,使得新旧两个AOF文件所保存的数据库状态一致。最后,服务器用新的AOF文件替换旧的AOF文件,以此来完成AOF文件重写操作。
缓存穿透
1.什么是缓存穿透?
缓存穿透说简单点就是大量请求的 key 根本不存在于缓存中,导致请求直接到了数据库上,根本没有经过缓存这一层。
举个例子:某个黑客故意制造我们缓存中不存在的 key 发起大量请求,导致大量请求落到数据库。下面用图片展示一下(这两张图片不是我画的,为了省事直接在网上找的,这里说明一下):
正常缓存处理流程:
缓存穿透情况处理流程:
一般MySQL 默认的最大连接数在 150 左右,这个可以通过
show variables like '%max_connections%'
;命令来查看。最大连接数一个还只是一个指标,cpu,内存,磁盘,网络等无力条件都是其运行指标,这些指标都会限制其并发能力!所以,一般 3000 个并发请求就能打死大部分数据库了。
2.解决方案
- 参数校验;
- 缓存无效key;
- 布隆过滤器;
2.1 参数校验
做好参数校验,一些不合法的参数请求直接抛出异常信息返回给客户端。比如查询的数据库 id 不能小于 0、传入的邮箱格式不对的时候直接返回错误消息给客户端等等。
2.2 缓存无效key
如果缓存和数据库都查不到某个 key 的数据就写一个到 redis 中去,并设置过期时间,具体命令如下:
SET key value EX 10086
。
这种方式可以解决请求的 key 变化不频繁的情况,但是如果黑客恶意攻击,每次构建的不同的请求key,会导致 redis 中缓存大量无效的 key 。很明显,这种方案并不能从根本上解决此问题。
如果非要用这种方式来解决穿透问题的话,尽量将无效的 key 的过期时间设置短一点比如 1 分钟。
一般情况下我们是这样设计 key 的: 表名_列名_主键名_主键值。
// 伪代码
public Object getObjectInclNullById(Integer id) {
// 从缓存中获取数据
Object cacheValue = cache.get(id);
// 缓存为空
if (cacheValue == null) {
// 从数据库中获取
Object storageValue = storage.get(key);
// 缓存对象
cache.set(key, storageValue);
if (storageValue == null) {
// 如果存储数据为空,需要设置一个过期时间(60秒)
cache.expire(key, 60);
}else{
// 如果存储数据不为空,需要设置一个过期时间(1小时)
cache.expire(key, 60 * 60);
}
return storageValue;
}
return cacheValue;
}
2.3 布隆过滤器
布隆过滤器是一个非常神奇的数据结构,通过它我们可以非常方便地判断一个给定数据是否存在与海量数据中。
我们需要的就是判断 key 是否合法。具体是这样做的:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,我会先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程。总结一下就是下面这张图(这张图片不是我画的,为了省事直接在网上找的):
布隆过滤器介绍:
布隆过滤器的原理我就不说了,网上介绍的很详细。
布隆过滤器判断某个元素存在,小概率会误判。也就是说布隆过滤器说某个元素不在,那么这个元素一定不在;布隆过滤器说某个元素存在,这个元素可能不存在。
自定义布隆过滤器
/**
* fshows.com
* Copyright (C) 2013-2019 All Rights Reserved.
*/
package com.example.redisdemo.config.filter;
import java.util.BitSet;
/**
* @author liuyuan
* @version MyBloomFilter.java, v 0.1 2019-12-18 16:28
*/
public class MyBloomFilter {
/**
* 位数组的大小
*/
private static final int DEFAULT_SIZE = 2 << 24;
/**
* 通过这个数组可以创建 6 个不同的哈希函数
*/
private static final int[] SEEDS = new int[]{3, 13, 46, 71, 91, 134};
/**
* 位数组。数组中的元素只能是 0 或者 1
*/
private BitSet bits = new BitSet(DEFAULT_SIZE);
/**
* 存放包含 hash 函数的类的数组
*/
private SimpleHash[] func = new SimpleHash[SEEDS.length];
/**
* 初始化多个包含 hash 函数的类的数组,每个类中的 hash 函数都不一样
*/
public MyBloomFilter() {
// 初始化多个不同的 Hash 函数
for (int i = 0; i < SEEDS.length; i++) {
func[i] = new SimpleHash(DEFAULT_SIZE, SEEDS[i]);
}
}
/**
* 添加元素到位数组
*/
public void add(Object value) {
for (SimpleHash f : func) {
bits.set(f.hash(value), true);
}
}
/**
* 判断指定元素是否存在于位数组
*/
public boolean contains(Object value) {
boolean ret = true;
for (SimpleHash f : func) {
ret = ret && bits.get(f.hash(value));
}
return ret;
}
/**
* 静态内部类。用于 hash 操作!
*/
public static class SimpleHash {
private int cap;
private int seed;
public SimpleHash(int cap, int seed) {
this.cap = cap;
this.seed = seed;
}
/**
* 计算 hash 值
*/
public int hash(Object value) {
int h;
return (value == null) ? 0 : Math.abs(seed * (cap - 1) & ((h = value.hashCode()) ^ (h >>> 16)));
}
}
}
测试:
/**
* 自定义过滤器
*/
@Test
public void myBloomFilterTest() {
MyBloomFilter filter = new MyBloomFilter();
//插入数据
for (int i = 0; i < 10; i++) {
filter.add(i);
}
for (int i = 1; i < 11; i++) {
if (filter.contains(i)) {
System.out.println(i + "存在");
} else {
System.out.println(i + "不存在");
}
}
}
结果:
利用Google开源的 Guava中自带的布隆过滤器
依赖:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>28.0-jre</version>
</dependency>
测试:
private static int SIZE = 1000000;//预计要插入多少数据
private static double FPP = 0.001;//期望的误判率
private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), SIZE, FPP);
/**
* guava实现布隆过滤器
*/
@Test
public void googleBloomFilterTest() {
//插入数据
for (int i = 0; i < 1000000; i++) {
bloomFilter.put(i);
}
int count = 0;
for (int i = 1000000; i < 2000000; i++) {
if (bloomFilter.mightContain(i)) {
count++;
// System.out.println(i + "误判了");
}
}
System.out.println("总共的误判数:" + count);
}
结果:
一共是1000000条数据,误判994条,误判率为0.000994,约等于0.001,基本和我们设置的误判率相等。
Redis 中的布隆过滤器
我还没有学会~~~