Redis解决商城问题的各种策略

Redis项目实战:

短信登录:

基于Redis缓存:

image-20220415100630648


(1)发送短信验证码:

实现逻辑 :
  1. 先校验手机号 ,
    1. 不符合
      1. 直接返回错误信息 , 使用的是封装的返回前端的方法
    2. 符合 ,
      1. 生成一个随机验证码 , 使用的是huTool中的RandomUtil工具类 , 生成随机的六位验证码
      2. 保存验证码到Redis中 , 使用手机号加前缀作为key值保存 , 保证key值唯一性 , 同时设置验证码有效时间
      3. 返回前端数据

代码实现 :
@Override
public Result sendCode(String phone) {
    //TODO 1.校验手机号:不符合是true
    if (RegexUtils.isPhoneInvalid(phone)) {
        //2.如果不符合 , 返回错误信息
        return Result.fail("手机号格式错误");
    }
    // TODO 3.符合 , 生成一个随机验证码 , 使用的是huTool中的工具类 ,
    String code = RandomUtil.randomNumbers(6);
    // TODO 4.保存验证码到Redis当中 , 使用手机号加前缀作为key来保存 , 保证可以的唯一性 , 同时 , 设置有效期为两分钟
    stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
    //5.模拟发送验证码
    log.debug("发送短信验证码成功, 验证码:{" + code + "}");
    //6.返回前端数据ok
    return Result.ok();
}

(2)短信验证码登录注册:

实现逻辑 :
  1. 校验手机号 ,
    1. 不符合
      1. 就直接返回错误信息 , 避免有人使用正确手机号获取验证码 , 但是注册时切换错误手机号
    2. 符合
      1. 从Redis中获取验证码 ,
        1. 判断验证码是否失效(也就是验证码查询不出来) 或者 校验用户输入验证码和Redis中的验证码是否一致
          1. 不符合
            1. 直接输出错误信息
          2. 符合
            1. 从数据库中根据手机号查询用户信息
              1. 查询不出来
                1. 创建新用户 , 使用随机的字符串加前缀作为用户名
              2. 查询出来
            2. 将获取的用户信息选择拷贝搭配DTO类中 , (隐藏用户隐私信息)
            3. 将UserDTO类转换为map类型数据 ,
            4. 设置一个随机字符串和前缀作为该用户的登录令牌
            5. 将令牌作为key , UserDTO作为key进行缓存 , 使用map格式作为缓存数据类型
            6. 将获取的UserDTO返回前端

代码实现 :
@Override
public Result login(LoginFormDTO loginForm) {
    //1.校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
        return Result.fail("手机号格式错误");
    }

    //2. TODO 校验验证码 , 从redis中获取验证码
    String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
    String code = loginForm.getCode();
    if (cacheCode == null || !cacheCode.equals(code)) {
        //3.判断验证码是否失效(也就是验证码查询不出来) 或者 校验用户输入验证码和Redis中的验证码是否一致
        return Result.fail("验证码错误");
        // TODO 使用反证的方式 , 可以减少if语句的判断次数
    }
    //4.一致 , 根据手机号查询用户

    // TODO 使用的是MyBatisPlus中的方法 , 进行查询的
    User user = query().eq("phone", phone).one();
    //5.判断用户是否存在
    if (user == null) {
        //6.不存在 , 创建新用户并保存
        user = createUserWithPhone(phone);
    }

    // TODO 7.保存用户信息到redis中
    // TODO 7.1 随机生成token作为登录令牌
    
    // TODO 使用huTool提供的UUID , 下边的写法是生成不带下划线的UUID , 默认值为false,带下划线的UUID
    String token = UUID.randomUUID().toString(true);

    // TODO 使用BeanUtil中的copyProperties方法 , 可以将user中的属性自动拷贝到UserDTO中 , 对于没有的属性,不进行拷贝
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);

    // TODO 7.2 将User对象转为Hash集合 , 将UserDTO转换为一个Map集合 ,
    //  这个时候 , 进行转换的时候 ,会出现异常 , 因为我们使用的是String类型的redis对象 , 在转换的时候 , key值只能是String类型的
    //  但是 , 这个BeanUtil工具类 , 允许我们进行自定义 ,
    //  添加两个参数 , 一个是new HashMap<>() ,
    //  一个是CopyOptions , 定义自定义的操作
    Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap<>(),
            CopyOptions.create()
                    .setIgnoreNullValue(true) // TODO 设置是否忽略空值
                    // TODO 对字段值的修改器 , 需要两个参数 , 修改前的字段名和字段值 , 修改后的字段值
                    .setFieldValueEditor((fileName,fileValue) -> fileValue.toString()));

    // TODO 7.3 存储
    String tokenKey = LOGIN_USER_KEY + token;
    stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
    // TODO 7.4 设置token的有效期 , redis中不能在上一个方法中直接设置有效期 , 可以在下边设置
    stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL,TimeUnit.MINUTES);
    // TODO 8.返回token , 用来将这个token保存在浏览器中 , 下次登录的时候 , 会携带这个token进行访问
    return Result.ok(token);
}

//TODO 创建用户
private User createUserWithPhone(String phone) {
    //1.创建用户 :
    User user = new User();
    user.setPhone(phone);
    //生成随机的字符串 , 用来当做用户名
    user.setNickName("user_" + RandomUtil.randomString(10));
    save(user);
    return user;
}

(3)拦截是否有登录令牌 , 有就刷新 , 没有就直接放行

  • 使用拦截器 , 负责检测用户登录的时间 , 使用缓存 token来定义用户的登录时间 , 只要有操作 , 就刷新token的时间 ,

  • 只负责检测请求中有没有携带token , 没有就直接放行 , 有了就刷新token ,


实现逻辑 :
  1. 设置前置拦截 , 获取请求头中的token
    1. 为空 , 说明没有登录 , 使用isBlank判断 ,
      1. 直接放行
    2. 不为空 , 获取Redis中的用户的信息
      1. 判断获取的map集合是否为空
        1. 为空 , 直接放行
        2. 不为空 , 将获取到的信息转为Map格式 , 存储在ThreadLocal域中
        3. 刷新token的有效期

代码实现 :
/**
 * 拦截器类 , 负责刷新token保存时间的 , 只有用户登录了才进行操作 , 其他的一概放行
 */
public class RefreshTokenInterceptor implements HandlerInterceptor {

