RedisPro1项目笔记

RedisPro

项目环境

从资料里解压压缩包,用idea打开,修改一下自己的mysql数据库和redis数据库配置。

注意:项目端口要是8081,不要改变,因为目前和前端ngnix是一致的。上下文路径也不用配置,默认/

启动服务后,访问http://localhost:8081/shop-type/list,测试服务是否正常。

ngnix前端服务:

解压压缩包后,放在任意一个目录下,注意不带中文和特殊符号,在文件路径上。

进入ngnix的所在目录,打开cmd窗口,执行命令:

start ngnix.exe

打开浏览器,进入手机模式,访问http://127.0.0.1:8080,可以访问到app主页即可。

短信登录

session流程分析

流程分析

ThreadLocal

在服务中,每一个请求到达服务都是一个线程,而ThreadLocal把数据保存到一个每一个线程内部,每一个线程内部都有一个map来保存这些数据,这样一来每一个线程都有自己独立的存储空间,每一个请求都有自己的独立的线程,相互之间没有干扰。数据验证通过后,放行请求。

后续的请求业务,从自己的ThradLocal取出自己的数据做验证,然后决定是否放行。

1)发送短信验证码

流程分析

1.用户输入手机号。2.发送请求(/api/user/code?phone=xxxxx POST)。3.controller处理。4.调用service,完成业务。5.校验手机号。6.根据校验结果决定是否生成验证码。7.保存验证码到session。8.发送验证码。9.返回业务结果。

编码实现
/**
 * 生成短信验证码
 * @param phone   手机号
 * @param session session
 * @return 处理结果
 */
@Override
public Result sendCode(String phone, HttpSession session) {
    //校验手机号是否合格
    if (RegexUtils.isPhoneInvalid(phone)) {
        //如果无效
        return Result.fail("您TM手机号格式写错了");
    }
    //生成验证码,调用工具类,6为数字验证码
    String numbers = RandomUtil.randomNumbers(6);
    //保存验证码
    session.setAttribute("code", numbers);
    //返回结果
    log.debug("发送短信验证码成功:" + numbers);
    return Result.ok();
}

2)登录功能

流程分析:

1.用户点击登录(参数:手机号,验证码,密码)。2.controller处理请求。3.校验手机号。4.校验验证码。5.查询用户是否是新用户。6.如果是新用户,则自动注册一个User并保存数据库。7.如果不是则不注册。8.把当前登录用户保存到session。9.返回登录成功(这里使用验证码登录,验证码对的情况下,一定登录成功。如果使用密码,可能会密码错误)。

编码实现:

手机号和验证码校验在controller中;

public Result loginConfirm(String phone, HttpSession session) {
    //查询用户是否是第一次登录,query是继承的ServiceImpl<UserMapper, User>中的方法,MP提供的。
    User user = query().eq("phone", phone).one();
    if (user == null) {
        //第一次登录,注册用户,保存到数据库
        user = new User();
        user.setPhone(phone);
        user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(6));
        //新用户入库
        save(user);//api
    }
    //保存到session
    session.setAttribute(USER_SESSION_KEY, user);
    return Result.ok();
}

3)校验登录状态

api/user/me

使用ThreadLocal,保存该次请求的需要的数据。

拦截器登录验证
User user = (User) request.getSession().getAttribute(SystemConstants.USER_SESSION_KEY);
if (user == null) {
    //如果session中用户不存在
    System.out.println("用户不存在");
    response.setStatus(401);
    return false;
}
//保存用户对象到ThreadLocal
UserHolder.saveUser(user);
//放行
return true;
拦截器配置
@Override
public void addInterceptors(InterceptorRegistry registry) {
    //排除一些不登陆也可以用的路径
    registry.addInterceptor(new loginInterceptor()).
            excludePathPatterns("/user/code", "/user/login", "/blog/hot",
                    "/shop-type/**", "/voucher/**", "/upload/**");
}

4)缺陷所在

session共享问题:

多台服务器之间无法共享一个session域。nginx在收到请求后会把请求进行负载均衡,选择一个压力小的服务器将请求发到该服务器。(Tomcat会有多台,组成一个集群)。而如果两次请求发在了不同的Tomcat服务器上,那么登录验证就失效了。

解决办法:

作为存储容器,应该可以满足多台Tomcat之间共享,在内存存储(保证高效读写,低延迟),key-value结构。

Redis流程分析

