黑马点评Redis实战(短信登录;商户查询缓存)

黑马点评

通过一个类似于大众点评的项目了解学习redis在实战项目中的使用,下面是项目中会涉及到的模块:
黑马点评Redis实战(短信登录;商户查询缓存)_第1张图片

一、导入黑马点评项目

导入springboot项目,导入sql脚本到数据库,开启nginx,更改项目配置文件中的redis和mysql的地址
没什么好写的,跟着视频做,nginx目录不要包含中文。

二、登录模块

1.基于session实现登录

下面是session实现登录的流程图
黑马点评Redis实战(短信登录;商户查询缓存)_第2张图片
将实现逻辑写在UserServiceImpl.java中

1.1 发送短信验证码功能

@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Override
    public 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
     */
    @Override
    public 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中,那么后面在每个线程中就可以获取到这些信息
黑马点评Redis实战(短信登录;商户查询缓存)_第3张图片

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 {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //1.获取session
        HttpSession 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;
    }

    @Override
    public 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 {
    @Autowired
    LoginInterceptor loginInterceptor;
    @Override
    public 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
黑马点评Redis实战(短信登录;商户查询缓存)_第4张图片
然后在登录拦截器中又将session中的用户信息存在了ThreadLocal中
黑马点评Redis实战(短信登录;商户查询缓存)_第5张图片
在/user/me接口将信息全部返回给了前端。
黑马点评Redis实战(短信登录;商户查询缓存)_第6张图片

这样会造成一个问题,就是用户的所有信息都被返回给了前端,包括用户的密码等敏感信息,这样肯定是不行的,所以我们需要在登录时仅仅将非敏感信息存进session,并且返回。
我们可以创建UserDTO,用于其中的字段为前端所必须的用户信息,但不包括敏感信息,然后将用户信息封装进入UserDTO中后再返回。

2.集群的session共享问题

session共享问题:多台tomcat服务器之间并不共享session存储空间,当请求切换到不同的tomcat服务器时导致数据丢失的问题。
黑马点评Redis实战(短信登录;商户查询缓存)_第7张图片
tomcat本身提供了一个session复制的方案,各个tomcat服务器之间会互相拷贝session。这样看似解决了session共享问题,但是又出现了几个新的问题
1.每个tomcat内存中都保存了同样的session,造成资源浪费。
2.tomcat彼此拷贝session的时候是存在延迟的,如果用户在延迟的这段时间内再次请求,还是会造成上面的情况。
所以我们需要一个替代方案,这个方案需要满足以下几个条件
1.数据共享
2.内存存储(速度快,安全
3.key-value结构
感觉答案已经呼之欲出了,这不就是redis的特点吗?只要将用户信息存在redis中,然后各个tomcat服务器去存取就可以了。
黑马点评Redis实战(短信登录;商户查询缓存)_第8张图片

3.基于redis实现共享session登录

流程图
黑马点评Redis实战(短信登录;商户查询缓存)_第9张图片

3.1 使用redis代替session
3.1.1 发送验证码功能,改写sendCode方法
    /**
     * 获取手机验证码功能
     *
     * @param phone
     * @param session
     * @return
     */
    @Override
    public 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.返回值:OK
        return Result.ok();
    }
3.1.2 短信验证登录注册功能,改写login方法
/**
     * 短信验证码登录注册
     *
     * @param loginForm
     * @param session
     * @return
     */
    @Override
    public 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返回token
        return 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 {
    @Autowired
    StringRedisTemplate stringRedisTemplate;
    @Override
    public 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);//将用户信息保存到ThreadLocal
        UserDTO 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;
    }
        
    @Override
    public 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进行刷新。