    // TODO 注意: 拦截器是我们自己创建的类 , 不受Spring容器管理 , 所以 , 不能直接注入RedisTemplate
    // TODO 我们只能使用构造函数的方式进行注入 , 谁调用这个拦截器 ,谁负责注入这个RedisTemplate
    private StringRedisTemplate stringRedisTemplate;

    public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    //前置拦截
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // TODO 1.获取请求头中的token
        String token = request.getHeader("authorization");
        // TODO StrUtil中的isBlank方法就是判断是否是空值
        if (StrUtil.isBlank(token)){
            // TODO 为空,说明没有登录 , 直接放行
            return true;
        }
        // TODO 2.基于token获取redis中的用户信息
        // TODO 不能简单的使用get来获取值了 , 使用get获取的只是hash中的map中的一个值 , 而我们想获取的是全部的值 , 使用entries这个方法
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);
        // TODO 不用判断是否为null了,entries会做判断 , 如果为null会返回一个空的map , 所以这里只用判断是否为空就可以了
        if (userMap.isEmpty()){
            // TODO 为空说明用户没有登录, 直接放行 , 不作操作
            return true;
        }
        // TODO 不为空 , 进行token以及数据的保存工作
        // TODO 5.将查询到的Hash数据转为UserDTO对象 , 最后一个参数是否忽略转换中的异常 , false是不忽略
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);

        // TODO 5.存在 , 保存用户信息到ThreadLocal , 这是一个工具类 , 内部创建了一个ThreadLocal对象,来进行操作
        UserHolder.saveUser(userDTO);

        // TODO 7.刷新token的有效期 , 也就是从新设置对应key的有效时间
        stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY+ token,LOGIN_USER_TTL, TimeUnit.MINUTES);
        //6.放行
        return true;
    }

    @Override
    //后置拦截
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //删除用户
        UserHolder.removeUser();
    }

(4)拦截是否登录 :

有了第一个拦截器 , 这个类 就只用拦截是否登录即可 ,

实现逻辑 :
  1. 从ThreadLocal域中获取用户信息 .
    1. 只要为null , 就说明没有登录 , 直接拦截
    2. 不为null , 放行
代码实现 :
/**
 * 拦截器类 , 负责拦截是否登录的
 */
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    //前置拦截
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // TODO 1.判断是否需要拦截 , ThreadLocal中是否有用户信息
        if (UserHolder.getUser() == null){
            // 没有 , 需要拦截 , 设置状态码 ,
            response.setStatus(401);
            // 拦截
            return false;
        }
        //有用户 , 直接放行
        return true;
    }
}

(5)拦截器的配置类:

@Configuration
// TODO 配置拦截器
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // TODO token 刷新拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
                .addPathPatterns("/**")
                .order(0); // TODO 设置优先级 ,值越小 , 优先级越高

        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                "/blog/hot",
                "/shop-type/**",
                "/upload/**",
                "/voucher/**",
                "/shop/**",
                "/user/code",
                "/user/login"
        ).order(1);
    }
}

知识点 :


(1)编写返回前端的工具类 , 后端统一使用这个工具类返回前端数据

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor  //无参构造
@AllArgsConstructor //有参构造
// TODO 这个类用于整个工程中返回前端数据的封装 , 不管是查询出错 , 还是封装数据 , 都是通过这个类进行封装的 ,
//   对其中的方法进行重载 , 使其可以适用于多种情况
public class Result {
    private Boolean success;
    private String errorMsg;
    private Object data;
    private Long total;

    public static Result ok(){
        return new Result(true, null, null, null);
    }
    public static Result ok(Object data){
        return new Result(true, null, data, null);
    }
    public static Result ok(List<?> data, Long total){
        return new Result(true, null, data, total);
    }
    public static Result fail(String errorMsg){
        return new Result(false, errorMsg, null, null);
    }
}

(2)编写实体类的时候 , 使用注解指定对应的表名和标记主键

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.time.LocalDateTime;

@Data//@Data相当于@Getter @Setter @RequiredArgsConstructor @ToString @EqualsAndHashCode这5个注解的合集。
@EqualsAndHashCode(callSuper = false) 
// TODO 默认就是false
//此注解会生成equals(Object other) 和 hashCode()方法
//当使用@Data注解时,则有了@EqualsAndHashCode注解,那么就会在此类中存在equals(Object other) 和 hashCode()方法,
//且不会使用父类的属性 , 在继承之后 , 只要子类属性里面的相同的话,那hashcode的值就相同啦 , 
// 这时会出现问题 : 
//  有多个类有相同的部分属性,把它们定义到父类中,恰好id(数据库主键)也在父类中,那么就会存在部分对象在比较时,它们并不相等,却因为lombok自动生成的equals(Object other) 和 hashCode()方法判定为相等,从而导致出错

// 当使用true的时候 , 在进行比较的时候 , 就会引用父类中继承过来的属性 , 这样进行判定的时候 , 就不会出现相等的情况了

@Accessors(chain = true)
// TODO 开启链式访问 , 可以将所有的set方法连在一起写

@TableName("tb_user") // TODO 指定是哪个数据库中的表
public class User implements Serializable {
    // TODO 序列化
    private static final long serialVersionUID = 1L;
    
    //主键
    @TableId(value = "id", type = IdType.AUTO) //指定主键 , type : 指定id是自增
    private Long id;
    
    //手机号码
    private String phone;
    //密码,加密存储
    private String password;
    //昵称,默认是随机字符
    private String nickName;
    //用户头像
    private String icon = "";
    //创建时间
    private LocalDateTime createTime;
    //更新时间
    private LocalDateTime updateTime;
}

(3)编写常量类 : 定义一些固定的值

// TODO 指定的常量类 , 用于一些基础值的定义
// 一个公有类 , 常量都是静态公有属性 , final ,不可修改的属性
public class RedisConstants {
    public static final String LOGIN_CODE_KEY = "login:code:";
    public static final Long LOGIN_CODE_TTL = 2L;
    public static final String LOGIN_USER_KEY = "login:token:";
    public static final Long LOGIN_USER_TTL = 30L;
}

(4)编写校验类 , 和正则常量类

import cn.hutool.core.util.StrUtil;

public class RegexUtils {
    /**
     * 是否是无效手机格式
     * @param phone 要校验的手机号
     * @return true:符合,false:不符合
     */
    public static boolean isPhoneInvalid(String phone){
        return mismatch(phone, RegexPatterns.PHONE_REGEX);
    }
    
