当前位置: 首页 > news >正文

黑马程序员-redis项目实践笔记1

目录

一、

基于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(mail+"code",mail+code);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.一致,根据手机号查询用户LambdaQueryWrapper<User> 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 ThreadLocal<User> 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 ("717055919@qq.com".equals(phone)) {log.info("管理员登录,跳过登录验证");} else {if (codeInRedis == null || !codeInRedis.equals(code)) {//3.不一致,失败return Result.fail("邮箱号或密码错误,请检查后再试");}//2.一致,登录成功}}//4.一致,根据手机号查询用户LambdaQueryWrapper<User> 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类型,因为Map<String, 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;Map<Object, 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;Map<Object, 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 IService<Shop> {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);
}

缓存更新策略

为什么需要缓存更新?因为数据同时保存在缓存和数据中,涉及数据一致性问题,如果对数据库数据做了一些修改缓存是不知道的,这种场景下会造成业务数据错误。

业务场景:

  • 低一致性需求:比如店铺分类这种一般不会发送变化的场景
  • 高一致性需求:比如付款等场景,但主动更新还是需要加上超时作为兜底方案,因为不知道主动更新是否会失败,所以两种结合使用

数据库和缓存不一致解决方案

由于我们的缓存数据源来自数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在,其后果是用户使用缓存中的过时数据,就会产生类似多线程数据安全问题,从而影响业务,产品口碑等。

那么如何解决这个问题呢?有如下三种方式:

  1. Cache Aside 人工编码方式:缓存调用者在更新完数据库之后再去更新缓存,也称之为双写方案。
  2. Read/Write Through :缓存与数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题。但是维护这样一个服务很复杂,市面上也不容易找到这样的一个现成的服务,开发成本高。
  3. Write Behind Caching Pattern:调用者只操作缓存,其他线程去异步处理数据库,最终实现一致性。但是维护这样的一个异步的任务很复杂,需要实时监控缓存中的数据更新,其他线程去异步更新数据库也可能不太及时,而且缓存服务器如果宕机,那么缓存的数据也就丢失了。

综上所述,在企业的实际应用中,还是方案一最可靠,但是方案一的调用者该如何处理呢?

缓存更新策略的最佳实践方案

如果采用方案一,假设我们每次操作完数据库之后,都去更新一下缓存,但是如果中间并没有人查询数据,那么这个更新动作只有最后一次是有效的,中间的更新动作意义不大,所以我们可以把缓存直接删除,等到有人再次查询时,再将缓存中的数据加载出来。

对比删除缓存与更新缓存?

  • 更新缓存:每次更新数据库都需要更新缓存,无效写操作较多
  • 删除缓存:更新数据库时让缓存失效,再次查询时更新缓存

如何保证缓存与数据库的操作同时成功/同时失败,保证两个操作的原子性?

  • 单体系统,将缓存与数据库操作放在同一个事务,利用事务本身的特征。
  • 分布式系统,利用TCC等分布式事务方案。

先操作缓存还是先操作数据库?我们来仔细分析一下这两种方式的线程安全问题。

1)先删除缓存,再操作数据库

删除缓存的操作很快,但是更新数据库的操作相对较慢,如果此时有一个线程2刚好进来查询缓存,由于我们刚刚才删除缓存,所以线程2需要查询数据库,并写入缓存,但是我们更新数据库的操作还未完成,所以线程2查询到的数据是脏数据,出现线程安全问题。

2)先操作数据库,再删除缓存

线程1在查询缓存的时候,缓存TTL刚好失效,需要查询数据库并写入缓存,这个操作耗时相对较短(相比较于上图来说),但是就在这么短的时间内,线程2进来了,更新数据库,删除缓存,但是线程1虽然查询完了数据(更新前的旧数据),但是还没来得及写入缓存,所以线程2的更新数据库与删除缓存,并没有影响到线程1的查询旧数据,写入缓存,造成线程安全问题。

虽然这二者都存在线程安全问题,但是相对来说,后者出现线程安全问题的概率相对较低,所以我们最终采用后者先操作数据库,再删除缓存的方案。

缓存更新策略的最佳实践方案

  • 低一致性需求:使用Redis自带的内存淘汰机制
  • 高一致性需求:主动更新,并以超时剔除作为兜底方案
    • 读操作:缓存命中则直接返回,缓存未命中则查询数据库,并写入缓存,设定超时时间。
    • 写操作:先写数据库,然后再删除缓存。要确保数据库与缓存操作的原子性。

