Redis知识-实战篇(1)

时间:2022-09-26 12:12:02

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

Redis实战篇

Redis知识-实战篇(1)

1. 短信登录

1.1 基于Session实现登录流程

Redis知识-实战篇(1)

1.2 实现发送短信验证码

Redis知识-实战篇(1)

核心代码:

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.3 实现登录、注册功能

Redis知识-实战篇(1)

核心代码:

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.4 实现登录拦截功能

Redis知识-实战篇(1)

拦截器代码:

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;
}

相关配置

public void addInterceptors(InterceptorRegistry registry) {
        // 登录拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                );
    }

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

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

1.5 session共享问题

Redis知识-实战篇(1)

1.6 基于Redis实现共享session登录

1.6.1 设计key的结构

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

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

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

Redis知识-实战篇(1)

1.6.2 设计key的小细节

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

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

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

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

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

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

1.7 基于Redis实现短信登录

Redis知识-实战篇(1)

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

Redis知识-实战篇(1)

核心代码:

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.8 解决状态登录刷新问题

1.8.1 初始方案问题

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

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

案他是存在问题的。

Redis知识-实战篇(1)

1.8.2 优化方案

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

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

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

Redis知识-实战篇(1)

核心代码:

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();
    }
}

2. 商户查询缓存

2.1 添加商户缓存

Redis知识-实战篇(1)

核心代码:

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);
}

2.2 缓存更新策略

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

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

Redis知识-实战篇(1)

主动更新策略

Redis知识-实战篇(1)

Redis知识-实战篇(1)

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

Redis知识-实战篇(1)

Redis知识-实战篇(1)

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

  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);

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

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

2.4 缓存穿透

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

Redis知识-实战篇(1)

缓存空对象逻辑

Redis知识-实战篇(1)

核心代码:

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);
}

小总结

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

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

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

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

2.5 缓存雪崩

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

Redis知识-实战篇(1)

2.6 缓存击穿

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

带来巨大的冲击。

Redis知识-实战篇(1)

解决方案逻辑

Redis知识-实战篇(1)

二者对比

Redis知识-实战篇(1)

2.6.1 互斥锁解决方案

Redis知识-实战篇(1)

操作锁代码:

/**
 * 获取互斥锁
 */
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);
}

核心代码:

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;
}

2.6.2 逻辑过期解决方案

Redis知识-实战篇(1)

核心代码:

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;
}

2.7 缓存工具封装

详情见 CacheClient.java 中查找