    /**
     * @param str 要检验的值
     * @param regex 正则属性
     * @return true|false
     */
    // 校验是否不符合正则格式 , 这个方法没必要暴露在外边 , 定义为私有方法
    private static boolean mismatch(String str, String regex){
        if (StrUtil.isBlank(str)) {  //判断是否为空
            return true; //这里返回的是true , 但是这个是空字符
        }
        return !str.matches(regex); // str.matches(regex) : 如果输入的值符合正则表达式 , 则返回true , 前边加一个! , 只要符合 , 返回的就是false , 这样做是用反证法 , 用于调用此方法时的反证逻辑
    }
}

(5)huTool中的StrUtil类

  • isEmpty仅仅是判断null长度为0字符串
  • isBlank判断的是null长度为0空白字符(包括空格,制表符\t,换行符\n,换页符\f,回车\r)组成的字符串。
  private void tstStr2() {
        System.out.println(StrUtil.isEmpty(null)); // true
        System.out.println(StrUtil.isEmpty("")); // true
        System.out.println(StrUtil.isEmpty(" ")); // false
        System.out.println(StrUtil.isEmpty("aaa")); // false
        System.out.println(StrUtil.isEmpty("\r \t \n \f")); // false
      
        System.out.println(StrUtil.isBlank(null)); // true
        System.out.println(StrUtil.isBlank("")); // true
        System.out.println(StrUtil.isBlank(" ")); // true
        System.out.println(StrUtil.isBlank("aaa")); // false
        System.out.println(StrUtil.isBlank("\r \n \t \f")); // true
    }

(6)编写线程域的存储类:将登录信息的DTO信息存储进线程域中

public class UserHolder {
    // TODO ThreadLocal : 每一次请求都会是一个新的线程 , ThreadLocal就是一个线程域 ,
    //  在线程内部是以map的形式进行存储的 , 每个线程之间相互独立 , 没有干扰
    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();
    }
}

(7)获取互斥锁的方法

使用StringRedisTemplate 对象来进行setnx操作时 , 使用的是这个操作来进行的

#### 获取锁 : 

使用的是Redis中的setnx , 这个操作在执行的时候 , 只有当它不存在的时候 , 才能执行成功 , 一旦创建成功之后 , 后边的所有setnx操作都会失败 , 

  // TODO 设置获取锁的方法 , 这个key值就是锁的名称 , 谁调用 , 谁来赋予
  private boolean tryLock(String key) {
      // TODO setIfAbsent : 就是setnx的操作 , 可以设置过期时间 , 一般设置为业务完成时间的十倍左右 , 可以自定义
      Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
      // TODO 不要直接返回这个Boolean ,
      //  因为直接返回的话是要进行拆箱的 , 这时可能会造成空指针异常 , 我们使用的是huTool中的工具类
      //  他可以帮你进行判断 , isTrue , isFalse(判断是否为false) , isBoolean(拆箱) ,
      return BooleanUtil.isTrue(flag);
  }


  // TODO 删除锁
  private void unLock(String key) {
      stringRedisTemplate.delete(key);
  }

(8)设置当前时间和增加多少时间

//LocalDateTime.now() : 表示当前时间 ,
//  plusSeconds : 表示添加多少时间
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));

(9)判断逻辑时间是否过期

//判断是否过期 , 是否在当前时间之后 , isBefore是判断是否在当前时间之前
    if (expireTime.isAfter(LocalDateTime.now())){
        
    }

(10)创建一个线程池

// TODO 创建一个线程池 , 里边有十个线程
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

(11)开启新的线程 :

// TODO 6.3 成功 , 开启独立线程实现缓存重建 , 线程池在上边创建 , 线程池的名称.submit
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                this.saveShop2Redis(id,20L);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }finally{
                // TODO 释放锁
                unLock(lockKey);
            }
        });

(12)自定义锁 :

注意 , 使用user.toString , 虽然每次获取的值是一致的 , 但是 , 底层实现是每次新now一个对象 , 所以 , 两次之间作比较 , 是不一样的 , 使用intern方法 , 是在字符串常量池中 . 寻找相同的值的地址 , 这样两个字符串就是完全一样的了

synchronize(user_id.toString.intern()){
	代码块 ;
}

(13)指定单元素集合 :

//这个操作 , 是指定是一个单元素的集合
Collections.singletonList(KEY_PREFIX + name)

(14)lua语言

Redis提供了Lua脚本功能 , 在一个脚本中编写多条Redis命令 , 确保多条命令执行时的原子性 , lua语言是一种编程语言 , 它的基本语法可以参考网站 : https://www.runoob.com/lua/lua-tutorial.html

编写脚本 :

执行脚本 :

image-20220416192926141在这后边 , 加一个数字 , 表示参数的个数 , 从这个数字后边开始 , 数几个 , 这些都是keys数组中的参数 , 剩下的都是ARGV数组中的参数 ,

注意 lua语言的数组 , 初始脚标是1


(15)将从数据库中查询出来的数据

按照指定的列进行排序

 List<ShopType> sort = query().orderByAsc("sort").list();

(16)


什么是缓存 :

缓存就是数据交换的缓冲区 , 称作 (Cache) , 是存储数据的临时地方 , 一般读写性能较高

image-20220414102914100

  • 缓存的作用 :
    • 降低后端的负载
    • 提高读写效率 , 降低响应时间
  • 缓存的成本 :
    • 数据一致性成本
    • 代码维护成本
    • 运维成本

商户查询缓存 :

img


逻辑实现 :

  1. 从缓存中查询商铺信息
    1. 判断是否有缓存
      1. 有缓存
        1. 直接返回商铺信息 , 转为list格式
      2. 没有缓存 , 根据id从数据库中查询数据
        1. 判断是否有这个商铺
          1. 没有
            1. 直接返回错误信息
            1. 将数据保存到Redis中 , 并返回数据

代码实现 :

