黑马点评
通过一个类似于大众点评的项目了解学习redis在实战项目中的使用,下面是项目中会涉及到的模块:
一、导入黑马点评项目
导入springboot项目,导入sql脚本到数据库,开启nginx,更改项目配置文件中的redis和mysql的地址
没什么好写的,跟着视频做,nginx目录不要包含中文。
二、登录模块
1.基于session实现登录
下面是session实现登录的流程图
将实现逻辑写在UserServiceImpl.java中
1.1 发送短信验证码功能
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {@Overridepublic Result sendCode(String phone, HttpSession session) {//1.校验手机号if(RegexUtils.isPhoneInvalid(phone)){//2.如果手机号不合法则返回错误信息return Result.fail("手机号不合法");}//3.如果手机号合法,使用hutool工具生成验证码String code = RandomUtil.randomNumbers(6);//4.保存验证码到session中session.setAttribute("code",code);//5.发送验证码//模拟短信验证码发送,实际会调用阿里云等第三方服务log.debug("发送验证码成功,验证码:{}",code);return Result.ok();}
}
1.2 短信验证码登录注册功能
/*** 短信验证码登录注册* @param loginForm* @param session* @return*/@Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {//1.校验手机号String phone = loginForm.getPhone();if(RegexUtils.isPhoneInvalid(phone)){//3.校验失败,返回错误信息return Result.fail("手机号格式不正确");}//2.校验验证码String code = loginForm.getCode();if(RegexUtils.isCodeInvalid(code)){//3.格式校验失败,返回错误信息return Result.fail("验证码格式不正确");}String cacheCode = (String) session.getAttribute("code");if(!code.equals(cacheCode)){return Result.fail("验证码错误");}//4.根据手机号查询用户LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();lambdaQueryWrapper.eq(User::getPhone,phone);User user = baseMapper.selectOne(lambdaQueryWrapper);//5.用户不存在,创建新用户存在数据库if(user == null){User newUser = new User();newUser.setPhone(phone);newUser.setNickName(SystemConstants.USER_NICK_NAME_PREFIX+ RandomUtil.randomString(10));baseMapper.insert(newUser);session.setAttribute("user",newUser);}//6.用户存在session.setAttribute("user",user);//7.返回登录信息return Result.ok();//不需要返回登录凭证,因为这里是基于session实现的,//浏览器发起请求会携带cookie中的sessionId,然后tomcat通过sessionId找到对应session}
1.3 登录校验功能
在每次请求之前都需要校验请求是否有用户登录,我们在拦截器中做这个功能,并且将后续需要的用户信息存在ThreadLocal中,那么后面在每个线程中就可以获取到这些信息
1.3.1 编写一个登录拦截器
package com.hmdp.utils;
import com.hmdp.entity.User;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
/*** @author Watching* * @date 2023/4/2* * Describe:登录拦截器*/
@Component
public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//1.获取sessionHttpSession session = request.getSession();//2.获取session中的用户User user = (User) session.getAttribute("user");//3.判断用户是否存在if(user == null){response.setStatus(401);//返回状态信息return false;//拦截,禁止通行}//5.存在,保存用户信息到ThreadLocal中,这个UserHolder是我们自己封装的一个类UserHolder.saveUser(user);//将用户信息保存到ThreadLocal//6.放行return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {//移除ThreadLocal中的用户信息,避免内存泄漏(可以去了解ThreadLocal的原理)UserHolder.removeUser();}
}
//UserHoder类
package com.hmdp.utils;
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();}
}
1.3.2 注册登录拦截器
package com.hmdp.config;
import com.hmdp.utils.LoginInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/*** @author Watching* * @date 2023/4/2* * Describe:注册拦截器*/
@Configuration
public class MvcConfig implements WebMvcConfigurer {@AutowiredLoginInterceptor loginInterceptor;@Overridepublic void addInterceptors(InterceptorRegistry registry) {//注册登录拦截器,并列出拦截白名单registry.addInterceptor(loginInterceptor).excludePathPatterns("/shop/**","/voucher/**","/shop-type/**","/upload/**","/blog/hot","/user/code","/user/login");}
}
1.3.3完成用户模块/user/me接口的编写
/*** 很多项目中需要在代码中使用当前登录用户的信息,但是又不方便把保存用户信息的session对象传来传去,* 这种情况下,就可以考虑使用 ThreadLocal* @return*/@GetMapping("/me")public Result me(){// TODO 获取当前登录的用户并返回UserDTO user = UserHolder.getUser();return Result.ok(user);}
1.4 隐藏用户敏感信息
在上面的代码中,我们在登录接口中根据手机号将用户信息从DB中查出来并存在了session
然后在登录拦截器中又将session中的用户信息存在了ThreadLocal中
在/user/me接口将信息全部返回给了前端。
这样会造成一个问题,就是用户的所有信息都被返回给了前端,包括用户的密码等敏感信息,这样肯定是不行的,所以我们需要在登录时仅仅将非敏感信息存进session,并且返回。
我们可以创建UserDTO,用于其中的字段为前端所必须的用户信息,但不包括敏感信息,然后将用户信息封装进入UserDTO中后再返回。
2.集群的session共享问题
session共享问题:多台tomcat服务器之间并不共享session存储空间,当请求切换到不同的tomcat服务器时导致数据丢失的问题。
tomcat本身提供了一个session复制的方案,各个tomcat服务器之间会互相拷贝session。这样看似解决了session共享问题,但是又出现了几个新的问题:
1.每个tomcat内存中都保存了同样的session,造成资源浪费。
2.tomcat彼此拷贝session的时候是存在延迟的,如果用户在延迟的这段时间内再次请求,还是会造成上面的情况。
所以我们需要一个替代方案,这个方案需要满足以下几个条件:
1.数据共享
2.内存存储(速度快,安全
3.key-value结构
感觉答案已经呼之欲出了,这不就是redis的特点吗?只要将用户信息存在redis中,然后各个tomcat服务器去存取就可以了。
3.基于redis实现共享session登录
流程图
3.1 使用redis代替session
3.1.1 发送验证码功能,改写sendCode方法
/*** 获取手机验证码功能** @param phone* @param session* @return*/@Overridepublic Result sendCode(String phone, HttpSession session) {//1.校验手机格式,符合/不符合if (RegexUtils.isPhoneInvalid(phone)) {return Result.fail("手机号格式错误!");}String code = RandomUtil.randomNumbers(6);//生成验证码//2.保存验证码到redis,stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);//3.发送验证码log.debug("发送短信验证码成功,验证码:{}", code);//4.返回值:OKreturn Result.ok();}
3.1.2 短信验证登录注册功能,改写login方法
/*** 短信验证码登录注册** @param loginForm* @param session* @return*/@Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {//1.校验手机号和验证码String phone = loginForm.getPhone();if (RegexUtils.isPhoneInvalid(phone)) return Result.fail("手机号格式错误!");//1.2获取验证码并校验String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);String code = loginForm.getCode();if (cacheCode == null || !cacheCode.equals(code)) {//2.不一致报错return Result.fail("验证码错误");}//3.一致,查询用户是否已经注册,是/否 select * from tb_user where phone = ?User user = query().eq("phone", phone).one();//4否,创建用户并保存if (user == null) {user = createUserWithPhone(phone);}//5是,保持用户信息到redis//5.1随机生成32位数字字符token作为登录令牌String token = UUID.randomUUID().toString();//5.2将User对象转为Hash存储UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));//使用CopyOptions参数避免Long转String异常//5.3往redis存储用户信息String tokenKey = LOGIN_USER_KEY + token;stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);//5.3.1设置token有效期stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.SECONDS);//6返回tokenreturn Result.ok(token);}private User createUserWithPhone(String phone) {//1.创建用户User user = new User();user.setPhone(phone);user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomNumbers(10));//2.保存用户save(user);return user;}
3.1.3 登录校验功能,改写登录拦截器
package com.hmdp.utils;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;/*** @author Watching* * @date 2023/4/2* * Describe:登录拦截器*/
@Component
public class LoginInterceptor implements HandlerInterceptor {@AutowiredStringRedisTemplate stringRedisTemplate;@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//1.获取session,改为获取请求头中的token
// HttpSession session = request.getSession();String token = request.getHeader("authorization");if(StrUtil.isBlank(token)){response.setStatus(401);return false;}//2.获取session中的用户,根据token获取redis中的用户信息
// Object user = session.getAttribute("user");Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);//3.判断用户是否存在if(entries.isEmpty()){response.setStatus(401);return false;}//5.存在,保存用户信息到ThreadLocal中
// UserHolder.saveUser((UserDTO)user);//将用户信息保存到ThreadLocalUserDTO userDTO = BeanUtil.fillBeanWithMap(entries, new UserDTO(), false);UserHolder.saveUser(userDTO);//TODO 放行之前要刷新token的有效期stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token,RedisConstants.LOGIN_USER_TTL, TimeUnit.SECONDS);//6.放行return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {//移除ThreadLocal中的用户信息,避免内存泄漏(可以去了解ThreadLocal的原理)UserHolder.removeUser();}
}
3.2 解决登录状态刷新问题
我们上面对token的刷新操作存在一个问题
我们在拦截器LoginInteceptor中对token进行了一个刷新,但是这个拦截器是排除了很多路径的,所以当用户登录后,他访问被排除的这些路径请求,token是不会刷新的。
而我们的要求是,用户的每次请求都会刷新token。
解决方法:在LoginInteceptor执行之前再添加一个拦截器,进行token刷新,这样的话每次请求都会对token进行刷新。
3.2.1 编写一个token刷新拦截器RefreshTokenInteceptor
RefreshTokenInteceptor不需要对请求进行拦截,只需要完成以下几个要求:
1.获取前端传来的token
2.根据token从redis中查询用户信息
3.如果从redis中查询出来的用户信息不为空,则存在ThreadLocal中
4.如果从redis中查询出来的用户信息不为空,则刷新redis中的token有效期
5.放行所有请求
/*** @author Watching* * @date 2023/4/2* * Describe:用于拦截所有请求,保证用户的请求都会刷新token有效期*/
@Component
public class RefreshTokenInterceptor implements HandlerInterceptor {@AutowiredStringRedisTemplate stringRedisTemplate;@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//1.获取session,改为获取请求头中的token
// HttpSession session = request.getSession();String token = request.getHeader("authorization");if(StrUtil.isBlank(token)){return true;}//2.获取session中的用户,根据token获取redis中的用户信息
// Object user = session.getAttribute("user");Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);//3.判断用户是否存在if(entries.isEmpty()){return true;}//5.存在,保存用户信息到ThreadLocal中
// UserHolder.saveUser((UserDTO)user);//将用户信息保存到ThreadLocalUserDTO userDTO = BeanUtil.fillBeanWithMap(entries, new UserDTO(), false);UserHolder.saveUser(userDTO);//TODO 放行之前要刷新token的有效期stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token,RedisConstants.LOGIN_USER_TTL, TimeUnit.SECONDS);//6.放行return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {//移除ThreadLocal中的用户信息,避免内存泄漏(可以去了解ThreadLocal的原理)UserHolder.removeUser();}
}
3.2.2 改写LoginInterceptor
因为很多操作都在RefreshTokenInterceptor中执行了,所以登录拦截器中只需要从localthread中取数据,并判断是否为空就行。
如果没有用户,说明当前没有用户登录,所以直接拦截请求返回401
如果存在用户,说明当前有用户登录,放行
/*** @author Watching* * @date 2023/4/2* * Describe:登录拦截器*/
@Component
public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//1.判断是否需要拦截(ThreadLocal中是否有用户)if(UserHolder.getUser() == null){//没有用户,则说明未登录,拦截response.setStatus(401);return false;}//不为空,有用户,则放行return true;}
}
三、商户查询缓存
1.什么是缓存?
缓存就是数据交换的缓冲区( 称作Cache [kaef),是存贮数据的临时地方,一般读写性能较高。
缓存的作用:
- 提高读写效率,降低响应时间
- 降低后端负载
缓存的成本: - 数据一致性成本,要保证数据库中的数据和缓存中的数据保持一致
- 代码维护成本
- 运维成本,比如集群搭建
2.商户信息添加缓存
为查询商户信息添加缓存
/*** 根据商户id查询商户信息,并缓存在redis中* @param id* @return*/@Overridepublic Result queryById(Long id) {//1.从redis查询商铺信息Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(RedisConstants.CACHE_SHOP_KEY + id);Shop shop = BeanUtil.fillBeanWithMap(entries, new Shop(), false);//2.判断是否存在if (!entries.isEmpty()) {//3.存在直接返回return Result.ok(shop);}//4.不存在,根据id查询DBShop dbShop = baseMapper.selectById(id);//5.DB中也不存在,返回错误if (dbShop == null) {return Result.fail("商户不存在");}//6.将查询到的数据存在redis缓存中Map<String, Object> map = BeanUtil.beanToMap(dbShop, new HashMap<>(), CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue) -> {if (fieldValue != null) {//需要判空,否则空指针fieldValue += "";}return fieldValue;}));stringRedisTemplate.opsForHash().putAll(RedisConstants.CACHE_SHOP_KEY + id, map);//7.返回数据return Result.ok(dbShop);}
重点就是使用hash存储时,需要将bean转为hash,转的时候需要使用CopyOptions将shop的属性转为String类型,因为我们使用的是StringRedisTemplate。使用CopyOptions的时候还需要注意空值的问题。
注意:
建议是大部分情况下使用 String 存储就好,毕竟在存储具有多层嵌套的对象时方便很多,占用的空间也比 Hash 小。当我们需要存储一个特别大的对象时,而且在大多数情况中只需要访问该对象少量的字段时,可以考虑使用 Hash。
3.为商铺分类列表添加缓存
首页的商户分类列表也是需要从DB查询的,而且每人每次访问首页都会访问DB,这样对DB的压力是很大的,所以也需要将他们放入缓存中
/*** 查询商户类型列表并存储在redis中* @return*/@Overridepublic Result getTypeList() {//1.查询redis中是否有数据String shopTypeStr = stringRedisTemplate.opsForValue().get(RedisConstants.SHOP_TYPE_KEY);//2.如果有,直接返回if (!StrUtil.isBlank(shopTypeStr)) {List<ShopType> shopTypes = JSONUtil.toList(shopTypeStr, ShopType.class);//还要将json字符串转换为对象再传入ok()函数,否则前端无法解析字符串会报错return Result.ok(shopTypes);}//3.如果没有,则查询DBList<ShopType> shopTypes = baseMapper.selectList(null);//4.如果DB中没有,返回错误信息if(shopTypes.isEmpty()){return Result.fail("商户类型数据为空");}//5.DB中有,则放入Redis缓存String parse = String.valueOf(JSONUtil.parse(shopTypes));stringRedisTemplate.opsForValue().set(RedisConstants.SHOP_TYPE_KEY,parse);//6.返回数据return Result.ok(shopTypes);}
这里没有什么需要特别注意的地方,但是redis存储类型我们可以斟酌一下,因为商户类型是一个list,我们可以使用String,List等数据类型。
============2023/4/3更新bug
在存放Result.ok(Object)数据时,我之前存放的是json字符串,而mvc会自动将对象转成字符串传给前端,导致字符串再被转了一边json,所以前端解析不了,显示undefined,导致商户分类无法显示图片。只需要将json字符串转为对象,再放到Result.ok()函数中就正确了。
四、缓存更新策略
1.缓存更新策略;内存淘汰;超时剔除;主动更新;
为了保证缓存与数据库的一致性,我们需要使用使用一些缓存更新策略,有以下三种:
- 内存淘汰是指redis在检测到内存已经满了之后会自动删除一些数据,来腾出空间,但是这样很难保证数据一致性
- 超时剔除是指我们在存数据的时候为数据设置过期时间,到期自动删除,这样和内存淘汰策略一样存在一个问题,就是在数据库数据发生改变后,redis中的数据并没有到过期时间,在过期之前,为前端返回的都是过期数据。
- 主动更新是指我们在修改数据库的同时,主动修改缓存数据,这样就可以保证缓存数据和数据库数据的一致性
2.主动更新
主动更新是需要我们每次修改数据库的时候手动对象缓存进行操作的,常见的三种主动更新策略
综合考虑我们会选择第一种。主动更新策略
操作数据库和缓存时,我们需要考虑三个问题:
1.删除缓存还是更新缓存?
①如果更新缓存,那么每次更新数据库都更新缓存,如果我们多次更新数据库,就需要同步更新多次缓存,但是实际只有最后一次更新缓存操作有效。
②如果删除缓存,那么下次查询会直接访问数据库,然后更新缓存,无效操作较少。
2.如何保证缓存操作和数据库操作同时成功或同时失败?
①如果是单体应用,我们可以使用事务,将缓存操作和数据库操作放在同一个事务中
②如果是分布式应用我们就需要使用TCC等分布式事务方案
3.先删除缓存还是先删除数据库?
两种删除方案都可以,但是建议使用先删除数据库
我们来模拟一下先删缓存,在线程1删除缓存且还未更新数据库的时候,线程2进来查询缓存,未命中,直接就去查询数据库,并且将数据库中的数据存在缓存中。但是!此时数据库的数据还没更新,导致缓存中的数据是错误的。
我们再来模拟先删数据库,线程1在查询缓存时,缓存恰好失效,那么线程1就去查询数据库,然后写入缓存。线程2更新数据库并删除缓存,但是由于线程1写入缓存是在线程2结束之后,所以缓存中也存放了过期的数据
先删除数据库数据还是先删除缓存数据
3.给查询商铺的缓存添加超时剔除和主动更新策略
①根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间。这里只需要加一行代码就行
②根据id修改店铺时,先修改数据库,再删除缓存
注意点:我们存商户信息时是使用的hash数据结构,删除hash类型数据时,如果要删除的是该key下的所有数据,应该直接使用stringRedisTemplate的delete,而不是opsForxxx下的delete
/*** 更新商铺信息* 更新数据库的同时还要修改缓存数据* @param shop* @return*/@Override@Transactionalpublic Result update(Shop shop) {Long id = shop.getId();if(id==null){return Result.fail("商户id为空");}//1.更新数据库baseMapper.updateById(shop);//2.删除缓存String key = RedisConstants.CACHE_SHOP_KEY + shop.getId();stringRedisTemplate.delete(key);//删除hash类型数据时,如果要删除的是该key下的所有数据,应该直接使用stringRedisTemplate的delete,而不是opsForxxx下的deletereturn Result.ok();}
}
五、缓存穿透、缓存击穿、缓存雪崩
1.缓存穿透
缓存穿透是指客户端请求的数据在数据库和缓存中都不存在,这样的话缓存永远不会生效,这些请求都会打的数据库。
两种解决方案:
- 缓存空对象
优点:实现简单,维护方便
缺点:
①造成额外的内存消耗,因为会缓存很多空值(可以通过对空值设置较短的过期时间解决)
②可能会造成短期不一致,比如当我们缓存空值的时候,数据库真的插入了一条不为空的数据,但是此时我们在缓存中缓存的却是空值,只有当空值过期被删除后才能缓存真正的值,所以造成了短暂不一致。
- 布隆过滤
优点:内存占用较少,没有多余的key
缺点:①实现复杂②存在误判可能
1.1 使用缓存空值解决缓存商户信息时的缓存击穿问题
使用缓存空值解决缓存商户信息缓存击穿问题,需要在原有的缓存操作上添加两个操作:
①查询数据库发现无数据后,将空值缓存进redis中 (5.1步)
②查询缓存命中后,判断是否是空值,如果是空值则直接返回空,如果不是空值则返回商户信息。(2.1步)
因为我们使用的是hash结构存储商户信息,所以在做缓存空值时无法像String结构那样直接缓存null,使用String结构可以看看视频
预防缓存击穿(String结构)
/*** 根据商户id查询商户信息,并缓存在redis中** @param id* @return*/@Overridepublic Result queryById(Long id) {HashOperations<String, Object, Object> ops = stringRedisTemplate.opsForHash();//1.从redis查询商铺信息Map<Object, Object> entries = ops.entries(RedisConstants.CACHE_SHOP_KEY + id);Shop shop = BeanUtil.fillBeanWithMap(entries, new Shop(), false);//2.判断是否存在if (!entries.isEmpty()) {//2.1判断是否是我们为了防止缓存穿透放的一个空键值对if(entries.size() == 1){return Result.fail("商户不存在(id无法匹配)");}//3.存在直接返回return Result.ok(shop);}entries.size();//4.不存在,根据id查询DBShop dbShop = baseMapper.selectById(id);//5.DB中也不存在,返回错误if (dbShop == null) {//5.1如果不存在,则缓存一个空值,并设置一个较短的有效期,避免缓存击穿ops.put(RedisConstants.CACHE_SHOP_KEY+id,"","");stringRedisTemplate.expire(RedisConstants.CACHE_SHOP_KEY+id,RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);return Result.fail("商户不存在(id无法匹配)");}//6.将查询到的数据存在redis缓存中Map<String, Object> map = BeanUtil.beanToMap(dbShop, new HashMap<>(), CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue) -> {if (fieldValue != null) {//需要判空,否则空指针fieldValue += "";}return fieldValue;}));ops.putAll(RedisConstants.CACHE_SHOP_KEY + id, map);stringRedisTemplate.expire(RedisConstants.CACHE_SHOP_KEY + id, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);//7.返回数据return Result.ok(dbShop);}
上面的缓存空值、布隆过滤器都是被动预防换尺寸穿透,下面还有一些主动预防缓存穿透的方法:
- 增强id的复杂度,避免被猜测id规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流
2.缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,给DB带来巨大压力。
解决方案:
- 给不同的key的TTL添加随机值(解决key失效
- 利用redis集群提高服务可用性(解决redis宕机
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
3.缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
发生条件:
- 高并发
- 缓存重建耗时较长
比如一个热点key失效之后,大量线程进入,访问缓存后都会未命中,都会直接取查询数据库,导致DB压力过大。
常见的两种解决方案: - 互斥锁
- 逻辑过期
3.1 互斥锁
线程1在查询缓存未命中之后会获取互斥锁,然后进行DB查询重建缓存。如果此时有线程2进入,线程2会去查缓存,如果未命中也会尝试去获取锁,具体流程看下图:
但是这样使用互斥锁会有一个问题,就是大量的线程都会因为获取不到互斥锁而等待,直到获取到锁的线程完成缓存重建,这样的效率是比较低的。
3.1.1 基于互斥锁解决缓存击穿问题
在解决缓存穿透的代码的基础上,添加互斥锁解决缓存击穿问题
/*** 根据商户id查询商户信息,并缓存在redis中* 预防缓存穿透和缓存击穿* @param id* @return*/@Overridepublic Result queryById(Long id) {//获取商铺信息,并且在queryWithPassThrough方法中预防了缓存穿透
// Shop shop = queryWithPassThrough(id);//预防了缓存穿透并使用互斥锁解决缓存击穿Shop shop = queryWithMutex(id);if (shop != null) {return Result.ok(shop);}return Result.fail("商家不存在");}
编写queryWithMutex()方法解决。
/*** 使用互斥锁解决缓存击穿** @param id* @return*/public Shop queryWithMutex(Long id) {HashOperations<String, Object, Object> ops = stringRedisTemplate.opsForHash();//1.从redis查询商铺信息Map<Object, Object> entries = ops.entries(RedisConstants.CACHE_SHOP_KEY + id);Shop shop = BeanUtil.fillBeanWithMap(entries, new Shop(), false);//2.判断是否存在if (!entries.isEmpty()) {//2.1 判断是否存在我们为了防止缓存穿透放的一个空键值对if (entries.size() == 1) {return null;}//3.存在直接返回return shop;}//4.不存在 实现缓存重建//4.1 获取互斥锁Shop dbShop = null;try {boolean isLock = tryLock(RedisConstants.LOCK_SHOP_KEY);//4.2 判断是否获取成功if (!isLock) {//4.3 失败,则休眠并重试Thread.sleep(50);return queryWithMutex(id);}//4.4 成功 则根据id从数据库中查询//获取锁成功,再次检测redis缓存是否存在 做一个DoubleCheck,
// Map<Object, Object> entries1 = ops.entries(RedisConstants.CACHE_SHOP_KEY + id);
// Shop shop1 = BeanUtil.fillBeanWithMap(entries, new Shop(), false);
// //2.判断是否存在
// if (!entries1.isEmpty()) {
// //2.1 判断是否存在我们为了防止缓存穿透放的一个空键值对
// if (entries1.size() == 1) {
// return null;
// }
// //3.存在直接返回
// return shop1;
// }dbShop = baseMapper.selectById(id);//模拟重建的耗时Thread.sleep(200);//5.DB中也不存在,返回错误if (dbShop == null) {//5.1如果不存在,则缓存一个空值,并设置一个较短的有效期,避免缓存击穿ops.put(RedisConstants.CACHE_SHOP_KEY + id, "", "");stringRedisTemplate.expire(RedisConstants.CACHE_SHOP_KEY + id, RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);return null;}//6.将查询到的数据存在redis缓存中Map<String, Object> map = BeanUtil.beanToMap(dbShop, new HashMap<>(), CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue) -> {if (fieldValue != null) {//需要判空,否则空指针fieldValue += "";}return fieldValue;}));ops.putAll(RedisConstants.CACHE_SHOP_KEY + id, map);stringRedisTemplate.expire(RedisConstants.CACHE_SHOP_KEY + id, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);} catch (InterruptedException e) {throw new RuntimeException(e);} finally {//7.释放互斥锁stringRedisTemplate.delete(RedisConstants.LOCK_SHOP_KEY);}//8.返回数据return dbShop;}
3.2 逻辑过期
线程1查询缓存未命中后,它也会获取一个互斥锁,并开启新线程2去做缓存重建工作,然后线程1直接返回过期数据。线程3在查询缓存未命中后会尝试获取互斥锁重建缓存,但是获取失败后就会直接返回过期数据,不会循环获取锁,就不会阻塞。
3.2.1 基于逻辑过期解决缓存击穿问题
以下是代码示范:
/*** 使用逻辑过期解决缓存击穿问题** @param id* @return*///创建一个线程池用于重建缓存private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);public Shop queryWithLogicExpire(Long id) {HashOperations<String, Object, Object> ops = stringRedisTemplate.opsForHash();//1.从redis查询商铺信息Map<Object, Object> entries = ops.entries(RedisConstants.CACHE_SHOP_KEY + id);Shop shop = BeanUtil.fillBeanWithMap(entries, new Shop(), false);//2.判断是否命中if (entries.isEmpty()) {//3.未命中,返回空return null;}//4.命中,获取逻辑过期字段//获取店铺数据String str = (String) stringRedisTemplate.opsForHash().get(RedisConstants.CACHE_SHOP_KEY + id, "data");Shop data = JSONUtil.toBean(str, Shop.class);//获取逻辑过期时间Object o = stringRedisTemplate.opsForHash().get(RedisConstants.CACHE_SHOP_KEY + id, "expireTime");String s = o.toString();LocalDateTime expireTime = LocalDateTime.parse(s);//5.根据逻辑过期字段判断数据是否过期//5.1未过期,直接返回数据if (expireTime.isAfter(LocalDateTime.now())) {return data;}//5.2过期,需要缓存重建//6.缓存重建//6.1获取互斥锁boolean b = tryLock(RedisConstants.LOCK_SHOP_KEY + id);//6.2判断是否取锁成功//6.3成功,开启单独线程进行缓存重建if (b) {//TODO 获取锁成功之后应该再次检查redis缓存是否过期,做DOUBLE_CHECK,如果缓存未过期就不需要重建了,因为获取到的锁可能是其它线程重建线程完成之后刚释放的锁,而当前线程还不知道缓存已经被重建了//开启线程进行缓存重建CACHE_REBUILD_EXECUTOR.submit(() -> {try {this.saveShop2Redis(id, 20L);} catch (Exception e) {throw new RuntimeException();} finally {//解锁要在finally中,保证肯定会被解锁unLock(RedisConstants.LOCK_SHOP_KEY + id);}});}//6.4失败,直接返回过期数据return data;}/*** 数据预热** @param id* @param expireTime*/public void saveShop2Redis(Long id, Long expireTime) {//模拟缓存重建耗时try {Thread.sleep(100);} catch (InterruptedException e) {e.printStackTrace();}//1.从DB查询店铺数据Shop shop = baseMapper.selectById(id);//2.封装逻辑过期时间RedisData redisData = new RedisData();redisData.setData(shop);//获取当前时间并添加一段时间,单位为secondredisData.setExpireTime(LocalDateTime.now().plusSeconds(expireTime));//3.写入redisMap<String, Object> map = new HashMap<>();BeanUtil.beanToMap(redisData, map, CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue) -> {String s = "";//判断属性的类型,如果不是LocalDateTime,则说明是引用数据类型(Object),则转为json字符串//如果是LocalDateTime类型,则保持原样if (!(fieldValue instanceof LocalDateTime)) {s = JSONUtil.toJsonStr(fieldValue);}else {s = fieldValue.toString();}return s;}));stringRedisTemplate.opsForHash().putAll(RedisConstants.CACHE_SHOP_KEY + id, map);}
上面这两段代码主要需要注意的地方有以下几点:
- 因为redis使用的存储结构是hash,所以在往里存数据和取数据的时候要注意数据类型之间的转换,比如在saveShop2Redis() 方法中,要根据数据类型进行判断,来决定存入redis的数据类型(data用Json格式,LocaldateTime用字符串形式)。然后往外取的时候方便使用JSONUtil工具直接将json格式的data数据转换为目标对象,将字符串格式的LocaldateTime通过 LocaldateTime.parse(String s) 方法转换为LocaldateTime类型的对象。
- 一些热点数据是需要预热的,所以在未命中缓存的时候直接返回null就行了,不需要去查询DB来构建缓存,所以也就不存在缓存穿透问题了。
- 在另外的线程中获取互斥锁重建缓存后,需要在finally中解锁。
- //TODO 获取锁成功之后应该再次检查redis缓存是否过期,做DOUBLE_CHECK,如果缓存未过期就不需要重建了,因为获取到的锁可能是其它线程重建线程完成之后刚释放的锁,而当前线程还不知道缓存已经被重建了
3.3 互斥锁和逻辑过期的对比
互斥锁死锁是指当一个业务需要获取多个缓存锁,但是锁却在另外一个业务里,彼此都需要被对方持有的锁,这样就会死锁。
4.封装一个redis操作类
这个类中对查询预防缓存穿透和逻辑过期预防缓存击穿做了封装,并且这两个方法使用了泛型,支持缓存任意类型的数据
package com.hmdp.utils;import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.bean.copier.CopyOptions;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.hmdp.entity.Shop;
import com.hmdp.service.impl.ShopServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
/*** @author Watching* * @date 2023/4/7* * Describe:封装redis工具类* 默认redis存储结构为String*/
@Component
@Slf4j
public class CacheClient {@Autowiredprivate ShopServiceImpl shopService;@Autowiredprivate StringRedisTemplate stringRedisTemplate;public CacheClient() {}/*** 写入redis** @param key* @param value* @param time* @param unit*/public void set(String key, Object value, Long time, TimeUnit unit) {stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);}/*** 向redis中添加数据,并添加逻辑过期字段** @param key* @param value* @param time* @param unit*/public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {RedisData redisData = new RedisData();redisData.setData(value);//使用unit.toSeconds(time)将传来的单位换算成秒redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));//写入redisstringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));}/*** 预防缓存穿透** @param id* @return*/public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> doFallback, Long time, TimeUnit unit) {String key = keyPrefix + id;//1.从redis查询商铺信息String json = stringRedisTemplate.opsForValue().get(key);//2.判断是否存在if (StrUtil.isNotBlank(json)) {//3.存在直接返回return JSONUtil.toBean(json, type);}//判断返回的是否是一个空值,如果是则返回错误信息if (json != null) {//不是null,说明是个空字符串""return null;}//4.不存在,根据id查询DB, 函数式编程,数据库数据由调用者主动提供R apply = doFallback.apply(id);//5.DB中也不存在,返回错误if (apply == null) {//5.1如果不存在,则缓存一个空值,并设置一个较短的有效期,避免缓存击穿stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);//返回错误信息return null;}//6.将查询到的数据存在redis缓存中this.set(key, apply, time, unit);//7.返回数据return apply;}private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);/*** 逻辑过期预防缓存击穿*/public <R, ID> R queryWithLogicExpire(String prefix, ID id, Class<R> type, Function<ID, R> doFallback, Long time, TimeUnit unit) {String key = prefix + id;//1.从redis查询商铺信息String shopJson = stringRedisTemplate.opsForValue().get(key);//2.判断是否命中if (StrUtil.isBlank(shopJson)) {//3.未命中,返回空return null;}RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);//获取RedisData中保存的数据R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);//4.命中,获取逻辑过期字段LocalDateTime expireTime = redisData.getExpireTime();//5.根据逻辑过期字段判断数据是否过期//5.1未过期,直接返回当前数据if (expireTime.isAfter(LocalDateTime.now())) {return r;}//5.2过期,需要缓存重建//6.缓存重建//6.1获取互斥锁boolean b = tryLock(RedisConstants.LOCK_SHOP_KEY);//6.2判断是否取锁成功//6.3成功,开启单独线程进行缓存重建if (b) {CACHE_REBUILD_EXECUTOR.submit(() -> {try {Thread.sleep(500);//查询数据库R apply = doFallback.apply(id);//写入redissetWithLogicalExpire(key, apply, time, unit);} catch (Exception e) {throw new RuntimeException();} finally {unLock(RedisConstants.LOCK_SHOP_KEY);}});}//6.4失败,直接返回过期数据return r;}/*** 尝试获取互斥锁** @return*/private boolean tryLock(String key) {//使用setIfAbsent()来执行创建缓存操作,setIfAbsent是redis命令setNX的java函数,只有当缓存中没有该key存在时才会插入成功Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);//使用hutool的工具来判断包装类,预防空指针异常return BooleanUtil.isTrue(flag);}/*** 解锁(删除缓存中的锁*/private void unLock(String key) {//删除缓存中的锁stringRedisTemplate.delete(key);}
}