缓存穿透描述
缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如用户伪造特别大的Id 值(数据库中不存在此Id),并且自增循环频繁请求接口,大量请求打到数据库上,导致数据库压力过大,从而导致服务器响应缓慢,影响用户体验。
缓存穿透理解
缓存穿透是指查询一个根本不存在的数据,缓存层和持久层都不会命中。在日常工作中出于容错的考虑,如果从持久层查不到数据则不写入缓存层,缓存穿透将导致不存在的数据每次请求都要到持久层去查询,失去了缓存保护持久层的意义。
缓存穿透示意图:
缓存穿透危害
缓存穿透问题可能会使后端存储负载加大,由于很多后端持久层不具备高并发性,甚至可能造成后端存储宕机。通常可以在程序中统计总调用数、缓存层命中数、如果同一个Key的缓存命中率很低,可能就是出现了缓存穿透问题。
造成缓存穿透的基本原因有两个。第一,自身业务代码或者数据出现问题(例如:set 和 get 的key不一致),第二,一些恶意攻击、爬虫等造成大量空命中(爬取线上商城商品数据,超大循环递增商品的ID)
解决方案
解决方案一:缓存空串
从缓存中取不到的数据,在数据库中也没有取到,这时可以将key-value对写为key-null,缓存的有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击。
优点 :编码简单,容易理解。
缺点 :缓存中会冗余大量无效的key-null 值,占用大量的内存空间,有可能会导致缓存容量不够,从而引发其他有效的key 被清理。
下面我们来模拟实现
导入需要的jar包
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
对象定义
我们先定义2个对象,一个是需要缓存的空对象NullResult,一个是我们需要缓存的用户信息对象User,代码分别如下所示:
/**
* 空对象定义
* @author yangyanping
* @date 2020-09-12
*/
public class NullResult implements Serializable {
}
/**
* 用户信息对象
* @author yangyanping
* @date 2020-09-12
*/
public class User implements Serializable {
private Integer id;
private String name;
public User(Integer id, String name) {
this.id = id;
this.name = name;
}
@Override
public String toString() {
return "User{" +
"id=" + id +
", name='" + name + '\'' +
'}';
}
}
redisTemplate 配置
/**
* 缓存模版
*
* @author yangyanping
* @date 2022-11-11
*/
@Slf4j
@Configuration
public class CacheConfig {
/**
* Jedis数据源的连接工厂
*/
@Bean("readWebJedisFactory")
public JedisConnectionFactory readWebJedisFactory(@Value("${spring.redis.host}") String host,
@Value("${spring.redis.port}") int port,
@Value("${spring.redis.password}") String password) {
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setHostName(host);
redisStandaloneConfiguration.setPort(port);
redisStandaloneConfiguration.setPassword(password);
return new JedisConnectionFactory(redisStandaloneConfiguration);
}
/**
* 缓存模版
*/
@Primary
@Bean(name = "redisTemplate")
public RedisTemplate<String, Object> redisTemplate(@Qualifier("readWebJedisFactory") RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
// key 序列化方式
redisTemplate.setKeySerializer(new StringRedisSerializer());
// value 序列化
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
// hash 类型 key序列化
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
// hash 类型 value序列化方式
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
// 注入连接工厂
redisTemplate.setConnectionFactory(redisConnectionFactory);
// 让设置生效
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
单元测试代码
单元测试代码,我们分别测试用户Id = 1 (数据库不存在数据) 和 用户Id=2 (数据库中存在数据) 的情况。
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = RedisApplication.class)
public class EmptyDataTest {
private static final NullResult NULL = new NullResult();
@Resource
private RedisTemplate redisTemplate;
@Test
public void testNullData() {
this.findUserByCache(1);
this.findUserByCache(2);
}
/**
* 查询数据
*/
private void findUserByCache(Integer userId){
Object value = redisTemplate.opsForValue().get(userId);
if (value == null) {
User user = getUserName(userId);
value = user == null ? NULL : user;
redisTemplate.opsForValue().set(userId, value);
return;
}
if (value instanceof NullResult) {
System.out.println("不存在的数据:" + value);
} else {
System.out.println("数据库中用户信息:" + value);
}
}
/**
* 模拟数据库查询,耗时2秒
*
* @param userId
* @return
*/
private User getUserName(Integer userId) {
try {
Thread.sleep(2000);
if (userId % 2 == 0) {
return new User(userId, "yangyanping_" + userId);
}
} catch (Exception ex) {
}
return null;
}
}
运行单元测试,输出如下:
不存在的数据:com.yyp.rdis.test.NullResult@19489b27
数据库中用户信息:User{id=2, name='yangyanping_2'}
解决方案二:使用布隆过滤器
在访问缓存层和存储层之前,将存在的key用布隆过滤器提前保存起来,做第一层拦截,当收到一个对key请求时先用布隆过滤器验证是key否存在,如果存在在进入缓存层、存储层。可以使用bitmap做布隆过滤器。这种方法适用于数据命中不高、数据相对固定、实时性低的应用场景,代码维护较为复杂,但是缓存空间占用少。
本质上布隆过滤器是一种数据结构,比较巧妙的概率型数据结构(probabilistic data structure),特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”。相比于传统的 List、Set、Map 等数据结构,它更高效、占用空间更少,但是缺点是其返回的结果是概率性的,而不是确切的。
优点:有效解决缓存内存占用问题,可以节约大量内存空间。
缺点:编码复杂,而且需要定时任务重构缓存中的数据(处理已删除的数据)。理解上有一定的难度。
好了,我们先使用guava工具类 里的布隆过滤器BloomFilter 类,测试一下。
1 : 先放入一百万条数据到布隆过滤器中。
2 : 测试我们放入的一百万条数据,在 布隆过滤器 中是否存在。
3 : 在测试1000条,我们没有放入到布隆过滤器中的数据,测试布隆过滤器否包含。
导入jar
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>23.0</version>
</dependency>
编写测试代码:
/**
* 布隆过滤器 测试类
* @author yangyanping
* @date 2020-09-11
*/
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = RedisApplication.class)
public class BloomFilterTest {
/**
* 定义用户数据 total = 一百万
*/
private static int total = 1000000;
/**
* 初始化布隆过滤器
*/
private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), total, 0.01);
/**
* 初始化一百万的用户数据到布隆过滤器
*/
@Before
public void init(){
for (int userId = 1; userId <= total; userId++) {
// 放入数据
bloomFilter.put(Integer.valueOf(userId));
}
}
/**
* 测试缓存穿透
*/
@Test
public void testBloomFilter() {
// 测试放入的数据
for (int userId = 1; userId <= total; userId++) {
// 如果不 包含数据则打印
if (!bloomFilter.mightContain(userId)) {
System.out.println("有数据逃脱了~~~=" + userId);
}
}
// 测试误差数据
int count = 0;
for (int userId = total + 1; userId <= total + 1000; userId++) {
if (bloomFilter.mightContain(userId)) {
count++;
}
}
System.out.println("误伤的数量:" + count);
}
}