@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public List<ShopType> queryShopTypeList() {
        // TODO 1.从缓存中查询商铺信息
        String lockShop = stringRedisTemplate.opsForValue().get(LOCK_SHOP_KEY);
        //  TODO 2.判断不为空 , 就直接返回 , 转为list格式
        if (StrUtil.isNotBlank(lockShop)) {
            List<ShopType> list = JSONUtil.toList(lockShop, ShopType.class);
            return list;
        }
        // 3.为空 , 从数据库中查询
        List<ShopType> sort = query().orderByAsc("sort").list();
        // 判断查询数据是否为空
        if (ArrayUtil.isEmpty(sort)){
            return null;
        }
        // TODO 4.将查询出来的数据添加到Redis缓存 , 转为字符串
        stringRedisTemplate.opsForValue().set(LOCK_SHOP_KEY,JSONUtil.toJsonStr(sort),LOCK_SHOP_TTL, TimeUnit.DAYS);
        // TODO 5.将数据返回
        return sort;
    }
}

缓存更新策略:

image-20220414151513530

image-20220414153441713


03方案 :

先是将数据缓存 , 在一个异步操作中 , 将这些数据保存到数据库 , 这么做的好处是 , 在两次异步操作之间 , 进行的数据增删改 , 不用频繁的对数据库进行操作 , 只用在下一次异步操作时 , 将最终的数据进行保存即可

但是 , 如果在两次异步操作之间 , 出现宕机 , 可能会造成数据的丢失 , 一致性和可靠性都会存在一定的问题 ,

02方案 : 开发和维护成本较高

01方案 : 在开发中经常使用


操作缓存和数据库是有三个问题需要考虑

  1. 删除缓存还是更新缓存
    • 更新缓存 : 每次更新数据库都要更新缓存 , 无效写操作较多
    • 删除缓存 : 更新数据库时让缓存失效 , 查询时在更新缓存 (推荐)
  2. 如何保证缓存与数据库的操作同时成功或失败?
    • 单体系统 : 将缓存与数据库操作放在一个事务
    • 分布式系统 : 利用TCC等分布式事务方案
  3. 先操作缓存还是操作数据库 (两种方案都有可能造成线程安全问题 )
    • 先删除缓存 , 再操作数据库(出现的可能性较高)
    • 先操作数据库 , 再删除缓存 (出现的可能性极低)

总结 :

综上所述 : 选择缓存更新策略的最佳实践方案是 :

  1. 低一致性需求 : 使用Redis自带的内存淘汰机制
  2. 高一致性需求 : 主动更新 , 并以超时剔除作为兜底方案
    • 读操作 :

      • 缓存命中直接返回
      • 缓存未命中则查询数据库 , 并写入缓存 , 设定超时时间
    • 写操作 :

      • 先写数据库 , 然后在删除缓存

      • 要确保数据库与缓存操作的原子性

        • 在更新操作上加一个注解

          @Transactional 
          //TODO 添加事务处理 , 整个方法是一个事务
          
// TODO 数据的更新操作
@Override
@Transactional //TODO 添加事务处理 , 整个方法是一个事务
public Result update(Shop shop) {
    Long id = shop.getId();
    if (id == null) {
        return Result.fail("店铺id不能为空!");
    }
    // 1.更新数据库
    updateById(shop);
    // 2.删除缓存
    stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
    return Result.ok();
}

缓存穿透 :

  • 缓存穿透 :
    • 是指客户端请求的数据缓存中数据库中不存在 , 这样的缓存永远都不会生效 , 这些请求都会打到数据库

常见的解决方案 有两种 :

  1. 缓存空对象
    • 实现 :
      • 在查询时 , 缓存中没有 , 去查询数据库 , 数据库中也没有 , 返回一个 " " , 将这个" “缓存到缓存中 , 下次再请求的时候 , 直接从redis中返回这个” "
    • 优点 :
      • 实现简单 , 维护方便
    • 缺点 :
      • 额外的内存消耗
      • 可能造成短期的不一致
  2. 布隆过滤
    • image-20220414163414798
    • 优点 :
      • 内存占用较少 , 没有多于key
    • 缺点 :
      • 实现复杂
      • 存在误判可能 , 并不是百分百的准确
  3. 增强id的复杂度 , 避免被猜测id规律
  4. 做好数据的基础格式校验
  5. 加强用户权限校验
  6. 做好热点参数的限流

实现逻辑 :

  1. 从redis中查询商铺缓存
    1. 判断是否存在
      1. 存在 , 直接输出
      2. 不存在 , 判断是否是空字符串 " " ,
        1. 是 , 返回一个错误信息
        2. 不是 , 根据id查询数据库
          1. 数据库中不存在该店铺
            1. 以id加前缀 作为key , 以 " " 作为key值保存在缓存中 , 设置过期时间
            2. 返回错误信息
          2. 数据库中存在该店铺
            1. 将查询出来的数据转换 , 存储在Redis中
            2. 返回商铺信息

代码实现 :

// TODO 解决缓存穿透问题
public Shop queryWithPassThrough(Long id) {
    // TODO 1.从redis中查询商铺缓存 , 以商铺id作为唯一标识
    String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
    // 2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
        // TODO isNotBlank只有是有数据的情况加才为true , "" , null都为false
        // TODO 3.判断不为空 , 直接返回 , 将字符串转为bean
        return JSONUtil.toBean(shopJson, Shop.class);
    }
    //TODO 判断命中是否是空值 ,
    if (shopJson != null) { //结果是空字符串
        // 返回一个错误信息
        return null;
    }

    // 4.不存在 , 根据id查询数据库
    Shop shop = getById(id);
    // 5.不存在返回错误
    if (shop == null) {
        // TODO 将空值写入redis
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        return null;
    }
    //  TODO 6.存在 , 写入redis , 使用huTool中的工具类 , 将对象转为json数据
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    // 7.返回
    return shop;
}

缓存雪崩 :

  • 缓存雪崩 :
    • 是指在同一时间段大量的缓存key同时失效或者Redis服务宕机 , 导致大量请求到达数据库 , 带来巨大压力
  • 解决方案 :
    • 给不同的key的TTL添加随机值
    • 利用Redis集群提高服务的可用性
    • 给缓存业务添加降级限流策略
      • 添加机制 , 检测到redis服务出现宕机等严重事故时 , 牺牲一部分的业务来减少数据库的压力
    • 给业务添加多级缓存

缓存击穿:

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

image-20220414172640349

常用的解决方案 :

image-20220414173603697


基于互斥锁方式解决缓存击穿问题 :

互斥锁

  • image-20220414172846392
  • 问题 : 线程中只有一个线程 能够获取互斥锁 , 其他线程就只能进行等待 , 效率不高

需求 : 修改根据id查询商铺的业务 , 基于互斥锁方式来解决缓存击穿问题


