目录
1、使用redis解决session共享问题
使用session时,容易在集群时出现问题
那么如何解决session的不共享问题:使用redis来记录登陆后的用户信息。因为redis和session都是存储在内存,查询速度快,并且redis的信息,是所有tomcat共享的。
业务逻辑:用户使用手机号码+验证码进行登录;
1、redis存储数据:1、 以手机号码为key,验证码为value 存储验证码 2、以一个随机的token为key,存储用户信息
2、用户生成验证码请求:用户输入手机号,点击发送验证码,后端生成一个验证码,以手机号为key,验证码为value,存入redis。
3、用户登录请求:用户输入手机号,输入验证码 点击登录,即可发送请求。后端 根据手机号获取验证码,和用户输入验证码进行比对。 比对成功,从mysql的tb_user中根据手机号码获取用户信息,查找成功,将user信息存入redis,失败,根据手机号码创建新用户,将用户信息存入redis。存入时,以随机的token为key,user对象为value,将token值返回前端。
4、用户登录状态验证:从redis根据token获取对象,判断用户是否存在。
CREATE TABLE `tb_user` (
`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`phone` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '手机号码',
`password` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '密码,加密存储',
`nick_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '昵称,默认是用户id',
`icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '人物头像',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `uniqe_key_phone`(`phone`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1010 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Compact;
@Data
public class User {
private int id;
private String phone;
private String password;
private String nick_name;
private String icon;
private String create_time;
private String update_time;
}
1、生成验证码
package com.example.demo.Controller;
import cn.hutool.captcha.CaptchaUtil;
import cn.hutool.captcha.LineCaptcha;
import cn.hutool.core.util.RandomUtil;
import com.example.demo.Model.Result;
import com.sun.org.apache.regexp.internal.RE;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.Random;
import java.util.concurrent.TimeUnit;
/**
* 获取验证码图片
*/
@Controller
@ResponseBody
@RequestMapping("/app")
public class SendCodeController {
@Autowired
StringRedisTemplate stringRedisTemplate;
@PostMapping("/getcode")
public Result sendCode( String tel, HttpServletRequest request, HttpServletResponse response) throws IOException {
//1、检验手机号码格式
if (!checkTel(tel)) return Result.fail("手机号码错误");
//2、生成验证码图片
//生成 带有线条的验证码图片
LineCaptcha circleCaptcha = CaptchaUtil.createLineCaptcha(100/*宽*/, 40/*高*/, 4/*验证码显示几个数据*/, 50/*有几个线段*/);
//告诉浏览器输出内容为jpeg类型的图片
response.setContentType("image/png");
//禁止浏览器缓存
response.setHeader("Pragma", "No-cache");
//输出图形 内的数字
// System.out.println(circleCaptcha.getCode());
//3、将 验证码数据 和 手机号存入redis
stringRedisTemplate.opsForValue().set("user:login:tel"+tel, circleCaptcha.getCode());
//设置有效期 两分钟
//redis的有效期指的是 在tel存入后,两分钟后就会过期,不论期间是否访问 tel, 有效期都不会改变
//但是重新set之后 有效期要重新设置
stringRedisTemplate.expire("user:login:tel"+tel, 2, TimeUnit.MINUTES);
//图片写出
circleCaptcha.write(response.getOutputStream());
return Result.ok("验证码发送成功");
}
public boolean checkTel(String tel) {
if (tel.length() != 11) return false;
for (int i = 0; i < 11; i++) {
if (!(tel.charAt(i) <= '9' && tel.charAt(i) >= '0'))
return false;
}
return true;
}
}
在刷新之后,重置value值,并重新设置有效值
2、用户登录
package com.example.demo.Controller;
import cn.hutool.core.util.RandomUtil;
import com.example.demo.Mapper.SaveUserByTelMapper;
import com.example.demo.Mapper.getUserByTelMapper;
import com.example.demo.Model.Result;
import com.example.demo.Model.User;
import com.sun.org.apache.regexp.internal.RE;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* 用户登录请求处理
*/
@Controller
@ResponseBody
@RequestMapping("/app")
public class UserController {
@Autowired
com.example.demo.Mapper.getUserByTelMapper getUserByTelMapper;
@Autowired
SaveUserByTelMapper saveUserByTelMapper;
@Autowired
StringRedisTemplate stringRedisTemplate;
@PostMapping("/login")
public Result user(String tel,String code) {
System.out.println(code);
System.out.println(tel);
//1、用户手机号码验证
if (!checkTel(tel)) return Result.fail("手机号码错误");
//2、校验验证码是否正确
String cache_code = stringRedisTemplate.opsForValue().get("user:login:tel" + tel);
if (cache_code == null) {//验证码获取不到 (过期)
return Result.fail("验证码过期");
}
System.out.println(cache_code);
if (!cache_code.equals(code))
return Result.fail("验证码错误");
//3、验证码校验成功了 从mysql数据库根据手机号码 查找用户 查找不到,就根据手机号码创建用户
User user = getUserByTelMapper.get(tel);
if (user == null) {
//根据手机号创建用户 用户的其他信息为随机
user = new User();
user.setPhone(tel);
user.setNick_name("user_" + RandomUtil.randomString(10));
//将用户信息存入mysql
saveUserByTelMapper.save(user);
}
//4、此时登录成功,将用户信息存入redis
Map<String, String> map = new HashMap<>();
map.put("phone", user.getPhone());
map.put("icon", user.getIcon());
map.put("nick_name", user.getNick_name());
UUID uuid = UUID.randomUUID();
stringRedisTemplate.opsForHash().putAll("user:login" + uuid, map);
//设置有效期 30分钟
//这里的有效期 在 此时计时 30分钟; 这个逻辑在用户正常使用时,30分钟也会过期 因此在登录验证时 要重新更新有效期
stringRedisTemplate.expire("user:login" + uuid, 30, TimeUnit.MINUTES);
return Result.ok(uuid);
}
public boolean checkTel(String tel) {
if (tel.length() != 11) return false;
for (int i = 0; i < 11; i++) {
if (!(tel.charAt(i) <= '9' && tel.charAt(i) >= '0'))
return false;
}
return true;
}
}
登录成功 返回token值
redis中存储如下:
mysql新增用户记录:
3、拦截器
public class LoginInterceptor implements HandlerInterceptor {
//
// @Autowired
// RedisTemplate redisTemplate;
//不能通过 @Autowired注入 因为LoginInterceptor 这个类 没有被Spring管理
//因此使用构造函数构造 也就是调用 LoginInterceptor对象的类 去创建 redisTemplate对象
//即 LoginConfig类
private StringRedisTemplate redisTemplate;
public LoginInterceptor(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1、从redis获取登录的用户 来检验用户登录状态
String token = request.getHeader("token");//从请求头获取 token值
if (token == null)
return false;
//2、从redis获取不到登录的用户
//redisTemplate.opsForHash().entries("login:user" + token) 获取token对应的map结构
Map<Object, Object> map = redisTemplate.opsForHash().entries("user:login" + token);
if (map.isEmpty()) //entries方法 在获取map时,如果获取不到,会返回一个空的map
return false;
System.out.println(map.get("phone"));
//3、获取到了这个用户 那么就去刷新有效期 不要 只在用户登录请求时设置有效期
redisTemplate.expire("user:login" + token, 30, TimeUnit.MINUTES);
return true;
}
}
@Configuration
public class LoginConfig implements WebMvcConfigurer {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
//无论用户是否登录 都会放行 getcode 和 login接口
//在用户没有登陆时,拦截text接口 在用户登陆时,放行text接口
//text接口会进入LoginInterceptor类,进行用户登录校验,校验用户登录后才会放行
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor(redisTemplate)).addPathPatterns("/**")
.excludePathPatterns("/app/getcode")
.excludePathPatterns("/app/login");
}
}
除了/app/getcode和/app/login不会做任何处理之外,其余接口会进入LoginInterceptor,进行用户登录的校验,一旦校验用户已经登录成功,那么就刷新有效期,才可以放行请求。
LoginInterceptor可以放行的除了登录接口外,还有的就是获取了登录权限的其余接口
但是,在某些业务,比如用户访问商品列表,是不需要获取登录权限的,用户可以在不登陆时查看商品信息。但是用户如果此时是登录的,也应该去刷新有效期。
就是说,基于不登陆时可以访问,这个接口就不能被拦截进入LoginInterceptor进行登陆状态验证,但是基于如果此时是登录的,也应该去刷新有效期,他又应该进入LoginInterceptor进行登陆状态验证,验证后进行有效期刷新。
也就是说需要再加入一个拦截器,拦截所有接口,让所有接口进入RefreshInterceptor,如果登录了,就去刷新有效期。在LoginInterceptor去判断那些需要登陆的接口的登陆状态
/**
* 定制拦截规则 不管用户是否登录 都放行
*/
public class RefreshInterceptor implements HandlerInterceptor {
private StringRedisTemplate redisTemplate;
public RefreshInterceptor(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
System.out.println("RefreshInterceptor接口调用");
//1、从redis获取登录的用户 来检验用户登录状态
String token = request.getHeader("token");//从请求头获取 token值
if (token == null)
return true;
//2、从redis获取不到登录的用户
//redisTemplate.opsForHash().entries("login:user" + token) 获取token对应的map结构
Map<Object, Object> map = redisTemplate.opsForHash().entries("user:login" + token);
if (map.isEmpty()) //entries方法 在获取map时,如果获取不到,会返回一个空的map
return true;
User user = new User();
user.setPhone((String) map.get("phone"));
user.setIcon((String) map.get("icon"));
user.setNick_name((String) map.get("nick_name"));
//3、获取到了这个用户 那么就去刷新有效期 不要 只在用户登录请求时设置有效期
redisTemplate.expire("user:login" + token, 30, TimeUnit.MINUTES);
//4、在RefreshInterceptor 是放行所有的接口 让所有接口都可以实现 在用户登陆时刷新有效期
//在LoginInterceptor 是 放行检测登录状态成功之后的接口
//那么这里 通过ThreadLocal去存储已经登录的用户信息,避免在LoginInterceptor 二次判断
UserHolder.set(user);
return true;
}
}
public class LoginInterceptor implements HandlerInterceptor {
//
// @Autowired
// RedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
System.out.println("LoginInterceptor接口调用");
//没有用户登录
if (UserHolder.get() == null) {
return false;
}
return true;
}
}
@Configuration
public class LoginConfig implements WebMvcConfigurer {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
//无论用户是否登录 都会放行 getcode 和 login接口
//拦截 getcode 和 login接口 外的 所有接口 让所有路径进入LoginInterceptor制定的规则
//在用户没有登陆时,拦截text接口 在用户登陆时,放行text接口
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor()).addPathPatterns("/**")
.excludePathPatterns("/app/getcode")
.excludePathPatterns("/app/login").order(1);
//拦截所有接口 让所有路径进入RefreshInterceptor制定的规则
registry.addInterceptor(new RefreshInterceptor(redisTemplate)).addPathPatterns("/**").order(0);
//order约小 先执行 让RefreshInterceptor先执行
}
}
public class UserHolder {
private static ThreadLocal<User> t = new ThreadLocal<>();
public static void set(User user) {
t.set(user);
}
public static User get() {
return t.get();
}
}
这时,在没有登录下,去访问text接口,会进入RefreshInterceptor,检验没有登录,放行请求,进入LoginInterceptor,检验没有登录,从而请求被拦截
在登录状态下,去访问text接口,会进入RefreshInterceptor,检验已经登录,刷新有效期,放行请求,进入LoginInterceptor,检验已经登录,从而请求被放行
如果想要让text接口在没有登陆时可以放行,就需要设置在LoginInterceptor时不拦截此接口,从而不进入LoginInterceptor进行登录验证。
2、redis缓存
缓存是实现数据存储的位置,cpu的计算和读写速度都很快,为了平衡和内存之间的速度,引入缓存。cpu从缓存读取数据,提高运行速度。
redis是可以充当缓存的,因为redis是存于内存的,读写速度很快,请求来到服务器,服务器从redis读取数据,就返回数据,可以降低响应时间,提高读写速度,降低后端负载。
当请求发出之后,先去redis查找,redis命中直接返回结果,否则从数据库查找,数据库查找到了之后,将数据加入redis,然后返回结果
对查找店铺功能加入缓存
第一次调用,在数据库查找,并且加入缓存
第二次查找,直接在缓存查
缓存更新策略
缓存在提高响应速度的同时,会引入问题:缓存和数据库内容不一致,比如数据库内容修改,缓存数据没有修改;缓存内容修改,数据库内容没修改。
内存淘汰
机制:redis在内存不足时,会自动淘汰部分数据。这些淘汰的数据在下一次请求访问时,就会进入mysql查找,将从数据库的新数据加入缓存。
好处:不用自己实现
坏处:淘汰的数据不一定是我们想要删除的数据,不可控
超时剔除
机制:给缓存数据加入TTL有效期,过期之后会自动删除
好处:数据在过期之后被删除,下一次请求到来,就要到数据库查询,查找的数据加入缓存,缓存里就会是新数据。
坏处:在TTL有效期内查询数据,都是从缓存中读取,一旦数据库修改数据,缓存没有同步修改,得到的仍然是旧数据。
主动更新
在业务逻辑上处理:更新数据库的同时,更新缓存
实现方式:
1、由更新数据库的调用者,在更新数据库的同时,更新缓存
2、将缓存和数据库整合成为一个服务,由服务来维护一致性,调用者调用该服务即可实现缓存一致性。
3、调用者只操作缓存,由其他线程异步的将缓存数据持久化到数据库。
考虑三个点:
1、删除缓存还是更新缓存:如果更新缓存,数据库如果对同一行数据修改100次,此时缓存也要更新100次,但其实最终数据也是以第100次为准; 删除缓存,数据库修改100次,最终在请求时,缓存只需要从数据库刷新最终数据。 一般选取删除缓存方案
2、更新数据库和删除缓存两个操作要是原子性的:单体系统:两个方法在同一个事务中。分布式系统:分布式事务的解决方案
3、线程安全问题:在多线程并发情况下,是先删除缓存还是先修改数据库
对修改店铺功能的缓存加入主动更新
先修改数据库,后删除缓存
@Service
public class UpdateShopByIdService {
@Autowired
UpdateShopByIdMapper updateShopByIdMapper;
@Autowired
StringRedisTemplate stringRedisTemplate;
//保证两个操作是原子性的 加入事务
@Transactional
public Result update(Shop shop) {
if (shop == null || shop.getId() == null)
return Result.fail("数据错误");
//1、先修改数据库
updateShopByIdMapper.update(shop);
//2、删除缓存
stringRedisTemplate.delete("shop" + shop.getId());
return Result.ok("修改成功");
}
}
缓存穿透
指的是一个数据,在缓存中找不到,在数据库中也找不到。从而在并发请求时,所有的请求都会来到数据库。
解决办法:缓存空数据 布隆过滤
缓存空数据
缓存空数据是指:比如查找id为3的店铺,在缓存中找不到,在数据库中也找不到,这时在缓存放置 key为“shop3”,value为空的数据,下一次请求查找id为3的店铺,在缓存中就获取到了这个值,只不过内容为空而已
优点:实现简单。 缺点:1、发送多个不存在的id时,会存储多个垃圾数据。2、在并发时可能会存在不一致性。解决办法:设置有效期,清楚垃圾数据
布隆过滤
它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都比一般的算法要好的多,缺点是有一定的误识别率和删除困难。
使用缓存空数据,解决缓存穿透
@Service
public class GetShopByIdService {
@Autowired
com.example.demo.Mapper.getShopByIdMapper getShopByIdMapper;
@Autowired
StringRedisTemplate redisTemplate;
public Result get(Integer id) {
//1、现在缓存查找数据
String str = redisTemplate.opsForValue().get("shop" + id);
// 1.1 在缓存查找到了数据
if (StrUtil.isNotBlank(str)) {
//isNotBlank(str) 在str是 ”“,null,"\t\n" 是返回false 在”abc“这种正常字符串 返回true
Shop shop = JSONUtil.toBean(str, Shop.class);//将 字符串转为 shop对象
return Result.ok(shop);
}
//redis没有命中一个合法的字符串 判断是否命中了空值 如果是空值 就应该去返回
if (Objects.equals(str, "")) {
return Result.fail("商品不存在");
}
//2、在缓存没有查找到数据 在数据库查找
Shop shop = getShopByIdMapper.get(id);
//2.1 在数据库没有找到数据
if (shop == null) {
//这里 向缓存存入空数据
redisTemplate.opsForValue().set("shop" + id, JSONUtil.toJsonStr(""), 30, TimeUnit.MINUTES);
return Result.fail("商品不存在");
}
//2.2 在数据库中找到了 将这个数据加入缓存
redisTemplate.opsForValue().set("shop" + id, JSONUtil.toJsonStr(shop), 30, TimeUnit.MINUTES);
return Result.ok(shop);
}
}
缓存雪崩
在同一时间段大量的缓存失效或者redis宕机,导致大量的请求来到数据库,造成巨大压力
解决办法:
1、设置不同的有效期,让缓存不至于同时大量失效
2、设置redis集群,来实现redis的高可用性
3、给缓存业务加入降级:在redis发生问题时,直接返回服务不可用,不至于让请求来到数据库
4、添加多级缓存:比如在浏览器加入缓存,redis出现问题之后,就可以从浏览器缓存获取数据
缓存击穿
也就是热点k问题:一个被高并发访问,并且重建困难的 缓存失效了,数据库就会在瞬间接收到巨大的请求
比如秒杀,多个用户秒杀商品,突然缓存失效了,那么数据库就会在瞬间接收到巨大的请求
解决办法:互斥锁 逻辑过期
互斥锁
多个线程并发,只会有一个线程可以查询数据库,获取数据,并加入缓存
出现的问题就是:如果这个查询数据库的时间过长,其余线程就只能不断循环,等到查询完成之后,写入缓存,才会命中缓存,返回结果。这个等待时间之内,是无法返回结果的。引入了逻辑过期。
逻辑过期
不给缓存设置有效期,而是设置一个字段,表示它的结束时间,在业务逻辑上进行判断是否过期。
这样,缓存是不会找不到的。
也就是线程本身直接返回缓存旧数据,在发现数据过期之后,开启一个线程完成缓存更新,
之后,其余线程从缓存拿到的就是新数据。
1、使用互斥锁,解决查询店铺信息的缓存击穿问题
这里不能使用synchronized锁,因为它是自旋锁,而我们现在想要的是等待后自旋
这里除了自己实现锁外,可以借助redis的setnx命令:存在时不可修改,不存在时才会被创建
public Result throwSolveByMudex(Integer id) {
//1、基于缓存查找数据
String str = redisTemplate.opsForValue().get("shop" + id);
// 1.1 在缓存查找到了数据
// //isBlank 方法会先将字符串去除头尾空格后再进行判断。如果字符串为 null 或者去除头尾空格后的长度为0,则返回 true;否则返回 false。
if (StrUtil.isNotBlank(str)) {
Shop shop = JSONUtil.toBean(str, Shop.class);//将 字符串转为 shop对象
return Result.ok(shop);
}
// 1.2 商品存入的是 空值 如果 str 是 "" 说明是存入的不存在的数据
if ("".equals(str)) {
return Result.fail("商品不存在");
}
//2、在缓存没有查找到数据
//2.1 获取互斥锁
boolean islock = trylock("lock" + id);
//2.2 竞争互斥锁失败 休眠一段时间之后 重新执行业务逻辑
Shop shop = null;
try {
if (!islock) {
Thread.sleep(60);
return throwSolveByMudex(id);
}
//2.3 竞争互斥锁 获取成功
//2.3.1 考虑并发操作 :可能获取锁之后,缓存其实已经刷新了
str = redisTemplate.opsForValue().get("shop" + id);
if (StrUtil.isNotBlank(str)) {
shop = JSONUtil.toBean(str, Shop.class);//将 字符串转为 shop对象
return Result.ok(shop);
}
if ("".equals(str)) {
return Result.fail("商品不存在");
}
//2.3.2 这时 缓存没有被刷新 且拿到了互斥锁 就去数据库操作
System.out.println("获取锁成功");
//在数据库查找
shop = getShopByIdMapper.get(id);
//加入等待 模拟 数据库查询慢的情况
Thread.sleep(200);
// 在数据库没有找到数据
if (shop == null) {
//这里 向缓存存入空数据
redisTemplate.opsForValue().set("shop" + id, JSONUtil.toJsonStr(""), 30, TimeUnit.MINUTES);
return Result.fail("商品不存在");
}
// 在数据库中找到了 将这个数据加入缓存
redisTemplate.opsForValue().set("shop" + id, JSONUtil.toJsonStr(shop), 30, TimeUnit.MINUTES);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//释放锁
unlock("lock" + id);
}
return Result.ok(shop);
}
在1000个并发线程里,有两个线程查询了数据库,其他线程都是在缓存获取了数据
使用逻辑过期时间来解决
1、封装新的对象
@Data
public class ShopWithOutTime {
private LocalDateTime time;
private Object object;
}
可以创建此对象,设置过期时间和内部object对象
2、使用单元测试,在redis存入数据
@SpringBootTest
public class DemoTest {
@Autowired
com.example.demo.Mapper.getShopByIdMapper getShopByIdMapper;
@Autowired
StringRedisTemplate redisTemplate;
/**
* 在redis 存入ShopWithOutTime 对象 过期时间20s后
*/
@Test
public void test() {
//1、从数据库读数据
Shop getshop = getShopByIdMapper.get(1);
//2、设置到期时间
ShopWithOutTime shop1 = new ShopWithOutTime();
shop1.setObject(getshop);
shop1.setTime(LocalDateTime.now().plusSeconds(20));
//3、存入redis
redisTemplate.opsForValue().set("shopwithouttime" + 1, JSONUtil.toJsonStr(shop1));
}
}
3、然后去修改数据库的数据 那么此时,数据库和redis的数据是不同的
4、此时,并发执行多个线程 ,去查找id为1的店铺
预期结果:如果此时redis数据过期,那么并发情况就是有一部分获取得到的是旧数据,在启动的线程修改完缓存数据之后,获得的是新数据
private Result throwSolveBySetOutTime(Integer id) {
//1、基于缓存查找数据
String str = redisTemplate.opsForValue().get("shopwithouttime" +id);
//2、缓存没有命中
// isBlank 方法会先将字符串去除头尾空格后再进行判断。如果字符串为 null 或者去除头尾空格后的长度为0,则返回 true;否则返回 false。
//这里直接返回的原因是 : 这里是设置逻辑的过期时间,没有设置ttl,不会存在数据不存在的情况
//如果是热点k,这个数据本身 就是 存在 于缓存的。
if (StrUtil.isBlank(str)) {
return Result.fail("缓存不存在数据");
}
//3、缓存命中 判断是否过期
ShopWithOutTime shopWithOutTime = JSONUtil.toBean(str, ShopWithOutTime.class);//将 字符串转为 ShopWithOutTime对象
//3.1 缓存没有到期 不需要更新
if (shopWithOutTime.getTime().isAfter(LocalDateTime.now())) {
return Result.ok(shopWithOutTime);
}
//3.2缓存到期
//4、尝试竞争互斥锁
boolean islock = trylock("shopwithouttimelock" + id);
//4.1 获取锁失败 返回旧数据
if (!islock)
return Result.ok(shopWithOutTime);
//4.2 获取锁成功 启动其余线程 去刷新缓存
// 4.2.1考虑并发操作 :可能获取锁之后,缓存其实已经刷新了
str = redisTemplate.opsForValue().get("shopwithouttime" +id);
if (StrUtil.isBlank(str)) {
return Result.fail("缓存不存在数据");
}
//4.2.2 缓存没有刷新 启动其余线程 去刷新缓存
executorService.submit(new Runnable() {
@Override
public void run() {
//1、从数据库读数据
Shop getshop = getShopByIdMapper.get(id);
try {
Thread.sleep(200);
//2、设置到期时间
ShopWithOutTime shop1 = new ShopWithOutTime();
shop1.setObject(getshop);
shop1.setTime(LocalDateTime.now().plusSeconds(20));
//3、存入redis
redisTemplate.opsForValue().set("shopwithouttime" + id, JSONUtil.toJsonStr(shop1));
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//4、释放锁
unlock("shopwithouttimelock" + id);
}
}
});
//4.2.3 返回旧值
return Result.ok(shopWithOutTime);
}
只会有一个线程去执行数据库查询
缓存工具封装
@Component
public class ClientCatch {
@Autowired
private StringRedisTemplate stringRedisTemplate;
//1、set方法 设置ttl过期
public void setExpire(String key, Object value, Long time, TimeUnit timeUnit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, timeUnit);
}
//2、set方法 设置逻辑过期 创建RedisData对象 ()
//public class RedisData {
// private LocalDateTime time;
// private Object object;
//}
public void setLogicalExpire(String key, Object value, Long time, TimeUnit timeUnit) {
RedisData redisData = new RedisData();
redisData.setObject(value);
redisData.setTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(time)));
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
3、设置缓存穿透:存入空值
这里,就需要使用泛型,传入返回类.class,传入参数也是泛型,同时要传入数据库查询的方法
因为匹配所有类型,这里的数据库查询方法返回值也要是泛型,且应该有增删改查四种。这里就可以使用MyBatis-Plus,MyBatis-Plus可以简化单表操作。
1、添加依赖
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.0.5</version>
</dependency>
2、使用方法:(假设实体类为USer)
service层接口可以继承IService接口,IService的使用(需要另外两个接口baseMapper和ServiceImpl的配合)
①mapper接口继承basemapper接口:
@Mapper
public interface UserMapper extends BaseMapper<User> {}
②service接口继承Iservice:
@Service
public interface IUserService extends IService<User> {
}
③service接口的实现类继承ServiceImpl<继承basemapper的mapper,实体类>,实现IUserService接口
@Service
public class IUserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
}
这时,就可以在IUserServiceImpl类里,调用相关方法,实现对Shop数据库的增删改查
如果说,Shop类对应的表名不是Shop,可以在Shop的实体类前加注解@TableName("tb_shop"),就会对tb_shop表进行操作
//3、解决缓存穿透 : 存入空值
/**
*
* @param keyPrefix redis缓存前缀
* @param value redis的key
* @param type 返回的对象的类型
* @param function 调用数据库的逻辑 是一个有返回值,有参数的函数 参数1 参数类型 参数2 返回值类型
* @param <T>
* @param <V>
* @return
*/
public <T,V> T ThrowSolveBySavezero(String keyPrefix, V value, Class<T> type, Function<V,T> function,Long time,TimeUnit timeUnit) {
//1、现在缓存查找数据
String str = stringRedisTemplate.opsForValue().get(keyPrefix + value);
// 1.1 在缓存查找到了数据
if (StrUtil.isNotBlank(str)) {
//返回
T t = JSONUtil.toBean(str, type);//将 字符串转为 T 对象
return t;
}
//redis没有命中一个合法的字符串 判断是否命中了空值 如果是空值 就应该去返回
if (Objects.equals(str, "")) {
return null;
}
//2、在缓存没有查找到数据 在数据库查找
T t = function.apply(value);
//2.1 在数据库没有找到数据
if (t == null) {
//这里 向缓存存入空数据
stringRedisTemplate.opsForValue().set(keyPrefix + value, JSONUtil.toJsonStr(""), time, timeUnit);
return null;
}
//2.2 在数据库中找到了 将这个数据加入缓存
stringRedisTemplate.opsForValue().set(keyPrefix + value, JSONUtil.toJsonStr(t), time, timeUnit);
return t;
}
调用这个方法:
查询id为0的店铺,redis存入空值
使用逻辑过期, 解决缓存击穿
private ExecutorService executorService= Executors.newFixedThreadPool(10);
public <T,V> T ThrowSolveBySetTimeout(String keyPrefix, V value, Class<T> type, Function<V,T> function,Long time,TimeUnit timeUnit) {
//1、基于缓存查找数据
String str = stringRedisTemplate.opsForValue().get(keyPrefix + value);
//2、缓存没有命中 直接返回
//isBlank 方法会先将字符串去除头尾空格后再进行判断。如果字符串为 null 或者去除头尾空格后的长度为0,则返回 true;否则返回 false。
if (StrUtil.isBlank(str)) {
return null;
}
//3、缓存命中 判断是否过期
RedisData redisData = JSONUtil.toBean(str, RedisData.class);//将 字符串转为 RedisData对象
//3.1 缓存没有到期 不需要更新
if (redisData.getTime().isAfter(LocalDateTime.now())) {
//获取 对象 返回
return JSONUtil.toBean((JSONObject) redisData.getObject(), type);
}
//3.2缓存到期
//4、尝试竞争互斥锁
boolean islock = trylock("lock" + keyPrefix + value);
//4.1 获取锁失败 返回旧数据
//4.2 获取锁成功 启动其余线程 去刷新缓存
// 4.2.1考虑并发操作 :可能获取锁之后,缓存其实已经刷新了
if (islock) {
str = stringRedisTemplate.opsForValue().get(keyPrefix + value);
//元素没有命中
if (StrUtil.isBlank(str)) {
return null;
}
//4.2.2 缓存没有刷新 启动其余线程 去刷新缓存
executorService.submit(new Runnable() {
@Override
public void run() {
//1、从数据库读数据
T t = function.apply(value);
try {
Thread.sleep(200);
//2、设置到期时间
RedisData redisData1 = new RedisData();
redisData1.setObject(t);
redisData1.setTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(time)));
//3、存入redis
stringRedisTemplate.opsForValue().set(keyPrefix + value, JSONUtil.toJsonStr(redisData1));
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//4、释放锁
unlock("lock" + keyPrefix + value);
}
}
});
}
//4.2.3 返回旧值
return JSONUtil.toBean((JSONObject) redisData.getObject(), type);
}
//获取锁
public boolean trylock(String lockStr) {
return Boolean.TRUE.equals(stringRedisTemplate.opsForValue().setIfAbsent(lockStr, "1", 1, TimeUnit.SECONDS));
}
//释放锁
public void unlock(String lockStr) {
stringRedisTemplate.delete(lockStr);
}