1:基本流程和session大概一致,验证码保存在redis中,用phone做key。

2:登录功能时,成功登录的话,将userDto对象保存到redis,数据结构使用string(json)即可。也可以使用hash(内存占用更小)。而key的选择需要保证在拦截器做登录验证的时候也方便取到。所以决定采用token(随机的字符串序列)作为key保存在redis,同时作为数据响应给客户端。

3:登录成功后从后端收到token数据,保存在浏览器本地存储,方便发请求的时候获取。

4:前端怎么保证每次发送都带着token呢?这里前端配置了拦截器,在每次请求发送的时候都从浏览器本地存储获取token然后作为请求头发送请求。

关键代码
验证码到redis
//保存到redis,并设置存活时间
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, numbers, LOGIN_CODE_TTL, TimeUnit. TimeUnit.MINUTES);
登录业务
{   //验证手机号
    //TODO 从redis取出验证码并验证
    String code = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
    if (code == null || !code.equals(loginForm.getCode())) {
        return Result.fail("验证码错误");
    }
    //查询用户是否是第一次登录
    User user = query().eq("phone", phone).one();
    //TODO 转存UserDTo
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    //保存到session,为了减轻session,服务器的压力,不保存整个user对象,保存部分user属性就行了。
    //session.setAttribute(USER_SESSION_KEY, BeanUtil.copyProperties(user, UserDTO.class));
    //保存到redis TODO 1.生成随机token
    String token = UUID.randomUUID().toString().replaceAll("-","");
    //TODO 2.将user对象以map格式存到redis/或者String.json
    Map<String,String> userDtoMap=new HashMap<>();
    userDtoMap.put("id", String.valueOf(userDTO.getId()));
    userDtoMap.put("nickName", userDTO.getNickName());
    userDtoMap.put("icon", userDTO.getIcon());
    //TODO 3.保存到redis,并设置存活时间
    stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token,userDtoMap);
    stringRedisTemplate.expire(LOGIN_USER_KEY+token,LOGIN_USER_TTL,TimeUnit.MINUTES);
    //TODO 4.响应token到前端
    return Result.ok(token);
}
拦截器token刷新
  • 如果用户一直在操作,那么就会刷新redis的user对象存活时间,保证用户一直处于登录状态。
  • 此拦截器会拦截所有请求,并做出刷新时间的操作。
//拦截器做刷新token,刷新用户user在redis的存活时间
public class RefreshTokenInterceptor implements HandlerInterceptor {
    //TODO 注意这里自动注入,需要自己手动创建bean到sprig容器,spring才会帮我们自动注入
    //在MvcConfig,配置拦截器的类中使用@Bean放入容器
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //TODO 1.获取前端发送token,用来获取redis的userDto
        String token = request.getHeader("authorization");
        if (StringUtils.isBlank(token))
            //放行,给下一个拦截器,就是登录验证拦截器
            return true;
        //TODO 2.获取redis的对象
        Map<Object, Object> userDtoMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);
        //判断map是否为空
        if (userDtoMap.isEmpty())
            //如果session中用户不存在
            return true;
        //TODO 3.转换map为userDTO
        UserDTO userDto = BeanUtil.fillBeanWithMap(userDtoMap, new UserDTO(), false);
        //TODO 4.保存对象到ThreadLocal,生命周期一个request完成周期
        UserHolder.saveUser(userDto);
        //TODO 5.刷新userDto在redis的存活时间
        stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);
        //放行
        return true;
    }
登录验证拦截器

此拦截器用于拦截未登录的情况下发出的请求。

由于前置拦截器做了判断,只有成功拿到redis的userDto对象,并保存在ThreadLocal后,后者的拦截器才会成功取到userDTO对象,做出放行。否则拦截此次请求。

@Override
//请求前
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    if (UserHolder.getUser() == null) {	//从ThreadLocal获取
        response.setStatus(401);
        return false;
    }
    //放行
    return true;
}
拦截器配置
@Configuration
public class MvcConfig implements WebMvcConfigurer {
    private final RefreshTokenInterceptor refreshTokenInterceptor = new RefreshTokenInterceptor();
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //排除一些不登陆也可以用的路径
        registry.addInterceptor(new loginInterceptor()).
                excludePathPatterns("/user/code", "/user/login", "/blog/hot",
                        "/shop-type/**", "/voucher/**", "/upload/**").order(1);
        //设置拦截器的顺序,数值越小越靠前,越先执行
        registry.addInterceptor(refreshTokenInterceptor).order(0);
    }
    @Bean
    //把拦截器对象放入spring容器,方便spring容器做自动注入拦截器中的成员属性stringRedisTemplate
    public RefreshTokenInterceptor loginInterceptor() {
        return this.refreshTokenInterceptor;
    }
}

