- 注重版权,转载请注明原作者和原文链接
- 作者:全栈小袁
- 原创个人开源博客项目(目前V2.0微服务版本):https://github.com/yuanprogrammer/xiaoyuanboke
- 开源项目觉得还行的话点点star,有什么需要完善或者点子欢迎提issue
众所周知,Redis三大问题,缓存穿透
、缓存击穿
、缓存雪崩
,也是最常见的缓存问题,在面试当中也是经常被问到,今天我们就先来讲讲 缓存穿透
问题的解决以及如何编写代码
之前我也是看过很多相关的知识,这篇文章是结合自己所学总结的一篇文章,如果什么地方有问题或者不足之处可以评论区留言告诉我
缓存击穿和缓存雪崩,后续出~
随便创建一个表,这里以用户表作为演示
CREATE TABLE `user` (
`id` int(10) NOT NULL AUTO_INCREMENT,
`username` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '用户名',
`password` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '密码',
`name` varchar(30) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '昵称',
`mobile` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '号码',
`email` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '邮箱',
`gmt_create` datetime(0) NULL DEFAULT NULL COMMENT '时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1001 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
使用在线工具随机生成一千条数据,工具地址:https://datum.codedefault.com/
由于SQL比较长,就不放在这里展示了,打开我的笔记即可复制SQL语句https://note.youdao.com/s/13OsjC3d
下面这些网上都搜得到的,用解压版即可,如果你们找不到可以文章留言(邮箱+工具),我看到会一起打包发给你
安装好redis,并成功连接上
redis客户端工具,跟navicat这种作用相似,方便查看数据情况,当然你不用也行
http请求工具
高并发测试工具
创建一个Maven项目,
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
<version>2.3.12.RELEASEversion>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
<version>2.3.12.RELEASEversion>
dependency>
<dependency>
<groupId>com.baomidougroupId>
<artifactId>mybatis-plus-boot-starterartifactId>
<version>3.5.0version>
dependency>
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
<version>8.0.22version>
dependency>
<dependency>
<groupId>cn.hutoolgroupId>
<artifactId>hutool-allartifactId>
<optional>trueoptional>
<version>5.7.7version>
dependency>
dependencies>
防止端口冲突,修改端口号
server:
port: 8085
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/redis?useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2B8
username: xiaoyuan
password: root
redis:
port: 6379
host: localhost
database: 0
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
@Data
@TableName(value = "user")
public class User {
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
private String username;
private String password;
private String name;
private String mobile;
private String email;
private Date gmtCreate;
}
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.redis.entity.User;
import org.springframework.stereotype.Repository;
@Repository
public interface UserMapper extends BaseMapper<User> {
}
import com.baomidou.mybatisplus.extension.service.IService;
import com.redis.entity.User;
import java.util.List;
public interface UserService extends IService<User> {
// 用户查询, 用name字段来模拟查询不存在的用户
List<User> queryUser(String name);
}
一个简单的查询
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.redis.entity.User;
import com.redis.mapper.UserMapper;
import com.redis.service.UserService;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
@Override
public List<User> queryUser(String name) {
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getName, name);
return this.baseMapper.selectList(wrapper);
}
}
我们先写一个一般业务写法
redisMap
存储每个key成功获取缓存次数的情况
mysqlMap
存储每个key访问数据库次数的情况
clear接口 —— 情况Map结果集,getMap接口 —— 查看Map结果集,query接口 用户查询接口,以name
字段为例简单模拟场景
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.redis.entity.User;
import com.redis.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/user/api")
public class UserController {
// 存储每个key走了多少次缓存
private ConcurrentHashMap<String, Integer> redisMap = new ConcurrentHashMap<>();
// 存储每个key走了多少次数据库
private ConcurrentHashMap<String, Integer> mysqlMap = new ConcurrentHashMap<>();
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private UserService userService;
@GetMapping("clear")
public void clear() {
this.redisMap.clear();
this.mysqlMap.clear();
}
@GetMapping("getMap")
public Object getMap() {
JSONObject json = new JSONObject();
json.set("redisMap", this.redisMap);
json.set("mysqlMap", this.mysqlMap);
return json;
}
@GetMapping("query")
public Object queryUser(@RequestParam("name") String key) {
String cache = redisTemplate.opsForValue().get(key);
// 是否存在缓存
if (cache != null) {
this.redisMap.put(key, (this.redisMap.get(key) == null ? 0 : this.redisMap.get(key)) + 1);
return JSONUtil.parse(cache);
} else {
// 不存在缓存, 查询数据库
this.mysqlMap.put(key, (this.mysqlMap.get(key) == null ? 0 : this.mysqlMap.get(key)) + 1);
List<User> users = userService.queryUser(key);
// 有数据, 丢入缓存
if (users != null && users.size() > 0) {
redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(users), 60 * 50, TimeUnit.SECONDS);
}
return users;
}
}
}
我们用 postman
先普通测试一下接口,测试一个存在的数据,正常显示
接下来进入正题,用 jmeter
测试不存在的数据,并发1000个线程,循环10次,设置随机变量,控制在 全栈小袁001 ~ 全栈小袁100 之间
执行jemter,接着使用postmant查看一下结果,从结果可以看到全部访问了数据库
如果并发非常大,是不是会给数据库造成压力,甚至导致数据库宕奔溃?
加入这一段代码
// 缓存空值或者默认值
JSONObject json = new JSONObject();
json.set("res", null);
redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(json), 10, TimeUnit.SECONDS);
return json;
重新启动项目,进行jemeter压测,测试结果可以看到大部分走了redis缓存的默认值,访问mysql的次数大幅度降低了
在 方案一中 对所有的非法key都做了缓存,而且都是同样的value,这样的操作造成了数据冗余,而且key的数量非常多
我们可以完善一下,利用redis中的 set
集合,设置 黑名单列表
@GetMapping("query")
public Object queryUser(@RequestParam("name") String key) {
// 是否在黑名单中
if (redisTemplate.opsForSet().isMember("NullSet", key)) {
this.redisMap.put(key, (this.redisMap.get(key) == null ? 0 : this.redisMap.get(key)) + 1);
JSONObject json = new JSONObject();
json.set("res", null);
// 返回空值
return json;
}
String cache = redisTemplate.opsForValue().get(key);
if (cache != null) {
// 存在缓存
return JSONUtil.parse(cache);
}else {
// 查询数据库
this.mysqlMap.put(key, (this.mysqlMap.get(key) == null ? 0 : this.mysqlMap.get(key)) + 1);
List<User> users = userService.queryUser(key);
if (users != null && users.size() > 0) {
// 有数据, 丢入缓存
redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(users), 60 * 5, TimeUnit.SECONDS);
return users;
}else {
// 无数据, 加入黑名单列表
redisTemplate.opsForSet().add("NullSet", key);
JSONObject json = new JSONObject();
json.set("res", null);
// 返回空值
return json;
}
}
}
启动进行jmeter压测,测试结果可以也是大部分走了redis,减少mysql访问的次数,同时set集合也方便管理,减少数据的冗余
无论是 方案一 还是 方案二,数据量大起来对空间消耗还是非常大的,所以就有了第三种方案—— 布隆过滤器
布隆过滤器
我就不不详细介绍了,网上也有很多详细的解释,这里我就大概说一下就行
(1)首先,布隆过滤器的结构是由
二进制 0 1
组成的数组,0是不存在,1是存在
(2)拥有 k 个独立的哈希函数映射
,通过要判断的字符分别计算出哈希值
计算出下标位置,当 k 个下标获取到的值都为 1 时,则认为当前字符存在,否则不存在
(3)优点:速度非常快,占用空间极少,操作的是机器底层二进制向量;缺点:一是存在误判
,不同的字符会出现相同的哈希值,二是删除困难
(4)这里插一点:上面说到删除困难,于是衍生出了布谷过滤器
,可以删除操作,有兴趣的可以自己去看看
这里我采用的是 hutool
工具库已经封装好的布隆过滤器,当然你也可以使用其他的工具库或者自己封装一个,原理都是一样的
import cn.hutool.bloomfilter.BloomFilter;
import cn.hutool.bloomfilter.BloomFilterUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.redis.entity.User;
import com.redis.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/user/api")
public class UserController {
// 存储每个key走了多少次布隆过滤器
private ConcurrentHashMap<String, Integer> bloomMap = new ConcurrentHashMap<>();
// 存储每个key走了多少次数据库
private ConcurrentHashMap<String, Integer> mysqlMap = new ConcurrentHashMap<>();
// 布隆过滤器, 设置大约1000个数据
private BloomFilter bloomFilter = BloomFilterUtil.createBitMap(1000);
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private UserService userService;
@GetMapping("clear")
public void clear() {
this.bloomMap.clear();
this.mysqlMap.clear();
}
@GetMapping("getMap")
public Object getMap() {
JSONObject json = new JSONObject();
json.set("bloomMap", this.bloomMap);
json.set("mysqlMap", this.mysqlMap);
return json;
}
@GetMapping("query")
public Object queryUser(@RequestParam("name") String key) {
// 布隆过滤器过滤, 判断是否出现在过滤器里
if (bloomFilter.contains(key)) {
this.bloomMap.put(key, (this.bloomMap.get(key) == null ? 0 : this.bloomMap.get(key)) + 1);
JSONObject json = new JSONObject();
json.set("res", null);
// 返回空值
return json;
}
String cache = redisTemplate.opsForValue().get(key);
if (cache != null) {
// 返回缓存
return JSONUtil.parse(cache);
}else {
// 查询数据库
this.mysqlMap.put(key, (this.mysqlMap.get(key) == null ? 0 : this.mysqlMap.get(key)) + 1);
List<User> users = userService.queryUser(key);
if (users != null && users.size() > 0) {
// 有数据, 丢入缓存
redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(users), 60 * 5, TimeUnit.SECONDS);
return users;
}else {
// 无数据, 添加到过滤器里
bloomFilter.add(key);
JSONObject json = new JSONObject();
json.set("res", null);
// 返回空值
return json;
}
}
}
}
重新启动,压测,测试结果可以看出大部分都被 布隆过滤器
给过滤掉了
好了,整篇文章到这里就结束了,做个总结,也是我的个人习惯之一
客户端发送请求获取数据的时候,在redis中未命中,接着查询数据库也未命中,如果这时候大量请求这些不存在的数据,那么就会给数据库造成一定的压力甚至宕机,这就是 缓存穿透
问题的产生
我自己在项目中一般都是用第一种方案,方便刷新,有可能这次这个查询是不存在数据,下次就存在了,那二三方案就比较不好实现刷新