Redis学习笔记(实战篇)(自用)

Redis学习笔记(实战篇)(自用)

本文根据黑马程序员的课程资料与百度搜索的资料共同整理所得,仅用于学习使用,如有侵权,请联系删除

文章目录

  • Redis学习笔记(实战篇)(自用)
  • 1.基于Session实现短信登录
    • 1.1 发送验证码
    • 1.2 实现短信验证码登录和注册
    • 1.3 实现登录校验拦截器
    • 1.4 UserHolder
    • 1.5 DTO介绍
  • 2. 基于Redis实现短信登录
    • 2.1 实现发送验证码
    • 2.2 实现短信验证码登录和注册
    • 2.3 实现登录校验拦截器
    • 2.4 登录拦截器的优化
  • 3. 缓存
    • 3.1 缓存简介
    • 3.2 缓存练习
    • 3.3 缓存更新策略
    • 3.4 缓存穿透
    • 3.5 缓存雪崩
    • 3.6 缓存击穿
    • 3.7 缓存工具类
  • 4.全局唯一ID
    • 4.1 简介
    • 4.2 实现Redis全局ID生成器
    • 4.3 总结
    • 4.4 实现优惠券秒杀下单

  • 运行nginx前端项目:

在其所在目录中打开CMD

start nginx.exe

用开发者工具中的”手机模式“访问127.0.0.1:8080

1.基于Session实现短信登录

1.1 发送验证码

public Result sendCode(String phone, HttpSession session) {
    // 1.校验手机号
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 2.不符合,返回错误信息
        return Result.fail("手机号码格式错误!");
    }
    // 3.符合,生成验证码
    String code = RandomUtil.randomNumbers(6);
    // 4.保存验证码到session
    session.setAttribute("code",code);
    // 5.模拟发送验证码
    log.debug("发送短信验证码成功,验证码:{}",code);
    // 6.返回成功信息
    return Result.ok();
}

1.2 实现短信验证码登录和注册

public Result login(LoginFormDTO loginForm, HttpSession session) {
    // 1.校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
        return Result.fail("手机号码格式错误!");
    }
    // 2.校验验证码
    String code = loginForm.getCode();
    Object cacheCode = session.getAttribute("code");
    if (cacheCode == null || cacheCode.toString().equals(code)){
        return Result.fail("验证码错误!");
    }
    // 3.一致,根据手机号查询用户
    User user = query().eq("phone", phone).one();

    // 4.判断用户是否存在
    if (user == null){
        // 不存在,创建新用户并保存(注册)
        user = createUserWithPhone(phone);
    }

    // 5.保存用户信息到session中(登录)
    session.setAttribute("user",BeanUtil.copyProperties(user, UserDTO.class));
    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;
}

1.3 实现登录校验拦截器

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.不存在,拦截
            response.setStatus(401);
            return false;
        }
        // 5.存在,保存用户信息到ThreadLocal
        UserHolder.saveUser((UserDTO) user);
        // 6.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 销毁user对象,防止内存泄露
        UserHolder.removeUser();
    }
}


// 让拦截器生效,以及设置放行路径
@Configuration
public class MvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                );
    }
}

1.4 UserHolder

在Web开发中,service层或者某个工具类中需要获取到HttpServletRequest对象。

一种方式是将HttpServletRequest作为方法的参数从controller层一直放下传递(繁琐,不推荐);

另一种则是写一个RequestHolder,直接保存在当前线程中,以下举例:

public class UserHolder {
        private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();

        public static void saveUser(UserDTO user){
            tl.set(user);
        }

        public static UserDTO getUser(){
            return tl.get();
        }

        public static void removeUser(){
            tl.remove();
        }
    }

1.5 DTO介绍

出于各种目的(如节省内存空间、保护数据安全、保护用户隐私等),
我们希望保存在内存里的对象数据只留下必须的,一些用不到的就不要了,反正都在数据库里,用到的时候再去查询就是了。
这个时候就引入了DTO的概念,就是把必须的属性提取出来生成一个DTO对象。

//同时在查询SQL时,我们一般都是返回一个完整的对象,我们该如何把这个对象转换为DTO对象呢?
//原办法就是new DTO对象,然后set,
//但是我们也可以直接使用Hutool工具包的BeanUtil.copyProperties(user, UserDTO.class)来快速转换
public class User {
    private Long id;
    private String phone;
    private String password;
    private String nickName;
    private String icon = "";
    private LocalDateTime createTime;
    private LocalDateTime updateTime;
}

public class UserDTO {
    private Long id;
    private String nickName;
    private String icon;
}

集群的session共享问题