商店缓存

缓存:高速的读取能力
缺点:数据一致性问题,人力资源问题。

将店铺信息存到缓存中(使用redis作为缓存),减轻数据库压力。

流程分析:

1.用户提交店铺id。2.service处理业务。3.从redis查询缓存是否命中。4.如果命中,(判断是否是空对象)直接返回。5.如果没有命中,去查询数据库。6.如果数据库中不存在,响应404(缓存空对象,并设置过期时间)。7.如果数据库中存在,同步redis和响应店铺信息。

编码实现:
@Override
public Result queryShopById(Long id) {
    //这里序列化了JSON字符串,也可以用hash,不过我嫌实体类shop转map麻烦,就没有用
    //shop转map,有工具类,但是可能会产生类型转换异常,比如long不能转为string
    redisTemplate.setKeySerializer(new StringRedisSerializer());
    redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Shop.class));
    //TODO 查询redis
    Shop shop = (Shop) redisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
    //TODO 判断是否存在,存在直接返回shop
    //TODO 否则查询数据库
    shop = query().eq("id", id).one();
    //TODO 判断数据库是否存在,不存在返回404
    //TODO 同步redis,这里使用了JSON格式
    String key = RedisConstants.CACHE_SHOP_KEY + id;
    redisTemplate.opsForValue().set(key, shop);
    //TODO 设置存活30分钟,和user一致
    redisTemplate.expire(key, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
    //TODO 响应
    return Result.ok(shop);
}

缓存更新策略

主动更新策略

1.由缓存调用者,更新数据库的同时更新缓存。(胜出)

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

    • 更新:每次更新数据库都更新缓存,但无效写操作较多(修改了多次,但只有最后一次有效)
    • 删除:更新数据库的时候让缓存失效,在查询的时候才更新缓存(较好)。
  • 2)如何保证缓存和数据库的操作的原子性?

    • 单体系统:将缓存的数据库的操作作为一个事务处理。
    • 分布式系统:利用TTC等分布式事务方案。
  • 3)线程安全问题,先操作缓存还是先操作数据库?
    无

  • 数据库的操作比较慢,而缓存的操作快,较小概率出现第二次情况。

    • 需要保证操作的原子性

2.缓存和数据库合为一个服务,由服务来维护一致性。

3.调用者只操作缓存,由异步的线程将缓存的数据持久化到数据库,保证最终一致。

缓存穿透

指用户请求的数据在缓存和数据库中都不存在,这样每次这样的请求都会到达数据库,好像缓存就不存在似的,缓存被传过去了,被穿透力。

如何解决?

1)缓存空对象

当请求查询数据库不存在的时候,把一个空对象添加到缓存(并设置一个不长的有效期),这样下次请求就会从缓存得到这个空对象。

优点:实现简单,维护方便

缺点:1.可能出现短期的数据不一致问题。用户可能查询到空对象但实际数据库刚好插入了这个对象。(设置合理的存活时间,或者在插入数据库的时候也更新缓存(删除))2.内存占用问题,可能缓存中会有很多空对象。(设置空对象的存活时间,并合理设置时长)。

2)布隆过滤

在redis前加一个过滤器,当请求发过来的时候,先走过滤器,布隆过滤器会判断数据是否存在,如果不存在会直接拒绝请求。否则,放行请求,执行后续的操作。

优点:内存占用少,独有的hash算法不会占用大量内存

缺点:1.实现复杂。2.可能出现误判的情况。

修改商铺查询业务

主要更新点:

//TODO 查询redis
Shop shop = (Shop) redisTemplate.opsForValue().get(key);
//TODO 判断是否存在
if (shop != null) {
    //TODO 缓存是空对象,或者不存在,先前放入的时候是一个没有赋值的裸对象
    if(shop.getId()==null)
        return Result.ok("店铺不存在");
    return Result.ok(shop);
}

当数据库也不存在时:

