一、缓存穿透
(一)缓存穿透概念
缓存穿透,是指查询一个数据库一定不存在的数据。
正常的使用缓存流程大致是,数据查询先进行缓存查询,如果key不存在或者key已经过期,再对数据库进行查询,并把查询到的对象,放进缓存。如果数据库查询对象为空,则不放进缓存。
查询一个根部不存在的Key, 必然就会每次都去查询数据库,而每次查询都是空,每次又都不会进行缓存。假如有恶意攻击,就可以利用这个漏洞,对数据库造成压力,甚至压垮数据库。
代码流程如下:
点击这里查看redisutils https://blog.csdn.net/HBliucheng/article/details/112614392
public class redisServiceImpl {
@Value("${userId}")
private String userId;
@Autowired
private RedisUtils redisUtils;
public String getById(int id) {
/*
* 1.参数传入对象主键ID
2.根据redisKey从缓存中获取对象
3.如果缓存对象不为空,直接返回
4.如果缓存对象为空,进行数据库查询
5.如果从数据库查询出的对象不为空,则放入缓存
* */
// 根据id构建redis主键ID
String redisKey = userId + id;
// 根据redisKey从缓存中获取对象的name值
String redisValue = (String) redisUtils.get(redisKey);
// 如果缓存对象不为空,直接返回
if(null!=redisValue){
//
return redisValue;
}
// 如果缓存中未查询到值,查询数据库,然后放入缓存中
// 枚举类MysqlData 模拟从mysql中查询数据
String name = MysqlData.getById(id);
// 如果数据库中查询到的值不为空则存入redis中,返回查询到的数据
if(null!=name){
redisUtils.set(redisKey,name);
return name;
}
return null;
}
}
(二)解决方案
1,缓存空对象
当存储层不命中后,即使返回的空对象也将其存储起来,同事设置一个过期时间,之后再访问这个数据将会从缓存中获取,保护了后端数据源
解决案例代码
@Service
public class redisServiceImpl {
@Value("${userId}")
private String userId;
@Autowired
private RedisUtils redisUtils;
public String getById(int id) {
/*
* 1.参数传入对象主键ID
2.根据redisKey从缓存中获取对象
3.如果缓存对象不为空,直接返回
4.如果缓存对象为空,进行数据库查询
5.如果从数据库查询出的对象不为空,则放入缓存
* */
// 根据id构建redis主键ID
String redisKey = userId + id;
// 根据redisKey从缓存中获取对象的name值
String redisValue = (String) redisUtils.get(redisKey);
// 如果缓存对象不为空,直接返回
if(null!=redisValue){
//
return redisValue;
}
// 如果缓存中未查询到值,查询数据库,然后放入缓存中
// 枚举类MysqlData 模拟从mysql中查询数据
String name = MysqlData.getById(id);
// 空值也保存到缓存中并设置过期时间
redisUtils.setEx(redisKey,name,3000, TimeUnit.MICROSECONDS);
return name;
}
}
但是这中方法会存在两个问题:
1,如果空值能够被缓存起来,这就意味着缓存需要更多的空间存储更过的键,因为这当中可能会有很多的空值的键
2,即使对空值设置了过期时间,还是会存在缓存层和存储层的数据会有一段时间窗口的不一致,这对需要保存一致性的业务会有影响
2,布隆过滤器
(1)布隆过滤器原理以及介绍
(2)代码实现
a,布隆过滤器测试(谷歌)
pom文件
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>29.0-jre</version>
</dependency>
1,测试数据不存在时
package com.example.redisdemo;
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
/**
* 布隆滤器测试
* @author LiuCheng
* @data 2021/1/26 11:21
*/
@SpringBootTest
public class BloomFilterTest {
/**
* 预计要插入多少数据
*/
private static int size = 1000000; //一百万
/**
* 误判率
*/
private static double fpp = 0.001;
/**
* 布隆过滤器
*/
private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), size, fpp);
@Test
public void test1(){
// 插入一百万样本数据
for (int i = 0; i < size; i++) { // 从 0 ~ 1000000 (不包含1000000)
bloomFilter.put(i);
}
// 用另外 十万测试数据,测试误判率
int count = 0;
for (int i = size; i < size + 100000; i++) { // 1000000 ~ 1100000 (不包含1100000)
if ( bloomFilter.mightContain(i) ) {
count++;
System.out.println(i + "误判了");
}
}
System.out.println("总共的误判数:" + count);
}
}
测试结果
误判数据和fpp 参数有关
2,测试数据存在时
@Test
public void test2(){
// 插入样本数据
for (int i = 0; i <size ; i++) {
bloomFilter.put(i);
}
// 记数
int count=0;
for (int i = 0; i <size ; i++) {
// 本来存在的数据 判断不会有误差
if(bloomFilter.mightContain(i)){
count++;
}
}
System.out.println("总次数 " +count);
}
测试结果
b,Redis整合布隆过滤器
pom文件引入GA坐标
<!-- Redis 客户端工具 redisson 实现对 Redisson 的自动化配置-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.13.4</version>
</dependency>
yml
spring:
redis:
host: 192.168.119.3
port: 6379
# connect-timeout: 3000
userId: "userId:"
1,redis布隆过滤器测试
package com.example.redisdemo;
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import org.junit.jupiter.api.Test;
import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.redisson.config.TransportMode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Redis布隆滤器测试
*
* @author LiuCheng
* @data 2021/1/26 11:21
*/
@SpringBootTest
public class RedisBloomFilterTest {
@Value("${spring.redis.host}")
private String redistHost;
@Value("${spring.redis.port}")
private Integer redistPort;
/**
* 预计要插入多少数据
*/
private static int size = 10000; //一万
/**
* 误判率
*/
private static double fpp = 0.001;
/**
* 布隆过滤器
*/
@Test
public void test1() {
String redisAddress = "redis://" + redistHost + ":" + redistPort;
Config config = new Config();
config.useSingleServer().setAddress(redisAddress);
RedissonClient redissonClient = Redisson.create(config);
RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("sample");
bloomFilter.tryInit(size, fpp);
// 插入数据
for (int i = 0; i < size; i++) {
bloomFilter.add(String.valueOf(i));
}
// 用1000条数据测试
int count = 0;
for (int i = size; i < size + 1000; i++) {
if (bloomFilter.contains(String.valueOf(i))) {
count++;
System.out.println("误判了 " + count);
}
}
System.out.println("一起误判了 " + count);
int sure = 0;
for (int i = 0; i < size; i++) {
if (bloomFilter.contains(String.valueOf(i))) {
sure++;
}
}
System.out.println("总次数 " + sure);
redissonClient.shutdown();
}
}
测试结果
2,redis布隆过滤器实战案例
config类,用于初始化布隆过滤器用
package com.example.redisdemo.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.redisson.config.TransportMode;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* @author LiuCheng
* @data 2021/1/14 14:25
*/
@Configuration
public class RedisConfig {
/**
* 预计要插入多少数据
*/
private static int size = 100000; // 十万
/**
* 期望的误判率
*/
private static double fpp = 0.001;
@Value("${spring.redis.host}")
private String redistHost;
@Value("${spring.redis.port}")
private Integer redistPort;
@Bean
public RBloomFilter redisBloomFilter(){
String redissAddress="redis://"+redistHost+":"+redistPort;
Config config = new Config();
// config.setTransportMode(TransportMode.EPOLL);
config.useSingleServer().setAddress(redissAddress);
RedissonClient redissonClient = Redisson.create(config);
RBloomFilter<Object> bloomFilter = redissonClient.getBloomFilter("userInfo");
bloomFilter.tryInit(size,fpp);
return bloomFilter;
}
}
数据存储层,相当于mysql数据库,简单点来写了个枚举
package com.example.redisdemo.data;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
/**
* mysql数据库
* redis数据库还未安装,先用枚举模拟数据库的值
* @author LiuCheng
* @data 2021/1/26 9:52
*/
@Getter
@AllArgsConstructor
@Slf4j
public enum MysqlData {
ZS(1001,"张三"),
LI(1002,"李四"),
WW(1003,"王五"),
ZL(1004,"赵六")
;
private int id;
private String name;
// 模拟dao层获取数据
public static String getById(int id){
// 只为记录是操作数据库,在mybatis中这里会打印sql
log.info("操作数据库,查询条件 " +id);
MysqlData[] mysqlDatas=values();
for (MysqlData mysqlData:mysqlDatas) {
if(mysqlData.getId()==id){
return mysqlData.getName();
}
}
return null;
}
}
布隆过滤器初始化,将数据库存在的数据放入缓存中,实战中请查询数据库初始化
package com.example.redisdemo.common;
import com.alibaba.fastjson.JSONObject;
import com.example.redisdemo.data.MysqlData;
import org.redisson.api.RBloomFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
/**
*初始化, 将所有的 redisKey "放" 入布隆过滤器中
* @author LiuCheng
* @data 2021/1/26 17:05
*/
@Component
public class RBloomFilterDataInit {
@Value("${userId}")
private String USERID_PREIFX;
@Autowired
private RBloomFilter rBloomFilter;
@PostConstruct
public void init(){
MysqlData[] mysqlDatas=MysqlData.values();
for (MysqlData mysqldata :mysqlDatas ) {
String redisKey=USERID_PREIFX +mysqldata.getId();
rBloomFilter.add(redisKey);
}
}
}
业务层
package com.example.redisdemo.service;
import com.example.redisdemo.data.MysqlData;
import com.example.redisdemo.utils.RedisUtils;
import org.redisson.api.RBloomFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* redisService层
*
* @author LiuCheng
* @data 2021/1/26 9:50
*/
@Service
@Slf4j
public class RedisServiceImpl {
@Value("${userId}")
private String USERID_PREIFX;
@Autowired
private RedisUtils redisUtils;
@Autowired
private RBloomFilter rBloomFilter;
public String bloomFilterGetById(int id) {
log.info("查询数据 " + id);
String redisKey = USERID_PREIFX + id;
/* 1.如果这个 key 本来存在的 一定不会有判断失误
2.如果这 key 本来不存在的 可能会判断失误
注意这里 把本来就不存在的 key 判断失误 ( 判断成了 存在 )
最多也就是多查一次数据库而已, 没有什么大问题, 失误率可以自己设置, 设置小一点 如 0.001
就可以挡住大部分的请求
*/
// 如果存在
if(rBloomFilter.contains(redisKey)){
String redisVal= (String)redisUtils.get(redisKey);
// 如果查询到了 直接返回
if(null!=redisVal){
return redisVal;
}
// 如果未查询到,则从数据库查询
String name = MysqlData.getById(id);
// 如果数据库查询到了,则存入缓存中
if(null!=name){
redisUtils.set(redisKey,name);
return name;
}
}
return null;
}
}
测试结果
查询 1001数据,由于redis缓存中没有存入1001数据,所以第一次会走一次数据库,后就不会走数据,直接从缓存中查询。查询1006的数据,由于布隆过滤器的作用,不会走数据库查询直接返回空值
(3)总结
a,布隆过滤器判断数据存在时 准确率100%
b,布隆过滤器判断数据不存在时 准确率不是100%, 也就是说存在误差, 不准确, 由过滤器的误判率 fpp设置调节
c,布隆过滤器 可以判断某样东西 一定不存在 或者
布隆过滤器 可以判断某样东西 可能存在
(4)应用场景
判断一个元素是否存在一个集合中
a,检查一个用户是否在白名单中
b,在 FBI,一个嫌疑人的名字是否已经在嫌疑名单上
c,在网络爬虫里,一个网址是否被访问过
d,yahoo, gmail等邮箱垃圾邮件过滤功能
二、缓存雪崩
(一)缓存雪崩概念
缓存雪崩,是指在某一个时间段,缓存集中过期, 集中失效(可能是物理原因)。
举例:
1 马上就要到双十一零点( 00:00 ),很快就会迎来一波抢购,大量的并发访问, 这波热门商品时间比较集中的放入了缓存(可以理解为统一时间并发的) , 假设缓存是一个小时。那么到了凌晨一点钟 ( 01:00 ) 的时候,这批商品的缓存就都过期了。也就是缓存集中过期, 集中失效. 而再对这批商品的访问查询,就都落到了数据库上,对于数据库而言,就会产生周期性的压力波峰。
2 系统在某一个大量并发访问的时候, 突然缓存服务器宕机, 缓存突然全部丢失, 全部失效 (物理原因) 此时大量的缓存失效导致, 大量并发请求到达数据库DB,
给数据库造成压力冲击, 这就是缓存雪崩.
(二)缓存雪崩解决方法
1,时间失效
对应举例1中的情况
a, 数据预热,在正式部署之前,先把可能的数据预先访问一遍,这样部分可能大量访问的数据就会加载到缓存中,将缓存失效时间分散开,让缓存的失效时间尽量均匀点,比如我们可以在原有的失效时间基础上增加一个随机值 而且热点的数据, 尽量过期时间设置的更加长一些, 冷门的数据可以相对设置过期时间短一些。
b,限流降级,在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数,比如对某个key只允许一个线程查询数据和写缓存,其他线程等待(该方法会在下面的缓存击穿中说明)
编码实现
/**
* 缓存雪崩解决方案
* @return
*/
public String snowsLideGetById(int id){
log.info("查询数据 " + id);
//构建rediskey
String redisKey=USERID_PREIFX+id;
//布隆过滤器判断 redisKey 不存在
if(!rBloomFilter.contains(redisKey)){
return null;
}
// 从缓存中去获取数据
String name = (String) redisUtils.get(redisKey);
// 如果查询到了
if(null!=name){
return name;
}
// 未查询到时 从数据库查询数据,并返回且分散过期时间
String userName = MysqlData.getById(id);
if(null!=userName){
Long tineOut=getExpireTime();
// 放到缓存中, 时间取随机, 时间单位小时,按实际需求而定
redisUtils.setEx(redisKey,userName,tineOut, TimeUnit.HOURS);
return userName;
}
return null;
}
private Long getExpireTime(){
long min=24;
long max=72;
Random random = new Random();
long timeOut= min+(long)random.nextDouble()*(max-min);
return timeOut;
}
测试结果
当这个值过期后,再次访问的时候,又会从新设置一个新值
2,物理因素
对应举例2中的情况
一个简单方案就是使用缓存集群 Redis Sentinel 或者 Redis Cluster
缓存集群高可用, 避免单节点故障; 避免了单机宕机造成缓存雪崩。
详细的缓存集群 Redis Sentinel搭建https://blog.csdn.net/HBliucheng/article/details/112845681
三、缓存击穿 (缓存并发)
(一)缓存击穿概念
缓存击穿. 是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个key进行访问,
当这个key在失效的瞬间,持续的大并发就击穿缓存,大量的并发直接请求数据库, 造成数据库的压力,
就像在一个屏障上凿开了一个洞。
(二)解决办法
1加锁
对缓存查询加锁, (首先是经过布隆过滤器, 必然不存在的Key直接返回) ,
如果key不在缓存中,就加锁,然后去查寻DB数据库, 然后放入缓存中,最后解锁;
这里的锁可以使用Redis锁; 其他进程如果发现有锁就等待,然后等到解锁后, 直接去缓存中查;
用锁的方式,会造成部分请求等待, 但问题不严重, 只是部分请求,短暂的等待;
案例代码
a,redis锁
package com.example.redisdemo.common;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* redis锁
*
* @author LiuCheng
* @data 2021/1/27 10:38
*/
@Component
public class RedisLock {
@Autowired
private RedissonClient redissonClient;
/**
* 加锁
* @param lockKey
* @return
*/
public RLock lock(String lockKey) {
RLock lock = redissonClient.getLock(lockKey);
lock.lock();
return lock;
}
/**
* 释放锁
*
* @param lockKey
*/
public void unlock(String lockKey) {
RLock lock = redissonClient.getLock(lockKey);
lock.unlock();
}
/**
* 释放锁
*
* @param lock
*/
public void unlock(RLock lock) {
lock.unlock();
}
/**
* 带超时的锁
* @param lockKey
* @param timeOut 超时时间 单位:秒
* @return
*/
public RLock lock(String lockKey, int timeOut) {
RLock lock = redissonClient.getLock(lockKey);
lock.lock(timeOut, TimeUnit.SECONDS);
return lock;
}
/**
* 带超时的锁
*
* @param lockKey
* @param timeUnit 时间单位
* @param timeOut 超时时间
* @return
*/
public RLock lock(String lockKey, TimeUnit timeUnit, int timeOut) {
RLock lock = redissonClient.getLock(lockKey);
lock.lock(timeOut, timeUnit);
return lock;
}
/**
* 尝试获取锁
*
* @param lockKey
* @param waitTime 最多等待时间
* @param leaseTime 上锁后自动释放锁时间
* @return
*/
public boolean tryLock(String lockKey, int waitTime, int leaseTime) {
RLock lock = redissonClient.getLock(lockKey);
try {
return lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
return false;
}
}
/**
* 阐释获取锁
* @param lockKey
* @param timeUnit 时间单位
* @param waitTime 最多等待时间
* @param leaseTime 上锁后自动释放锁时间
* @return
*/
public boolean tryLock(String lockKey, TimeUnit timeUnit,int waitTime,int leaseTime){
RLock lock = redissonClient.getLock(lockKey);
try {
return lock.tryLock(waitTime,leaseTime,timeUnit);
} catch (InterruptedException e) {
e.printStackTrace();
return false;
}
}
}
b,业务代码
/**
* redis锁
* @param id
* @return
*/
public String lockGetById(int id){
log.info("查询数据 " + id);
// 构建rediskey
String redisKey=USERID_PREIFX+id;
// 布隆过滤器判断,redisKey是否存在
if(!rBloomFilter.contains(redisKey)){
return null;
}
// 从缓存中取数据
String name = (String) redisUtils.get(redisKey);
if(null!=name){
return name;
}
// 如果未查询到,则查询数据库,查询数据库之前先加锁
redisLock.lock(String.valueOf(id));
try {
// 再一次查询缓存,是为了分布式并发情况下,其他的并发请求,能在解锁之后再次查询缓存
String afterName = (String) redisUtils.get(redisKey);
if(null!=afterName){
return name;
}
// 从数据库中查询
String selectName = MysqlData.getById(id);
if(null!=selectName){
redisUtils.set(redisKey,selectName);
return selectName;
}
return null;
} catch (Exception e) {
e.printStackTrace();
return null;
} finally {
// 释放锁
redisLock.unlock(String.valueOf(id));
}
}
2,设置过期时间永不过期
对一些已知的热门数据, 例如一些( 爆款商品 ) 直接设置 缓存永不过期
小提示:缓存数据不设置过期时间,默认永不过期,TTL值为 -1
Gitee地址 https://gitee.com/newACheng/redis-concurrent/tree/master/