session共享问题:多台Tomcat并不共享session存储空间,当请求切换到不同tomcat服务时导致数据丢失的问题。
session的替代方案应该满足:1.数据共享 2.内存存储 3.key、value结构。(Redis!)

2. 基于Redis实现短信登录

2.1 实现发送验证码

// 4.保存验证码到Redis set key value ex 120
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone,code,LOGIN_CODE_TTL,TimeUnit.MINUTES);

2.2 实现短信验证码登录和注册

    // 3.从redis获取验证码并校验
    String code = loginForm.getCode();
    String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
    if (cacheCode == null || !cacheCode.equals(code)){
        // 不一致,报错
        return Result.fail("验证码错误!");
    }

    // 4.一致,根据手机号查询用户
    User user = query().eq("phone", phone).one();

    // 5.判断用户是否存在
    if (user == null){
        // 6.不存在,创建新用户并保存
        user = createUserWithPhone(phone);
    }

    // 7.保存用户信息到redis中
    // 7.1 随机生成token,作为登录令牌
    String token = UUID.randomUUID().toString(false);
    // 7.2 将User对象转换为HashMap存储
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap<>(),
                                                     CopyOptions.create()
                                                     .setIgnoreNullValue(true)
                                                     // 因为Long无法直接强转为String,使用StringRedisTemplate必须为String,而它底层做的是强转所以报错,我们需要在这里指定一下类型
                                                     .setFieldValueEditor((fieldName,fieldValue) -> fieldValue.toString()));

    // 7.3 存储到redis,并设置过期时间
    String tokenKey = LOGIN_USER_KEY + token;
    stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);
    stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL,TimeUnit.MINUTES);

    // 8.返回token
    return Result.ok(token);
}

2.3 实现登录校验拦截器

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,判断token是否存在
        String token = request.getHeader("authorization");
        if (StrUtil.isEmpty(token)) {
            // token不存在,拦截,返回401状态码
            response.setStatus(401);
            return false;
        }
        // 2.基于token获取redis中的用户
        String tokenKey = LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);
        // 3.判断用户是否存在
        if (userMap.isEmpty()) {
            // 4.不存在,拦截,返回401状态码
            response.setStatus(401);
            return false;
        }
        // 5.将查询到的Hash数据转换为UserDTO对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap,new UserDTO(),false);
        // 6.存在,保存用户信息到ThreadLocal
        UserHolder.saveUser(userDTO);
        // 7.刷新redis中token有效期
        stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 8.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 销毁user对象,防止内存泄露
        UserHolder.removeUser();
    }
}

2.4 登录拦截器的优化

在最开始的方案中,刷新token过期时间是在一个只拦截需要登录才能访问的路径,但是有一些不需要登录的路径并不会被拦截(如首页等)。

那就会出现一个场景:当用户登陆完成以后,只在首页停留,来回刷新首页,就是不访问别的路径,那么这个时候token的过期时间是不会被刷新的,时间一到以后用户就被提出登陆状态了,这是不合理的。

解决方案就是:在该拦截器前面再加一个拦截一切路径的拦截器,该拦截器只负责刷新token过期时间,不管token存不存在都放行,若token存在且对应的用户存在才刷新token过期时间,并把对应的用户放入ThreadLocal中。然后再第二个拦截器里判断ThreadLocal中的用户是否存在,不存在则拦截,反之放行。

// 第一个拦截器
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,判断token是否存在
        String token = request.getHeader("authorization");
        if (StrUtil.isEmpty(token)) {
            return true;
        }
        // 2.基于token获取redis中的用户
        String tokenKey = LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);
        // 3.判断用户是否存在
        if (userMap.isEmpty()) {
            return true;
        }
        // 5.将查询到的Hash数据转换为UserDTO对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap,new UserDTO(),false);
        // 6.存在,保存用户信息到ThreadLocal
        UserHolder.saveUser(userDTO);
        // 7.刷新redis中token有效期
        stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 8.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 销毁user对象,防止内存泄露
        UserHolder.removeUser();
    }
}

// 第二个拦截器
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        // 判断是否需要拦截(通过ThreadLocal中是否有用户来判断)
        if (UserHolder.getUser() == null) {
            // 没有,需要拦截,设置状态码
            response.setStatus(401);
            // 拦截
            return false;
        }
        // 有用户,放行
        return true;
    }
}

// 添加两个拦截器,并设置拦截器顺序
@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 先后顺序由order属性值决定,默认都是0,可以修改order值,order越小优先级越高,order越大顺序越靠后
        // 同时order值相同,就通过添加顺序来决定先后顺序。
        // 登录拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                ).order(1);
        // 刷新token拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);
    }
}

Redis代替session需要考虑的问题:

