7.1 缓存雪崩
7.1.1 发生
Redis主机挂了,Redis全盘崩溃;比如缓存中有大量数据同时过期。
7.1.2 解决
Redis缓存集群实现高可用
- 主从 + 哨兵
- Redis Cluster
ehcache本地缓存 + Sentinel限流 & 降级
开启Redis持久化机制AOF/RDB,尽快恢复缓存集群
7.2 缓存穿透
7.2.1 是什么
请求去查询一条记录,先redis后mysql,发现都查询不到该条记录,但是请求每次都会打到数据库上面去,导致后台数据库压力暴增,这种现象我们称为缓存穿透,这个redis变成了一个摆设。。。
简单说就是:本来无一物,既不在Redis缓存中,也不在数据库中。
7.2.2 危害
第一次来查询后,一般我们有回写redis机制。第二次来查的时候,redis就有了,偶尔出现穿透现象一般情况无关紧要。
7.2.3 解决
缓存穿透 | 恶意攻击 | 空对象缓存、BloomFilter |
---|
1)方案1:空对象缓存或者缺省值
一般情况下:一旦发生缓存穿透,我们就可以针对查询的数据,在Redis中缓存一个控制或是和业务层协商确定的缺省值(例如,库存的缺省值可以设为0)。紧接着,应于发送的后续请求再进行查询时,就可以直接从Redis中读取控制或缺省值,返回给业务应用了,避免了把大量请求发送给数据库处理,保持了数据库的正常运行。
但是,遇到黑客或者恶意攻击?
黑客会对你的系统进行攻击,哪一个不存在的id去查询数据,会产生大量的请求到数据库取查询。
可能会导致你的数据库由于压力过大而宕机。
id相同打你系统:第一次打到mysql,空对象缓存后第二次就返回null了,避免mysql被攻击,不用再到数据库中去走一圈了。
id不相同打你系统:由于存在空对象缓存和缓存回写,redis中的无关紧要的key也会越写越多(记得设置redis过期时间)。
2)方案2:Google布隆过滤器Guava解决缓存穿透【一般用于单机版】
Guava中布隆过滤器的实现算是比较权威的,所以实际项目中我们不需要手动实现一个布隆过滤器。
代码实现:
-
建Module:bloomfilter-demo
-
改pom
<dependencies> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>30.1-jre</version> </dependency> <dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.15.3</version> </dependency>
-
写yaml
-
主启动
-
业务类
public class GuavaBloomFilterDemo { public static final int _1w = 10000; //布隆过滤器里预计要插入多少数据 public static int size = 100 * _1w; //误判率,它越小误判的个数也就越少(思考:是不是可以设置为无限小,没有误判岂不更好?) public static double fpp = 0.03; /** * hello world入门 */ public void bloomFilter(){ //创建布隆过滤器对象 BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(),100); //判断指定元素是否存在 System.out.println(bloomFilter.mightContain(1)); //false System.out.println(bloomFilter.mightContain(2)); //将元素添加进布隆过滤器 bloomFilter.put(1); bloomFilter.put(2); System.out.println(bloomFilter.mightContain(1)); //true System.out.println(bloomFilter.mightContain(2)); } /** * 误判率演示 + 源码分析 */ public void bloomFilter2(){ //创建布隆过滤器对象 BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(),size); //1.先往布隆过滤器里面插入100w样本数据 for (int i = 0; i < size; i++) { bloomFilter.put(i); } List<Integer> listSample = new ArrayList<>(size); //2.这100w的样本数据,是否都在布隆过滤器里面存在? 是的 for (int i = 0; i < size; i++) { if (bloomFilter.mightContain(i)){ listSample.add(i); } } System.out.println("存在的数量:"+listSample.size()); //3.故意取10w个不在过滤器里面的值,看看有多少个会被认为在过滤器里,误判率演示 List<Integer> list = new ArrayList<>(10*_1w); //100000 / 3033 = 0.03(误判率) for (int i = size; i < size+100000; i++) { if (bloomFilter.mightContain(i)) { System.out.println(i+"--->被误判啦……"); list.add(i); } } System.out.println("误判的数量:"+list.size()); // 误判的数量:3033 } }
-
源码分析:
//创建布隆过滤器对象 BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(),size); ---> //expectedInsertions: public static <T> BloomFilter<T> create(Funnel<? super T> funnel, int expectedInsertions) { return create(funnel, (long) expectedInsertions); } ---> public static <T> BloomFilter<T> create(Funnel<? super T> funnel, long expectedInsertions) { return create(funnel, expectedInsertions, 0.03); // FYI, for 3%, we always get 5 hash functions }
默认误判率:0.03
1百万的数据底层用来
7298440
个bit数组存储
-
布隆过滤器说明
//加了一个误判率:0.01 ========> 需要的bit位(numBits):9585058,numHashFunctions:7 BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(),size,0.01); //误判的数量:947 //继续改小,这样写可以,但是程序执行效率急剧下降 BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(),size,0.000000000001); //误判的数量:0
3)方案3:Redis布隆过滤器解决缓存穿透
Guava缺点:Guava提供的布隆过滤器的实现还是很不错的,但是它有一个重大的缺陷就是只能单机使用,而现在互联网一般都是分布式的场景。为了解决这个问题,我们就需要用到Redis中的布隆过滤器了。
白名单过滤器:
误判问题,但是概率小可以接受,不能从布隆过滤器删除。
全部合法的key都需要放入过滤器 + redis 里面,不然数据就是返回null。
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.15.3</version>
</dependency>
public class RedissonBloomFilterDemo {
public static final int _1w = 10000;
//布隆过滤器里预计要插入多少数据
public static int size = 100 * _1w;
//误判率,它越小误判的个数也就越少(思考:是不是可以设置为无限小,没有误判岂不更好?)
public static double fpp = 0.03;
static RedissonClient redisClient = null; // <==>jedis
static RBloomFilter rbloomFilter = null; // redis内置的布隆过滤器
@Autowired
RedisTemplate redisTemplate;
static {
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.123.133:6379").setDatabase(0);
//构造redisson
redisClient = Redisson.create(config);
//通过redisson构造BloomFilter
rbloomFilter = redisClient.getBloomFilter("phoneListBloomFilter", new StringCodec());
rbloomFilter.tryInit(size, fpp);
//1.测试 布隆过滤器有 + redis有
// rbloomFilter.add("10086");
// redisClient.getBucket("10086", new StringCodec()).set("chinamobile10086");
//2.测试 布隆过滤器有 + redis无
// rbloomFilter.add("10087");
//3.测试 布隆过滤器无 + redis无
}
public static String getPhoneListById(String IdNumber) {
String result = null;
if (IdNumber == null) {
return null;
}
//1.先去布隆过滤器里面查询
if (rbloomFilter.contains(IdNumber)) {
//2.布隆过滤器里面有,再去redis里面查询
RBucket<String> rBucket = redisClient.getBucket(IdNumber, new StringCodec());
result = rBucket.get();
if (result != null) {
return "i com from redis:" + result;
} else {
result = getPhoneListByMySQL(IdNumber);
if (result == null) {
return null;
}
//重新将数据更新回redis
redisClient.getBucket(IdNumber, new StringCodec()).set(result);
}
return "i come from mysql:" + result;
}
return result;
}
public static String getPhoneListByMySQL(String IdNumber) {
return "chinamobile" + IdNumber;
}
public static void main(String[] args) {
String phoneListById = getPhoneListById("10088");
System.out.println("---查询出来的结果:" + phoneListById);
//暂停几秒钟线程
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
redisClient.shutdown();
}
}
重要总结:
4)作业:黑名单的使用
7.2.4 CentOS7下布隆过滤器两种安装方式
1)采用docker安装RedisBloom
Redis在4.0之后有了插件功能(Module),可以使用外部的扩展功能,可以使用RedisBloom作为Redis布隆过滤器插件。
docker run -p 6379:6379 --name redis6379bloom -d redislabs/rebloom
docker exec -it redis6379bloom /bin/bash
redis-cli
常用命令:
bf.reserve key error_rate的值 inital_size的值 #默认的error+rate是0.01,默认的initial_size是100
bf.add key 值
bf.exists key 值
bf.madd 一次添加多个元素
bf.mexists 一次查询多个元素是否存在
2)编译安装
7.3 缓存击穿
7.3.1 是什么
大量的请求同时查询一个key时,此时这个key正好失效了,就会导致大量的请求都打到数据库上面去。
简单说就是热点key突然失效了,暴打MySQL。
7.3.2 危害
会造成某一时刻数据库请求量过大,压力剧增。
7.3.3 解决
缓存击穿 | 热点key失效 | 互斥更新、随机退避、差异失效时间 |
---|---|---|
方案1:对于访问频繁的热点key,干脆就不设置过期时间
方案2:互斥独占锁防止击穿。
-
多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个互斥锁来锁住它。
-
其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。
public String get(String key){ String value = redis.get(key); //查询缓存 if(value != null){ //缓存存在直接返回 return value; }else{ //缓存不存在则对方加锁 //假设请求量很大,缓存过期 synchronized(TestFurure.class){ values = redis.get(key); //再查一遍redis if(value != null){ //查到数据直接返回 return value; }else{ //第二次查询缓存也不存在,直接查DB value = dao.get(key); //数据缓存 redis.setnx(key,value,time); //返回 return value; } } } }
7.3.4 案例:聚划算功能实现+防止缓存击穿
1)分析过程
步骤 | 说明 |
---|---|
1 | 100%高并发,绝对不可以用MySQL实现 |
2 | 先把MySQL里面参加活动的数据抽取进Redis,一般采用定时器 扫描来决定上线活动还是下线取消 |
3 | 支持分页功能,一页20条记录 |
请大家思考,redis里面什么样子的数据类型支持上述功能? —— list(zset主要做排行榜) |
2)代码解析
/**
* 聚划算项目 —— 商品
* <p>
* 分布式定时工具,是什么? —— 'xxl-job'
*
* @author fy
* @date 2022/11/8 13:57
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Product {
private Long id;
private String name;
private Integer price;
private String detail;
}
@Service
@Slf4j
public class JHSTaskService {
@Autowired
private RedisTemplate redisTemplate;
@PostConstruct
public void initJHS() {
log.info("启动定时器淘宝聚划算功能模拟......" + DateUtil.now());
new Thread(() -> {
//模拟定时器,定时把数据库的特价商品,刷新到redis中
while (true) {
//模拟从数据库读取100件商品,用于加载到聚划算的页面中
List<Product> list = this.products();
//采用redis list数据结构的push来实现存储
redisTemplate.delete("jhs");
//lpush命令
redisTemplate.opsForList().leftPushAll("jhs", list);
//间隔一分钟 执行一遍
try {
TimeUnit.MINUTES.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.info("run jhs定时刷新.........");
}
}, "t1").start();
}
public List<Product> products() {
List<Product> list = new ArrayList<>();
for (int i = 1; i <= 20; i++) {
Random random = new Random();
int id = random.nextInt(10000);
Product obj = new Product((long) id, "product" + i, i, "detail");
list.add(obj);
}
return list;
}
}
@RestController
@Slf4j
public class JHSProductController {
@Autowired
private RedisTemplate redisTemplate;
/**
* 分页查询:在高兴发情况下,只能走redis查询,走db的话必定会把db打垮
*
* @param page
* @param size
* @return
*/
@GetMapping("/product/find")
public List<Product> find(int page, int size) {
List<Product> list = null;
long start = (page - 1) * size;
long end = start + size - 1;
try {
//采用redis list数据结构的lrange命令实现分页查询
list = redisTemplate.opsForList().range("jhs", start, end);
if (CollectionUtils.isEmpty(list)) {
// TODO 走DB查询
}
log.info("查询结果:{}", list);
} catch (Exception e) {
//这里的异常,一般是redis瘫痪,或redis网络超时
log.error("exception:",e);
//TODO 走DB查询
}
return list;
}
}
3)Bug说明
上述聚划算的功能算是完成,请思考在高并发下有什么经典生产问题?
QPS上1000后导致可怕的缓存击穿~
4)定时轮询,互斥更新,差异失效时间
@PostConstruct
public void initJHS() {
log.info("启动AB定时器淘宝聚划算功能模拟......" + DateUtil.now());
new Thread(() -> {
//模拟定时器,定时把数据库的特价商品,刷新到redis中
while (true) {
//模拟从数据库读取100件商品,用于加载到聚划算的页面中
List<Product> list = this.products();
/**
* 互斥更新、随机退避、差异失效时间
*/
//先更新B缓存
redisTemplate.delete("jhs-B");
redisTemplate.opsForList().leftPushAll("jhs-B",list);
redisTemplate.expire("jhs-B",20L,TimeUnit.DAYS);
//再更新A缓存
redisTemplate.delete("jhs-A");
redisTemplate.opsForList().leftPushAll("jhs-A",list);
redisTemplate.expire("jhs-A",15L,TimeUnit.DAYS);
//间隔一分钟 执行一遍
try {
TimeUnit.MINUTES.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.info("run jhs定时刷新.........");
}
}, "t1").start();
}
@GetMapping("/product/findPlus")
public List<Product> findPlus(int page, int size) {
List<Product> list = null;
long start = (page - 1) * size;
long end = start + size - 1;
try {
//采用redis list数据结构的lrange命令实现分页查询
list = redisTemplate.opsForList().range("jhs-A", start, end);
if (CollectionUtils.isEmpty(list)) {
log.info("==========A缓存已经失效了,记得人工修补,B缓存自动延续5天");
//用户先查询缓存A,如果缓存A查询不到,再查询缓存B
list = redisTemplate.opsForList().range("jhs-B", start, end);
}
log.info("查询结果:{}", list);
} catch (Exception e) {
//这里的异常,一般是redis瘫痪,或redis网络超时
log.error("exception:",e);
//TODO 走DB查询
}
return list;
}
用lru脚本也可以,这只是换一种解决方案。
另一种方案:服务降级,提前关闭入口,预热下一波数据。(客户买单)