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 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(); 本地缓存
由于其被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
*/
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);
}
}