1.选择合适的数据结构 2.选择合适的key 3.选择合适的存储粒度

3. 缓存

3.1 缓存简介

  • 分类:

1.硬件缓存: 一般指的是机器上的 CPU、硬盘等等组件的缓存区间;一般是利用的内存作为一块中转区域,都通过内存交互信息,减少系统负载,提供传输效率。
2.客户端缓存: 一般指的是某些应用(例如浏览器、手机App、视频缓冲等);都是在加载一次数据后将数据临时存储到本地,当再次访问时候先检查本地缓存中是否存在,存在就不必去远程重新拉取,而是直接读取缓存数据,这样来减少远端服务器压力和加快载入速度。
3.服务端缓存: 一般指远端服务器上;考虑到客户端请求量多,某些数据请求量大,这些热点数据经常要到数据库中读取数据,给数据库造成压力,还有就是 IO、网络等原因有一定延迟,响应客户端较慢。所以,在一些不考虑实时性的数据中,经常将这些数据存在内存中(内存速度非常快),当请求时候,能够直接读取内存中的数据及时响应。

  • 作用:降低后端负载、提高读写效率、降低响应时间

  • 成本:数据一致性成本、代码维护成本、运维成本

Redis缓存

	Redis缓存,属于服务器缓存,就是把Redis当作数据库的缓存。因为Redis是在内存上存储的,比在硬盘上存储的数据库运行速度快很多。
	客户端发起查询数据的请求,都先去Redis中查询,Redis中若有,则直接返回,反之没有,那就再去数据库查询,数据库查询到以后,再存入Redis中,然后返回。以此往复,Redis中的数据越来越多,命中率也越来越大,大大加快了服务端的响应速度。

3.2 缓存练习

  • 实现商铺查询缓存
public Result queryById(Long id) {
    // 1.在redis中查询店铺缓存
    String key = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否命中
    if (StrUtil.isNotBlank(shopJson)) {
        // 3.命中,直接返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }

    // 4.未命中,查询数据库
    Shop shop = getById(id);
    // 5.判断商铺是否存在
    if (shop == null) {
        // 6.不存在,返回错误信息
        return Result.fail("商铺不存在!");
    }

    // 7.存在,将商铺数据写入redis
    stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
    // 8.返回
    return Result.ok(shop);
}
  • 实现店铺类型缓存
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryList() {
        // 1.在redis中查询店铺类型缓存
        String key = CACHE_SHOP_KEY + "list";
        List<String> stringTypeList = stringRedisTemplate.opsForList().range(key, 0, -1);
        // 2.判断是否命中
        if (!stringTypeList.isEmpty()) {
            // 3.命中,直接返回
            List<ShopType> shopTypeList = JSONUtil.toList(stringTypeList.toString(), ShopType.class);
            return Result.ok(shopTypeList);
        }

        // 4.没有命中,查询数据库
        List<ShopType> typeList = query().orderByAsc("sort").list();
        // 5.判断数据库中是否存在
        if (typeList == null) {
            // 6.不存在,返回错误信息
            return Result.fail("店铺类型不存在!");
        }

        // 7.存在,存入redis
        stringTypeList = JSONUtil.toList(new JSONArray(typeList), String.class);
        stringRedisTemplate.opsForList().rightPushAll(key, stringTypeList);
        // 8.返回
        return Result.ok(typeList);
    }
}

3.3 缓存更新策略

  • 分类:

1.内存淘汰策略:一致性差、维护成本无。Redis是默认开启的,我们也可以制定一些内存不足时淘汰数据的规则,且是由Redis管理的,我们不需要去人为维护。
2.超时剔除策略:一致性一般(属于最终一致性),维护成本低。给缓存数据添加TTL时间,到期自动删除,下次查询时再查询数据库写入缓存,一致性不是很好,但维护成本低,一般作为兜底策略,不会作为主策略。
3.主动更新策略:一致性好(任何方案都做不到完美一致),维护成本高。在修改数据库的时候就更新缓存(一般是删除缓存),下次查询时再查询数据库写入缓存,一般把超时剔除策略作为兜底策略,作为出现脏数据时的补救措施。

  • 业务场景:

    低一致性需求:使用内存淘汰策略,并且可以视情况以超时剔除策略作为兜底方案。

    高一致性需求:使用主动更新策略,并以超时剔除策略作为兜底方案。

  • 主动更新策略

    主动更新策略有三种主流的实现方案:
    一般都是使用Cache Aside Pattern实现方案,虽然会稍微复杂一点,但是可以我们自己人为控制,
    Read/Writer Through Pattern实现方案作为调用者无需关心内部实现,但是维护者维护难度高,市面上的这类第三方服务商也不好找;
    而Write Behind Caching Psttern实现方案只操作缓存,异步将缓存数据持久化,当出现系统宕机时,缓存数据会丢失,而且当缓存被操作了很多次,但是还没有被异步持久化时,会出现很严重的不一致性。

  • 操作缓存和数据库需要考虑的问题