获取锁 :
  • 使用的是Redis中的setnx , 这个操作在执行的时候 , 只有当它不存在的时候 , 才能执行成功 , 一旦创建成功之后 , 后边的所有setnx操作都会失败 ,

  • // TODO 设置获取锁的方法 , 这个key值就是锁的名称 , 谁调用 , 谁来赋予
    private boolean tryLock(String key) {
        // TODO setIfAbsent : 就是setnx的操作 , 可以设置过期时间 , 一般设置为业务完成时间的十倍左右 , 可以自定义
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
        // TODO 不要直接返回这个Boolean ,
        //  因为直接返回的话是要进行拆箱的 , 这时可能会造成空指针异常 , 我们使用的是huTool中的工具类
        //  他可以帮你进行判断 , isTrue , isFalse(判断是否为false) , isBoolean(拆箱) ,
        return BooleanUtil.isTrue(flag);
    }
    
删除锁 :
  • // TODO 删除锁
    private void unLock(String key) {
        stringRedisTemplate.delete(key);
    }
    
  • 缺陷 :

    • 如果设置锁之后 , 程序出现错误 , 导致没有人删除这个锁 , 那么后续所有的操作都会出错 ,
  • 解决 : 给锁设置一个有效期 , 来进行兜底


实现逻辑 :

image-20220415105035791

代码实现 :
// TODO 互斥锁解决缓存击穿 + 解决缓存穿透问题
public Shop queryWithMutex(Long id) {
    // TODO 1.从redis中查询商铺缓存 , 以商铺id作为唯一标识
    String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
    // 2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
        // TODO isNoyBlank只有是有数据的情况加才为true , "" , null都为false
        // TODO 3.判断不为空 , 直接返回 , 将字符串转为bean
        return JSONUtil.toBean(shopJson, Shop.class);
    }
    //TODO 判断命中是否是空值 ,
    if (shopJson != null) { //结果是空字符串
        // 返回一个错误信息
        return null;
    }
    // TODO 开始缓存重建 , 获取互斥锁
    // TODO 给每一个店铺设置互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    Shop shop;
    try {
        if (!tryLock(lockKey)) {
            // 获取互斥锁 失败 , 则休眠并重试
            Thread.sleep(50);
            // TODO 重试就是递归调用这个方法
            return queryWithMutex(id);
        }

        // 4. 获取互斥锁成功 , 根据id查询数据库
        shop = getById(id);
        // TODO 模拟重建的延时
        Thread.sleep(200);
        // 5.不存在返回错误
        if (shop == null) {
            // TODO 将空值写入redis
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        //  TODO 6.存在 , 写入redis , 使用huTool中的工具类 , 将对象转为json数据
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }finally { // 确保一定会释放锁
        // TODO 释放互斥锁
        unLock(lockKey);
    }
    // 7.返回
    return shop;
}

基于逻辑过期的方式解决缓存击穿问题:

逻辑过期

  • image-20220414173301267

image-20220414195355039


原有的实体类中并没有设置逻辑过期的时间 , 但是也不能直接在原有的实体类中加这个属性 , 不然会对其他的代码有影响 , 可以新建一个类 , 来单独存放这个逻辑过期时间

@Data
public class RedisData {
    // TODO 设置的逻辑过期时间
    private LocalDateTime expireTime;
    // TODO 用来封装想存入redis的实体类数据 ,
    private Object data;
}
封装逻辑过期时间 :缓存重建
public void saveShop2Redis(Long id , Long expireSeconds){
    // TODO 1.查询店铺数据
    Shop shop = getById(id);
    try {
        Thread.sleep(200);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
    // TODO 2.封装逻辑过期时间
    RedisData redisData = new RedisData();
    redisData.setData(shop);
    // TODO  LocalDateTime.now() : 表示当前时间 ,
    //  plusSeconds : 表示添加多少时间
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
    // TODO 3.写入Redis , 不用设置过期时间 , 在redisData中已经封装了逻辑过期时间了
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
}

逻辑实现 :
  1. 从Redis中查询商铺数据 , 用商铺id加前缀作为唯一标识
    1. 判断是否命中 ,
      1. 未命中 , 直接返回错误信息
      2. 命中 , 将json格式反序列化为对象
        1. 判断逻辑时间是否过期
          1. 未过期 , 直接返回数据
          2. 过期 , 进行缓存重建
            1. 获取互斥锁 ,
              1. 成功 ,
                1. 开启一个新的线程 , 进行缓存重建
                2. 返回旧数据
              2. 未成功 , 返回旧数据
代码实现 :
// TODO 创建一个线程池 , 里边有十个线程
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

// TODO 逻辑过期解决缓存击穿问题
public Shop queryWithLogicalExpire(Long id) {
    // TODO 1.从redis中查询商铺缓存 , 以商铺id作为唯一标识
    String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
    // 2.判断是否命中
    if (StrUtil.isBlank(shopJson)) {
        // TODO isNoyBlank只有是有数据的情况加才为true , "" , null都为false
        // TODO 3.判断为空 , 直接返回null
        return null;
    }

    // TODO 4.命中 , 需要先把json反序列化为对象 ,
    RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
    // TODO 因为实体类中设置的是Object , 所以这里在转换的时候 , 转换的是一个JSONObject类型的数据
    JSONObject data = (JSONObject) redisData.getData();
    Shop shop = JSONUtil.toBean(data, Shop.class);
    LocalDateTime expireTime = redisData.getExpireTime();
    // TODO 5.判断是否过期 , 是否在当前时间之后 , isBefore是判断是否在当前时间之前
    if (expireTime.isAfter(LocalDateTime.now())){
        // TODO 5.1未过期 , 直接返回信息
        return shop;
    }
    // TODO 5.2 过期 , 缓存重建
    // TODO 6.缓存重建 ,
    // TODO 6.1 获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    boolean isLock = tryLock(lockKey);
    // TODO 6.2 判断是否获取互斥锁
    if (isLock){
        // TODO 6.3 成功 , 开启独立线程实现缓存重建 , 线程池在上边创建
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                this.saveShop2Redis(id,20L);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }finally{
                // TODO 释放锁
                unLock(lockKey);
            }
        });
    }
    return shop;
}