黑马点评Redis实战(短信登录;商户查询缓存)_第10张图片

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 {
    @Autowired
    StringRedisTemplate stringRedisTemplate;
    @Override
    public 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);//将用户信息保存到ThreadLocal
        UserDTO 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;
    }
    @Override
    public 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 {
    @Override
    public 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.商户信息添加缓存

为查询商户信息添加缓存
黑马点评Redis实战(短信登录;商户查询缓存)_第11张图片

    /**
     * 根据商户id查询商户信息,并缓存在redis中
     * @param id
     * @return
     */
    @Override
    public 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查询DB
        Shop 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实战(短信登录;商户查询缓存)_第12张图片

    /**
     * 查询商户类型列表并存储在redis中
     * @return
     */
    @Override
    public 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.如果没有,则查询DB
        List<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实战(短信登录;商户查询缓存)_第13张图片

  • 内存淘汰是指redis在检测到内存已经满了之后会自动删除一些数据,来腾出空间,但是这样很难保证数据一致性
  • 超时剔除是指我们在存数据的时候为数据设置过期时间,到期自动删除,这样和内存淘汰策略一样存在一个问题,就是在数据库数据发生改变后,redis中的数据并没有到过期时间,在过期之前,为前端返回的都是过期数据。
  • 主动更新是指我们在修改数据库的同时,主动修改缓存数据,这样就可以保证缓存数据和数据库数据的一致性

2.主动更新

主动更新是需要我们每次修改数据库的时候手动对象缓存进行操作的,常见的三种主动更新策略
黑马点评Redis实战(短信登录;商户查询缓存)_第14张图片
综合考虑我们会选择第一种。主动更新策略

操作数据库和缓存时,我们需要考虑三个问题:
1.删除缓存还是更新缓存?
①如果更新缓存,那么每次更新数据库都更新缓存,如果我们多次更新数据库,就需要同步更新多次缓存,但是实际只有最后一次更新缓存操作有效。
②如果删除缓存,那么下次查询会直接访问数据库,然后更新缓存,无效操作较少。
2.如何保证缓存操作和数据库操作同时成功或同时失败?
①如果是单体应用,我们可以使用事务,将缓存操作和数据库操作放在同一个事务中
②如果是分布式应用我们就需要使用TCC等分布式事务方案
3.先删除缓存还是先删除数据库?
两种删除方案都可以,但是建议使用先删除数据库
们来模拟一下先删缓存,在线程1删除缓存且还未更新数据库的时候,线程2进来查询缓存,未命中,直接就去查询数据库,并且将数据库中的数据存在缓存中。但是!此时数据库的数据还没更新,导致缓存中的数据是错误的。
黑马点评Redis实战(短信登录;商户查询缓存)_第15张图片
们再来模拟先删数据库,线程1在查询缓存时,缓存恰好失效,那么线程1就去查询数据库,然后写入缓存。线程2更新数据库并删除缓存,但是由于线程1写入缓存是在线程2结束之后,所以缓存中也存放了过期的数据
黑马点评Redis实战(短信登录;商户查询缓存)_第16张图片
先删除数据库数据还是先删除缓存数据

3.给查询商铺的缓存添加超时剔除和主动更新策略

根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间。这里只需要加一行代码就行
黑马点评Redis实战(短信登录;商户查询缓存)_第17张图片

根据id修改店铺时,先修改数据库,再删除缓存
注意点:我们存商户信息时是使用的hash数据结构,删除hash类型数据时,如果要删除的是该key下的所有数据,应该直接使用stringRedisTemplate的delete,而不是opsForxxx下的delete

    /**
     * 更新商铺信息
     * 更新数据库的同时还要修改缓存数据
     * @param shop
     * @return
     */
    @Override
    @Transactional
    public 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下的delete
        return Result.ok();
    }
}

五、缓存穿透、缓存击穿、缓存雪崩

1.缓存穿透