1、更新缓存的操作的实现是:删除缓存还是修改缓存?
	删除缓存,
因为如果是修改缓存的话,每次更新数据库都要更新缓存,但是若这期间若一个缓存值修改多次,期间却没有几次读操作,那么期间的无效写操作过多,浪费系统性能。
而删除缓存,在每次更新数据库时都删除缓存,等之后有请求需要查找这条被删除的缓存数据时再去数据库中查找出来并再次写入缓存,那么这样不会有无效写操作,同时也最大节省了系统性能。
2、如何保证缓存与数据库的操作同时成功或失败(即原子性)?
	单个系统:将缓存与数据库操作放在一个事务内
	分布式系统:利用TCC等分布式事务方案
3、先操作缓存还是先操作数据库?
	一般情况下选择先操作数据库,再删除缓存。
	由以下两图可得,不管是先缓存后数据库,还是先数据库后缓存,都会有脏数据的出现,
	但是因为Redis缓存是基于内存的,数据库是基于磁盘的,所以操作缓存的速度是远大于操作数据库的,出现脏数据概率更小的是先数据库后缓存。同时,这里出现的脏数据问题,可以通过超时剔除兜底,来缓解脏数据带来的影响。

先删除缓存,再操作数据库

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YtNPKlL9-1666350744859)(C:\Users\29851\Desktop\学习笔记\Redis学习笔记\asset\111.png)]

​ 如图所示,在正常流程下,先缓存后数据库的流程完全没问题,但是正如右图,也有出现脏数据的情况。

先操作数据库,再删除缓存

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hAxEUYwB-1666350744860)(C:\Users\29851\Desktop\学习笔记\Redis学习笔记\asset\222.png)]

​ 如图所示,在正常流程下,先数据库后缓存的流程完全没问题,但是正如右图,也有出现脏数据的情况。

  • 总结

在需要高一致性的场景下,我们一般都会选择主动更新策略以及超时剔除策略兜底,

并且更新缓存的操作为删除缓存

先操作数据库再删除缓存。

  • 缓存更新策略的最佳实践方案

低一致性需求:使用Redis自带的内存淘汰机制,视情况选择以超时剔除作为兜底方案
高一致性需求:主动更新,并以超时剔除作为兜底方案
读操作:
缓存命中则直接返回
缓存未命中则查询数据库,并写入缓存,设定超时时间
写操作:
先写数据库,然后再删除缓存
要确保数据库与缓存操作的原子性

  • 给查询商铺的缓存添加超时剔除和主动更新的策略
//修改ShopController中的业务逻辑,满足下面的需求:
//根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
//根据id修改店铺时,先修改数据库,再删除缓存
	@Override
    public Result queryById(Long id) {

        // 1.在redis中查询店铺缓存
        String key = CACHE_SHOP_KEY + id;
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否命中
        if (StrUtil.isNotBlank(shopJson)) {
            // 3.命中,直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }

        // 4.未命中,查询数据库
        Shop shop = getById(id);
        // 5.判断商铺是否存在
        if (shop == null) {
            // 6.不存在,返回错误信息
            return Result.fail("商铺不存在!");
        }

        // 7.存在,将商铺数据写入redis,并设置过期时间
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
        // 8.返回
        return Result.ok(shop);
    }



    @Override
    @Transactional  // 基于方法的事务,不是数据库的事务操作
    public Result update(Shop shop) {

        // 1.更新数据库
        updateById(shop);
        // 2.删除缓存
        stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());
        // 3.返回成功状态码
        return Result.ok();
    }

3.4 缓存穿透

  • 定义

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求永远都会打到数据库,占用系统I/O性能。

如果有人利用这个原理,恶意多次请求这个不存在的数据,就会使系统宕机。

  • 解决方案
    1.缓存空对象
    实现原理:数据库中不存在也缓存到redis中,缓存值为null,可以设置较短的TTL,防止长期的数据不一致
    优点:实现简单,维护方便
    缺点:
    额外的内存消耗
    可能造成短期的不一致
    2.布隆过滤器
    实现原理:基于Hash算法实现的二进制字节数组
    优点:内存占用较少,没有多余key
    缺点:
    自行实现复杂(好在Redis提供了BitMap类型,自带的一种布隆过滤器)
    存在误判可能(他判断不存在的值一定是不存在的,但是判断存在的值不一定真的存在)

  • 解决商户查询的缓存穿透问题

