【Redis实战篇】Redis有可能出现的问题以及如何解决问题

🔥作者主页小林同学的学习笔录

🔥mysql专栏小林同学的专栏

目录

1.Redis

[1.1 短信登录](#1.1 短信登录)

[1.1.1 基于Session实现登录](#1.1.1 基于Session实现登录)

[1.1.2 集群的session共享问题](#1.1.2 集群的session共享问题)

[1.1.3 基于Redis实现共享session登录](#1.1.3 基于Redis实现共享session登录)

[1.1.4 登录拦截器的优化](#1.1.4 登录拦截器的优化)

[1.2 商户查询缓存](#1.2 商户查询缓存)

[1.2.1 什么是缓存](#1.2.1 什么是缓存)

[1.2.2 添加Redis缓存](#1.2.2 添加Redis缓存)

[1.2.3 缓存更新策略](#1.2.3 缓存更新策略)

[1.2.3.1 主动更新策略](#1.2.3.1 主动更新策略)

[1.2.4 缓存穿透](#1.2.4 缓存穿透)

[1.2.5 缓存雪崩](#1.2.5 缓存雪崩)

[1.2.6 缓存击穿](#1.2.6 缓存击穿)

[1.2.7 缓存工具封装](#1.2.7 缓存工具封装)


主打逻辑清晰有条理,代码实现可以找我要
小林同学学JAVA,不是干货不制作https://blog.csdn.net/2301_77358195

1.Redis

Redis数据库很多人知道,但是对于它的作用还是有点不清晰,一张图让你了解

1.1 短信登录

资源获取:可以找我要

启动项目后,在浏览器访问:http://localhost:8081/shop-type/list ,如果可以看到数据则证明运行

没有问题

启动nginx,然后访问:http:// http://localhost:8080 ,即可看到页面

1.1.1 基于Session实现登录

threadlocal: Java Web 应用程序中使用 ThreadLocal 来保存用户登录信息。这样做的好处是,每

个线程(即每个请求)都可以访问自己独立的用户登录信息,而不会与其他线程的登录信息混淆。

用户退出时清除 ThreadLocal 数据。

有一个问题就是,当我修改页面的信息并且退出用户,然后继续登录该用户是否会数据同步问题?

这其实要是不关threadlocal的事情,threadlocal只不过是保证你这次登录请求里面的其他请求是否

会被拦截器放行,拦截器校验该threadlocal,如果是同一个用户就会选择放行。

数据同步的话,需要你把用户信息保存到数据库,然后第二次登录查看该用户信息,从而实现数据

同步。

①.接下来完成一下发送验证码的请求以及后端响应验证码

主要逻辑:

  • 1.验证手机号
  • 2.如果手机号不符合则返回错误信息
  • 3.手机号符合,生成验证码
  • 4.把验证码保存到session
  • 5.发送验证码

②.然后按登录又发送一个请求

主要逻辑:

  • 1.检验手机号(因为是另外一个请求,有可能电话号码被改了)
  • 2.校验验证码
  • 3.验证码不一致或者为null,返回错误信息
  • 4.验证码一致根据手机号查询用户
  • 5.判断用户是否存在
  • 6.不存在,就创建用户并保存到数据库
  • 7.将用户信息保存到session,但是只保存DTO(用户的id和名称),隐藏用户的敏感信息

③.点完登录页面

前端再一次发送请求后会携带sessionId访问后端

为了不让请求直接请求到对应的controller,要配置一个拦截器进行请求的转发到不同的controller

拦截器的主要逻辑:

  • 1.获取session
  • 2.获取session的用户
  • 3.判断用户是否存在
  • 4.用户不存在,拦截并返回401的代码
  • 5.存在,把用户信息保存到threadlocal
  • 6.放行

1.1.2 集群的session共享问题

session共享问题:多台Tomcat并不共享session存储空间,当请求切换到不同tomcat服务时导致数

据丢失的问题。

session的替代方案应该满足:

  • 数据共享
  • 内存存储
  • key、value结构

1.1.3 基于Redis实现共享session登录

改进后的效果

①.接下来完成一下发送验证码的请求以及后端响应验证码

主要逻辑:

  • 1.验证手机号
  • 2.如果手机号不符合则返回错误信息
  • 3.手机号符合,生成验证码
  • 4.把验证码保存到redis,并且设置验证码的有效期
  • 5.发送验证码

②.然后按登录又发送一个请求

主要逻辑:

  • 1.检验手机号(因为是另外一个请求,有可能电话号码被改了)
  • 2.从redis获取验证码并校验
  • 3.验证码不一致或者为null,返回错误信息
  • 4.验证码一致根据手机号查询用户
  • 5.判断用户是否存在
  • 6.不存在,就创建用户并保存到数据库
  • 7.保存用户到redis中
  • 7.1 随机生成token,作为登录的令牌
  • 7.2 将User对象转为HashMap
  • 7.3 存储对象到HashMap
  • 7.4 设置toke有效期为30分钟
  • 8.返回token给客户端,这里一定要返回给客户端

拦截器的主要逻辑:

1.获取session

2.基于TOKEN获取redis中的用户

3.判断用户是否存在

4.用户不存在,拦截并返回401的代码

5.将查询到的Hash数据转为UserDTO对象

6.存在,把用户信息保存到threadlocal

7.刷新token的有效期

8.放行

拦截器刷新token有效期的原因是为了仿造session,如果在30分钟内没有任何活动,tomcat会删除该sessionId,需要用户重新登录,

如果超出没走拦截器redis对于前端的token信息将被删除,从而需要重新登录

如果走拦截器就会一直刷新token,防止登录的时候设置token时间为30分钟,超过30分钟被强制退出

1.1.4 登录拦截器的优化

为什么需要优化呢?

因为我们只对应一个拦截器,并且该拦截器只拦截登录的路径,这就存在问题,如果用户活动其他

路径,但是token有效期并没有被刷新,因此需要再定义一个拦截器来拦截所有路径,给token更新

有效期

可以看到第一个拦截器负责更新token以及保存用户到ThreadLocal,第二个拦截器则只需要判断

ThreadLocal是否有用户,从而确定是不是要放行

1.2 商户查询缓存

1.2.1 什么是缓存

缓存是一种临时存储数据的技术,旨在提高数据访问速度和性能。当程序需要访问某些数据时,它

们通常会从缓存中获取数据,而不是直接从原始数据源(如数据库或网络)获取。

这样可以减少访问原始数据源的频率,从而降低系统的负载并提高响应速度。

缓存涉及的地方很广,比如:

1.2.2 添加Redis缓存

代码用到了许多hutool工具类方法

①.toBean(String jsonString, Class<T> beanClass)

JSONUtil.toBean(type, ShopType.class)

含义是将type字符串转成ShopType对象

②.toJsonStr(Object obj)

JSONUtil.toJsonStr(shopType)

含义是将shopType实例转成String类型

③.toList(String jsonArray, Class<T> elementType)

JSONUtil.toList(shopType, ShopType.class)

含义是将字符串转换为ShopType对象

主要逻辑:

  • 1.从redis查询商铺
  • 2.判断一下商铺存在不存在
  • 3.redis商铺存在,返回商铺信息
  • 4.redis商铺不存在,就去数据库查询
  • 5.数据库中商铺不存在,返回错误信息
  • 6.数据库商铺存在,将数据缓存到redis
  • 7.返回数据

1.2.3 缓存更新策略

业务场景:

低一致性需求:使用内存淘汰机制。例如:修改比较少

高一致性需求:主动更新,并以超时剔除作为兜底方案。例如:修改操作比较多

1.2.3.1 主动更新策略

操作缓存和数据库时有三个问题需要考虑:

1.删除缓存还是更新缓存?

更新缓存:每次更新数据库都更新缓存,无效写操作较多

删除缓存:更新数据库时让缓存失效,查询时再更新缓存

答案:可以看出删除缓存比较高效

2.如何保证缓存与数据库的操作的同时成功或失败?

单体系统,将缓存与数据库操作放在一个事务

分布式系统,利用TCC等分布式事务方案

3.先操作缓存还是先操作数据库?

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

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

答案:先操作数据库,再删除缓存,这种方案线程安全性高

为什么先操作数据库,再删除缓存,这种方案线程安全性高?

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

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

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

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

1.2.4 缓存穿透

缓存穿透是指恶意访问者通过构造恶意的查询条件,使得每次查询都无法命中缓存,从而大量请求

直接穿透缓存到达数据库,导致数据库负载过大。

常见的解决方案有两种:

①.缓存空对象:

  • 优点:实现简单,维护方便
  • 缺点:
  • 额外的内存消耗
  • 可能造成短期的不一致

②.布隆过滤:

  • 优点:内存占用较少,没有多余key
  • 缺点:
  • 实现复杂
  • 存在误判可能

注意:这里的空对象是"",不是null,null不是对象

解决缓存穿透

用缓存空对象解决缓存穿透

主要逻辑:

  • 1.从redis查询商铺
  • 2.判断一下商铺存在不存在
  • 3.redis商铺存在,返回商铺信息
  • 4.如果命中的是空值就直接返回错误信息,不用再去查数据库解决缓存穿透
  • 5.redis商铺不存在,就去数据库查询
  • 6.数据库中商铺不存在,返回错误信息前加上values=""的缓存
  • 7.数据库商铺存在,将数据缓存到redis
  • 8.返回数据

改变的地方有两点:

java 复制代码
//4.然后在查数据库之前,判断是否为空值,如果为空值("")就返回错误信息,如果不为空值则去查找数据库
if(shopJson.equals("")){
            //返回错误信息
            return Result.fail("店铺不存在");
        }

//6.如果店铺不存在,缓存空值,缓存时间尽量设计少点,因为有可能出现数据不一致问题
if (shop == null) {
            //将空值写入redis
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
            //返回错误信息
            return Result.fail("店铺不存在"); 
        }

总结:

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

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

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

  • 缓存null值
  • 布隆过滤
  • 增强id的复杂度(如雪花算法),避免被猜测id规律,因为可以通过过滤id,如果id格式不符合可以被过滤掉
  • 加强用户权限校验
  • 做好热点参数的限流

1.2.5 缓存雪崩

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

带来巨大压力。

解决方案:

  • 给不同的Key的TTL添加随机值,避免同一时间多个key失效问题
  • 利用Redis集群提高服务的可用性
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存

1.2.6 缓存击穿

缓存击穿是指一个缓存中不存在但数据库中存在的数据,在高并发情况下,当有大量请求同时访问

这个不存在于缓存但存在于数据库的数据时,这些请求会直接穿透缓存,去请求数据库,导致数据

库负载激增,甚至可能引起数据库压力过大而崩溃。

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效

了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

注意:热点数据,缓存预热需要提前加载。

常见的解决方案有两种:

  • 互斥锁
  • 逻辑过期
java 复制代码
 //尝试获取锁
    private boolean tryLock(String key){
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }
    //释放锁
    private void unlock(String key){
        stringRedisTemplate.delete(key);
    }

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

这里的互斥锁用字符串里面的setnx来实现,因为它只能有一个key,其他key是加不进去的

主要逻辑:

  • 1.从redis查询商铺
  • 2.判断一下商铺存在不存在
  • 3.redis商铺存在,返回商铺信息
  • 4.如果命中的是空值就直接返回错误信息,不用再去查数据库解决缓存穿透
  • 5.实现缓存重建
  • 5.1 获取互斥锁
  • 5.2 判断是否获取成功
  • 5.3 失败,则休眠并重试
  • 5.4 成功,根据id查询数据库
  • 6.数据库中商铺不存在,返回错误信息前加上values=""的缓存
  • 7.数据库商铺存在,将数据缓存到redis
  • 8.释放互斥锁
  • 9.返回数据

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

java 复制代码
//设置逻辑时间
RedisData redisData = new RedisData();
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));

主要逻辑:

  • 1.从redis查询商铺
  • 2.判断一下商铺存在不存在
  • 3.商铺存在,返回商铺信息
  • 4.命中,需要先把json反序列化为对象
  • 5.判断是否过期
  • 5.1 未过期,直接返回数据
  • 6.已过期,缓存重建
  • 6.1 获取互斥锁
  • 6.2 判断是否获取锁
  • 6.3 成功,开启独立线程,实现缓存重建
  • 6.4 释放锁
  • 6.5 返回过期的商铺信息
java 复制代码
//5.判断是否过期
 if (expireTime.isAfter(LocalDateTime.now())){
        //5.1 未过期,直接返回数据
        return shop;
    }

总结:

1.2.7 缓存工具封装

基于StringRedisTemplate封装一个缓存工具类,满足下列需求:

方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间

方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题

方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题

方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题

java 复制代码
@Slf4j
@Component
public class CacheClient {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    //创建一个线程池
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    //将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
    public void set(String key, Object values, Long time, TimeUnit unit){
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(values),time,unit);
    }

    //将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
    public void setWithLogicalExpire(String key, Object values, Long time, TimeUnit unit){
        //设置逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setData(values);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));

        //写入redis
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
    }


    //根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
    public <R,ID> R queryWithPassThrough(
            String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback,Long time,TimeUnit unit){
        String key = keyPrefix + id;

        //1.从redis查询商铺
        String json = stringRedisTemplate.opsForValue().get(key);
        //2.判断一下商铺存在不存在
        //3.商铺存在,返回商铺信息
        if (StrUtil.isNotBlank(json)) {
            return JSONUtil.toBean(json, type);
        }

        //如果命中的是空值就直接返回,不用再去查数据库解决缓存穿透
        if(json != null){
            //返回错误信息
            return null;
        }

        //4.查数据库
        R r = dbFallback.apply(id);

        //5.商铺不存在,返回错误信息
        if (r == null) {
            //将空值写入redis
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
            //返回错误信息
            return null;
        }
        //6.商铺存在,将数据缓存到redis
        //stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(r),CACHE_SHOP_TTL, TimeUnit.MINUTES);
        this.set(key,r,time,unit);
        //7.返回数据
        return r;
    }

    //缓存击穿,用逻辑时间解决方案
    public <R,ID> R queryWithLoginExpire(
            String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback,Long time,TimeUnit unit){
        String key = keyPrefix + id;

        //1.从redis查询商铺
        String json = stringRedisTemplate.opsForValue().get(key);
        //2.判断一下商铺存在不存在
        //3.商铺存在,返回商铺信息
        if (StrUtil.isBlank(json)) {
            return null;
        }
        //4.命中,需要先把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        JSONObject data = (JSONObject) redisData.getData();
        R r = JSONUtil.toBean(data, type);
        LocalDateTime expireTime = redisData.getExpireTime();
        //5.判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())){
            //5.1 未过期,直接返回数据
            return r;
        }

        //6.已过期,缓存重建
        //6.1 获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean tryLock = tryLock(lockKey);
        //6.2 判断是否获取锁
        if (tryLock) {
            //6.3 成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(()->{
                //重构缓存
                try {
                    //查询数据库
                    R r1 = dbFallback.apply(id);
                    //写入redis
                    this.setWithLogicalExpire(key,r1,time,unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    //释放锁
                    unlock(lockKey);
                }
            });
        }
        //6.4 返回过期的商铺信息
        return r;
    }
    
    //互斥锁解决缓存穿透
    public <R, ID> R queryWithMutex(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(shopJson, type);
        }
        // 判断命中的是否是空值
        if (shopJson != null) {
            // 返回一个错误信息
            return null;
        }

        // 4.实现缓存重建
        // 4.1.获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        R r = null;
        try {
            boolean isLock = tryLock(lockKey);
            // 4.2.判断是否获取成功
            if (!isLock) {
                // 4.3.获取锁失败,休眠并重试
                Thread.sleep(50);
                return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
            }
            // 4.4.获取锁成功,根据id查询数据库
            r = dbFallback.apply(id);
            // 5.不存在,返回错误
            if (r == null) {
                // 将空值写入redis
                stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                // 返回错误信息
                return null;
            }
            // 6.存在,写入redis
            this.set(key, r, time, unit);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            // 7.释放锁
            unlock(lockKey);
        }
        // 8.返回
        return r;
    }

    //尝试获取锁
    private boolean tryLock(String key){
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

    //释放锁
    private void unlock(String key){
        stringRedisTemplate.delete(key);
    }

}
相关推荐
idealzouhu8 分钟前
Java 并发编程 —— AQS 抽象队列同步器
java·开发语言
听封12 分钟前
Thymeleaf 的创建
java·spring boot·spring·maven
写bug写bug18 分钟前
6 种服务限流的实现方式
java·后端·微服务
后端小张26 分钟前
Redis 执行 Lua,能保证原子性吗?
数据库·redis·缓存
离开地球表面_9928 分钟前
索引失效?查询结果不正确?原来都是隐式转换惹的祸
数据库·后端·mysql
楠枬29 分钟前
双指针算法
java·算法·leetcode
奔驰的小野码34 分钟前
java通过org.eclipse.milo实现OPCUA客户端进行连接和订阅
java·开发语言
lipviolet35 分钟前
Redis系列---Redission分布式锁
数据库·redis·分布式
Zhen (Evan) Wang36 分钟前
.NET 6 API + Dapper + SQL Server 2014
数据库·c#·.net