缓存穿透是指客户端请求的数据在数据库和缓存中都不存在,这样的话缓存永远不会生效,这些请求都会打的数据库。
两种解决方案:

  • 缓存空对象
    优点:实现简单,维护方便
    缺点
    造成额外的内存消耗,因为会缓存很多空值(可以通过对空值设置较短的过期时间解决)
    可能会造成短期不一致,比如当我们缓存空值的时候,数据库真的插入了一条不为空的数据,但是此时我们在缓存中缓存的却是空值,只有当空值过期被删除后才能缓存真正的值,所以造成了短暂不一致。
    黑马点评Redis实战(短信登录;商户查询缓存)_第18张图片
  • 布隆过滤
    优点:内存占用较少,没有多余的key
    缺点:①实现复杂②存在误判可能
    黑马点评Redis实战(短信登录;商户查询缓存)_第19张图片

1.1 使用缓存空值解决缓存商户信息时的缓存击穿问题

黑马点评Redis实战(短信登录;商户查询缓存)_第20张图片
使用缓存空值解决缓存商户信息缓存击穿问题,需要在原有的缓存操作上添加两个操作:
①查询数据库发现无数据后,将空值缓存进redis中 (5.1步)
②查询缓存命中后,判断是否是空值,如果是空值则直接返回空,如果不是空值则返回商户信息。(2.1步)
因为我们使用的是hash结构存储商户信息,所以在做缓存空值时无法像String结构那样直接缓存null,使用String结构可以看看视频
预防缓存击穿(String结构)

    /**
     * 根据商户id查询商户信息,并缓存在redis中
     *
     * @param id
     * @return
     */
    @Override
    public 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查询DB
        Shop 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带来巨大压力。
黑马点评Redis实战(短信登录;商户查询缓存)_第21张图片
解决方案:

  • 给不同的key的TTL添加随机值(解决key失效
  • 利用redis集群提高服务可用性(解决redis宕机
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存

3.缓存击穿

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

发生条件

  • 高并发
  • 缓存重建耗时较长
    比如一个热点key失效之后,大量线程进入,访问缓存后都会未命中,都会直接取查询数据库,导致DB压力过大。
    黑马点评Redis实战(短信登录;商户查询缓存)_第22张图片
    常见的两种解决方案:
  • 互斥锁
  • 逻辑过期

3.1 互斥锁

线程1在查询缓存未命中之后会获取互斥锁,然后进行DB查询重建缓存。如果此时有线程2进入,线程2会去查缓存,如果未命中也会尝试去获取锁,具体流程看下图:
黑马点评Redis实战(短信登录;商户查询缓存)_第23张图片
但是这样使用互斥锁会有一个问题,就是大量的线程都会因为获取不到互斥锁而等待,直到获取到锁的线程完成缓存重建,这样的效率是比较低的。

3.1.1 基于互斥锁解决缓存击穿问题

黑马点评Redis实战(短信登录;商户查询缓存)_第24张图片
在解决缓存穿透的代码的基础上,添加互斥锁解决缓存击穿问题

    /**
     * 根据商户id查询商户信息,并缓存在redis中
     * 预防缓存穿透和缓存击穿
     * @param id
     * @return
     */
    @Override
    public 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 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在查询缓存未命中后会尝试获取互斥锁重建缓存,但是获取失败后就会直接返回过期数据,不会循环获取锁,就不会阻塞。
黑马点评Redis实战(短信登录;商户查询缓存)_第25张图片

3.2.1 基于逻辑过期解决缓存击穿问题

黑马点评Redis实战(短信登录;商户查询缓存)_第26张图片
以下是代码示范:

    /**
     * 使用逻辑过期解决缓存击穿问题
     *
     * @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);
        //获取当前时间并添加一段时间,单位为second
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireTime));
        //3.写入redis
        Map<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 互斥锁和逻辑过期的对比

黑马点评Redis实战(短信登录;商户查询缓存)_第27张图片
互斥锁死锁是指当一个业务需要获取多个缓存锁,但是锁却在另外一个业务里,彼此都需要被对方持有的锁,这样就会死锁。

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 {
    @Autowired
    private ShopServiceImpl shopService;
    @Autowired
    private 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)));
        //写入redis
        stringRedisTemplate.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);
                    //写入redis
                    setWithLogicalExpire(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);
    }
}

你可能感兴趣的:(redis,java,数据库)