​ 这里选择的是缓存空对象的解决方案,因为实现简单。

@Override
public Result queryById(Long id) {

    // 1.在redis中查询店铺缓存
    String key = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否命中,命中的是null或""这里结果是false
    if (StrUtil.isNotBlank(shopJson)) {
        // 3.命中,直接返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    // 判断命中的是否是空字符串
    // 因为stringRedisTemplate.opsForValue().get(key)的key不存在,就返回null,不会返回"",所以这里可以用这个判断条件
    if ("".equals(shopJson)){
        // 返回错误信息
        return Result.fail("店铺信息不存在!");
    }

    // 4.未命中,查询数据库
    Shop shop = getById(id);
    // 5.判断商铺是否存在
    if (shop == null) {
        // 为不存在的key设置空字符串值,设置较短的过期时间,缓解缓存穿透
        stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
        // 6.不存在,返回错误信息
        return Result.fail("店铺信息不存在!");
    }

    // 7.存在,将商铺数据写入redis,并设置过期时间
    stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
    // 8.返回
    return Result.ok(shop);
}

  • 总结

1.缓存穿透产生的原因:用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力。
2.缓存穿透的解决方案有哪些?
缓存null值、布隆过滤的解决方案都属于被动解决。

​ 我们还有很多主动的解决方案,比如:
​ (1)给id设置一定的格式规律,同时增强id的复杂度,避免被猜测id规律,然后在此基础上每次都先对id进行基础格式校验。
​ (2)加强用户权限校验
​ (3)做好热点参数的限流

3.5 缓存雪崩

  • 定义

​ 缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机时,导致大量请求直接到达数据库,带来巨大压力。

  • 解决方案:
    1、给不同的Key的TTL添加随机值(尤其是缓存预热的时候,同时加入大批量数据)
    2、利用Redis集群提高服务的可用性(高级篇会讲):多台Redis服务器,可以实现多台宏观调控
    3、给缓存业务添加降级限流策略(SpringCloud会学):当服务器请求压力大时,直接拦截请求快速返回,不再让服务器承受那么多请求
    4、给业务添加多级缓存(高级篇会讲):就是多端缓存,利用本地缓存等。

3.6 缓存击穿

  • 定义

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

  • 解决方案

    ​ 1、互斥锁:就是在缓存未命中后,线程准备去查询数据库重建缓存数据之前,获取一个互斥锁,除了第一个线程拿到锁以外,其他后来的线程拿互斥锁失败以后会休眠一段时间,然后再从查询缓存从新走流程。

    ​ 2、逻辑过期(内包含互斥锁):就是设置热点key永不过期,但是把expire过期时间属性写入value中,而key的过期时间是(写入该数据当时时间加上过期时间的时间戳)。这样每次查询缓存的时候都可以拿到缓存数据,然后通过value中的expire属性来判断当前这个缓存是否已经过期。

    若已经过期,那就先获取互斥锁,然后在这个线程里开启新的线程,让这个新的线程去执行查询数据库重建缓存数据的任务,而原来的线程就直接返回以及过期数据先用着,在新线程释放锁之前(也有可能是写入缓存之前),其他线程来查询缓存,都会因为因为尝试获取互斥锁失败后返回过期数据继续用着先。

解决方案 互斥锁(保证数据一致性) 逻辑过期(服务可用性)
优点 没有额外内存消耗、保持一致性、实现简单 线程无需等待(性能好)
缺点 线程需等待(性能受影响)、可能有死锁风险 存在额外内存消耗、不保持一致性、实现复杂
  • 基于互斥锁方式解决缓存击穿问题
//这里的互斥锁方案只是一个简易的实现
//对普通的互斥锁来说,拿不到这个互斥锁后,是在无限的等待当中的,直到拿到锁为止。
//通过利用redis中setnx命令,来实现了自定义的锁。我们直接把加锁、释放锁操作封装到了方法里

// 加锁方法
public boolean tryLock(String key){
    // 加锁操作,同时这里加过期时间是为了防止:拿到锁的进程发生以后是死锁等其他原因
    // 导致该进程花了超过原本正常可以完成的时间数十倍以上时间还没释放锁,让整个服务停止了,所以加了过期时间
    // setIfAbsent()就是redis中的setnx,同时redis中的返回值应该只有0或1,分别代表失败和成功
    // 这里spring data redis进行了封装,直接转换为对应的false或true,分别代表失败和成功
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
    // 因为boolean自动拆箱为Boolean可能会产生空指针异常
    // 所以这里需要判断传入的的Boolean类型的值是不是true,并且做安全的拆箱。
    return BooleanUtil.isTrue(flag);
}

// 释放锁方法
public void unlock(String key){
    stringRedisTemplate.delete(key);
}


@Override
public Result queryById(Long id) {

    // 缓存击穿
    Shop shop = queryWithMutex(id);
    if (shop == null) {
        return Result.fail("店铺不存在!");
    }
    return Result.ok(shop);
}

// 封装的缓存击穿解决方法
public Shop queryWithMutex(Long id){
    // 1.在redis中查询店铺缓存
    String key = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否命中,命中的是null或""这里结果是false
    if (StrUtil.isNotBlank(shopJson)) {
        // 3.命中,直接返回
        return JSONUtil.toBean(shopJson, Shop.class);
    }
    // 判断命中的是否是空字符串
    // 因为stringRedisTemplate.opsForValue().get(key)的key不存在,就返回null,不会返回"",所以这里可以用这个判断条件
    if ("".equals(shopJson)){
        // 返回错误信息
        return null;
    }

    // 4.实现缓存重建
    // 4.1.获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    Shop shop;
    try {
        boolean isLock = tryLock(lockKey);
        // 4.2.判断是否获取成功
        if (!isLock) {
            // 4.3.失败,则休眠并重试
            Thread.sleep(50);
            return queryWithMutex(id);
        }

        // 4.4.检测此时缓存是否存在了,若存在,直接返回,反之继续走下去
        shopJson = stringRedisTemplate.opsForValue().get(key);
        if (shopJson != null) {
            return JSONUtil.toBean(shopJson, Shop.class);
        }

        // 4.5.获取锁成功,根据id查询数据库
        shop = getById(id);
        // 5.判断商铺是否存在
        if (shop == null) {
            // 6.不存在,为不存在的key设置空字符串值,设置较短的过期时间,缓解缓存穿透
            stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 不存在,返回错误信息
            return null;
        }

        // 7.存在,将商铺数据写入redis,并设置过期时间
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
    } catch (InterruptedException e) {
        throw new RuntimeException();
    }finally {
        // 8.释放互斥锁
        unlock(lockKey);
    }

    // 9.返回
    return shop;
}

  • 基于逻辑过期解决缓存击穿问题

因为逻辑过期一般应用于活动商品的库存,不在活动内的商品,在缓存内肯定找不到的

所以缓存未命中直接返回null。

// 创建线程池
public static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

@Override
public Result queryById(Long id) {

    // 缓存击穿
    Shop shop = queryWithLogicalExpire(id);
    if (shop == null) {
        return Result.fail("店铺不存在!");
    }
    return Result.ok(shop);
}

// 封装的缓存击穿解决方法
public Shop queryWithLogicalExpire(Long id){
    // 1.在redis中查询店铺缓存
    String key = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否不存在
    if (StrUtil.isBlank(shopJson)) {
        // 3.不存在,直接返回
        // 因为逻辑过期一般应用于活动商品的库存,不在活动内的商品,在缓存内肯定找不到的
        // 并且也不能因为找不到就去数据库中找,这不合逻辑,不存在即代表不参加活动
        return null;
    }
    // 4.存在,需要先把json反序列化为对象
    RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
    LocalDateTime expireTime = redisData.getExpireTime();
    // 这里要这么做是因为Data属性是Object类型的,而在JsonUtil无法分辨他具体是哪个类
    // 所以就先转换成了JSONObject类型,这里可以直接强转为JSONObject类型,然后再通过toBean()方法转换为目标类型
    // 注意!!!不能直接强转为目标类型!
    Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(),Shop.class);
    // 5.判断是否过期
    if (expireTime.isAfter(LocalDateTime.now())) {
        // 5.1.未过期,直接返回店铺信息
        return shop;
    }
    // 5.2.已过期,需要缓存重建
    String lockKey = LOCK_SHOP_KEY + id;
    // 6.缓存重建
    // 6.1.获取互斥锁
    boolean isLock = tryLock(lockKey);
    // 6.2.判断是否获取锁成功
    if (isLock) {
        // 做DoubleCheck,防止第一个线程完成缓存重建后,别的线程已经执行到缓存重建这里了
        // 这里的DoubleCheck是我自己写的,估计有问题,之后复习一下多线程
        shopJson = stringRedisTemplate.opsForValue().get(key);
        redisData = JSONUtil.toBean(shopJson, RedisData.class);
        expireTime = redisData.getExpireTime();
        shop = JSONUtil.toBean((JSONObject) redisData.getData(),Shop.class);
        // 5.判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            // 5.1.未过期,直接返回店铺信息
            return shop;
        }

        // 6.3.成功,通过线程池开启独立线程,让该线程实现缓存重建
        // 使用线程池的原因是,频繁创建和销毁线程很消耗性能
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                this.saveShop2Redis(id, 20L);
            }catch (Exception e){
                throw new RuntimeException(e);
            }finally {
                // 释放锁
                unlock(lockKey);
            }
        });
    }
    // 7.返回过期的店铺信息
    return shop;
}