实现商铺缓存与数据库双写一致

核心思路:修改ShopController中的业务逻辑,满足以下要求,

  1. 根据id查询店铺时,如果缓存未命中,则查询数据库,并将数据库结果写入缓存,并设置TTL。
  2. 根据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语句查询,对数据库访问压力过大。

常见的解决方案有两种:

  1. 互斥锁
  2. 逻辑过期

解决方案一:互斥锁

  • 优点:没有额外的内存消耗、保证一致性、实现简单
  • 缺点:线程需要等待,性能受影响;可能有死锁风险

利用锁的互斥性,假设线程过来,只能一个人一个人的访问数据库,从而避免对数据库频繁访问产生过大压力,但这也会影响查询的性能,将查询的性能从并行变成了串行,我们可以采用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_KEY+id,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, Class<R> type,Long timeout, TimeUnit unit, Function<ID,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,Class<R> type,Long timeout,TimeUnit unit,Function<ID,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:第一次获取锁成功,后两次获取锁失败,所以返回的都是旧数据,且只查询了一次数据库,第四次以后直接通过缓存获取未访问数据库,测试成功!

相关文章:

黑马程序员-redis项目实践笔记1

目录 一、 基于Session实现登录 发送验证码 验证用户输入验证码 校验登录状态 Redis代替Session登录 发送验证码修改 验证用户输入验证码 登录拦截器的优化 二、 商铺查询缓存 缓存更新策略 数据库和缓存不一致解决方案 缓存更新策略的最佳实践方案 实现商铺缓…...

ES-入门聚合查询

url 请求地址 http://192.168.1.108:9200/shopping/_search {"aggs": { //聚合操作"price_group":{ //名称,随意起名"terms":{ //分组"field": "price" //分组字段}}} } 查询出来的结果是 查询结果中价格的平均值 {&q…...

七维大脑: 探索人类认知的未来之路

七维大脑&#xff1a; 探索人类认知的未来之路 随着科技的不断发展&#xff0c;人们对于大脑的认知也在不断扩展。近年来&#xff0c;科学家们提出了一个名为“七维大脑”的概念&#xff0c;试图通过七个维度来理解人类的认知过程。这个概念的提出&#xff0c;让人们开始思考&…...

spring |Spring Security安全框架 —— 认证流程实现

文章目录 开头简介环境搭建入门使用1、认证1、实体类2、Controller层3、Service层3.1、接口3.2、实现类3.3、实现类&#xff1a;UserDetailsServiceImpl 4、Mapper层3、自定义token认证filter 注意事项小结 开头 Spring Security 官方网址&#xff1a;Spring Security官网 开…...

Django+vue自动化测试平台---正式开源!!!

自动化测试&#xff1a;接口、Web UI 与 App 的全面探索 在此郑重声明&#xff1a;本文内容未经本人同意&#xff0c;不得随意转载。若有违者&#xff0c;必将追究其法律责任。同时&#xff0c;禁止对相关源码进行任何形式的售卖行为&#xff0c;本内容仅供学习使用。 Git 地…...

电子电气架构 --- 智能网联汽车未来是什么样子?

我是穿拖鞋的汉子,魔都中坚持长期主义的汽车电子工程师。 老规矩,分享一段喜欢的文字,避免自己成为高知识低文化的工程师: 屏蔽力是信息过载时代一个人的特殊竞争力,任何消耗你的人和事,多看一眼都是你的不对。非必要不费力证明自己,无利益不试图说服别人,是精神上的节…...

docker安装elasticsearch(es)+kibana

目录 docker安装elasticsearch 一.准备工作 1.打开docker目录 2.创建elasticsearch目录 3.打开elasticsearch目录 4.拉取elasticsearch镜像 5.检查镜像 二.挂载目录 1.创建数据挂载目录 2.创建配置挂载目录 3.创建插件挂载目录 4.权限授权 三.编辑配置 1.打开con…...

大厂面试真题-说说redis的雪崩、击穿和穿透

缓存雪崩、击穿、穿透是缓存系统中常见的三种问题&#xff0c;它们都会对系统的性能和稳定性造成严重影响。以下是对这三种问题的详细解释以及相应的解决方案&#xff1a; 一、缓存雪崩 问题解释&#xff1a; 缓存雪崩指的是因为某些原因导致缓存中大量的数据同时失效或过期…...

【Spring】获取Cookie和Session(@CookieValue()和@SessionAttribute())