缓存工具的封装 :

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

  1. 方法1 : 将任意java对象序列化为JSON并存储在string类型的key中 , 并且可以设置TTL过期时间
  2. 方法2 : 将任意java对象序列化为JSON并存储在string类型的key中 , 并且可以设置逻辑过期时间 , 用于处理缓存击穿问题
  3. 方法3 : 根据指定的key查询缓存 , 并反序列换为指定类型 , 利用缓存空值的方式解决缓存穿透问题
  4. 方法3 : 根据指定的key查询缓存 , 并反序列化为指定类型 , 需要利用逻辑过期解决缓存击穿问题

代码实现 :

@Component
@Slf4j
public class CacheClient {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Resource
    private RedisData redisData;

    /**
     * 普通类型的缓存
     *
     * @param key   key
     * @param value value
     * @param time  TTL
     * @param unit  时间单位
     */
    public void set(String key, Object value, Long time, TimeUnit unit) {
        // TODO 这里需要将value转为json类型的字符串
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    /**
     * 解决缓存穿透问题
     *
     * @param keyPrefix 前缀
     * @param id        id
     * @param type      数据类型
     * @param function  查询数据库函数
     * @param time      缓存时间
     * @param unit      时间单位
     * @param <R>       泛型 , 返回值
     * @param <ID>      泛型 , 主键
     * @return 返回的数据
     * TODO 解决缓存穿透问题 , 定义泛型 ,使用泛型 , 对于不确定的类型 , 统统使用泛型定义类型
     */
    public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> function, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.从redis中查询缓存 , 以id加key作为唯一标识
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(json)) {
            //  3.判断不为空 , 直接返回 , 将字符串转为bean
            return JSONUtil.toBean(json, type); // TODO 这里直接使用泛型的数据类型 ,
        }
        // 判断命中是否是空值 ,
        if (json != null) { //结果是空字符串
            // 返回一个错误信息
            return null;
        }

        //TODO 4.不存在 , 根据id查询数据库 ,
        // 这里不能直接调用数据库进行查询了 , 因为对应的类型不一致 , 查询的数据库也不一致 ,
        // 这里可以使用参数传递逻辑  , java中传递逻辑 , 使用的是Function<T,R> function , 可以传递一个函数到方法中
        // 调用函数形参中的方法 , apply , 将参数传递进去即可
        R r = function.apply(id);
        // 5.不存在返回错误
        if (r == null) {
            // TODO 将空值写入redis
            stringRedisTemplate.opsForValue().set(key, "", time, unit);
            return null;
        }
        //  TODO 6.存在 , 写入redis , 使用huTool中的工具类 , 将对象转为json数据
        this.set(key, r, time, unit);
        // 7.返回
        return r;
    }


    // TODO 创建一个线程池 , 里边有十个线程
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);


    /**
     * 封装逻辑过期时间
     *
     * @param key key
     * @param value value
     * @param time 过期时间
     * @param unit 时间单位
     */
    public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
        redisData = new RedisData();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        // TODO 这里需要将value转为json类型的字符串
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    // TODO 逻辑过期解决缓存击穿问题
    public <R , ID> R queryWithLogicalExpire(String keyPrefix, String lockPrefix ,ID id , Class<R> type , Function<ID ,R> function , Long time, TimeUnit unit) {
        redisData = new RedisData();
        String key = keyPrefix + id ;
        // TODO 1.从redis中查询缓存 , 以id作为唯一标识
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否命中
        if (StrUtil.isBlank(json)) {
            // TODO isNoyBlank只有是有数据的情况加才为true , "" , null都为false
            // TODO 3.判断为空 , 直接返回null
            return null;
        }

        // TODO 4.命中 , 需要先把json反序列化为对象 ,
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        // TODO 因为实体类中设置的是Object , 所以这里在转换的时候 , 转换的是一个JSONObject类型的数据
        JSONObject data = (JSONObject) redisData.getData();
        R r = JSONUtil.toBean(data, type);
        // TODO 5.判断是否过期 , 是否在当前时间之后 , isBefore是判断是否在当前时间之前
        if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
            // TODO 5.1未过期 , 直接返回旧信息
            return r;
        }
        // TODO 5.2 过期 , 缓存重建
        // TODO 6.缓存重建 ,
        // TODO 6.1 获取互斥锁
        String lockKey = lockPrefix + id;
        boolean isLock = tryLock(lockKey);
        // TODO 6.2 判断是否获取互斥锁
        if (isLock) {
            // TODO 6.3 成功 , 开启独立线程实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    R r1 = function.apply(id);
                    this.setWithLogicalExpire(key , r1 ,time , unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    // TODO 释放锁
                    unLock(lockKey);
                }
            });
        }
        return r;
    }

    // TODO 设置获取锁的方法 , 这个key值就是锁的名称 , 谁调用 , 谁来赋予
    private boolean tryLock(String key) {
        // TODO setIfAbsent : 就是setnx的操作 , 可以设置过期时间 , 一般设置为业务完成时间的十倍左右 , 可以自定义
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
        // TODO 不要直接返回这个Boolean ,
        //  因为直接返回的话是要进行拆箱的 , 这时可能会造成空指针异常 , 我们使用的是huTool中的工具类
        //  他可以帮你进行判断 , isTrue , isFalse(判断是否为false) , isBoolean(拆箱) ,
        return BooleanUtil.isTrue(flag);
    }

    // TODO 删除锁
    private void unLock(String key) {
        stringRedisTemplate.delete(key);
    }
}

优惠券秒杀 :

全局ID生成器 策略:

全局ID生成器 , 是一种在分布式系统下用来生成全局唯一ID的工具 , 一般要满足下列特性

  1. 唯一性 :全局只有一个
  2. 高可用 :
  3. 递增性 :
  4. 安全性 : 规律不明显 , 让用户猜不到订单的信息
  5. 高性能 : 生成速度快 , 不影响其他业务的执行

为了增加ID的安全性 , 我们可以不直接使用Redis自增的数值 , 而是拼接一些其他信息

image-20220415152030759

  • ID的组成部分 :
    • 符号位 : 1bit , 永远为0
    • 时间戳 : 31bit , 可以使用60年
    • 序列号 : 32bit , 秒内的计数器 , 支持每秒产生2^32个不同ID

编写实现 :

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

/**
 * 全局ID生成器
 */
@Component
public class RedisIDWorker {

    @Resource
    private StringRedisTemplate stringRedisTemplate;
    // TODO 指定的初始时间戳
    private static final Long BEGIN_TIMESTAMP = 946684800L;
    // TODO 序列号的位数 , 一般不写死
    private static final int COUNT_BITS = 32;