//TODO 否则查询数据库
shop = query().eq("id", id).one();
//TODO 判断数据库是否存在
if (shop == null) {
    //TODO 避免缓存穿透
    redisTemplate.opsForValue().set(key,new Shop());//序列化的json:{id:null,icon:null,...}
    //TODO 设置存活时间,不要太长 2min
    redisTemplate.expire(key,RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
    return Result.fail("店铺不存在");	
}

LocalTime的JSON序列化问题

(43条消息) Cannot construct instance of java.time.LocalDateTime (no Creators, like default construct, exist)_超人go的博客-CSDN博客

对象序列化JSON的时候,把LocalTime当作对象序列化了,序列化后的结果是

createTime:{
    "year":2021,
    "monthValue":12,
    "dayOfMonth":22,
    "hour":18,
      ...
}//这样的格式在反序列化的过程中抛出异常,redisTemplate设置的序列化方式
    redisTemplate.setKeySerializer(new StringRedisSerializer());
    redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Shop.class));

解决办法:

/**
 * 创建时间
 * 添加注解,规范化LocalTime的序列化格式
 */
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime createTime;

添加注解后:“createTime”:[2021,12,22,18,10,39]

缓存雪崩

指同一时间段大量的key同时失效或者Redis服务宕机,导致大量的请求到达数据库,带来巨大压力。

如果解决?

1)给不同的key的TTL添加不同的随机值

2)利用redis集群,提高服务的可用性.避免主机宕机导致失误.

3)给缓存业务添加降级限流策略

4)给业务添加多级缓存

缓存击穿

又叫热点key问题,就是一个被该并发访问并且缓存重建业务比较复杂的key突然失效了,无数/大量的请求达到数据库,造成数据量压力骤增。

如何解决?

1)互斥锁

多线程并发的情况下,每一个线程都需要先获取锁才能重建key,获取失败的话,就等待,直到锁释放。

特点:线程间串行执行,发现有人在重建了,就等待,直到重建完成,返回数据,保证了数据的一致性。

优点:没有额外的内存消耗,保证了数据一致性,实现简单

缺点:线程需要等待,性能较差。可能产生死锁。

2)逻辑删除

通过在保存缓存的时候,手动维护一个表示过期时间的属性,根据属性值判断该key是否过期。

在多线程并发环境下,如果key过期了,首个到达的线程,如果发现不存在,就直接返回,否则如果发现key过期,会先获取锁然后另开一个线程去重建key。而其他线程发现过期后会获取不到锁,而直接就直接把过期的key返回。直到线程重建key完成,所有线程都会获取到最新的key。

特点:线程间无需等待,发现已经在重建了,就返回。只是返回的数据可能过期了,丢失了短暂的一致性。

优点:线程无需等待,性能较好,最多达到过期的数据。

缺点:不保证数据一致性,有额外的内存消耗,实现复杂。
无

编码实现