获取 Cookie 传统获取 Cookie 这是没有 Spring 的时候&#xff0c;用 Servlet 来获取&#xff08;获取所有的 Cookie&#xff09; Spring MVC 是基于 Servlet API 构建的原始 Web 框架&#xff0c;也是在 Servlet 的基础上实现的 RequestMapping("/getcookie") …...

【C++打怪之路Lv8】-- string类

&#x1f308; 个人主页&#xff1a;白子寰 &#x1f525; 分类专栏&#xff1a;重生之我在学Linux&#xff0c;C打怪之路&#xff0c;python从入门到精通&#xff0c;数据结构&#xff0c;C语言&#xff0c;C语言题集&#x1f448; 希望得到您的订阅和支持~ &#x1f4a1; 坚持…...

【JS】node.js压缩文件的方式

在 Node.js 中&#xff0c;有多种方法可以压缩文件。以下是几种常见的压缩方式及其对应的代码示例&#xff1a; 使用 archiver 压缩成 ZIP 文件使用 zlib 压缩成 GZIP 文件使用 tar 压缩成 TAR 文件 1. 使用 archiver 压缩成 ZIP 文件 archiver 是一个功能强大的库&#xff…...

2024免费mac苹果电脑清理垃圾软件CleanMyMac X4.15.8

对于苹果电脑用户来说&#xff0c;设备上积累的垃圾文件可能会导致存储空间变得紧张&#xff0c;影响电脑的性能和使用体验。尤其是那些经常下载和安装新应用、编辑视频或处理大量照片的用户&#xff0c;更容易感受到存储空间的压力。面对这种情况&#xff0c;寻找一种有效的苹…...

MPA-SVM多变量回归预测|海洋捕食者优化算法-支持向量机|Matalb

目录 一、程序及算法内容介绍&#xff1a; 基本内容&#xff1a; 亮点与优势&#xff1a; 二、实际运行效果&#xff1a; 三、算法介绍&#xff1a; 四、完整程序下载&#xff1a; 一、程序及算法内容介绍&#xff1a; 基本内容&#xff1a; 本代码基于Matlab平台编译&am…...

【uni-app】HBuilderX安装uni-ui组件

目录 1、官网找到入口 2、登录帐号 3、打开HuilderX 4、选择要应用的项目 5、查看是否安装完成 6、按需安装 7、安装完毕要重启 8、应用 前言&#xff1a;uniapp项目使用uni-ui组件方式很多&#xff0c;有npm安装等&#xff0c;或直接创建uni-ui项目&#xff0c;使用un…...

ROS2 通信三大件之动作 -- Action

通信最后一个&#xff0c;也是不太容易理解的方式action&#xff0c;复杂且重要 1、创建action数据结构 创建工作空间和模块就不多说了 在模块 src/action_moudle/action/Counter.action 下创建文件 Counter.action int32 target # Goal: 目标 --- int32 current_value…...

大数据治理:构建数据驱动的智能决策体系

✅作者简介&#xff1a;2022年博客新星 第八。热爱国学的Java后端开发者&#xff0c;修心和技术同步精进。 &#x1f34e;个人主页&#xff1a;Java Fans的博客 &#x1f34a;个人信条&#xff1a;不迁怒&#xff0c;不贰过。小知识&#xff0c;大智慧。 &#x1f49e;当前专栏…...

k8s微服务

一 、什么是微服务 用控制器来完成集群的工作负载&#xff0c;那么应用如何暴漏出去&#xff1f;需要通过微服务暴漏出去后才能被访问 Service是一组提供相同服务的Pod对外开放的接口。 借助Service&#xff0c;应用可以实现服务发现和负载均衡。 service默认只支持4层负载均…...

【Java】Java 的反射机制(一):反射概述

Java 的反射机制&#xff08;一&#xff09;&#xff1a;反射概述 1.反射概述1.1 静态语言 / 动态语言1.1.1 动态语言1.1.2 静态语言 1.2 Java Reflection1.3 Java 反射机制提供的功能1.4 反射的优点和缺陷1.5 反射相关的主要 API1.6 代码示例 2.Class 类2.1 什么是 Class 类2.…...

Monorepo pnpm 模式管理多个 web 项目

Monorepo pnpm 模式管理多个 web 项目 Monorepo pnpm 模式管理多个 web 项目项目地址git flow 工作流程pnpm workspace.npmrc初始化项目架构引入Husky规范git提交配置eslint和prettiereslint 配置prettier 配置 配置lint-staged创建项目创建shared项目全局安装 vue在 packages …...