    //一般这个key , 是根据谁调用谁传递过来的字符串 , 进行拼接
    public Long nextId(String keyPrefix) {
        // TODO 1.生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timeStamp = nowSecond - BEGIN_TIMESTAMP;

        // TODO 2.生成序列号 , 使用redis的自增长机制  ,
        //  不能使用同一个key作为自增长的key值 ,
        //  Redis单个key的最大自增数是2^64 , 我们设置的序列号最大是 2^32位
        //  一直使用同一个key会序列号溢出的问题的 ,
        //  可以在key的后边加一个当天的时间戳 , 一天使用一个key的值

        // TODO 2.1 获取当前日期 , 精确到天
        //  中间使用 : 分隔,在Redis中会进行分层 , 这个时候 , 就可以根据对应的前缀 , 来进行数据的统计
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        //自增长 , 默认是从1开始自增的 , 每次增加 1  
        Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);

        // TODO 3.拼接并返回 ,
        //  使用的是移位运算 , 将时间戳左移32位
        //  同时使用或运算 , 来进行拼接 ,
        //  或运算的逻辑是 , 在位移后的位置上 , 全部都是0 ,
        //  进行运算时 , 参加运算的两个对象,一个为1,其值为1 ,也就是count值是1就是1 , 是零就是0
        //  或的运算速度要比加快
        return timeStamp << COUNT_BITS | count ;
    }

    // TODO 使用下边的方法可以生成指定时间的时间戳
    public static void main(String[] args) {
        LocalDateTime time = LocalDateTime.of(2000, 1, 1, 0, 0, 0);
        long l = time.toEpochSecond(ZoneOffset.UTC);
        System.out.println(l);
    }
}
 30212046316 62362635   703429019
 30212047433 31512332   703429045

多线程检验:

@Resource
private RedisIDWorker redisIDWorker;

// TODO 创建一个线程池
private ExecutorService ex = Executors.newFixedThreadPool(500);

@Test
void testIdWorker() throws InterruptedException {
    CountDownLatch latch = new CountDownLatch(300);

    Runnable task = () -> {
        //一个线程循环执行100次 , 操作
        for (int i = 0; i < 100; i++) {
            Long id = redisIDWorker.nextId("order");
            System.out.println("id =" + id);
        }
        latch.countDown();
    };
    long begin = System.currentTimeMillis();
    for (int i = 0; i < 300; i++) {
        ex.submit(task);
    }
    latch.await();
    long end = System.currentTimeMillis();
    System.out.println("time =" + (end -begin));

}

优惠券秒杀 :

基础逻辑:

数据库表的创建 : 优惠券订单表

image-20220415151222405

image-20220415151401945

image-20220415175136407


代码实现 :

@Service
@Transactional
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIDWorker redisIDWorker;

    @Override
    public Result sekillVoucher(Long voucherId) {
        // TODO 1.查询优惠券
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        // TODO 2.判断秒杀是否开始
        //开始时间在当前时间之后
        if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀未开始");
        }
        // TODO 3.判断秒杀是否已经结束
        if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("活动以结束");
        }
        // TODO 4.判断库存是否充足
        Integer stock = seckillVoucher.getStock();
        if (stock < 1){
            return Result.fail("库存不足");
        }
        // TODO 5.扣减库存
        boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();
        if (!success){
            return Result.fail("库存不足");
        }
        // TODO 6.创建订单
        VoucherOrder order = new VoucherOrder();
        // TODO 6.1订单id
        Long orderId = redisIDWorker.nextId("order");
        order.setId(orderId);
        // TODO 6.2用户id
        Long userId = UserHolder.getUser().getId();
        order.setUserId(userId);
        // TODO 6.3代金卷id
        order.setVoucherId(seckillVoucher.getVoucherId());
        // 写入数据库
        save(order);
        // TODO 7.返回订单id
        return Result.ok(orderId);
    }
}

秒杀的超卖问题 :

image-20220416132548930

image-20220416132744838

乐观锁优化超卖问题 :

  • 乐观锁的关键是判断之前查询得到的数据是否有被修改过 , 常见的方法有两种
    • 版本号法
      • 设置一个版本号 , 在每次执行操作之前 , 先查询库存和版本号 ,
      • 在每次执行操作库存扣减之前 , 先进行判断 , 看现在数据库中的版本号 , 和之前获取的版本号是否一致 , 一致就进行操作
      • 在执行完库存操作之后 , 将版本号加一
    • CAS法 : 在版本号法基础上做了一些改进 , 不用添加版本号 , 每次操作之前 , 先和自己查询的库存进行比较 , 出现不一致 , 就不操作
      • 先比较 , 后操作

image-20220416133915622

  • 但是直接和之前获取的库存数进行比较 , 会造成大量的失败 ,

  • 我们再次进行改进 , 在进行操作时 , 添加判断条件 , 只要库存大于 0 , 就可以操作

// TODO 5.扣减库存
boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1")  // TODO set stock = stock - 1
        .eq("voucher_id", voucherId)  // TODO where id = ?
        //.eq("stock", seckillVoucher.getStock()) 
    	// TODO and stock = ? , 这么做会造成大量的失败
        .gt("stock",0)  
    //TODO 使用gt , 添加的条件是  , stock > 0 , 这样刚好符合业务逻辑
        .update();  

一人一单问题 :

逻辑实现 :

image-20220416141434985

代码实现 :

使用乐观锁和悲观锁同时使用的形式 ,来优化超卖和一人一单问题 ,

将有关数据库的操作 , 抽取为一个方法 , 并且自定义悲观锁 , 以userid作为锁 , 确保多个相同id的线程中 , 只有一个线程能进行操作

@Transactional
public Result getResult(Long voucherId) {
    // TODO 6. 一人一单
    Long userId = UserHolder.getUser().getId();
    // TODO 给对应的id加锁 , 确保同一个id只能有一个线程能操作 , 这样就没有线程安全问题了, 
    synchronized(userId.toString().intern()){
        // TODO 6.1 查询订单
        Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // TODO 6.2 判断是否存在
        if (count > 0){
            return Result.fail("您已经购买过了");
        }

        // TODO 5.扣减库存 , 要在判断表中是否有相同id的数据之后进行操作 , 
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")  // TODO set stock = stock - 1
                .eq("voucher_id", voucherId)  // TODO where id = ?
                .gt("stock",0)  //TODO 使用gt , 添加的条件是  , stock > 0 , 这样刚好符合业务逻辑
                .update();
        
        if (!success){
            return Result.fail("库存不足");
        }
        // TODO 6.创建订单
        VoucherOrder order = new VoucherOrder();
        // TODO 6.1订单id
        Long orderId = redisIDWorker.nextId("order");
        order.setId(orderId);
        // TODO 6.2用户id
        order.setUserId(userId);
        // TODO 6.3代金卷id
        order.setVoucherId(voucherId);
        // 写入数据库
        save(order);
        // TODO 7.返回订单id
        return Result.ok(orderId);
    }
}

