• Redis知识-实战篇(1)


    详细代码在我的Github上,地址:
    https://github.com/CodeTeng/RedisCase

    Redis实战篇

    image-20220917214317341

    1. 短信登录

    1.1 基于Session实现登录流程

    image-20220917230605854

    1.2 实现发送短信验证码

    image-20220917230802381

    核心代码:

    public Result sendCode(String phone, HttpSession session) {
        // 1. 校验手机号
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("非法的手机号码");
        }
        // 2. 生成验证码
        String code = RandomUtil.randomNumbers(6);
        // 3. 保存验证码到session中
        session.setAttribute(SystemConstants.USER_SESSION_CODE, code);
        // 4. 模拟发送验证码
        log.debug("短信验证码为:{}", code);
        return Result.ok();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    1.3 实现登录、注册功能

    image-20220917231459359

    核心代码:

    public Result login(LoginFormDTO loginForm, HttpSession session) {
        String code = loginForm.getCode();
        String phone = loginForm.getPhone();
        // 1. 校验表单
        if (StrUtil.isBlank(phone)) {
            return Result.fail("手机号不能为空");
        }
        if (StrUtil.isBlank(code)) {
            return Result.fail("验证码不能为空");
        }
        // 2. 校验手机号
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("手机号格式错误");
        }
        // 3. 校验验证码
        String sessionCode = (String) session.getAttribute(SystemConstants.USER_SESSION_CODE);
        if (!code.equals(sessionCode)) {
            return Result.fail("验证码错误");
        }
        // 4. 根据手机号查询用户
        User user = this.query().eq("phone", phone).one();
        // 5. 若不存在 进行注册
        if (Objects.isNull(user)) {
            user = createUserWithPhone(phone);
        }
        // 6. 若存在,将用户保存到session中
        session.setAttribute(SystemConstants.USER_SESSION_USER, BeanUtil.copyProperties(user, UserDTO.class));
        return Result.ok();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29

    1.4 实现登录拦截功能

    image-20220917234850290

    拦截器代码:

    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 获取session
        HttpSession session = request.getSession();
        // 2.获取session中的用户
        Object user = session.getAttribute(SystemConstants.USER_SESSION_USER);
        // 3. 判断用户是否存在
        if (user == null) {
            // 4. 不存在,拦截
            response.setStatus(401);
            return false;
        }
        // 5. 存在 保存用户信息到ThreadLocal
        UserHolder.saveUser((UserDTO) user);
        // 6. 放行
        return true;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    相关配置

    public void addInterceptors(InterceptorRegistry registry) {
            // 登录拦截器
            registry.addInterceptor(new LoginInterceptor())
                    .excludePathPatterns(
                            "/shop/**",
                            "/voucher/**",
                            "/shop-type/**",
                            "/upload/**",
                            "/blog/hot",
                            "/user/code",
                            "/user/login"
                    );
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    注意:可以使用threadlocal来做到线程隔离,每个线程操作自己的一份数据

    threadLocal中,无论是他的put方法和他的get方法, 都是先从获得当前用户的线程,然后从线程中取出线程的成员变量map,只要线程不一样,map就不一样,所以可以通过这种方式来做到线程隔离

    1.5 session共享问题

    image-20220918002157478

    1.6 基于Redis实现共享session登录

    1.6.1 设计key的结构

    首先我们要思考一下利用redis来存储数据,那么到底使用哪种结构呢?由于存入的数据比较简单,我们可以考虑使用String,或者是使用

    哈希,如下图,如果使用String,注意他的value,用多占用一点空间,如果使用哈希,则他的value中只会存储他数据本身,如果不是特

    别在意内存,其实使用String就可以。

    image-20220918003115508

    1.6.2 设计key的小细节

    所以我们可以使用String结构,就是一个简单的key,value键值对的方式,但是关于key的处理,session他是每个用户都有自己的

    session,但是redis的key是共享的,就不能使用code了

    在设计这个key的时候,我们之前讲过需要满足两点

    • key要具有唯一性
    • key要方便携带

    如果我们采用phone:手机号这个的数据来存储当然是可以的,但是如果把这样的敏感数据存储到redis中并且从页面中带过来毕竟不太

    合适,所以我们在后台生成一个随机串token,然后让前端带来这个token就能完成我们的整体逻辑了。

    1.7 基于Redis实现短信登录

    image-20220918003644509

    stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code, RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);
    
    • 1

    image-20220918003234219

    核心代码:

    public Result login(LoginFormDTO loginForm, HttpSession session) {
        String code = loginForm.getCode();
        String phone = loginForm.getPhone();
        // 1. 校验表单
        if (StrUtil.isBlank(phone)) {
            return Result.fail("手机号不能为空");
        }
        if (StrUtil.isBlank(code)) {
            return Result.fail("验证码不能为空");
        }
        // 2. 校验手机号
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("手机号格式错误");
        }
        // 3. 校验验证码--->从redis中进行获取
        // String sessionCode = (String) session.getAttribute(SystemConstants.USER_SESSION_CODE);
        String redisCode = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone);
        if (!code.equals(redisCode)) {
            return Result.fail("验证码错误");
        }
        // 4. 根据手机号查询用户
        User user = this.query().eq("phone", phone).one();
        // 5. 若不存在 进行注册
        if (Objects.isNull(user)) {
            user = createUserWithPhone(phone);
        }
        // 6. 若存在,将用户保存到session中--->保存到redis中---记得脱敏数据
        // session.setAttribute(SystemConstants.USER_SESSION_USER, BeanUtil.copyProperties(user, UserDTO.class));
        // 6.1 生成token
        String token = UUID.randomUUID().toString(true);
        // 6.2 将user对象转为Hash进行存储
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                                                         CopyOptions.create()
                                                         .setIgnoreNullValue(true)
                                                         .setFieldValueEditor((filedName, filedValue) -> filedValue.toString()));
        // 6.3 存到redis中
        String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
        stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
        // 6.4 设置token的有效期
        stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
    
        // 7. 返回token
        return Result.ok(token);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45

    1.8 解决状态登录刷新问题

    1.8.1 初始方案问题

    在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录token令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路

    径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方

    案他是存在问题的。

    image-20220918011847052

    1.8.2 优化方案

    既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做

    的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中

    的user对象是否存在即可,完成整体刷新功能。

    image-20220918011955189

    核心代码:

    public class RefreshTokenInterceptor implements HandlerInterceptor {
        
        private StringRedisTemplate stringRedisTemplate;
    
        public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
            this.stringRedisTemplate = stringRedisTemplate;
        }
    
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            // 1.获取请求头中的token
            String token = request.getHeader("authorization");
            if (StrUtil.isBlank(token)) {
                // 放行
                return true;
            }
            // 2.基于TOKEN获取redis中的用户
            String key = RedisConstants.LOGIN_USER_KEY + token;
            Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
            // 3.判断用户是否存在
            if (userMap.isEmpty()) {
                return true;
            }
            // 5.将查询到的hash数据转为UserDTO
            UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
            // 6.存在,保存用户信息到 ThreadLocal
            UserHolder.saveUser(userDTO);
            // 7.刷新token有效期
            stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
            // 8.放行
            return true;
        }
    
        @Override
        public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
            // 移除用户
            UserHolder.removeUser();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39

    2. 商户查询缓存

    2.1 添加商户缓存

    image-20220918105241145

    核心代码:

    public Result queryShopById(Long id) {
        // 1. 从redis中查询
        String cacheShopJson = redisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
        // 2. 判断是否存在
        if (StrUtil.isNotBlank(cacheShopJson)) {
            Shop shop = JSONUtil.toBean(cacheShopJson, Shop.class);
            return Result.ok(shop);
        }
        // 3. 不存在,根据id从数据库查询
        Shop shop = this.getById(id);
        // 4. 没有返回未查询到
        if (Objects.isNull(shop)) {
            return Result.fail("店铺不存在!");
        }
        // 5. 数据库中查询到,返回并存入缓存
        redisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop));
        return Result.ok(shop);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    2.2 缓存更新策略

    缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存

    中的数据过多,所以redis会对部分数据进行更新,或者把他叫为淘汰更合适。

    image-20220918113332150

    主动更新策略

    image-20220918113734705

    image-20220918114102593

    两种操作方案都有数据不一致性问题

    image-20220918115048005

    image-20220918114911644

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

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

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

    核心思路如下:

    修改ShopController中的业务逻辑,满足下面的需求:

    • 根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间

    • 根据id修改店铺时,先修改数据库,再删除缓存

    核心代码:

    查询修改—设置redis缓存时添加过期时间

    stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
    
    • 1

    更新修改—先修改数据库,再删除缓存

    @Transactional
    public Result updateShop(Shop shop) {
        // 1. 修改数据库
        this.updateById(shop);
        // 2. 删除缓存
        stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY + shop.getId());
        return Result.ok();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    2.4 缓存穿透

    缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库

    image-20220918133842487

    缓存空对象逻辑

    image-20220918140857130

    核心代码:

    public Result queryShopById(Long id) {
            // 1. 从redis中查询
        String cacheShopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
        // 2. 判断是否存在
        if (StrUtil.isNotBlank(cacheShopJson)) {
            Shop shop = JSONUtil.toBean(cacheShopJson, Shop.class);
            return Result.ok(shop);
        }
        // 命中的是否是空值
        if (cacheShopJson != null) {
            return Result.fail("店铺信息不存在!");
        }
        // 3. 不存在,根据id从数据库查询
        Shop shop = this.getById(id);
        // 4. 没有返回未查询到
        if (Objects.isNull(shop)) {
            // 将空值写入redis
            stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
            return Result.fail("店铺不存在!");
        }
        // 5. 数据库中查询到,返回并存入缓存,并且设置超时时间
        stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
        return Result.ok(shop);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    小总结

    缓存穿透产生的原因是什么?

    • 用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力

    缓存穿透的解决方案有哪些?

    • 缓存空对象值
    • 布隆过滤
    • 增强id的复杂度,避免被猜测id规律
    • 做好数据的基础格式校验
    • 加强用户权限校验
    • 做好热点参数的限流

    2.5 缓存雪崩

    缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

    image-20220918143023951

    2.6 缓存击穿

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

    带来巨大的冲击。

    image-20220918144408451

    解决方案逻辑

    image-20220918144628573

    二者对比

    image-20220918144644853

    2.6.1 互斥锁解决方案

    image-20220918145120850

    操作锁代码:

    /**
     * 获取互斥锁
     */
    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10L, TimeUnit.SECONDS);
        // 不要直接返回,因为有自动拆箱-防止空指针
        return BooleanUtil.isTrue(flag);
    }
    
    /**
     * 释放互斥锁
     */
    private void unlock(String key) {
        stringRedisTemplate.delete(key);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    核心代码:

    private Shop queryWithMutex(Long id) {
        // 1. 从redis中查询缓存
        String key = RedisConstants.CACHE_SHOP_KEY + id;
        String cacheShopJson = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isNotBlank(cacheShopJson)) {
            // 命中 直接返回
            return JSONUtil.toBean(cacheShopJson, Shop.class);
        }
        // 判断是否为空值
        if (cacheShopJson != null) {
            return null;
        }
        // 2. 获取互斥锁
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        Shop shop = null;
        try {
            boolean isLock = tryLock(lockKey);
            if (!isLock) {
                // 获取失败 休眠重试
                Thread.sleep(50);
                return queryWithMutex(id);
            }
            // 成功 根据id查询数据库
            shop = this.getById(id);
            // 3. 判断数据库中是否存在
            if (Objects.isNull(shop)) {
                // 不存在 存入空对象 防止缓存穿透
                stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
                return null;
            }
            // 4. 查询到 写入redis中 并设置过期时间
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 5. 释放互斥锁
            unlock(lockKey);
        }
        return shop;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    2.6.2 逻辑过期解决方案

    image-20220918145129881

    核心代码:

    private Shop queryWithLogicalExpire(Long id) {
        String key = RedisConstants.CACHE_SHOP_KEY + id;
        // 1. 从redis中查询
        String redisDataJson = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isBlank(redisDataJson)) {
            // 2. 不存在 直接返回空
            return null;
        }
        // 3. 命中缓存 判断是否过期
        RedisData redisData = JSONUtil.toBean(redisDataJson, RedisData.class);
        JSONObject data = (JSONObject) redisData.getData();
        Shop shop = JSONUtil.toBean(data, Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        if (expireTime.isAfter(LocalDateTime.now())) {
            // 未过期
            return shop;
        }
        // 4. 过期 获取互斥锁
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        if (isLock) {
            // 获取成功 开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    this.saveShopToRedis(id, 20L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    // 释放锁
                    unlock(lockKey);
                }
            });
        }
        return shop;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35

    2.7 缓存工具封装

    详情见 CacheClient.java 中查找

  • 相关阅读:
    【教程】Pycharm社区版中打开jupyter的方法
    知识产权维权类型有哪些
    Flink--6、输出算子(连接到外部系统、文件、kafka、MySQL、自定义Sink)
    大数据开发和软件开发哪个前景好?
    基于Kinect 动捕XR直播解决方案 - 技术实现篇
    【21-业务开发-基础业务-商品模块-分类管理-商品系统三级分类的新增类别前后端代码实现-商品系统三级分类的更新类别前后端代码实现-之前错误的Bug修正】
    【多线程/线程池】项目中实际应用场景
    基于STM32单片机红外遥控自动泊车智能车
    基于遗传算法、元胞自动机邻域和随机重启爬山混合优化算法(GA-RRHC)的柔性车间调度研究(Matlab代码实现)
    LeetCode 面试题 08.11. 硬币
  • 原文地址:https://blog.csdn.net/m0_52781902/article/details/126924654