Redis进阶学习02---Redis替代Session和Redis缓存
参考b站虎哥redis视频
本系列项目源码将会保存在gitee上面,仓库链接如下:
https://gitee.com/DaHuYuXiXi/redis-combat-project
基于Session登录流程
我们先来看一下基于Session实现登录的模板流程是什么样子的:
核心逻辑:
public Result sendCode(String phone, HttpSession session) {
if(RegexUtils.isPhoneInvalid(phone))
{
return Result.fail(getErrMsg("01",UserServiceImpl.class));
}
String code = RandomUtil.randomNumbers(6);
session.setAttribute("code",code);
log.debug("发送短信验证码成功,code {}",code);
return Result.ok();
}
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail(getErrMsg("01", UserServiceImpl.class));
}
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.toString().equals(code)) {
return Result.fail(getErrMsg("02", UserServiceImpl.class));
}
User user = query().eq("phone", phone).one();
if (user == null) {
user = createUserWithPhone(phone);
}
session.setAttribute("user", user);
return Result.ok();
}
private User createUserWithPhone(String phone) {
User user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
save(user);
return user;
}
我们需要把验证功能放到拦截器中实现:
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HttpSession session = request.getSession();
Object user = session.getAttribute("user");
if(user==null)
{
response.setStatus(401);
return false;
}
UserHolder.saveUser(getUserDTO(user));
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
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代码:
@Service
@Slf4j
@RequiredArgsConstructor
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
private final StringRedisTemplate stringRedisTemplate;
@Override
public Result sendCode(String phone, HttpSession session) {
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail(getErrMsg("01", UserServiceImpl.class));
}
String code = RandomUtil.randomNumbers(6);
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,2, TimeUnit.MINUTES);
log.debug("phone code {}", code);
return Result.ok();
}
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail(getErrMsg("01", UserServiceImpl.class));
}
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.toString().equals(code)) {
return Result.fail(getErrMsg("02", UserServiceImpl.class));
}
User user = query().eq("phone", phone).one();
if (user == null) {
user = createUserWithPhone(phone);
}
String token = generateToken();
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);
return Result.ok();
}
private Map<String, Object> beanToMap(UserDTO user) {
return BeanUtil.beanToMap(user, new HashMap<>(), CopyOptions.create().setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
}
private String generateToken() {
return UUID.randomUUID().toString(true);
}
private User createUserWithPhone(String phone) {
User user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
save(user);
return user;
}
}
LoginInterceptor 代码:
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 {
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
response.setStatus(401);
return false;
}
token = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(token);
if (userMap.isEmpty()) {
response.setStatus(401);
return false;
}
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
UserHolder.saveUser(userDTO);
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,另一个拦截器就负责需要登录的请求进行拦截即可
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 {
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
response.setStatus(401);
return false;
}
token = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(token);
if (userMap.isEmpty()) {
response.setStatus(401);
return false;
}
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
UserHolder.saveUser(userDTO);
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();
}
}
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中获取用户信息
@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缓存
下面给出一个例子:
@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;
String shopJson=stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isNotBlank(shopJson))
{
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
Shop shop = getById(id);
if(shop==null)
{
return Result.fail(ErrorMsgHandler.getErrMsg("03",ShopServiceImpl.class));
}
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
return Result.ok(shop);
}
}
缓存更新策略
主动更新策略
先操作缓存,还是先操作数据库
总结
案例
1.
@Override
public Result queryById(Long id) {
String key=CACHE_SHOP_KEY+id;
String shopJson=stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isNotBlank(shopJson))
{
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
Shop shop = getById(id);
if(shop==null)
{
return Result.fail(ErrorMsgHandler.getErrMsg("03",ShopServiceImpl.class));
}
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));
}
updateById(shop);
stringRedisTemplate.delete(CACHE_SHOP_KEY+id);
return Result.ok();
}
缓存穿透
缓存空对象解决缓存穿透
@Override
public Result queryById(Long id) {
String key=CACHE_SHOP_KEY+id;
String shopJson=stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isNotBlank(shopJson))
{
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));
}
Shop shop = getById(id);
if(shop==null)
{
stringRedisTemplate.opsForValue().set(key,NULL_OBJ_TAG,CACHE_NULL_TTL,TimeUnit.MINUTES);
return Result.fail(ErrorMsgHandler.getErrMsg("03",ShopServiceImpl.class));
}
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
缓存穿透小结
缓存雪崩
缓存击穿
互斥锁和逻辑过期解决缓存击穿的思路
互斥锁的实现其实很简单,既然热点key过期失效了,并且同时有很多个请求打进来,尝试重构缓存,那么就用一把锁,只让第一个请求去重构缓存,其余的请求线程就等待加重试,直到缓存重构成功
而对于逻辑过期的思路来讲,既然是因为热度key过期导致的缓存击穿,那我我就让这些热点key不会真的过期,而通过增加一个逻辑过期字段,每一次获取的时候,先去判断是否过期,如果过期了,就按照上图的流程执行
互斥锁可以实现一致性,但是牺牲了可用性。逻辑过期实现了可用性,但是牺牲了一致性。
一般是手动为热度key设置逻辑过期,然后等到热度过后,再删除这些热点key
互斥锁解决缓存击穿问题
为了防止出现死锁,我们还需要给锁设置一个过期时间,来确保锁一定会被释放掉
案例
@Override
public Result queryById(Long 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;
String shopJson=stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isNotBlank(shopJson))
{
shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
if(shopJson!=null&&shopJson.equals(NULL_OBJ_TAG))
{
return null;
}
String lockKey=LOCK_SHOP_KEY+id;
try{
if(!tryLock(lockKey)){
Thread.sleep(50);
return queryWithMutex(id);
}
shop = getById(id);
if(shop==null)
{
stringRedisTemplate.opsForValue().set(key,NULL_OBJ_TAG,CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
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的对象,都重用一个逻辑过期字段,并且与业务对象是不耦合的,这里我给出一种解决方案:
@Data
public class RedisData<T> {
private LocalDateTime expireTime;
private T data;
}
我们这里还需要写一个针对店铺信息进行逻辑过期保存的功能:
public void saveShopToRedis(Long id,Long expireSeconds){
Shop shop = getById(id);
RedisData<Shop> shopRedisObj = new RedisData<>();
shopRedisObj.setData(shop);
shopRedisObj.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shopRedisObj));
}
手动往redis中存入热点key:
@SpringBootTest
class HmDianPingApplicationTests {
@Autowired
private ShopServiceImpl service;
@Test
public void testRedisLogicTTL(){
service.saveShopToRedis(1L,10L);
}
}
在对店铺查询逻辑进行修改,增加逻辑过期:
@Override
public Result queryById(Long 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;
String shopJson=stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isBlank(shopJson)) {
return null;
}
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
if(expireTime.isAfter(LocalDateTime.now())) {
return shop;
}
String lockKey=LOCK_SHOP_KEY+id;
boolean lock = tryLock(lockKey);
if(lock){
CACHE_REBUILD_EXECUTOR.submit(()->{
this.saveShopToRedis(id,LOCK_SHOP_TTL);
unLock(lockKey);
});
}
return shop;
}
public void saveShopToRedis(Long id,Long expireSeconds){
Shop shop = getById(id);
try {
Thread.sleep(200L);
} catch (InterruptedException e) {
e.printStackTrace();
}
RedisData<Shop> shopRedisObj = new RedisData<>();
shopRedisObj.setData(shop);
shopRedisObj.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shopRedisObj));
}
下面我们启动项目,然后使用jemeter进行压力测试:
我们来测试一下,看是否只会触发一次数据库查询,并且缓存重建成功:
封装redis工具类
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;
@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;
}
public void set(String key,Object value,Long expireTime,TimeUnit timeUnit){
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(value),expireTime,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)));
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
}
public <ID,R> R queryWithPassThrough(String keyPrefix, ID id, Function<ID,R> dbCallBack, Class<R> dataClass, Long expireTime, TimeUnit timeUnit,
Long nullObjExpireTime,TimeUnit nullObjTimeunit){
String key=keyPrefix+id;
String shopJson=stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isNotBlank(shopJson))
{
R bean = JSONUtil.toBean(shopJson, dataClass);
return bean;
}
if(shopJson!=null&&shopJson.equals(NULL_OBJ_TAG))
{
return null;
}
R bean = dbCallBack.apply(id);
if(bean==null)
{
stringRedisTemplate.opsForValue().set(key,NULL_OBJ_TAG,nullObjExpireTime,nullObjTimeunit);
return null;
}
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(bean),expireTime, timeUnit);
return bean;
}
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;
String shopJson=stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isBlank(shopJson)) {
return null;
}
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
R bean = JSONUtil.toBean((JSONObject) redisData.getData(), dataClass);
LocalDateTime getExpireTime = redisData.getExpireTime();
if(getExpireTime.isAfter(LocalDateTime.now())) {
return bean;
}
String lockKey=lockKeyPrefix+id;
boolean lock = tryLock(lockKey);
if(lock){
CACHE_REBUILD_EXECUTOR.submit(()->{
this.saveHotBeanToRedisWithLogicTag(keyPrefix,id,dbCallBack,expireTime,timeUnit);
unLock(lockKey);
});
}
return bean;
}
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;
String shopJson=stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isNotBlank(shopJson))
{
bean = JSONUtil.toBean(shopJson, dataClass);
return bean;
}
if(shopJson!=null&&shopJson.equals(NULL_OBJ_TAG))
{
return null;
}
String lockKey=lockKeyPrefix+id;
try{
if(!tryLock(lockKey)){
Thread.sleep(50);
return queryWithMutex(keyPrefix,lockKeyPrefix,id,dataClass,dbCallBack,expireTime,timeUnit,nullObjExpireTime,nullObjTimeunit);
}
bean = dbCallBack.apply(id);
if(bean==null)
{
stringRedisTemplate.opsForValue().set(key,NULL_OBJ_TAG,nullObjExpireTime,nullObjTimeunit);
return null;
}
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(bean),expireTime,timeUnit);
return bean;
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
unLock(key);
}
return bean;
}
public <ID,R> void saveHotBeanToRedisWithLogicTag(String keyPrefix,ID id, Function<ID,R> dbCallBack, Long expireTime, TemporalUnit timeUnit){
String key=keyPrefix+id;
R bean = dbCallBack.apply(id);
RedisData redisData = new RedisData<>();
redisData.setData(bean);
redisData.setExpireTime(LocalDateTime.now().plus(expireTime,timeUnit));
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);
}
}
|