网站的seo优化报告,房价下跌最新消息,做网站什么好,建设拍卖网站目录
一、
基于Session实现登录
发送验证码
验证用户输入验证码
校验登录状态
Redis代替Session登录
发送验证码修改
验证用户输入验证码
登录拦截器的优化
二、
商铺查询缓存
缓存更新策略
数据库和缓存不一致解决方案
缓存更新策略的最佳实践方案
实现商铺缓…
目录
一、
基于Session实现登录
发送验证码
验证用户输入验证码
校验登录状态
Redis代替Session登录
发送验证码修改
验证用户输入验证码
登录拦截器的优化
二、
商铺查询缓存
缓存更新策略
数据库和缓存不一致解决方案
缓存更新策略的最佳实践方案
实现商铺缓存与数据库双写一致
缓存穿透问题的解决思路
缓存空对象的思路分析
布隆过滤器的思路分析
解决商品查询的缓存穿透问题
缓存雪崩问题及解决思路
缓存击穿问题及解决思路
解决方案一互斥锁
解决方案二逻辑过期方案
解决商铺缓存击穿问题
利用互斥锁解决缓存击穿问题
基于逻辑过期解决缓存击穿问题
自定义缓存工具类 一、
基于Session实现登录
业务流程 发送验证码
Value(${hmdp.mail.user})
private String user;Value(${hmdp.mail.password})
private String password;/*** 发送邮箱验证码* param mail* param session* return* throws MessagingException*/
Override
public Result sendCode(String mail, HttpSession session) throws MessagingException {//1.校验手机号if(RegexUtils.isEmailInvalid(mail)){//2.不符合返回错误信息return Result.fail(邮箱格式错误!);}//3.生成验证码String code RandomUtil.randomNumbers(6);//6为随机数字//4.保存验证码session.setAttribute(mailcode,mailcode);log.info(发送邮箱{}---生成的验证码为{},mail,code);log.info(获取配置文件信息 user{}---password{},user,password);//5.发送验证码MailUtils.setUser(user);MailUtils.setPassword(password);MailUtils.sendMail(mail,code);//返回return Result.ok();
}
验证用户输入验证码 /*** 验证码登录、注册* param loginForm* param session* return*/Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {String phone loginForm.getPhone();String code loginForm.getCode();String password loginForm.getPassword();//1.校验邮箱//2.不符合返回错误信息if (RegexUtils.isEmailInvalid(phone)) return Result.fail(邮箱格式错误!);//登录验证if (code.isEmpty()) {//用户输入的账号密码} else {//用户输入的验证码String codeInSession (String) session.getAttribute(loginForm.getPhone() code);log.debug(codeInSession:{},code:{},codeInSession,code);//3.不一致失败if(codeInSession.isEmpty() || !codeInSession.equals(code)) return Result.fail(邮箱号或密码错误请检查后再试);//2.一致登录成功}//4.一致根据手机号查询用户LambdaQueryWrapperUser qw new LambdaQueryWrapper();qw.eq(User::getPhone,phone);log.debug(准备查询用户);User user this.getOne(qw);
// User user query().eq(phone, phone).one();if(user null) user createUserWithPhone(phone);log.debug(获取用户成功准备存入session保存,用户信息{},user);//保存user到session中session.setAttribute(user,user);return Result.ok();}private User createUserWithPhone(String phone) {User user;String uuid UUID.randomUUID().toString();String nickname SystemConstants.USER_NICK_NAME_PREFIX RandomUtil.randomString(8);user new User();user.setNickName(nickname);user.setPhone(phone);this.save(user);return user;}
校验登录状态
用户ThreadLocal工具类
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;public class UserHolder {private static final ThreadLocalUser tl new ThreadLocal();public static void saveUser(User user){tl.set(user);}public static User getUser(){return tl.get();}public static void removeUser(){tl.remove();}
}增加一个拦截器interceptor
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;public class LoginIntercepter implements HandlerInterceptor {Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//1.获取sessionHttpSession session request.getSession();//2.获取session中的用户Object user session.getAttribute(user);//3.判断用户是否存在if(user null){//4.不存在拦截response.setStatus(401);return false;}//5.存在保存到ThreadLocalUserHolder.saveUser((User) user);return HandlerInterceptor.super.preHandle(request, response, handler);}Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);}Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {//移除用户UserHolder.removeUser();}
}增加一个拦截器的配置用于排除不需要登录就可以访问的资源
import com.hmdp.utils.LoginIntercepter;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;Configuration
public class MvcConfig implements WebMvcConfigurer {Overridepublic void addInterceptors(InterceptorRegistry registry) {//放行registry.addInterceptor(new LoginIntercepter()).excludePathPatterns(/user/code,//验证码发送/user/login,//登录验证
// /user/me,//登录验证/blog/hot,//热点博客/shop/**,//店铺/shop-type/**,//店铺类型/upload/**,//上传资源 方便测试/voucher/**//优惠卷信息查询);}
}
4从threadlocal中获取当前登录用户但直接获取用户所有信息是不安全的可以看到包含用户密码等信息 因此需要找到在哪儿存入的user信息并且把他封装为UserDto只获取部分需要的信息更改UserHolder中存储的User类型为UserDto编写一个controller返回数据给前端。
Redis代替Session登录 发送验证码修改
发送验证码只需要修改存储验证码使用session换为redis存储就可以了存储key使用业务存储数据名称唯一编号。
Resource
private StringRedisTemplate stringRedisTemplate;Overridepublic Result sendCode(String mail, HttpSession session) throws MessagingException {//1.校验手机号if (RegexUtils.isEmailInvalid(mail)) {//2.不符合返回错误信息return Result.fail(邮箱格式错误!);}//3.生成验证码String code RandomUtil.randomNumbers(6);//6为随机数字//4.保存验证码到redis 并设置有效期stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY mail, code,LOGIN_CODE_TTL, TimeUnit.MINUTES);log.info(发送邮箱{}---生成的验证码为{}, mail, code);log.info(获取配置文件信息 user{}---password{}, emailUsername, emailPassword);//5.发送验证码MailUtils.setUser(emailUsername);MailUtils.setPassword(emailPassword);MailUtils.sendMail(mail, code);//返回return Result.ok();}
验证用户输入验证码
从redis获取验证码并校验登录后存入用户信息到redis 随机生成token作为登录令牌将User转换为Hash存储存储数据到redis设置有效期拦截器获取redis中的用户信息 获取请求头中的token通过token获取redis中的用户信息将查询到的Hash数据转为UserDto对象存储用户信息到ThreadLocal重新设置有效期只要用户访问就重置用户登录命令redis的有效期 /*** 验证码登录、注册** param loginForm* param session* return*/Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {String phone loginForm.getPhone();String code loginForm.getCode();String password loginForm.getPassword();//1.校验邮箱if (RegexUtils.isEmailInvalid(phone)) {//2.不符合返回错误信息return Result.fail(邮箱格式错误!);}//登录验证if (code.isEmpty()) {//用户输入的账号密码} else {//用户输入的验证码
// String codeInSession (String) session.getAttribute(code);String codeInRedis stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY phone);log.debug(codeInSession:{},code:{}, codeInRedis, code);//跳过登录验证if (717055919qq.com.equals(phone)) {log.info(管理员登录跳过登录验证);} else {if (codeInRedis null || !codeInRedis.equals(code)) {//3.不一致失败return Result.fail(邮箱号或密码错误请检查后再试);}//2.一致登录成功}}//4.一致根据手机号查询用户LambdaQueryWrapperUser qw new LambdaQueryWrapper();qw.eq(User::getPhone, phone);log.debug(准备查询用户);User user this.getOne(qw);
// User user query().eq(phone, phone).one();if (user null) {user createUserWithPhone(phone);}log.debug(获取用户成功准备存入redis保存,用户信息{}, user);//保存user到session中log.debug(准备存入user到redis中 user{}, user);String token cn.hutool.core.lang.UUID.randomUUID().toString(true);UserDTO userDTO BeanUtil.copyProperties(user, UserDTO.class);
// session.setAttribute(user, userDTO);//把user转换为map 并把userDTO中的所有字段的值改为String类型因为MapString, Object userDTOMap BeanUtil.beanToMap(userDTO,new HashMap(),CopyOptions.create().setIgnoreNullValue(true) // 是否忽略一些空值//fieldName字段名//fieldValue字段值//返回值修改后的字段值.setFieldValueEditor((fieldName,fieldValue) - fieldValue.toString()) //修改字段值);//存入redisString tokenKey LOGIN_TOKEN_KEY token;stringRedisTemplate.opsForHash().putAll(tokenKey ,userDTOMap);//设置有效期stringRedisTemplate.expire(tokenKey,LOGIN_TOKEN_TTL,TimeUnit.MINUTES);return Result.ok(token);}
LoginIntercepter
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.LOGIN_TOKEN_KEY;
import static com.hmdp.utils.RedisConstants.LOGIN_TOKEN_TTL;Slf4j
public class LoginIntercepter implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;public LoginIntercepter(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate stringRedisTemplate;}Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//1.获取请求头中的tokenString token request.getHeader(authorization);//2.判断用户是否存在if(StrUtil.isBlank(token)){//不存在拦截log.debug(校验请求头的token为空);response.setStatus(401);return false;}//3.从redis中获取String loginKey LOGIN_TOKEN_KEY token;MapObject, Object userMap stringRedisTemplate.opsForHash().entries(loginKey);//4.判断用户是否存在if(userMap.isEmpty()){log.debug(在redis中未获取到token);response.setStatus(401);return false;}//将查询到的Hash数据转为UserDto对象//BeanUtil.fillBeanWithMap 填充bean通过map集合//参数一从哪个map中填充//参数二填充哪个bean//参数三是否忽略异常false不忽略抛出true忽略异常//返回值填充的beanUserDTO userDTO BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);//5.存在保存到ThreadLocalUserHolder.saveUser(userDTO);//6.重新设置有效期只要用户访问就重置用户登录命令redis的有效期stringRedisTemplate.expire(loginKey,LOGIN_TOKEN_TTL, TimeUnit.MINUTES);return HandlerInterceptor.super.preHandle(request, response, handler);}Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);}Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {//移除用户UserHolder.removeUser();}
}登录拦截器的优化 1初始方案
我们可以通过拦截器拦截到的请求来证明用户是否在操作如果用户没有任何操作30分钟则token会消失用户需要重新等了。通过查看请求我们发现我们存的token在请求头里那么我们就在拦截器里来刷新token的存活时间。
在这个方案中他确实可以使用对应路径的拦截同时刷新登录token令牌的存活时间但是现在这个拦截器他只是拦截需要被拦截的路径假设当前用户访问了一些不需要拦截的路径那么这个拦截器就不会生效所以此时令牌刷新的动作实际上就不会执行所以这个方案是存在问题的。 2登录拦截器的优化
现在的拦截器只刷新需要登录的页面如果用户一直访问不需要登录的资源那30分钟过后用户也会被注销登录。因此需要增加一个拦截器拦截一切路径。 修改WebConfig配置类拦截器的执行顺序可以由order来指定如果未设置拦截路径则默认是拦截所有路径。
package com.hmdp.config;import com.hmdp.interceptor.LoginInterceptor;
import com.hmdp.interceptor.RefreshToTokenInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;import javax.annotation.Resource;Configuration
public class MvcConfig implements WebMvcConfigurer {Resourceprivate StringRedisTemplate stringRedisTemplate;Overridepublic void addInterceptors(InterceptorRegistry registry) {//token刷新拦截器registry.addInterceptor(new RefreshToTokenInterceptor(stringRedisTemplate)).addPathPatterns(//拦截所有资源/**).order(0);//登录拦截器registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(//不需要登录的相关资源放行/user/code,//验证码发送/user/login,//登录验证/user/me,//登录验证/blog/hot,//热点博客/shop/**,//店铺/shop-type/**,//店铺类型/upload/**,//上传资源 方便测试/voucher/**//优惠卷信息查询).order(1);}
}
RefreshToTokenInterceptor刷新token全局拦截器新建一个RefreshTokenInterceptor类其业务逻辑与之前的LoginInterceptor类似就算遇到用户未登录也继续放行交给LoginInterceptor处理。由于这个对象是我们手动在WebConfig里创建的所以这里不能用AutoWired自动装配只能声明一个私有的到了WebConfig里再自动装配。
package com.hmdp.interceptor;import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import com.hmdp.utils.UserHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
import java.util.concurrent.TimeUnit;import static com.hmdp.utils.RedisConstants.LOGIN_TOKEN_KEY;
import static com.hmdp.utils.RedisConstants.LOGIN_TOKEN_TTL;Slf4j
public class RefreshToTokenInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;public RefreshToTokenInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate stringRedisTemplate;}Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {log.info(进入token刷新拦截器);//1.获取请求头中的tokenString token request.getHeader(authorization);//2.判断用户是否存在if(StrUtil.isBlank(token)){//4.不存在拦截log.info(校验请求头的token为空);return true;}//3.从redis中获取String loginKey LOGIN_TOKEN_KEY token;MapObject, Object userMap stringRedisTemplate.opsForHash().entries(loginKey);//4.判断用户是否存在if(userMap.isEmpty()){log.debug(在redis中未获取到token);return true;}//将查询到的Hash数据转为UserDto对象//BeanUtil.fillBeanWithMap 填充bean通过map集合//参数一从哪个map中填充//参数二填充哪个bean//参数三是否忽略异常false不忽略抛出true忽略异常//返回值填充的beanUserDTO userDTO BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);//5.存在保存到ThreadLocalUserHolder.saveUser(userDTO);//6.重新设置有效期只要用户访问就重置用户登录命令redis的有效期stringRedisTemplate.expire(loginKey,LOGIN_TOKEN_TTL, TimeUnit.MINUTES);return true;}Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);}Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {HandlerInterceptor.super.afterCompletion(request, response, handler, ex);}
}
LoginInterceptor 登录拦截器修改我们之前的LoginInterceptor类只需要判断用户是否存在不存在则拦截存在则放行。
package com.hmdp.interceptor;import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;
import com.hmdp.utils.UserHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;import java.util.Map;
import java.util.concurrent.TimeUnit;import static com.hmdp.utils.RedisConstants.LOGIN_TOKEN_KEY;
import static com.hmdp.utils.RedisConstants.LOGIN_TOKEN_TTL;Slf4j
public class LoginInterceptor implements HandlerInterceptor {Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {log.info(进入登录拦截器);UserDTO user UserHolder.getUser();if(user null){//为获取到登录信息response.setStatus(401);return false;}return HandlerInterceptor.super.preHandle(request, response, handler);}Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);}Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {//移除用户UserHolder.removeUser();}
}二、
商铺查询缓存
缓存(Cache)就是数据交换的缓冲区俗称的缓存就是缓冲区内的数据一般从数据库中获取存储于本地。
在我们查询商户信息时我们是直接操作从数据库中去进行查询的大致逻辑是这样直接查询数据库肯定慢。所以我们可以在客户端与数据库之间加上一个Redis缓存先从Redis中查询如果没有查到再去MySQL中查询同时查询完毕之后将查询到的数据也存入Redis这样当下一个用户来进行查询的时候就可以直接从Redis中获取到数据。
标准的操作方式就是查询数据库之前先查询缓存如果缓存数据存在则直接从缓存中返回如果缓存数据不存在再查询数据库然后将数据存入Redis。 GetMapping(/{id})
public Result queryShopById(PathVariable(id) Long id) {return shopService.queryById(id);
}
public interface IShopService extends IServiceShop {Result queryById(Long id);
}
Override
public Result queryById(Long id) {//先从Redis中查这里的常量值是固定的前缀 店铺idString shopJson stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY id);//如果不为空查询到了则转为Shop类型直接返回if (StrUtil.isNotBlank(shopJson)) {Shop shop JSONUtil.toBean(shopJson, Shop.class);return Result.ok(shop);}//否则去数据库中查Shop shop getById(id);//查不到返回一个错误信息或者返回空都可以根据自己的需求来if (shop null){return Result.fail(店铺不存在);}//查到了则转为json字符串String jsonStr JSONUtil.toJsonStr(shop);//并存入redisstringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY id, jsonStr);//最终把查询到的商户信息返回给前端return Result.ok(shop);
}
缓存更新策略
为什么需要缓存更新因为数据同时保存在缓存和数据中涉及数据一致性问题如果对数据库数据做了一些修改缓存是不知道的这种场景下会造成业务数据错误。 业务场景
低一致性需求比如店铺分类这种一般不会发送变化的场景高一致性需求比如付款等场景但主动更新还是需要加上超时作为兜底方案因为不知道主动更新是否会失败所以两种结合使用
数据库和缓存不一致解决方案
由于我们的缓存数据源来自数据库而数据库的数据是会发生变化的因此如果当数据库中数据发生变化而缓存却没有同步此时就会有一致性问题存在其后果是用户使用缓存中的过时数据就会产生类似多线程数据安全问题从而影响业务产品口碑等。
那么如何解决这个问题呢有如下三种方式 Cache Aside 人工编码方式缓存调用者在更新完数据库之后再去更新缓存也称之为双写方案。Read/Write Through 缓存与数据库整合为一个服务由服务来维护一致性。调用者调用该服务无需关心缓存一致性问题。但是维护这样一个服务很复杂市面上也不容易找到这样的一个现成的服务开发成本高。Write Behind Caching Pattern调用者只操作缓存其他线程去异步处理数据库最终实现一致性。但是维护这样的一个异步的任务很复杂需要实时监控缓存中的数据更新其他线程去异步更新数据库也可能不太及时而且缓存服务器如果宕机那么缓存的数据也就丢失了。
综上所述在企业的实际应用中还是方案一最可靠但是方案一的调用者该如何处理呢
缓存更新策略的最佳实践方案 如果采用方案一假设我们每次操作完数据库之后都去更新一下缓存但是如果中间并没有人查询数据那么这个更新动作只有最后一次是有效的中间的更新动作意义不大所以我们可以把缓存直接删除等到有人再次查询时再将缓存中的数据加载出来。
对比删除缓存与更新缓存
更新缓存每次更新数据库都需要更新缓存无效写操作较多删除缓存更新数据库时让缓存失效再次查询时更新缓存
如何保证缓存与数据库的操作同时成功/同时失败保证两个操作的原子性
单体系统将缓存与数据库操作放在同一个事务利用事务本身的特征。分布式系统利用TCC等分布式事务方案。
先操作缓存还是先操作数据库我们来仔细分析一下这两种方式的线程安全问题。
1先删除缓存再操作数据库
删除缓存的操作很快但是更新数据库的操作相对较慢如果此时有一个线程2刚好进来查询缓存由于我们刚刚才删除缓存所以线程2需要查询数据库并写入缓存但是我们更新数据库的操作还未完成所以线程2查询到的数据是脏数据出现线程安全问题。
2先操作数据库再删除缓存
线程1在查询缓存的时候缓存TTL刚好失效需要查询数据库并写入缓存这个操作耗时相对较短相比较于上图来说但是就在这么短的时间内线程2进来了更新数据库删除缓存但是线程1虽然查询完了数据更新前的旧数据但是还没来得及写入缓存所以线程2的更新数据库与删除缓存并没有影响到线程1的查询旧数据写入缓存造成线程安全问题。 虽然这二者都存在线程安全问题但是相对来说后者出现线程安全问题的概率相对较低所以我们最终采用后者先操作数据库再删除缓存的方案。
缓存更新策略的最佳实践方案
低一致性需求使用Redis自带的内存淘汰机制高一致性需求主动更新并以超时剔除作为兜底方案 读操作缓存命中则直接返回缓存未命中则查询数据库并写入缓存设定超时时间。写操作先写数据库然后再删除缓存。要确保数据库与缓存操作的原子性。 实现商铺缓存与数据库双写一致
核心思路修改ShopController中的业务逻辑满足以下要求
根据id查询店铺时如果缓存未命中则查询数据库并将数据库结果写入缓存并设置TTL。根据id修改店铺时先修改数据库再删除缓存。
修改ShopService的queryById方法写入缓存时设置一下TTL。
Override
public Result queryById(Long id) {//先从Redis中查这里的常量值是固定的前缀 店铺idString shopJson stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY id);//如果不为空查询到了则转为Shop类型直接返回if (StrUtil.isNotBlank(shopJson)) {Shop shop JSONUtil.toBean(shopJson, Shop.class);return Result.ok(shop);}//否则去数据库中查Shop shop getById(id);//查不到返回一个错误信息或者返回空都可以根据自己的需求来if (shop null){return Result.fail(店铺不存在);}//查到了则转为json字符串String jsonStr JSONUtil.toJsonStr(shop);//并存入redis设置TTLstringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY id, jsonStr,CACHE_SHOP_TTL, TimeUnit.MINUTES);//最终把查询到的商户信息返回给前端return Result.ok(shop);
}
修改update方法
Override
public Result update(Shop shop) {//先判空if (shop.getId() null) return Result.fail(店铺id不能为空);//先修改数据库updateById(shop);//再删除缓存stringRedisTemplate.delete(CACHE_SHOP_KEY shop.getId());return Result.ok();
}
缓存穿透问题的解决思路 缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在这样缓存永远都不会生效只有数据库查到了才会让redis缓存但现在的问题是查不到会频繁的去访问数据库。
常见的解决方案有两种 缓存空对象的思路分析
优点实现简单维护方便缺点额外的内存消耗可能造成短期的不一致
当我们客户端访问不存在的数据时会先请求redis但是此时redis中也没有数据就会直接访问数据库但是数据库里也没有数据那么这个数据就穿透了缓存直击数据库。但是数据库能承载的并发不如redis这么高所以如果大量的请求同时都来访问这个不存在的数据那么这些请求就会访问到数据库。
简单的解决方案就是哪怕这个数据在数据库里不存在我们也把这个这个数据存在redis中去这就是为啥说会有额外的内存消耗这样下次用户过来访问这个不存在的数据时redis缓存中也能找到这个数据不用去查数据库。
可能造成的短期不一致是指在空对象的存活期间我们更新了数据库把这个空对象变成了正常的可以访问的数据但由于空对象的TTL还没过所以当用户来查询的时候查询到的还是空对象等TTL过了之后才能访问到正确的数据不过这种情况很少见罢了。
布隆过滤器的思路分析
优点内存占用较少没有多余的key缺点实现复杂可能存在误判
布隆过滤器其实采用的是哈希思想来解决这个问题通过一个庞大的二进制数组根据哈希思想去判断当前这个要查询的数据是否存在如果布隆过滤器判断存在则放行这个请求会去访问redis哪怕此时redis中的数据过期了但是数据库里一定会存在这个数据从数据库中查询到数据之后再将其放到redis中。如果布隆过滤器判断这个数据不存在则直接返回。
这种思想的优点在于节约内存空间但存在误判误判的原因在于布隆过滤器使用的是哈希思想只要是哈希思想都可能存在哈希冲突。
解决商品查询的缓存穿透问题
在原来的逻辑中我们如果发现这个数据在MySQL中不存在就直接返回一个错误信息了但是这样存在缓存穿透问题。
现在的逻辑是如果这个数据不存在将这个数据写入到Redis中并且将value设置为空字符串然后设置一个较短的TTL返回错误信息。当再次发起查询时先去Redis中判断value是否为空字符串如果是空字符串则说明是刚刚我们存的不存在的数据直接返回错误信息 Override
public Result queryById(Long id) {//先从Redis中查这里的常量值是固定的前缀 店铺idString shopJson stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY id);//如果不为空查询到了则转为Shop类型直接返回if (StrUtil.isNotBlank(shopJson)) {Shop shop JSONUtil.toBean(shopJson, Shop.class);return Result.ok(shop);}//如果查询到的是空字符串则说明是缓存的空数据if (shopjson ! null) return Result.fail(店铺不存在);//否则去数据库中查Shop shop getById(id);if (shop null) { //查不到则将空字符串写入RedisstringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY id, , CACHE_NULL_TTL, TimeUnit.MINUTES); //这里的常量值是2分钟return Result.fail(店铺不存在);}//查到了则转为json字符串并存入redis设置TTLString jsonStr JSONUtil.toJsonStr(shop);stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY id, jsonStr, CACHE_SHOP_TTL, TimeUnit.MINUTES);//最终把查询到的商户信息返回给前端return Result.ok(shop);
} 小结 缓存穿透产生的原因是什么用户请求的数据在缓存中和在数据库中都不存在不断发起这样的请求会给数据库带来巨大压力。 缓存产投的解决方案有哪些 缓存null值布隆过滤增强id复杂度避免被猜测id规律可以采用雪花算法做好数据的基础格式校验加强用户权限校验做好热点参数的限流 缓存雪崩问题及解决思路 缓存雪崩是指在同一时间段大量缓存的key同时失效或者Redis服务宕机导致大量请求到达数据库带来巨大压力。 解决方案
给不同的Key的TTL添加随机值让其在不同时间段分批失效。利用Redis集群提高服务的可用性使用一个或者多个哨兵(Sentinel)实例组成的系统对redis节点进行监控在主节点出现故障的情况下能将从节点中的一个升级为主节点进行故障转义保证系统的可用性。给缓存业务添加降级限流策略。给业务添加多级缓存 浏览器访问静态资源时优先读取浏览器本地缓存访问非静态资源ajax查询数据时访问服务端请求到达Nginx后优先读取Nginx本地缓存如果Nginx本地缓存未命中则去直接查询Redis不经过Tomcat如果Redis查询未命中则查询Tomcat请求进入Tomcat后优先查询JVM进程缓存如果JVM进程缓存未命中则查询数据库
缓存击穿问题及解决思路 缓存击穿也叫热点Key问题就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了那么无数请求访问就会在瞬间给数据库带来巨大的冲击。
举个不太恰当的例子一件秒杀中的商品的key突然失效了大家都在疯狂抢购那么这个瞬间就会有无数的请求访问去直接抵达数据库从而造成缓存击穿。
假设线程1在查询缓存之后未命中本来应该去查询数据库重建缓存数据完成这些之后其他线程也就能从缓存中加载这些数据了。但是在线程1还未执行完毕时又进来了线程2、3、4同时来访问当前方法那么这些线程都不能从缓存中查询到数据那么他们就会在同一时刻访问数据库执行SQL语句查询对数据库访问压力过大。 常见的解决方案有两种
互斥锁逻辑过期 解决方案一互斥锁
优点没有额外的内存消耗、保证一致性、实现简单缺点线程需要等待性能受影响可能有死锁风险
利用锁的互斥性假设线程过来只能一个人一个人的访问数据库从而避免对数据库频繁访问产生过大压力但这也会影响查询的性能将查询的性能从并行变成了串行我们可以采用tryLock方法double check来解决这个问题。
线程1在操作的时候拿着锁把房门锁上了那么线程2、3、4就不能都进来操作数据库只有1操作完了把房门打开了此时缓存数据也重建好了线程2、3、4直接从redis中就可以查询到数据。
解决方案二逻辑过期方案
优点线程无需等待性能较好缺点不保证一致性、有额外内存消耗、实现复杂
之所以会出现缓存击穿问题主要原因是在于我们对key设置了TTL如果我们不设置TTL那么就不会有缓存击穿问题但是不设置TTL数据又会一直占用我们的内存所以我们可以采用逻辑过期方案。
我们之前是TTL设置在redis的value中注意这个过期时间并不会直接作用于Redis而是我们后续通过逻辑去处理。假设线程1去查询缓存然后从value中判断当前数据已经过期了此时线程1去获得互斥锁那么其他线程会进行阻塞获得了锁的进程他会开启一个新线程去进行之前的重建缓存数据的逻辑直到新开的线程完成者逻辑之后才会释放锁而线程1直接进行返回假设现在线程3过来访问由于线程2拿着锁所以线程3无法获得锁线程3也直接返回数据但只能返回旧数据牺牲了数据一致性换取性能上的提高只有等待线程2重建缓存数据之后其他线程才能返回正确的数据。
这种方案巧妙在于异步构建缓存数据缺点是在重建完缓存数据之前返回的都是脏数据。
解决商铺缓存击穿问题
利用互斥锁解决缓存击穿问题
核心思路相较于原来从缓存中查询不到数据后直接查询数据库而言现在的方案是进行查询之后如果没有从缓存中查询到数据则进行互斥锁的获取获取互斥锁之后判断是否获取到了锁如果没获取到则休眠一段时间过一会儿再去尝试知道获取到锁为止才能进行查询。
如果获取到了锁的线程则进行查询将查询到的数据写入Redis再释放锁返回数据利用互斥锁就能保证只有一个线程去执行数据库的逻辑防止缓存击穿。 实现方法利用redis的setnx方法来表示获取锁如果redis没有这个key则插入成功返回1如果已经存在这个key则插入失败返回0。在StringRedisTemplate中返回true/false我们可以根据返回值来判断是否有线程成功获取到了锁。
/*** 通过id查询商铺* param id* return*/
Override
public Result queryShopById(Long id) {//缓存穿透//Shop shop queryShopByIdWithPassThrough(id);//缓存击穿 互斥锁Shop shop queryShopByIdWithMutex(id);if(shop null) return Result.fail(店铺不存在);return Result.ok(shop);
}/*** 缓存击穿通过id查询商铺互斥锁* param id* return*/
private Shop queryShopByIdWithMutex(Long id) {//1.从redis查询商铺缓存String shopJson stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY id);//2.判断缓存是否命中// StrUtil.isNotBlank形参为null false形参为 false形参为\t\n false形参为abc trueif (StrUtil.isNotBlank(shopJson)) {//3.命中返回商铺信息Shop shop JSON.parseObject(shopJson, Shop.class);return shop;}//判断是否是空值if(shopJson ! null) return null; // 返回错误信息//4实现缓存重建//4.1获取互斥锁String lockKey lock:shop: id;Shop shop null;try {boolean isLock tryLock(lockKey);//4.2判断是否获取成功if(!isLock){//4.3失败休眠并且重试Thread.sleep(50);return queryShopByIdWithMutex(id);}//4.4成功根据id查询数据库shop this.getById(id);// 模拟重建延时Thread.sleep(200);if (shop null) {//5.不存在放回404//解决缓存穿透问题向redis插入空值stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY id,,CACHE_NULL_TTL, TimeUnit.MINUTES);return null;}//6.将商铺写入redis返回商铺信息stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY id,JSON.toJSONString(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);} catch (InterruptedException e) {throw new RuntimeException(e);} finally {//7.释放互斥锁unlock(lockKey);}return shop;
}/*** 获取锁* param key* return false锁被占用获取失败 true锁没被占用*/
private boolean tryLock(String key){Boolean flag stringRedisTemplate.opsForValue().setIfAbsent(key, 1, 10, TimeUnit.SECONDS);//需要转换为基本数据类型//拆箱可能会有空指针异常所以使用糊涂包的工具类拆箱return BooleanUtil.isTrue(flag);
}/*** 解锁* param key*/
private void unlock(String key){stringRedisTemplate.delete(key);
}
基于逻辑过期解决缓存击穿问题
需求根据id查询商铺的业务基于逻辑过期方式来解决缓存击穿问题。
思路分析当用户开始查询redis时判断是否命中
如果没有命中则直接返回空数据不查询数据库如果命中则将value取出判断value中的过期时间是否满足 如果没有过期则直接返回redis中的数据如果过期则在开启独立线程后直接返回之前的数据独立线程去重构数据重构完成后再释放互斥锁 /*** 缓存击穿通过id查询商铺逻辑删除* param id* return*/
public Shop queryShopByIdWithLogicalExpire(Long id) {//1.从redis查询商铺缓存String shopJson stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY id);//2.判断缓存是否命中//3.如果未命中直接返回if (StrUtil.isBlank(shopJson)) return null;//4.命中需要把json反序列化为对象RedisData shopRedisData JSON.parseObject(shopJson, RedisData.class);LocalDateTime shopExpireTime shopRedisData.getExpireTime();Shop shop JSON.parseObject(shopRedisData.getData().toString(),Shop.class);//5.判断是否过期//5.1未过期直接返回店铺信息if(LocalDateTime.now().isBefore(shopExpireTime)) return shop;//5.2过期需要缓存重建//6.缓存重建//6.1获取互斥锁String lockKey LOCK_SHOP_KEY id;boolean isLock tryLock(lockKey);//6.2判断是否获取锁成功if(isLock){log.debug(获取锁成功);//6.3 获取锁成功开启独立线程实现缓存重建//Redis doubleCheck 重新检查缓存可能在获取锁之前其他线程已经将数据放入缓存//Double Check 是指在查询缓存之前首先进行一次检查看看数据是否存在于缓存中。// 如果存在则直接返回缓存数据。如果不存在,再进一步进行查询数据库的操作并在查询到数据后将数据存入缓存中以供下一次查询使用。RedisData redisDataDoubleCheck JSON.parseObject(stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY id), RedisData.class);LocalDateTime expireTimeDoubleCheck redisDataDoubleCheck.getExpireTime();if (LocalDateTime.now().isBefore(expireTimeDoubleCheck)) {//3.未过期直接返回Shop shopDoubleCheck JSON.parseObject(shopRedisData.getData().toString(),Shop.class);log.debug(DoubleCheck未过期返回shop{},shopDoubleCheck);return shopDoubleCheck;}//过期开启独立线程实现缓存重建CACHE_REBUILD_EXECUTOR.submit(() - {//重建缓存try {this.saveShopToRedis(id,20L); //实际开发中应该设置30分钟这个地方只设置20s方便测试log.debug(重建缓存成功);} catch (Exception e) {throw new RuntimeException(e);} finally {// 释放锁unlock(lockKey);}});}//6.4获取锁失败返回旧的店铺信息log.debug(获取锁失败);return shop;
}/*** 获取锁* param key* return false锁被占用获取失败 true锁没被占用*/
private boolean tryLock(String key){Boolean flag stringRedisTemplate.opsForValue().setIfAbsent(key, 1, 10, TimeUnit.SECONDS);//需要转换为基本数据类型//拆箱可能会有空指针异常所以使用糊涂包的工具类拆箱return BooleanUtil.isTrue(flag);
}/*** 解锁* param key*/
private void unlock(String key){stringRedisTemplate.delete(key);
}/*** 重置缓存* param id 商铺id* param expireSecends 存活时间秒* throws InterruptedException*/
public void saveShopToRedis(Long id,Long expireSecends) throws InterruptedException {//1.查询店铺数据Shop shop getById(id);//测试设置延迟Thread.sleep(200);//2.封装逻辑过期时间RedisData redisData new RedisData();redisData.setData(shop);redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSecends));//3.写入redisstringRedisTemplate.opsForValue().set(CACHE_SHOP_KEYid,JSON.toJSONString(redisData));
}为了测试缓存一致性问题使用Jmeter进行测试。先来复现一遍场景当某个用户去Redis中访问缓存的数据时发现该数据已经过期了于是新开一个线程去重构缓存数据但在重构完成之前用户得到的数据都是脏数据重构完成之后才是新数据。
之后去数据库把这个数据修改一下这样逻辑过期前和逻辑过期后的数据就不一致当用户来访问数据的时候需要花时间来进行重构缓存数据但是在重构完成之前都只能获得脏数据也就是我们修改前的数据只有当重构完毕之后才能获得新数据我们修改后的数据。
测试结果如下同样是开了100个线程去访问逻辑过期数据前面的用户只能看到脏数据后面的用户看到的才是新数据。 自定义缓存工具类
自定义一个缓存工具类方法如下
方法1:将任意java对象序列化为json并存储在string类型的key中并且可以设置TTL过期时间方法2:将任意java对象序列化为json并存储在string类型的key中并且可以设置逻辑过期时间用于处理缓存击穿问题方法3:根据指定的key查询缓存并反序列化为指定类型利用缓存空值的方式解决缓存穿透问题方法4:根据指定的key查询缓存并反序列化为指定类型需要利用逻辑过期解决缓存击穿问题
Resource
private StringRedisTemplate stringRedisTemplate;/*** 将任意java对象序列化为json并存储在string类型的key中并且可以设置TTL过期时间* param key* param value* param timeout* param unit*/
public void set(String key, Object value, Long timeout, TimeUnit unit){stringRedisTemplate.opsForValue().set(key,JSON.toJSONString(value),timeout,unit);
}/*** 将任意java对象序列化为json并存储在string类型的key中并且可以设置逻辑过期时间用于处理缓存击穿问题* param key* param value* param timeout* param unit*/
public void setWithLogicalExpire(String key, Object value, Long timeout, TimeUnit unit){// 设置逻辑过期RedisData redisData new RedisData();redisData.setData(value);redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(timeout)));// 写入redisstringRedisTemplate.opsForValue().set(key,JSON.toJSONString(redisData));
}/*** 根据指定的key查询缓存并反序列化为指定类型利用缓存空值的方式解决缓存穿透问题* param keyPrefix* param id* param type* param timeout* param unit* param dbFallback* param R* param ID* return*/
public R,ID R getWithPassThrough(String keyPrefix, ID id, ClassR type,Long timeout, TimeUnit unit, FunctionID,R dbFallback){String key keyPrefix id;//1.从redis查询商铺缓存String json stringRedisTemplate.opsForValue().get(key);//2.判断缓存是否命中// StrUtil.isNotBlank形参为null false形参为 false形参为\t\n false形参为abc true//3.如果命中返回商铺信息if (StrUtil.isNotBlank(json)) return JSON.parseObject(json, type);//判断是否是空值if(json ! null) return null;//4.如果未命中根据id查询数据库R r dbFallback.apply(id);if (r null) {//5.不存在返回404//解决缓存穿透问题向redis插入空值stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY id,,CACHE_NULL_TTL, TimeUnit.MINUTES);return null;}//6.将商铺写入redis返回商铺信息this.set(key,r,timeout,unit);return r;
}//开启线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR Executors.newFixedThreadPool(10);//锁过期时间 对外提供getset方法,默认10s
private Long lockTimeout 10L;
private TimeUnit lockUnit TimeUnit.SECONDS;
public void setLockTimeout(Long lockTimeout,TimeUnit lockUnit) {this.lockTimeout lockTimeout;this.lockUnit lockUnit;
}/*** 根据指定的key查询缓存并反序列化为指定类型需要利用逻辑过期解决缓存击穿问题* param keyPrefix key前缀* param id 标识注如果方法中参数id这个地方就传id* param type 返回值类型* param timeout 过期时间* param unit 过期时间单位* param dbfailback 如果未获取到缓存中的数据执行的方法* param R* param ID* return 查询结果*/
public R,ID R getWithLogicalExpire(String keyPrefix,ID id,ClassR type,Long timeout,TimeUnit unit,FunctionID,R dbfailback) {String key keyPrefix id;//1.从redis查询商铺缓存String json stringRedisTemplate.opsForValue().get(key);//2.判断缓存是否命中//3.未命中直接返回if (StrUtil.isBlank(json)) return null;//4.命中需要把json反序列化为对象RedisData shopRedisData JSON.parseObject(json, RedisData.class);LocalDateTime shopExpireTime shopRedisData.getExpireTime();R r JSON.parseObject(shopRedisData.getData().toString(),type);//5.判断是否过期//5.1未过期直接返回店铺信息if(LocalDateTime.now().isBefore(shopExpireTime)) return r;//5.2过期需要缓存重建//6.缓存重建//6.1获取互斥锁String lockKey LOCK_SHOP_KEY id;boolean isLock tryLock(lockKey);//6.2判断是否获取锁成功if(isLock){log.debug(获取锁成功);//6.3成功//Redis doubleCheck 重新检查缓存可能在获取锁之前其他线程已经将数据放入缓存//Double Check 是指在查询缓存之前首先进行一次检查看看数据是否存在于缓存中.如果存在则直接返回缓存数据。如果不存在,再进一步进行查询数据库的操作并在查询到数据后将数据存入缓存中以供下一次查询使用。RedisData redisDataDoubleCheck JSON.parseObject(stringRedisTemplate.opsForValue().get(key), RedisData.class);LocalDateTime expireTimeDoubleCheck redisDataDoubleCheck.getExpireTime();if (LocalDateTime.now().isBefore(expireTimeDoubleCheck)) {//3.未过期直接返回R rDoubleCheck JSON.parseObject(shopRedisData.getData().toString(),type);log.debug(DoubleCheck未过期返回shop{},rDoubleCheck);return rDoubleCheck;}//过期开启独立线程实现缓存重建CACHE_REBUILD_EXECUTOR.submit(() - {//重建缓存try {//存入数据库R r1 dbfailback.apply(id);//写入redissetWithLogicalExpire(key,r1,timeout,unit);log.debug(重建缓存成功);} catch (Exception e) {throw new RuntimeException(e);} finally {// 释放锁unlock(lockKey);}});}//6.4获取锁失败返回旧的店铺信息log.debug(获取锁失败);return r;
}/*** 获取锁* param key* return false锁被占用获取失败 true锁没被占用*/
private boolean tryLock(String key){Boolean flag stringRedisTemplate.opsForValue().setIfAbsent(key, 1, lockTimeout, lockUnit);//需要转换为基本数据类型//拆箱可能会有空指针异常所以使用糊涂包的工具类拆箱return BooleanUtil.isTrue(flag);
}/*** 解锁* param key*/
private void unlock(String key){stringRedisTemplate.delete(key);
}
在ShopServiceImpl调用getWithPassThrough和getWithLogicalExpire
/*** 通过id查询商铺* param id* return*/
Override
public Result queryShopById(Long id) {//缓存穿透//Shop shop queryShopByIdWithPassThrough(id);//使用自定义工具类实现缓存穿透Shop shop cacheClient.getWithPassThrough(CACHE_SHOP_KEY,id,Shop.class,30L,TimeUnit.MINUTES,this::getById);if(shop null){return Result.fail(店铺不存在);}return Result.ok(shop);
}/*** 通过id查询商铺* param id* return*/Overridepublic Result queryShopById(Long id) {//缓存击穿 逻辑删除
// Shop shop queryShopByIdWithLogicalExpire(id);//使用自定义工具类实现缓存击穿 逻辑删除Shop shop cacheClient.getWithLogicalExpire(CACHE_SHOP_KEY,id,Shop.class,CACHE_SHOP_TTL,TimeUnit.MINUTES,this::getById);if(shop null){return Result.fail(店铺不存在);}return Result.ok(shop);}
测试
getWithPassThrough方法第一次访问查询数据库但未查询到数据向缓存中建立空值后几次访问没有走数据库测试成功 getWithLogicalExpire第一次获取锁成功后两次获取锁失败所以返回的都是旧数据且只查询了一次数据库第四次以后直接通过缓存获取未访问数据库测试成功