public void saveShop2Redis(Long id,Long expireSeconds) throws InterruptedException {
    // 1.查询店铺数据
    Shop shop = getById(id);
    // 2.封装逻辑过期时间
    RedisData redisData = new RedisData();
    redisData.setData(shop);
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
    // 3.写入redis
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(redisData));
}

3.7 缓存工具类

基于StringRedisTemplate封装一个缓存工具类,满足下列需求:

  • 将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间

  • 将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题

  • 根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题

  • 根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题

    普通的key通常使用方法一和方法三来进行维护,热点key通常使用方法二和方法四来进行维护。

  • 在工作中,很多操作都是可以封装成工具类来使用的,这里就举例互斥锁、逻辑过期两个方法来写一个工具类。

@Slf4j
@Component
public class CacheClient {

    // 这里不需要 @Resource,因为使用的有参构造方法注入,只有一个构造方法甚至不需要写@Bean
    private final StringRedisTemplate stringRedisTemplate;

    // 线程池
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    public CacheClient(StringRedisTemplate stringRedisTemplate) {
        this.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.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        // 写入Redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    public <R,ID> R queryWithPassThrough(
        	// keyPrefix是redis中key的前缀,id是和前缀拼接在一切的唯一标识
            // dbFallback是有传参有返回值的查询数据库方法,time、unit分别是时间值和时间单位
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
        String key = keyPrefix + id;
        // 1.从redis查询商铺缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(json)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(json, type);
        }
        // 判断命中的是否是空值
        if (json != null) {
            // 返回一个错误信息
            return null;
        }