2024年诺贝尔物理学奖颁发给了机器学习与神经网络领域的研究者,看是有点意料之外,其实也在情理之中。

近日&#xff0c;2024年诺贝尔物理学奖颁发给了机器学习与神经网络领域的研究者&#xff0c;这是历史上首次出现这样的情况。这项奖项原本只授予对自然现象和物质的物理学研究作出重大贡献的科学家&#xff0c;如今却将全球范围内对机器学习和神经网络的研究和开发作为了一种能…...

idea大量爆红问题解决

问题描述 在学习和工作中&#xff0c;idea是程序员不可缺少的一个工具&#xff0c;但是突然在有些时候就会出现大量爆红的问题&#xff0c;发现无法跳转&#xff0c;无论是关机重启或者是替换root都无法解决 就是如上所展示的问题&#xff0c;但是程序依然可以启动。 问题解决…...

设计模式和设计原则回顾

设计模式和设计原则回顾 23种设计模式是设计原则的完美体现,设计原则设计原则是设计模式的理论基石, 设计模式 在经典的设计模式分类中(如《设计模式:可复用面向对象软件的基础》一书中),总共有23种设计模式,分为三大类: 一、创建型模式(5种) 1. 单例模式(Sing…...

多场景 OkHttpClient 管理器 - Android 网络通信解决方案

下面是一个完整的 Android 实现&#xff0c;展示如何创建和管理多个 OkHttpClient 实例&#xff0c;分别用于长连接、普通 HTTP 请求和文件下载场景。 <?xml version"1.0" encoding"utf-8"?> <LinearLayout xmlns:android"http://schemas…...

循环冗余码校验CRC码 算法步骤+详细实例计算

通信过程&#xff1a;&#xff08;白话解释&#xff09; 我们将原始待发送的消息称为 M M M&#xff0c;依据发送接收消息双方约定的生成多项式 G ( x ) G(x) G(x)&#xff08;意思就是 G &#xff08; x ) G&#xff08;x) G&#xff08;x) 是已知的&#xff09;&#xff0…...

【论文笔记】若干矿井粉尘检测算法概述

总的来说&#xff0c;传统机器学习、传统机器学习与深度学习的结合、LSTM等算法所需要的数据集来源于矿井传感器测量的粉尘浓度&#xff0c;通过建立回归模型来预测未来矿井的粉尘浓度。传统机器学习算法性能易受数据中极端值的影响。YOLO等计算机视觉算法所需要的数据集来源于…...

Mac软件卸载指南,简单易懂!

刚和Adobe分手&#xff0c;它却总在Library里给你写"回忆录"&#xff1f;卸载的Final Cut Pro像电子幽灵般阴魂不散&#xff1f;总是会有残留文件&#xff0c;别慌&#xff01;这份Mac软件卸载指南&#xff0c;将用最硬核的方式教你"数字分手术"&#xff0…...

python报错No module named ‘tensorflow.keras‘

是由于不同版本的tensorflow下的keras所在的路径不同&#xff0c;结合所安装的tensorflow的目录结构修改from语句即可。 原语句&#xff1a; from tensorflow.keras.layers import Conv1D, MaxPooling1D, LSTM, Dense 修改后&#xff1a; from tensorflow.python.keras.lay…...

C++.OpenGL (14/64)多光源(Multiple Lights)

多光源(Multiple Lights) 多光源渲染技术概览 #mermaid-svg-3L5e5gGn76TNh7Lq {font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-3L5e5gGn76TNh7Lq .error-icon{fill:#552222;}#mermaid-svg-3L5e5gGn76TNh7Lq .erro…...

Windows安装Miniconda

一、下载 https://www.anaconda.com/download/success 二、安装 三、配置镜像源 Anaconda/Miniconda pip 配置清华镜像源_anaconda配置清华源-CSDN博客 四、常用操作命令 Anaconda/Miniconda 基本操作命令_miniconda创建环境命令-CSDN博客...

LRU 缓存机制详解与实现(Java版) + 力扣解决

&#x1f4cc; LRU 缓存机制详解与实现&#xff08;Java版&#xff09; 一、&#x1f4d6; 问题背景 在日常开发中&#xff0c;我们经常会使用 缓存&#xff08;Cache&#xff09; 来提升性能。但由于内存有限&#xff0c;缓存不可能无限增长&#xff0c;于是需要策略决定&am…...