Redis进阶学习02---Redis替代Session和Redis缓存
- 基于Session登录流程
- 集群session共享问题
- 基于Redis实现session共享
- 解决状态登录刷新问题
- 集群session共享问题
- Redis缓存应用
- 什么是缓存
- 添加redis缓存
- 缓存更新策略
- 主动更新策略
- 先操作缓存,还是先操作数据库
- 总结
- 案例
- 主动更新策略
- 缓存穿透
- 缓存空对象解决缓存穿透
- 缓存穿透小结
- 缓存雪崩
- 缓存击穿
- 互斥锁和逻辑过期解决缓存击穿的思路
- 互斥锁解决缓存击穿问题
- 案例
- 逻辑过期解决缓存击穿问题
- 封装redis工具类
参考b站虎哥redis视频
本系列项目源码将会保存在gitee上面,仓库链接如下:
https://gitee.com/DaHuYuXiXi/redis-combat-project
基于Session登录流程
我们先来看一下基于Session实现登录的模板流程是什么样子的:
- 发送短信验证码
核心逻辑:
代码语言:javascript复制 public Result sendCode(String phone, HttpSession session) {
//1.校验手机号
if(RegexUtils.isPhoneInvalid(phone))
{
//2.如果不符合,返回错误信息
return Result.fail(getErrMsg("01",UserServiceImpl.class));
}
//3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);
//4.保存验证码到session
session.setAttribute("code",code);
//5.发送验证码
log.debug("发送短信验证码成功,code {}",code);
//6.返回ok
return Result.ok();
}
- 短信验证码的登录和注册
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
//2.如果不符合,返回错误信息
return Result.fail(getErrMsg("01", UserServiceImpl.class));
}
//3.校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.toString().equals(code)) {
//4.不一致报错
return Result.fail(getErrMsg("02", UserServiceImpl.class));
}
//5.一致,根据手机号查询用户
User user = query().eq("phone", phone).one();
//6.判断用户是否存在
if (user == null) {
//7.不存在,创建新用户并保存
user = createUserWithPhone(phone);
}
//8.保存用户信息到session中
session.setAttribute("user", user);
return Result.ok();
}
private User createUserWithPhone(String phone) {
//1.创建用户
User user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX RandomUtil.randomString(10));
//2.保存用户
save(user);
return user;
}
- 校验登录状态
我们需要把验证功能放到拦截器中实现:
代码语言:javascript复制public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.获取session
HttpSession session = request.getSession();
//2.获取session中的用户
Object user = session.getAttribute("user");
//3.判断用户是否存在
if(user==null)
{
//4.不存在,拦截,返回401状态码
response.setStatus(401);
return false;
}
//5.存在,保存用户信息到ThreadLocal
UserHolder.saveUser(getUserDTO(user));
//6.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//清空ThreadLocal
UserHolder.removeUser();
}
private UserDTO getUserDTO(Object user) {
UserDTO userDTO = new UserDTO();
BeanUtils.copyProperties(user,userDTO);
return userDTO;
}
}
保存用户信息到ThreadLocal可以确保当前请求从开始到结束这段时间,我们可以轻松从ThreadLocal中获取当前用户信息,而不需要每次用到的时候,还去查询一遍
本节项目完整代码,参考2.0版本
集群session共享问题
既然多台tomcat之间的session存在隔离问题,那么我们是否可以将session中存储的内容移动到redis中进行存放,即用redis代替session
基于Redis实现session共享
这里说一下: 登录成功后,会将用户保存到redis中,这和上面讲用户保存到session中的思想是一致的,都是一种缓存思想,防止每次都需要拦截器拦截请求时,都需要去数据库查找,而是直接通过token去redis中获取即可
注意,这里的token不是jwt的token,这里的token只是随机生成的一段字符串,我们无法通过解析这个字符串拿到用户信息,而是只能通过这个token作为key,去redis中获取到对应用户的信息。
个人想法:即便是jwt的token,因为一般不会在里面token中保存完整的用户信息,并且每次请求打进拦截器的时候,还是需要去解析token,并去数据库查一下,防止token伪造,但是这样太浪费性能了,可以考虑在登录成功后,将用户信息存入redis,并且规定过期时间,然后拦截器每次根据token去redis获取用户完整信息,如果成功获取,那么刷新token过期时间,否则,从数据库重新获取,然后再放入缓存中。
我们这里选用HASH来存储User对象的信息:
UserServiceImpl代码:
代码语言:javascript复制@Service
@Slf4j
@RequiredArgsConstructor
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
private final StringRedisTemplate stringRedisTemplate;
@Override
public Result sendCode(String phone, HttpSession session) {
//1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
//2.如果不符合,返回错误信息
return Result.fail(getErrMsg("01", UserServiceImpl.class));
}
//3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);
//4.保存验证码到redis
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY phone,code,2, TimeUnit.MINUTES);
//5.发送验证码
log.debug("phone code {}", code);
//6.返回ok
return Result.ok();
}
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
//2.如果不符合,返回错误信息
return Result.fail(getErrMsg("01", UserServiceImpl.class));
}
//3.从redis中获取验证码然后进行校验
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.toString().equals(code)) {
//4.不一致报错
return Result.fail(getErrMsg("02", UserServiceImpl.class));
}
//5.一致,根据手机号查询用户
User user = query().eq("phone", phone).one();
//6.判断用户是否存在
if (user == null) {
//7.不存在,创建新用户并保存
user = createUserWithPhone(phone);
}
//7.保存用户信息到redis
//7.1 随机生成token,作为登录令牌
String token = generateToken();
//7.2 将User对象转换为Hash对象
Map map = beanToMap(BeanUtil.copyProperties(user, UserDTO.class));
String key=LOGIN_USER_KEY token;
stringRedisTemplate.opsForHash().putAll(key,map);
//设置有效期
stringRedisTemplate.expire(key,LOGIN_USER_TTL,TimeUnit.MINUTES);
//7.3 存储
return Result.ok();
}
private Map<String, Object> beanToMap(UserDTO user) {
return BeanUtil.beanToMap(user, new HashMap<>(), CopyOptions.create().setIgnoreNullValue(true)
//解决long转String报错的问题
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
}
private String generateToken() {
return UUID.randomUUID().toString(true);
}
private User createUserWithPhone(String phone) {
//1.创建用户
User user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX RandomUtil.randomString(10));
//2.保存用户
save(user);
return user;
}
}
LoginInterceptor 代码:
代码语言:javascript复制public class LoginInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public LoginInterceptor(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)) {
//不存在,拦截,返回401状态码
response.setStatus(401);
return false;
}
token = LOGIN_USER_KEY token;
//2.基于Token获取redis中的用户
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(token);
//3.判断用户是否存在
if (userMap.isEmpty()) {
//不存在,拦截,返回401状态码
response.setStatus(401);
return false;
}
//5.map转换为userDto
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//6.用户信息保存到threadLocal
UserHolder.saveUser(userDTO);
//7.刷新token的有效期
stringRedisTemplate.expire(token, LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
解决状态登录刷新问题
上面的代码设计思路: 如果用户长时间都在请求不需要拦截的请求,那么token就不会被刷新,进而导致用户浏览浏览着,token就过期了
优化后:分离拦截器职责,用一个单独的拦截器拦截所有请求,每次都刷新token,另一个拦截器就负责需要登录的请求进行拦截即可
代码语言:javascript复制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)) {
//不存在,拦截,返回401状态码
response.setStatus(401);
return false;
}
token = LOGIN_USER_KEY token;
//2.基于Token获取redis中的用户
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(token);
//3.判断用户是否存在
if (userMap.isEmpty()) {
//不存在,拦截,返回401状态码
response.setStatus(401);
return false;
}
//5.map转换为userDto
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//6.用户信息保存到threadLocal
UserHolder.saveUser(userDTO);
//7.刷新token的有效期
stringRedisTemplate.expire(token, LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
代码语言:javascript复制public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//判断是否需要去拦截
if(UserHolder.getUser()==null)
{
response.setStatus(401);
return false;
}
return true;
}
}
RefreshTokenInterceptor 要先于LoginInterceptor 执行,否则LoginInterceptor 中无法中ThreadLocal中获取用户信息
代码语言:javascript复制@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
)
//指定拦截器的执行顺序---数字越小,优先级越高
.order(2);
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(1);
}
}
还有一点需要注意:如果用户信息被修改了,需要清空redis中的缓存信息,让用户重新进行登录
本节项目完整代码,参考3.0版本
Redis缓存应用
什么是缓存
添加redis缓存
下面给出一个例子:
代码语言:javascript复制@Service
@RequiredArgsConstructor
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Autowired
private final StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
String key=CACHE_SHOP_KEY id;
//1.从redis中查询商铺缓存
String shopJson=stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if(StrUtil.isNotBlank(shopJson))
{
//3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//4.不存在,根据id查询数据库
Shop shop = getById(id);
//5.不存在,返回错误
if(shop==null)
{
return Result.fail(ErrorMsgHandler.getErrMsg("03",ShopServiceImpl.class));
}
//6.存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
return Result.ok(shop);
}
}
缓存更新策略
主动更新策略
先操作缓存,还是先操作数据库
总结
案例
1.
代码语言:javascript复制 @Override
public Result queryById(Long id) {
String key=CACHE_SHOP_KEY id;
//1.从redis中查询商铺缓存
String shopJson=stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if(StrUtil.isNotBlank(shopJson))
{
//3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//4.不存在,根据id查询数据库
Shop shop = getById(id);
//5.不存在,返回错误
if(shop==null)
{
return Result.fail(ErrorMsgHandler.getErrMsg("03",ShopServiceImpl.class));
}
//6.存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
@Override
public Result update(Shop shop) {
Long id = shop.getId();
if(id==null)
{
return Result.fail(ErrorMsgHandler.getErrMsg("04",ShopServiceImpl.class));
}
//1.更新数据库
updateById(shop);
//2.删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY id);
return Result.ok();
}
缓存穿透
缓存空对象解决缓存穿透
代码语言:javascript复制 @Override
public Result queryById(Long id) {
String key=CACHE_SHOP_KEY id;
//1.从redis中查询商铺缓存
String shopJson=stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if(StrUtil.isNotBlank(shopJson))
{
//3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//判断命中的是否是空值
if(shopJson!=null&&shopJson.equals(NULL_OBJ_TAG))
{
return Result.fail(ErrorMsgHandler.getErrMsg("05",ShopServiceImpl.class));
}
//4.不存在,根据id查询数据库
Shop shop = getById(id);
//5.不存在,返回错误
if(shop==null)
{
//将空值写入到redis
stringRedisTemplate.opsForValue().set(key,NULL_OBJ_TAG,CACHE_NULL_TTL,TimeUnit.MINUTES);
return Result.fail(ErrorMsgHandler.getErrMsg("03",ShopServiceImpl.class));
}
//6.存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
缓存穿透小结
缓存雪崩
缓存击穿
互斥锁和逻辑过期解决缓存击穿的思路
互斥锁的实现其实很简单,既然热点key过期失效了,并且同时有很多个请求打进来,尝试重构缓存,那么就用一把锁,只让第一个请求去重构缓存,其余的请求线程就等待加重试,直到缓存重构成功
而对于逻辑过期的思路来讲,既然是因为热度key过期导致的缓存击穿,那我我就让这些热点key不会真的过期,而通过增加一个逻辑过期字段,每一次获取的时候,先去判断是否过期,如果过期了,就按照上图的流程执行
互斥锁可以实现一致性,但是牺牲了可用性。逻辑过期实现了可用性,但是牺牲了一致性。
一般是手动为热度key设置逻辑过期,然后等到热度过后,再删除这些热点key
互斥锁解决缓存击穿问题
为了防止出现死锁,我们还需要给锁设置一个过期时间,来确保锁一定会被释放掉
案例
代码语言:javascript复制 @Override
public Result queryById(Long id) {
//缓存穿透逻辑
//Shop shop=passThrough(id);
//缓存击穿逻辑
Shop shop = queryWithMutex(id);
if(shop==null)
{
return Result.fail(ErrorMsgHandler.getErrMsg("03",ShopServiceImpl.class));
}
return Result.ok(shop);
}
private Shop queryWithMutex(Long id) {
Shop shop=null;
String key=CACHE_SHOP_KEY id;
//1.从redis中查询商铺缓存
String shopJson=stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if(StrUtil.isNotBlank(shopJson))
{
//3.存在,直接返回
shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
//判断命中的是否是空值---解决缓存雪崩
if(shopJson!=null&&shopJson.equals(NULL_OBJ_TAG))
{
return null;
}
//4.缓存重建---解决缓存击穿
//4.1 获取互斥锁
String lockKey=LOCK_SHOP_KEY id;
try{
//4.2判断是否获取成功
if(!tryLock(lockKey)){
//4.3失败,则休眠并重试
Thread.sleep(50);
//重试
return queryWithMutex(id);
}
//4.3 成功,根据id查询数据库
shop = getById(id);
//5.不存在,返回错误
if(shop==null)
{
//将空值写入到redis---解决缓存雪崩
stringRedisTemplate.opsForValue().set(key,NULL_OBJ_TAG,CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//6.存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
return shop;
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
unLock(key);
}
return shop;
}
/**
* 尝试获取锁
*/
private boolean tryLock(String key) {
return BooleanUtil.isTrue(stringRedisTemplate.opsForValue().setIfAbsent(key,"1",10,TimeUnit.SECONDS));
}
/**
* 解锁
*/
private void unLock(String key)
{
stringRedisTemplate.delete(key);
}
下载的源码包后,请删除mapper包下面的controller包,这是因为操作不当,导致存在两个完全相同的controller包
逻辑过期解决缓存击穿问题
首先我们需要给热点key增加一个逻辑过期字段,比如: 某个shop对象作为热点key,难道就因为几个shop对象作为热点key,我们就要给shop类增加一个逻辑过期字段吗?—显然这是极其不合理的
按照重构的思想,我们需要弄出一种方案,可以让所有的需要作为热点key的对象,都重用一个逻辑过期字段,并且与业务对象是不耦合的,这里我给出一种解决方案:
代码语言:javascript复制@Data
public class RedisData<T> {
private LocalDateTime expireTime;
//data封装任何想要作为热点key的对象
private T data;
}
我们这里还需要写一个针对店铺信息进行逻辑过期保存的功能:
代码语言:javascript复制 public void saveShopToRedis(Long id,Long expireSeconds){
//1.查询店铺数据
Shop shop = getById(id);
//2.封装逻辑过期时间
RedisData<Shop> shopRedisObj = new RedisData<>();
shopRedisObj.setData(shop);
shopRedisObj.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//3.写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY id,JSONUtil.toJsonStr(shopRedisObj));
}
手动往redis中存入热点key:
代码语言:javascript复制@SpringBootTest
class HmDianPingApplicationTests {
@Autowired
private ShopServiceImpl service;
@Test
public void testRedisLogicTTL(){
service.saveShopToRedis(1L,10L);
}
}
在对店铺查询逻辑进行修改,增加逻辑过期:
代码语言:javascript复制@Override
public Result queryById(Long id) {
//缓存穿透逻辑
//Shop shop=passThrough(id);
//缓存击穿逻辑
//Shop shop = queryWithMutex(id);
Shop shop=queryWithLogicExpire(id);
if(shop==null)
{
return Result.fail(ErrorMsgHandler.getErrMsg("03",ShopServiceImpl.class));
}
return Result.ok(shop);
}
private static final ExecutorService CACHE_REBUILD_EXECUTOR= Executors.newFixedThreadPool(10);
private Shop queryWithLogicExpire(Long id) {
String key=CACHE_SHOP_KEY id;
//1.从redis查询商铺缓存
String shopJson=stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在---不存在需要去数据库重新创建缓存,这里是针对热点Key处理,没有完善
//这里还需要考虑缓存穿透的问题处理--得到的是否是缓存空对象
if(StrUtil.isBlank(shopJson)) {
//3.不存在,直接返回
return null;
}
//4.命中,需要先把JSON反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//5.判断是否过期
if(expireTime.isAfter(LocalDateTime.now())) {
//5.1未过期,直接返回
return shop;
}
//5.2过期了,需要缓存重建
//6.缓存重建
//6.1获取互斥锁
String lockKey=LOCK_SHOP_KEY id;
boolean lock = tryLock(lockKey);
//6.2判断是否获取锁成功
if(lock){
//6.3成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(()->{
//重建缓存
this.saveShopToRedis(id,LOCK_SHOP_TTL);
//释放锁
unLock(lockKey);
});
}
//6.4返回过期的店铺信息
return shop;
}
public void saveShopToRedis(Long id,Long expireSeconds){
//1.查询店铺数据
Shop shop = getById(id);
try {
//200ms的延迟,模拟长时间数据库重建
Thread.sleep(200L);
} catch (InterruptedException e) {
e.printStackTrace();
}
//2.封装逻辑过期时间
RedisData<Shop> shopRedisObj = new RedisData<>();
shopRedisObj.setData(shop);
shopRedisObj.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//3.写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY id,JSONUtil.toJsonStr(shopRedisObj));
}
下面我们启动项目,然后使用jemeter进行压力测试:
我们来测试一下,看是否只会触发一次数据库查询,并且缓存重建成功:
封装redis工具类
代码语言:javascript复制package com.hmdp.cache;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.hmdp.utilObj.RedisData;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.temporal.TemporalUnit;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
/**
* @author 大忽悠
* @create 2022/4/27 13:35
*/
@Component
public class RedisCacheClient {
private final StringRedisTemplate stringRedisTemplate;
/**
* 默认线程池大小
*/
private static final int DEFAULT_THREAD_SIZE=10;
/**
* 负责缓存重建工作的线程池
*/
private static final ExecutorService CACHE_REBUILD_EXECUTOR= Executors.newFixedThreadPool(DEFAULT_THREAD_SIZE);
/**
* 缓存空对象存入的标记
*/
private static final String NULL_OBJ_TAG="nullObjSaveTag";
public RedisCacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* @param key redis中存入的key
* @param value redis中存入的value
* @param expireTime 过期时间
* @param timeUnit 过期时间的单位
*/
public void set(String key,Object value,Long expireTime,TimeUnit timeUnit){
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(value),expireTime,timeUnit);
}
/**
* <p>
* 这里的过期时间,是增加一个逻辑过期字段,传入的value会使用RedisData封装起来
* </p>
* @param key redis中存入的key
* @param value redis中存入的value
* @param expireTime 过期时间
* @param timeUnit 过期时间的单位
*/
public void setWithLogicalExpire(String key,Object value,Long expireTime,TimeUnit timeUnit){
//设置逻辑过期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(expireTime)));
//写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
}
/**
* <p>
* 查询过程中会处理缓存穿透问题,解决redis和数据库都不存在的key的查询问题
* 利用空对象缓存来解决这个问题
* </p>
* @param keyPrefix key的前缀
* @param id 去数据库查询的具体对象id
* @param dbCallBack 数据库的回调接口,向该回调接口传入一个id,然后获取到该回调接口查询出来的对象
* @param dataClass 目标对象的类型
* @param expireTime 过期时间
* @param timeUnit 过期时间单位
* @param nullObjExpireTime 缓存的空对象的过期时间
* @param nullObjTimeunit 缓存的空对象的过期时间单位
* @param <ID> id的类型
* @param <R> 返回值类型
* @return 返回的是查询到的对象
*/
public <ID,R> R queryWithPassThrough(String keyPrefix, ID id, Function<ID,R> dbCallBack, Class<R> dataClass, Long expireTime, TimeUnit timeUnit,
Long nullObjExpireTime,TimeUnit nullObjTimeunit){
//0.缓存key
String key=keyPrefix id;
//1.从redis中查询商铺缓存
String shopJson=stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if(StrUtil.isNotBlank(shopJson))
{
//3.存在,直接返回
R bean = JSONUtil.toBean(shopJson, dataClass);
return bean;
}
//判断命中的是否是空值
if(shopJson!=null&&shopJson.equals(NULL_OBJ_TAG))
{
return null;
}
//4.不存在,根据id查询数据库
R bean = dbCallBack.apply(id);
//5.不存在,返回错误
if(bean==null)
{
//将空值写入到redis
stringRedisTemplate.opsForValue().set(key,NULL_OBJ_TAG,nullObjExpireTime,nullObjTimeunit);
return null;
}
//6.存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(bean),expireTime, timeUnit);
return bean;
}
/**
* <p>
* 查询过程中会处理缓存击穿问题,解决热点key的查询问题
* 利用逻辑过期来解决这个问题
* </p>
* @param keyPrefix 缓存对象关联的key前缀
* @param lockKeyPrefix 锁住当前对象重构过程的锁前缀
* @param id 对象id
* @param dbCallBack 数据库的回调接口,向该回调接口传入一个id,然后获取到该回调接口查询出来的对象
* @param dataClass 对象类型
* @param expireTime 过期时间
* @param timeUnit 过期时间单位
* @param <ID> id的类型
* @param <R> 返回值类型
* @return 返回的是查询到的对象
*/
public <ID,R> R queryWithLogicExpire(String keyPrefix,String lockKeyPrefix, ID id, Function<ID,R> dbCallBack, Class<R> dataClass
,Long expireTime, TemporalUnit timeUnit) {
String key=keyPrefix id;
//1.从redis查询商铺缓存
String shopJson=stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if(StrUtil.isBlank(shopJson)) {
//3.不存在,直接返回
return null;
}
//4.命中,需要先把JSON反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
R bean = JSONUtil.toBean((JSONObject) redisData.getData(), dataClass);
LocalDateTime getExpireTime = redisData.getExpireTime();
//5.判断是否过期
if(getExpireTime.isAfter(LocalDateTime.now())) {
//5.1未过期,直接返回
return bean;
}
//5.2过期了,需要缓存重建
//6.缓存重建
//6.1获取互斥锁
String lockKey=lockKeyPrefix id;
boolean lock = tryLock(lockKey);
//6.2判断是否获取锁成功
if(lock){
//6.3成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(()->{
//重建缓存
this.saveHotBeanToRedisWithLogicTag(keyPrefix,id,dbCallBack,expireTime,timeUnit);
//释放锁
unLock(lockKey);
});
}
//6.4返回过期的店铺信息
return bean;
}
/**
* <P>
* 利用互斥锁解决缓存击穿问题
* </P>
* @param keyPrefix key的前缀
* @param id 去数据库查询的具体对象id
* @param dbCallBack 数据库的回调接口,向该回调接口传入一个id,然后获取到该回调接口查询出来的对象
* @param dataClass 目标对象的类型
* @param expireTime 过期时间
* @param timeUnit 过期时间单位
* @param nullObjExpireTime 缓存的空对象的过期时间
* @param nullObjTimeunit 缓存的空对象的过期时间单位
* @param <ID> id的类型
* @param <R> 返回值类型
* @return 返回的是查询到的对象
*/
public <ID,R> R queryWithMutex(String keyPrefix,String lockKeyPrefix,ID id,Class<R> dataClass,Function<ID,R> dbCallBack,Long expireTime,
TimeUnit timeUnit,Long nullObjExpireTime,TimeUnit nullObjTimeunit) {
R bean=null;
String key=keyPrefix id;
//1.从redis中查询商铺缓存
String shopJson=stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if(StrUtil.isNotBlank(shopJson))
{
//3.存在,直接返回
bean = JSONUtil.toBean(shopJson, dataClass);
return bean;
}
//判断命中的是否是空值---解决缓存雪崩
if(shopJson!=null&&shopJson.equals(NULL_OBJ_TAG))
{
return null;
}
//4.缓存重建---解决缓存击穿
//4.1 获取互斥锁
String lockKey=lockKeyPrefix id;
try{
//4.2判断是否获取成功
if(!tryLock(lockKey)){
//4.3失败,则休眠并重试
Thread.sleep(50);
//重试
return queryWithMutex(keyPrefix,lockKeyPrefix,id,dataClass,dbCallBack,expireTime,timeUnit,nullObjExpireTime,nullObjTimeunit);
}
//4.3 成功,根据id查询数据库
bean = dbCallBack.apply(id);
//5.不存在,返回错误
if(bean==null)
{
//将空值写入到redis---解决缓存雪崩
stringRedisTemplate.opsForValue().set(key,NULL_OBJ_TAG,nullObjExpireTime,nullObjTimeunit);
return null;
}
//6.存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(bean),expireTime,timeUnit);
return bean;
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
unLock(key);
}
return bean;
}
/**
* <p>
* 可以通过该方法,设置一个热点key进redis
* </p>
* @param keyPrefix 缓存对象关联的key前缀
* @param id 对象id
* @param dbCallBack 数据库的回调接口,向该回调接口传入一个id,然后获取到该回调接口查询出来的对象
* @param expireTime 过期时间
* @param timeUnit 过期时间单位
* @param <ID> id的类型
* @param <R> 返回值类型
* @return 返回的是查询到的对象
*/
public <ID,R> void saveHotBeanToRedisWithLogicTag(String keyPrefix,ID id, Function<ID,R> dbCallBack, Long expireTime, TemporalUnit timeUnit){
String key=keyPrefix id;
//1.查询店铺数据
R bean = dbCallBack.apply(id);
//2.封装逻辑过期时间
RedisData redisData = new RedisData<>();
redisData.setData(bean);
redisData.setExpireTime(LocalDateTime.now().plus(expireTime,timeUnit));
//3.写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
}
/**
* 尝试获取锁
*/
private boolean tryLock(String key) {
return BooleanUtil.isTrue(stringRedisTemplate.opsForValue().setIfAbsent(key,"locked",10,TimeUnit.SECONDS));
}
/**
* 解锁
*/
private void unLock(String key)
{
stringRedisTemplate.delete(key);
}
}