模拟互斥锁
//TODO 否则缓存重建
String shopLockKey = LOCK_SHOP_KEY + id;
try {
    //TODO 1.获取互斥锁
    while (!getShopLock(shopLockKey)) {
        //TODO 2.判断是否获取成功
        //TODO 3.失败,休眠重试 50ms
        Thread.sleep(50);
    }
    //TODO 4.成功再次查询缓存,是否存在
    Shop newShop = redisTemplate.opsForValue().get(key);
    //TODO 判断是否存在
    if (newShop != null) {
        //TODO 缓存是空对象,或者不存在,先前放入的时候是一个没有赋值的裸对象,内存消耗大一点
        if (newShop.getId() == null) {
            return null;
        }
        return newShop;
    }
    shop = query().eq("id", id).one();
    //TODO 判断数据库是否存在
    if (shop == null) {
        //TODO 避免缓存穿透,放入空对象 设置存活时间,不要太长 2min
        redisTemplate.opsForValue().set(key, new Shop(), RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
        return null;
    }
    //TODO 同步redis,这里使用了JSON格式 设置存活30分钟,和user一致,数据一致性问题
    redisTemplate.opsForValue().set(key, shop, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (Exception e) {
    unShopLock(shopLockKey);
    throw new RuntimeException();
}
unShopLock(shopLockKey);
模拟逻辑删除

见工具类

缓存穿透和缓存击穿的工具类(泛型)

缓存穿透

/**
 * 根据id查询数据并解决缓存穿透问题
 *
 * @param preKey     缓存的前缀key
 * @param id         查询的id
 * @param type       返回的数据类型
 * @param dbCallBack 查询操作
 * @param time       key的存活时间
 * @param unit       时间单位
 * @param <T>        泛型,哪个实体类
 * @param <ID>       泛型,id的数据类型
 * @return 从查询结果
 */
public <T, ID> T queryWithCacheThrough(String preKey, ID id, Class<T> type,
                                       Function<ID, T> dbCallBack, Long time, TimeUnit unit) {
    //缓存穿透 使用JSONUtil 保存空对象的时候内存消耗更少
    String key = preKey + id;
    //TODO 查询redis 返回普通的json格式
    String json = stringRedisTemplate.opsForValue().get(key);
    //TODO 判断是否存在
    if (StringUtils.isNotBlank(json)) {
        //TODO 缓存命中
        return JSONUtil.toBean(json, type);
    }
    //TODO 如果json是"",说明是空对象
    if (json != null)
        return null;
    //TODO 否则查询数据库
    T t = dbCallBack.apply(id);
    //TODO 判断数据库是否存在
    if (t == null) {
        //TODO 避免缓存穿透,放入空对象 并设置存活时间,不要太长 2min
        stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
        return null;
    }
    //TODO 同步redis,这里使用了JSON格式 设置存活30分钟,和user一致,数据一致性问题
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(t), time, unit);
    //TODO 响应
    return t;
}

缓存击穿(逻辑删除)

/**
 * 需要事前把数据放到redis中,并设置好逻辑过期时间
 * 逻辑删除,解决缓存击穿的情况
 *
 * @param preKey    数据在redis的前缀key
 * @param lockKey   模拟setnx的锁的key
 * @param id        数据的id
 * @param classType 数据实体类的类型
 * @param callback  数据库操作函数
 * @param time      逻过期时间
 * @param unit      时间单位
 * @param <T>       泛型,数据所属类的数据类型
 * @param <ID>      泛型,id的数据类型
 * @return 返回查询结果
 */
public <T, ID> T queryWithLogicallyDel(String preKey, String lockKey, ID id, Class<T> classType,
                                       Function<ID, T> callback, Long time, TimeUnit unit) {
    String key = preKey + id;
    //TODO 查询缓存
    String json = stringRedisTemplate.opsForValue().get(key);
    //TODO 判断是否存在
    if (StringUtils.isBlank(json))
        //TODO 如果不存在,直接返回
        return null;
    //"date:{id:"",nickName:"",...},expireTime:xxxx.xx.xx"
    //这里redisData的data其实是一个字符串类型json格式的数值
    RedisData redisData = JSONUtil.toBean(json, RedisData.class);
    //把实体类对象从json转换回来
    T t = JSONUtil.toBean((JSONObject) redisData.getData(), classType);
    //TODO 如果存在,判断是否过期
    if (redisData.getExpireTime().isAfter(LocalDateTime.now()))
        //TODO 如果没有过期,返回数据
        return t;
    //TODO 如果过期了,尝试获取锁
    if (getShopLock(lockKey)) {
        //TODO 获取锁成功,开个线程池去重建缓存
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                //TODO 重建缓存,并重新设置逻辑过期时间,新时间是当前加time
                T obj = callback.apply(id);
                this.saveDataToRedis(key, obj, time, unit);
            } catch (Exception e) {
                throw new RuntimeException();
            } finally {
                unShopLock(lockKey);
            }
        });
    }
    //TODO 获取失败,返回过期数据
    return t;
}

其他方法:

//保存一个具有逻辑过期时间的数据到redis
public void saveDataToRedis(String key, Object data, Long time, TimeUnit unit) {
    RedisData newRedisData = new RedisData();
    newRedisData.setData(data);
    //设置逻辑过期时间
    newRedisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
    //保存到redis
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(newRedisData));
}

//获取锁
private boolean getShopLock(String key) {
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
    return BooleanUtil.isTrue(flag);
}

//释放锁
private void unShopLock(String key) {
    stringRedisTemplate.delete(key);
}

Function<K, T> callback

作为参数,传递一个函数。可以使用匿名函数。

前一个K,表示参数类型

T表示返回类型

edisData();
newRedisData.setData(data);
//设置逻辑过期时间
newRedisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
//保存到redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(newRedisData));
}

//获取锁
private boolean getShopLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, “1”, LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}

//释放锁
private void unShopLock(String key) {
stringRedisTemplate.delete(key);
}


### Function<K, T>  callback

作为参数,传递一个函数。可以使用匿名函数。

前一个K,表示参数类型

T表示返回类型

方法:T apply(K key),表示调用传递的函数内容。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值