集群部署中的一人一单问题 :

之前使用悲观锁和乐观锁同时并存的方式 , 可以解决一人一单的问题 ,

这是因为 , 锁的原理是在jvm中创建了一个锁监视器 , 通过第一个线程的访问 , 获取锁的名称, 进jvm , 这是 , 其他用户在进行访问的时候 , 就无法获取到锁 , 无法进行操作

如果是使用集群部署的形式 , 那么就会创建两个或多个jvm , 创建多个锁监视器 , 这个时候再进行访问的时候 , 就会导致一个锁只能锁住一个服务器的请求 , 访问其他服务器也可以同时进行操作


分布式锁:

image-20220416161809921

在redis中 , 使用set操作 , 可以同时指定多个属性 , 来完成互斥 和 设置失效时间的操作

set key thread1 NX (设置互斥) EX 10 (设置过期时间)

在jdk中 , 有两种获取锁失败的操作 : 失败返回 或 阻塞

阻塞会不断重试获取锁 , 会对内存造成很大的浪费 , 这里我们使用失败返回的操作 ,

image-20220416184250077


尝试获取锁 :

@Override
public boolean tryLock(Long timeoutSec) {
    // TODO 获取线程的标识
    long threadId = Thread.currentThread().getId();
    Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
    return Boolean.TRUE.equals(aBoolean); //使用boolean中的方法 , 判断是否是true , 也可以解决自动拆箱造成的空指针异常情况
}

在原来的代码中 , 就不需要加 synchronize锁了 , 而是直接 尝试获取锁 , 如果获取锁失败 , 那么就直接输出错误信息 , 成功 , 再进行下一步业务操作


分布式锁的极端情况出现的线程问题 :

(1)阻塞超时 ,删除别的线程的锁 :

因为一个带锁线程发生阻塞, 导致锁超时失效 , 其他线程获取这个锁 , 导致 ,阻塞线程删除锁的时候 , 将其他线程获取的锁删除了 ,

image-20220416184617897

  • 解决方案 :
    • 在获取锁的时候 , 将UUID和线程iD拼接 , 作为value值 , 存入redis中 , 在释放锁的时候 , 进行判断 , 如果二者一致 , 就释放锁 , 如果不一致 , 说明这个锁已经被别人获取了 , 那么就不用进行释放了

image-20220416185031242

public class SimpRedisLock implements ILock {


    private StringRedisTemplate stringRedisTemplate;

    private String name;

    public SimpRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.name = name;
    }

    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

    @Override
    public boolean tryLock(Long timeoutSec) {
        // TODO 获取线程的标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(aBoolean);
    }

    @Override
    public void unLock() {
        // TODO 获取线程标识
        String thread = ID_PREFIX + Thread.currentThread().getId();
        //TODO 获取redis中的数据
        String threadId = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        if (thread.equals(threadId)) {
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }
}

(2)使用lua脚本语言 , 解决释放锁时出现的问题

JVM执行垃圾回收的时候 , 所有的代码是无法工作的 , 造成在释放锁的时候发生阻塞

  • 极端情况 :
    • 线程1获取锁之后 , 顺利执行完业务 , 并且已经判断好了 , 锁是自己拥有的 , 正要执行释放锁的时候 , 垃圾回收进行触发 , 导致线程阻塞, 刚好 , 又到了锁的过期时间过期了 , 那么 , 这个时候 , 这个锁就会被别的线程2获取 , 但是线程1已经做了判断 , 认为这个锁是自己的了 , 继续执行 , 就将线程2的锁释放了
  • 解决方案 :
    • j将判断和释放锁 , 放在一个原子操作中
    • 使用lua脚本语言进行操作
-- 锁的key
-- local key = KEYS[1]
-- 当前线程标识 
-- local threadId = ARGV[1]

-- 获取锁中的线程标识 get key
-- local id = redis.call('get',KEYS[1])

-- 比较线程标识与锁中标识是否一致
if (ARGV[1] == redis.call('get',KEYS[1])) then
    -- 释放锁
    redis.call('del',KEYS[1])
end
return 0

image-20220416194954761

引入lua脚本 :

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static{
    UNLOCK_SCRIPT = new DefaultRedisScript<>(); //初始化脚本 , 
    //指定脚本的位置 , 一般是放在Resource包中 , 直接使用 ClassPathResource , 就是从Resource包中开始寻找 
    UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
    UNLOCK_SCRIPT.setResultType(Long.class);
}

编写释放锁代码 :

//释放锁
@Override
public void unLock() {
    // 调用lua脚本
    stringRedisTemplate.execute(
            UNLOCK_SCRIPT,
        	//这个操作 , 是指定是一个单元素的集合
            Collections.singletonList(KEY_PREFIX + name),
            ID_PREFIX + Thread.currentThread().getId());
}

Redisson:

Redisson是一个在Redis的基础上实现的java驻内存数据网络 , 它不仅提供了一系列的分布式的java常用对象 , 还提供了许多分布式服务 , 其中就包含了各种分布式锁的实现

官网地址 : https://redisson.org

GIthub地址 : https://github.com/redisson/redisson

image-20220416201826329

快速入门 :

引入依赖 :

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.16.8</version>
</dependency>

配置Redisson客户端

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient(){
        // 配置类
        Config config = new Config();
        //添加redis地址 , 这里添加了单点的地址 , 也可以使用config.userClusterServers()添加集群地址
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");
        // 创建客户端
        return Redisson.create(config);
    }
}

使用Redisson的分布式锁 :

// TODO 获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("anyLock");
// TODO 尝试获取锁 , 参数分别是 : 获取锁的最大等待时间(期间会重试) , 所自动释放时间 , 时间单位
//  如果最大等待时间结束 , 还是没有获取到锁 , 那么才会返回false
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);

// TODO 释放锁
lock.unlock();
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值