• SpringBoot整合Redis实现常用功能


    SpringBoot整合Redis实现常用功能

    • 建议大小伙们,在写业务的时候,提前画好流程图,思路会清晰很多。
      • 文末有解决缓存穿透和击穿的通用工具类。

    1 登陆功能

    我想,登陆功能是每个项目必备的功能吧,但是想设计好,却是很难!下面介绍两种登陆功能的解决方式:

    • 基于Session实现登录流程
    • 基于Redis实现登录流程

    1.1 基于Session实现登录流程

    功能流程:

    发送验证码:

    • 用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号

    • 如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户

    短信验证码登录、注册:

    • 用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,
    • 如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息

    校验登录状态:

    • 用户在请求时候,会从cookie中携带者JsessionId到后台,后台通过JsessionId从session中拿到用户信息,如果没有session信息,则进行拦截,如果有session信息,则
    • 将用户信息保存到threadLocal中,并且放行

    image-20220818114543795

    1.1.1 session共享问题

    基于session方式实现登陆功能,最大的缺点就是在多台tomcat下session无法共享,就会下出现下面问题。

    核心思路分析:

    每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了

    但是这种方案具有两个大问题

    1、每台服务器中都有完整的一份session数据,服务器压力过大。

    2、session拷贝数据时,可能会出现延迟

    所以咱们后来采用的方案都是基于redis来完成,我们把session换成redis,redis数据本身就是共享的,就可以避免session共享的问题了

    image-20220818212049739


    1.2 Redis替代Session

    1.2.1、设计key的结构

    首先我们要思考一下利用redis来存储数据,那么到底使用哪种结构呢?由于存入的数据比较简单,我们可以考虑使用String,或者是使用哈希,如下图,如果使用String,同学们注意他的value,用多占用一点空间,如果使用哈希,则他的value中只会存储他数据本身,如果不是特别在意内存,其实使用String就可以啦。

    image-20220818212300768

    1.2.2、设计key的具体细节

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

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

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

    如果我们采用phone:手机号这个的数据来存储当然是可以的,但是如果把这样的敏感数据存储到redis中并且从页面中带过来毕竟不太合适,所以我们在后台生成一个随机串token,然后让前端带来这个token就能完成我们的整体逻辑了.

    1.2.3、整体访问流程

    当注册完成后,用户去登录会去校验用户提交的手机号和验证码,是否一致,如果一致,则根据手机号查询用户信息,不存在则新建,最后将用户数据保存到redis,并且生成token作为redis的key,当我们校验用户是否登录时,会去携带着token进行访问,从redis中取出token对应的value,判断是否存在这个数据,如果没有则拦截,如果存在则将其保存到threadLocal中,并且放行。

    image-20220818212558208

    2 缓存功能

    2.1 什么是缓存?

    缓存(Cache),就是数据交换的缓冲区,俗称的缓存就是缓冲区内的数据,一般从数据库中获取,存储于本地代码(例如:

    1:Static final ConcurrentHashMap<K,V> map = new ConcurrentHashMap<>(); 本地用于高并发
    
    例2:static final Cache<K,V> USER_CACHE = CacheBuilder.newBuilder().build(); 用于redis等缓存
    
    例3:Static final Map<K,V> map =  new HashMap(); 本地缓存
    
    • 1
    • 2
    • 3
    • 4
    • 5

    由于其被Static修饰,所以随着类的加载而被加载到内存之中,作为本地缓存,由于其又被final修饰,所以其引用(例3:map)和对象(例3:new HashMap())之间的关系是固定的,不能改变,因此不用担心赋值(=)导致缓存失效;

    2.1.1 为什么要使用缓存

    一句话:因为**速度快,好用**

    缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低**用户访问并发量带来的**服务器读写压力

    实际开发过程中,企业的数据量,少则几十万,多则几千万,这么大数据量,如果没有缓存来作为"避震器",系统是几乎撑不住的,所以企业会大量运用到缓存技术;

    但是缓存也会增加代码复杂度和运营的成本:

    image-20220818212919507

    2.1.2 如何使用缓存

    实际开发中,会构筑多级缓存来使系统运行速度进一步提升,例如:本地缓存与redis中的缓存并发使用

    浏览器缓存:主要是存在于浏览器端的缓存

    **应用层缓存:**可以分为tomcat本地缓存,比如之前提到的map,或者是使用redis作为缓存

    **数据库缓存:**在数据库中有一片空间是 buffer pool,增改查数据都会先加载到mysql的缓存中

    **CPU缓存:**当代计算机最大的问题是 cpu性能提升了,但内存读写速度没有跟上,所以为了适应当下的情况,增加了cpu的L1,L2,L3级的缓存

    image-20220818213011130

    2.2.使用缓存

    2.2.1 、缓存模型和思路

    标准的操作方式就是查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入redis

    image-20220818213118682

    2.3 缓存更新策略

    缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,所以redis会对部分数据进行更新,或者把他叫为淘汰更合适。

    **内存淘汰:**redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)

    **超时剔除:**当我们给redis设置了过期时间ttl之后,redis会将超时的数据进行删除,方便咱们继续使用缓存

    **主动更新:**我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题

    image-20220818213231166

    2.3.1 、数据库缓存不一致解决方案:

    由于我们的**缓存的数据源来自于数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在**,其后果是:

    用户使用缓存中的过时数据,就会产生类似多线程数据安全问题,从而影响业务,产品口碑等;怎么解决呢?有如下几种方案

    Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案(一般采用

    Read/Write Through Pattern : 由系统本身完成,数据库与缓存的问题交由系统本身去处理

    Write Behind Caching Pattern :调用者只操作缓存,其他线程去异步处理数据库,实现最终一致

    image-20220818213327843

    2.3.2 、数据库和缓存不一致采用什么方案

    综合考虑使用方案一,但是方案一调用者如何处理呢?这里有几个问题

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

    如果采用第一个方案,那么假设我们每次操作数据库后,都操作缓存,但是中间如果没有人查询,那么这个更新动作实际上只有最后一次生效,中间的更新动作意义并不大,我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来

    • 删除缓存还是更新缓存?

      • 更新缓存:每次更新数据库都更新缓存,无效写操作较多
      • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存
    • 如何保证缓存与数据库的操作的同时成功或失败?

      • 单体系统,将缓存与数据库操作放在一个事务
      • 分布式系统,利用TCC等分布式事务方案

    应该具体操作缓存还是操作数据库,我们应当是先操作数据库,再删除缓存,原因在于,如果你选择第一种方案,在两个线程并发来访问时,假设线程1先来,他先把缓存删了,此时线程2过来,他查询缓存数据并不存在,此时他写入缓存,当他写入缓存后,线程1再执行更新动作时,实际上写入的就是旧的数据,新的数据被旧数据覆盖了。

    • 先操作缓存还是先操作数据库?
      • 先删除缓存,再操作数据库(存在线程安全问题)
      • 先操作数据库,再删除缓存

    image-20220818213625221

    2.4 缓存穿透问题的解决思路

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

    常见的解决方案有两种:

    • 缓存空对象
      • 优点:实现简单,维护方便
      • 缺点:
        • 额外的内存消耗
        • 可能造成短期的不一致
    • 布隆过滤
      • 优点:内存占用较少,没有多余key
      • 缺点:
        • 实现复杂
        • 存在误判可能

    **缓存空对象思路分析:**当我们客户端访问不存在的数据时,先请求redis,但是此时redis中没有数据,此时会访问到数据库,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库,我们都知道数据库能够承载的并发不如redis这么高,如果大量的请求同时过来访问这种不存在的数据,这些请求就都会访问到数据库,简单的解决方案就是哪怕这个数据在数据库中也不存在,我们也把这个数据存入到redis中去,这样,下次用户过来访问这个不存在的数据,那么在redis中也能找到这个数据就不会进入到缓存了.

    **布隆过滤:**布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中

    假设布隆过滤器判断这个数据不存在,则直接返回

    这种方式优点在于节约内存空间,存在误判,误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突

    image-20220818213925098

    小总结:

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

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

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

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

    3 工具类

    此工具类已经对缓存穿透,和缓存击穿实现了通用功能。

    • 可以对比上叙的流程图查阅
    import cn.hutool.core.util.BooleanUtil;
    import cn.hutool.core.util.StrUtil;
    import cn.hutool.json.JSONObject;
    import cn.hutool.json.JSONUtil;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.stereotype.Component;
    
    import javax.annotation.Resource;
    import java.time.LocalDateTime;
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    import java.util.concurrent.TimeUnit;
    import java.util.function.Function;
    
    import static com.hmdp.utils.RedisConstants.CACHE_NULL_TTL;
    
    /**
     * @author : look-word
     * 2022-08-19 17:02
     **/
    @Component
    public class CacheClient {
        @Resource
        private StringRedisTemplate stringRedisTemplate;
    
        public void set(String key, Object value, Long time, TimeUnit unit) {
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
        }
    
        /**
         * 设置逻辑过期时间
         */
        public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
            // .封装逻辑时间
            RedisData redisData = new RedisData();
            redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
            redisData.setData(value);
            String redisDataJson = JSONUtil.toJsonStr(redisData);
            // 写入Redis
            stringRedisTemplate.opsForValue().set(key, redisDataJson);
        }
    
        /**
         * 解决缓存穿透 对未存在的数据 设置为null
         */
        public <R, ID> R queryWithPassThrough
        (String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long cacheTime, TimeUnit cacheUnit) {
            // 缓存key
            String key = keyPrefix + id;
            // 1 查询缓存中是否命中
            String json = stringRedisTemplate.opsForValue().get(key);
            if (StrUtil.isNotBlank(json)) {
                R r = JSONUtil.toBean(json, type);
                return r;
            }
            // 解决缓存穿透 数据库不存在的数据 缓存 也不存在 恶意请求
            if (json != null) {
                return null;
            }
    
            // 2 查询数据库 存在 存入缓存 返回给前端
            R r = dbFallback.apply(id);
            if (r == null) {
                // 解决缓存穿透
                stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                return null;
            }
            // 2.1 转换成json 存入缓存中
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(r), cacheTime, cacheUnit);
    
            return r;
        }
    
        // 线程池
        public static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
    
        /**
         * 解决缓存击穿 逻辑过期时间方式
         */
        public <R, ID> R queryWithLogicalExpire
        (String keyPrefix, ID id, Class<R> type, String lockKeyPrefix, Function<ID, R> dbFallback, Long expiredTime, TimeUnit expiredUnit) {
            // 缓存key
            String key = keyPrefix + id;
            // 1 查询缓存中是否命中
            String redisDataJson = stringRedisTemplate.opsForValue().get(key);
            if (StrUtil.isBlank(redisDataJson)) {
                return null;
            }
            // 2.命中 查看是否过期,
            //     2.1 未过期 直接返回旧数据
            //     2.2 过期 获取锁 查询数据写入Redis设置新的过期时间
            //     2.3 过期 未获取锁 返回 旧数据
            RedisData redisData = JSONUtil.toBean(redisDataJson, RedisData.class);
            LocalDateTime expireTime = redisData.getExpireTime();
            R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
            if (LocalDateTime.now().isBefore(expireTime)) {
                return r;
            }
            String lockKey = lockKeyPrefix + id;
            // 获取锁
            boolean isLock = tryLock(lockKey);
            if (isLock) {
                CACHE_REBUILD_EXECUTOR.submit(() -> {
                    try {
                        // 查询数据库
                        R r1 = dbFallback.apply(id);
                        // 存储Redis 设置逻辑过期 过期时间
                        setWithLogicalExpire(key, r1, expiredTime, expiredUnit);
                    } catch (Exception e) {
                        throw new RuntimeException(e);
                    } finally {
                        // 释放锁
                        unlock(lockKey);
                    }
                });
            }
            // 未获取到锁
            return r;
        }
    
        /**
         * 获取锁
         */
        public boolean tryLock(String key) {
            Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 100, TimeUnit.SECONDS);
            return BooleanUtil.isTrue(flag);
        }
    
        /**
         * 释放锁
         */
        public void unlock(String key) {
            stringRedisTemplate.delete(key);
        }
    
    }
    
    • 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
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
  • 相关阅读:
    C语言练习之求第n个斐波那契数
    NPDP产品经理国际认证考试报名有什么要求?
    MySQL的卸载与安装(Linux)
    Java.lang.Class类 getClasses()方法有什么功能呢?
    Mock 测试详解:什么是 Mock 测试
    大数据-73 Kafka 高级特性 稳定性-事务 相关配置 事务操作Java 幂等性 仅一次发送
    vue按键全屏和F11全屏共存
    《乔布斯传》英文原著重点词汇笔记(一)【 Introduction 】
    C++笔记打卡第23天(STL常用算法)
    基于海康Ehome/ISUP接入到LiveNVR实现海康摄像头、录像机视频统一汇聚,做到物联网无插件直播回放和控制
  • 原文地址:https://blog.csdn.net/qq_50975965/article/details/126435590