SpringBoot整合Redis实现常用功能建议大小伙们,在写业务的时候,提前画好流程图,思路会清晰很多。文末有解决缓存穿透和击穿的通用工具类。
1 登陆功能
我想,登陆功能
是每个项目必备的功能吧,但是想设计好,却是很难!下面介绍两种登陆功能的解决方式:
- 基于Session实现登录流程
- 基于Redis实现登录流程
1.1 基于Session实现登录流程
功能流程:
发送验证码:
用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号
如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户
短信验证码登录、注册:
- 用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,
- 如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息
校验登录状态:
- 用户在请求时候,会从cookie中携带者JsessionId到后台,后台通过JsessionId从session中拿到用户信息,如果没有session信息,则进行拦截,如果有session信息,则
- 将用户信息保存到threadLocal中,并且放行
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共享的问题了
1.2 Redis替代Session
1.2.1、设计key的结构
首先我们要思考一下利用redis来存储数据,那么到底使用哪种结构呢?由于存入的数据比较简单,我们可以考虑使用String,或者是使用哈希,如下图,如果使用String,同学们注意他的value,用多占用一点空间,如果使用哈希,则他的value中只会存储他数据本身,如果不是特别在意内存,其实使用String就可以啦。
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中,并且放行。
2 缓存功能
2.1 什么是缓存?
缓存(Cache),就是数据交换的缓冲区,俗称的缓存就是缓冲区内的数据,一般从数据库中获取,存储于本地代码(例如:
例1:Static final ConcurrentHashMapmap = new ConcurrentHashMap<>(); 本地用于高并发 例2:static final Cache USER_CACHE = CacheBuilder.newBuilder().build(); 用于redis等缓存 例3:Static final Map map = new HashMap(); 本地缓存
由于其被Static修饰,所以随着类的加载而被加载到内存之中,作为本地缓存,由于其又被final修饰,所以其引用(例3:map)和对象(例3:new HashMap())之间的关系是固定的,不能改变,因此不用担心赋值(=)导致缓存失效;
2.1.1 为什么要使用缓存
一句话:因为速度快,好用
缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的
服务器读写压力
实际开发过程中,企业的数据量,少则几十万,多则几千万,这么大数据量,如果没有缓存来作为"避震器",系统是几乎撑不住的,所以企业会大量运用到缓存技术;
但是缓存也会增加代码复杂度和运营的成本:
2.1.2 如何使用缓存
实际开发中,会构筑多级缓存来使系统运行速度进一步提升,例如:本地缓存与redis中的缓存并发使用
浏览器缓存:主要是存在于浏览器端的缓存
应用层缓存:可以分为tomcat本地缓存,比如之前提到的map,或者是使用redis作为缓存
数据库缓存:在数据库中有一片空间是 buffer pool,增改查数据都会先加载到mysql的缓存中
CPU缓存:当代计算机最大的问题是 cpu性能提升了,但内存读写速度没有跟上,所以为了适应当下的情况,增加了cpu的L1,L2,L3级的缓存
2.2.使用缓存
2.2.1 、缓存模型和思路
标准的操作方式就是查询数据库之前先查询缓存
,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入redis
2.3 缓存更新策略
缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,所以redis会对部分数据进行更新
,或者把他叫为淘汰更合适。
内存淘汰:redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制
,淘汰掉一些不重要的数据(可以自己设置策略方式)
超时剔除:当我们给redis设置了过期时间ttl之后,redis会将超时的数据进行删除,方便咱们继续使用缓存
主动更新:我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题
2.3.1 、数据库缓存不一致解决方案:
由于我们的缓存的数据源来自于数据库
,而数据库的数据是会发生变化的
,因此,如果当数据库中数据发生变化,而缓存却没有同步
,此时就会有一致性问题存在
,其后果是:
用户使用缓存中的过时数据,就会产生类似多线程数据安全问题,从而影响业务,产品口碑等;怎么解决呢?有如下几种方案
Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案(一般采用
)
Read/Write Through Pattern : 由系统本身完成,数据库与缓存的问题交由系统本身去处理
Write Behind Caching Pattern :调用者只操作缓存,其他线程去异步处理数据库,实现最终一致
2.3.2 、数据库和缓存不一致采用什么方案
综合考虑使用方案一,但是方案一调用者如何处理呢?这里有几个问题
操作缓存和数据库时有三个问题需要考虑:
如果采用第一个方案,那么假设我们每次操作数据库后,都操作缓存,但是中间如果没有人查询,那么这个更新动作实际上只有最后一次生效,中间的更新动作意义并不大,我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来
- 删除缓存还是更新缓存?
- 更新缓存:每次更新数据库都更新缓存,无效写操作较多
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存
- 如何保证缓存与数据库的操作的同时成功或失败?
- 单体系统,将缓存与数据库操作放在一个事务
- 分布式系统,利用TCC等分布式事务方案
应该具体操作缓存还是操作数据库,我们应当是先操作数据库,再删除缓存
,原因在于,如果你选择第一种方案,在两个线程并发来访问时,假设线程1先来,他先把缓存删了,此时线程2过来,他查询缓存数据并不存在,此时他写入缓存,当他写入缓存后,线程1再执行更新动作时,实际上写入的就是旧的数据,新的数据被旧数据覆盖了。
- 先操作缓存还是先操作数据库?
- 先删除缓存,再操作数据库(
存在线程安全问题
) - 先操作数据库,再删除缓存
2.4 缓存穿透问题的解决思路
缓存穿透 :缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
常见的解决方案有两种:
- 缓存空对象
- 优点:实现简单,维护方便
- 缺点:
- 额外的内存消耗
- 可能造成短期的不一致
- 布隆过滤
- 优点:内存占用较少,没有多余key
- 缺点:
- 实现复杂
- 存在误判可能
缓存空对象思路分析:当我们客户端访问不存在的数据时,先请求redis,但是此时redis中没有数据,此时会访问到数据库,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库,我们都知道数据库能够承载的并发不如redis这么高,如果大量的请求同时过来访问这种不存在的数据,这些请求就都会访问到数据库,简单的解决方案就是哪怕这个数据在数据库中也不存在,我们也把这个数据存入到redis中去,这样,下次用户过来访问这个不存在的数据,那么在redis中也能找到这个数据就不会进入到缓存了.
布隆过滤:布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中
假设布隆过滤器判断这个数据不存在,则直接返回
这种方式优点在于节约内存空间,存在误判,误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突
小总结:
缓存穿透产生的原因是什么?
- 用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力
缓存穿透的解决方案有哪些?
- 缓存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 */ publicR queryWithPassThrough (String keyPrefix, ID id, Class type, Function 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 queryWithLogicalExpire (String keyPrefix, ID id, Class type, String lockKeyPrefix, Function 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); } }
到此这篇关于SpringBoot整合Redis实现常用功能超详细过程的文章就介绍到这了,更多相关SpringBoot整合Redis内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!