        // 4.不存在,根据id查询数据库
        R r = dbFallback.apply(id);
        // 5.不存在,返回错误
        if (r == null) {
            // 将空值写入redis
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 返回错误信息
            return null;
        }
        // 6.存在,写入redis
        this.set(key, r, time, unit);
        return r;
    }

    public <R, ID> R queryWithLogicalExpire(
            // keyPrefix是redis中key的前缀,id是和前缀凭借在一切的唯一标识
            // dbFallback是有传参有返回值的查询数据库方法,time、unit分别是时间值和时间单位
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.从redis查询商铺缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isBlank(json)) {
            // 3.存在,直接返回
            return null;
        }
        // 4.命中,需要先把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        LocalDateTime expireTime = redisData.getExpireTime();
        // 5.判断是否过期
        if(expireTime.isAfter(LocalDateTime.now())) {
            // 5.1.未过期,直接返回店铺信息
            return r;
        }
        // 5.2.已过期,需要缓存重建
        // 6.缓存重建
        // 6.1.获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        // 6.2.判断是否获取锁成功
        if (isLock){
            // 6.3.成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 查询数据库
                    R newR = dbFallback.apply(id);
                    // 重建缓存
                    this.setWithLogicalExpire(key, newR, time, unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }finally {
                    // 释放锁
                    unlock(lockKey);
                }
            });
        }
        // 6.4.返回过期的商铺信息
        return r;
    }

    public <R, ID> R queryWithMutex(
        	// keyPrefix是redis中key的前缀,id是和前缀凭借在一切的唯一标识
            // dbFallback是有传参有返回值的查询数据库方法,time、unit分别是时间值和时间单位
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(shopJson, type);
        }
        // 判断命中的是否是空值
        if (shopJson != null) {
            // 返回一个错误信息
            return null;
        }

        // 4.实现缓存重建
        // 4.1.获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        R r = null;
        try {
            boolean isLock = tryLock(lockKey);
            // 4.2.判断是否获取成功
            if (!isLock) {
                // 4.3.获取锁失败,休眠并重试
                Thread.sleep(50);
                return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
            }
            // 4.4.获取锁成功,根据id查询数据库
            r = dbFallback.apply(id);
            // 5.不存在,返回错误
            if (r == null) {
                // 将空值写入redis
                stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                // 返回错误信息
                return null;
            }
            // 6.存在,写入redis
            this.set(key, r, time, unit);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            // 7.释放锁
            unlock(lockKey);
        }
        // 8.返回
        return r;
    }
    
    // 获取锁方法
    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

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

4.全局唯一ID

在黑马点评项目中每个店铺都可以发布优惠券。

当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:
    1、id的规律性太明显,容易被人通过id号猜出数据信息,如一天销售量多少等
    2、受单表数据量的限制,以后分库分表自增id就会出现问题,因为mysql的自增都是每张表独立的。

正因为以上的原因,所以产生了一个概念叫全局ID生成器。

4.1 简介

全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,

  • 特性:1.唯一性 2.高可用 3.高性能 4.递增性 5.安全性

Redis很适合实现全局ID生成器,除了安全性(纯自增的值安全性不高),所以我们需要对redis中自增的id值进行拼接。
为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:
ID的组成部分:
符号位:1bit,永远为0
时间戳:31bit,以秒为单位,可以使用69年
序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID

PS:这里的ID规则是自定义的,个人认为可以其实可以直接使用雪花算法生成的id的,mp默认的雪花算法id生成就很好用

4.2 实现Redis全局ID生成器

@Component
public class RedisIdWorker {

    /**
     *  开始时间戳,可以自定义
     * */
    public static final long BEGIN_TIMESTAMP = 1640995200L;

    /**
     *  序列号位数,可以自定义
     * */
    public static final long COUNT_BITS = 32;

    private StringRedisTemplate stringRedisTemplate;

    // 使用构造方法注入
    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public long nextId(String keyPrefix){
        // 1.生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond - BEGIN_TIMESTAMP;

        // 2.生成序列号
        // 2.1.获取当前日期,精确到天
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        // 2.2.自增长
        long count = stringRedisTemplate.opsForValue().increment("incr:" + keyPrefix + ":" + date);

        // 3.拼接并返回
        // 这里通过位运算来实现 字符串的拼接效果,但结果又不是字符串,是long类型
        return timestamp << COUNT_BITS | count;
    }
}

  • 使用
// 直接注入调用nextId()方法即可。
@Resource
private RedisIdWorker redisIdWorker;

@Test
void test(){
    long id = redisIdWorker.nextId("order");
    System.out.println("id = " + id);
}

4.3 总结

全局唯一ID生成策略:
    UUID:生成的是16进制的字符串,也没有递增性
    Redis自增:采用数值类型,存储空间占用少。可以应用在分布式体系中,不依赖于机器码,不需要人员维护
    snowflake算法(雪花算法):不依赖Reids,性能强于Redis,依赖机器自身的机器码,需要人员去维护
    数据库自增:即创建一个专门用于存储全局唯一ID的表,每次批量创建,然后供查询使用,性能低于Redis自增方案
Redis自数据库增ID策略:
    每天一个key,方便统计订单量,同时这样可以限制key对应的value值不过于太大,因为value最大为2的64次方,看起来很大没什么问题,但是像淘宝这样的电商,几天可能就占满value最大值了,采用每天一个key可以有效解决这个问题。
    ID构造是 时间戳 + 计数器

4.4 实现优惠券秒杀下单

每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购:
    表关系如下:
    tb_voucher:优惠券的基本信息,优惠金额、使用规则等
    tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息
  • 添加优惠券
请求路径:http://localhost:8081/voucher/seckill
请求方式:POST
请求体:
    {
        "shopId":1,
        "title":"100元代金券",
        "subTitle":"周一至周五均可使用",
        "rules":"全场通用\\n无需预约\\n课无限叠加\\不兑现、不找零\\n仅限堂食",
        "payValue":8000,
        "actualValue":10000,
        "type":1,
        "stock":100,
        "beginTime":"2022-05-31T16:00:00",
        "endTime":"2022-05-31T20:00:00"
    }

  • 实现下单
@Override
@Transactional	// 这里添加了事务,因为扣减库存和保存订单要保持原子性
public Result seckillVoucher(Long voucherId) {

    // 1.查询优惠券信息
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
    if (seckillVoucher == null) {
        return Result.fail("优惠券不存在!");
    }
    // 2.判断秒杀是否开始
    if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
        return Result.fail("秒杀活动还未开始!");
    }
    // 3.判断秒杀是否结束
    if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
        return Result.fail("秒杀活动已经结束!");
    }
    // 4.判断库存是否充足
    if (seckillVoucher.getStock() < 1) {
        return Result.fail("库存不足!");
    }

    // 5.扣减库存
    boolean success = seckillVoucherService.update()
        .setSql("stock = stock -1")
        .eq("voucher_id", voucherId).update();
    if (!success) {
        // 扣减失败
        return Result.fail("库存不足!");
    }
    // 6.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 6.1.订单id
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 6.2.用户id
    Long userId = UserHolder.getUser().getId();
    voucherOrder.setUserId(userId);
    // 6.3.代金券id
    voucherOrder.setVoucherId(voucherId);
    // 6.4.保存订单到数据库中去
    save(voucherOrder);
    // 7.返回订单id
    return Result.ok(orderId);
}

你可能感兴趣的:(学习笔记(自用),redis,学习,java)