大众点评项目技术详解

黑马点评

一、项目导入

1.在本地/云服务器上配置版本5.7以上的MySQL并启动

2.创建hmdp数据库,并创建所使用的表:使用资料包中提供的sql脚本在hmdp数据库下创建

create database hmdp;

use hmdp;

在这里插入图片描述

3.启动redis服务:在本地或者云服务拉一个redis服务

4.导入后端项目

将提供的项目源码复制到自己的代码空间,用idea打开即可:
在这里插入图片描述

1)修改配置:项目中的mysql和redis配置需要修改为自己的连接配置

application.yaml:

server:
  port: 8081
spring:
  application:
    name: hmdp
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/hmdp?useSSL=false&serverTimezone=UTC
    username: root
    password: 123456
  redis:
    host: 120.55.xx.xx:22
    port: 6379
    password: 123456
    lettuce:
      pool:
        max-active: 10
        max-idle: 10
        min-idle: 1
        time-between-eviction-runs: 10s
  jackson:
    default-property-inclusion: non_null # JSON处理时忽略非空字段
mybatis-plus:
  type-aliases-package: com.hmdp.entity # 别名扫描包
logging:
  level:
    com.hmdp: debug
2)项目启动:在服务中配置Springboot的运行配置,然后启动项目

在这里插入图片描述

  • 项目启动后,访问http://localhost:8081/shop-type/list,也就是会访问本地数据库的数据并返回,如果能看到对应的商店类型则说明后端项目配置成功:
    在这里插入图片描述

5.前端项目导入

在这里插入图片描述

  • /nginx-1.18.0下打开一个cmd窗口,执行start nginx.exe即可
  • 然后打开chrome浏览器,使用F12,打开开发者工具:
    在这里插入图片描述
  • 最后访问http://localhost:8080即可看到页面展示:

在这里插入图片描述

二、短信登录功能

1.功能结构构思

  • 利用短信去完成用户的登录、注册
  • 总体逻辑:用户输入手机号 -> 点击发送验证码->接收到验证码之后,输入验证码与手机号一起提交->后端判断手机号和验证码是绑定的则登录成功(如果用户第一次登录则直接默认注册一个新用户并登录)
  • 从上往下细分:

2.使用session实现登录

  • 分为三个模块
  • 1.发送短信验证码:用户提交手机号之后,首先要判断手机号是否合法(校验手机号),合法的情况下则调用生成验证码逻辑,生成一个验证码,然后保存到session中,作为用户的登录校验手段,最后发送验证码给用户
  • 2.短信验证码登录、注册:当用户收到验证码之后,会提交手机号和验证码,首先需要(可以重复校验一下手机号的合法性)先校验手机号与验证码是否能对上(这个过程目前不太清楚),如果一致则需要根据手机号去查询是否有这个用户,如果有则直接将该用户与验证码保存到session,不存在则利用手机号直接创建新的用户,并保存到数据库的用户表中
  • 3.校验登录状态:也就是登录状态的维护,每次请求如何保证是该用户的?首先每次请求需要携带cookie,然后从cookie中获取session_id,然后根据session_idsession中获取用户,判断该用户是否在session中存在
  • 4.当前登录用户信息的保存:由于每次请求都是一个独立的线程,如果将用户信息保存到一个本地变量中,那么当多线程并发时其他线程可能就会修改当前登录用户的信息,出现并发安全的修改问题,而使用ThreadLocal则会将用户信息保存到每个线程的内部,创建一个map来保存具体的信息,所以每个请求都能有独立的空间去获取到自己的用户信息。
    在这里插入图片描述
1)发送验证码模块
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Override
    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();
    }
}
  • 前端发送请求:后端接收请求并验证通过
    在这里插入图片描述
  • 后端则触发验证码的发送逻辑:生成一个验证码并发送(发送逻辑为模拟发送到log日志中)
    在这里插入图片描述
2)短信验证码登录、注册
  • 点击登录按钮会提交表单信息,将phone和code以JSON的形式向后端传递并发送登录请求
  • 那么后端就需要准备接收JSON中的phone和code并解析出来,然后进行校验对应的登录信息
    在这里插入图片描述
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
    @Override
    public Result codeLogin(LoginFormDTO loginForm, HttpSession session) {
        //1.验证手机号是否合法
        String phone = loginForm.getPhone();
        String code = loginForm.getCode();
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("手机号格式不正确,请重新输入!");
        }
        //2.验证手机号和验证码是否一致
        if (code != null && !code.equals(session.getAttribute("code"))) {
            //3.如果不一致则返回错误信息
            return Result.fail("验证码错误!");
        }
        //4.根据手机号判断用户是否存在
        User user = query().eq("phone", phone).one(); //使用mybatisplus的单表查询,根据手机号进行查询
        //5.如果用户不存在,则根据手机号创建用户
        if (user == null) {
            user = createUserByPhone(phone);
        }

        //6.如果存在,则将用户保存到session中
        session.setAttribute("user", user);

        //7.返回登录结果
        return Result.ok();
    }

    private User createUserByPhone(String phone) {
        //创建用户
        User user = new User();
        user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(6));
        user.setPhone(phone);
        //保存用户
        save(user);
        return user;
    }
}
  • 将一个小的功能梳理成一个具体的流程,如果某个步骤较为繁琐,则抽取成方法,在方法中实现具体的操作逻辑,然后流程中对该方法进行调用即可,这样开发的功能代码流程清晰,简洁高效
  • 比如这里的根据手机号创建用户createUserByPhone()
3)校验登录状态
  • 当用户通过手机号和验证码成功核对通过之后,就可以通过携带的cookie中的SessionId并向服务器发送登录请求

  • 服务器则根据SessionId得到对应的session,并从session中获取用户,然后判断该用户是否存在,然后存在则返回即完成登录

  • 但是当业务增多时,会有越来越多的登录校验,于是考虑使用一个拦截器来实现用户的登录校验功能,后续用户的所有请求都需要先经过拦截器的登录校验才能继续访问

  • 同时有些业务需要使用到用户的信息,那么拦截器拦截请求之后如何将用户信息传递给后续的业务

  • 这里就是通过ThreadLocal将用户信息保存到每个线程中,从而完成用户信息传递以及并发安全问题
    在这里插入图片描述

  • 1.编写拦截器,从cookie中获取session,并从session中获取user,然后将用户保存到ThreadHolder中

public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //1.获取session
        HttpSession session = request.getSession();
        //2.从session中获取用户
        Object user = session.getAttribute("user");
        //3.判断用户是否存在
        if (user == null) {
            //4.如果不存在则拦截,返回401状态码
            response.setStatus(401);
            return false;
        }

        //5.将用户添加到ThreadLocal中
        UserHolder.saveUser((UserDTO) user);

        //6.放行
        return true;
    }
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserHolder.removeUser(); //移除用户
    }
}
  • 2.配置拦截器:使得拦截器生效,规定哪些路径需要拦截(addPathPatterns())(默认拦截一切)或者排除哪些路径(excludePathPatterns())——通常可以设为白名单,放置在配置文件中,减少硬编码
@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(   //排除一些不需要拦截的路径
                        "/user/code",
                        "/user/login",
                        "/blog/hot",
                        "/shop/**",
                        "/shop-type/**",
                        "/voucher/**",
                        "/upload/**"
                );
    }
}
  • 3.返回用户用于访问用户信息:因为已经将user放到ThreadLocal中所以,直接从UserHolder中取
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {

    @Resource
    private IUserService userService;

    @Resource
    private IUserInfoService userInfoService;

    @GetMapping("/me")
    public Result me(){
        //获取当前登录的用户并返回
        UserDTO user = UserHolder.getUser();
        return Result.ok(user);
    }
}

用户敏感信息的过滤
  • 当通过cookie中的session_id获取到session,并从session中获取到用户之后,此时的user对象包含的信息是用户对象的全部信息,比如:createTime, updateTime, password...这些信息有些是冗余不必要的,有些是敏感信息,比如password
  • 那么当用户登录时,我们存放到session的信息就需要进行一个过滤,只保留一些常用的非敏感信息
  • 于是我们创建一个UserDTO,只有user的id,nickName,icon字段
@Data
public class UserDTO {
    private Long id;
    private String nickName;
    private String icon;
}
  • 当登录校验通过之后,我们会根据传入的phone去数据库中查询user,那么返回的user的带有全部字段的
  • 我们使用BeanUtil.copyProperties(user, UserDTO.class),将user的对应属性复制给一个UserDTO对象并返回
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Override
    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();
    }

    @Override
    public Result codeLogin(LoginFormDTO loginForm, HttpSession session) {
        //1.验证手机号是否合法
        String phone = loginForm.getPhone();
        String code = loginForm.getCode();
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("手机号格式不正确,请重新输入!");
        }
        //2.验证手机号和验证码是否一致
        if (code != null && !code.equals(session.getAttribute("code"))) {
            //3.如果不一致则返回错误信息
            return Result.fail("验证码错误!");
        }
        //4.根据手机号判断用户是否存在
        User user = query().eq("phone", phone).one(); //使用mybatisplus的单表查询,根据手机号进行查询
        //5.如果用户不存在,则根据手机号创建用户
        if (user == null) {
            user = createUserByPhone(phone);
        }

        //6.如果存在,则将用户保存到session中
        session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));

        //7.返回登录结果
        return Result.ok();
    }

    private User createUserByPhone(String phone) {
        //创建用户
        User user = new User();
        user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(6));
        user.setPhone(phone);
        //保存用户
        save(user);
        return user;
    }
}
  • 这样session中存放的用户信息就是UserDTO类型的对象,后续访问时也不会出现敏感信息以及冗余信息

3.集群下的session共享问题分析

  • 当访问量上来之后,需要使用多台tomcat服务器来做负载均衡
  • 但是出现的问题是每个tomcat中都会有自己的session空间,导致用户第一次发送请求如果被分配到tomcatA那么他的登录信息会保留在tomcatA的session中,如果下一次负载均衡到了tomcatB上,但是tomcatB的session中没有该用户的登录信息,就会出现用户明明登录成功了,但是访问授权页面时会出现未登录的情况
  • 那么如何解决session不共享的问题:1.首先该方案需要可以完成数据的共享 ,2.响应数据要快(内存) 3.用户信息数据是以键值对的形式存储(方便获取和操作) ——那么必定是Redis无疑了(任何一台tomcat都能访问到,并且Redis基于内存存储,数据的读写非常快,并是以键值对的形式存储)
    在这里插入图片描述

4.基于Redis实现共享session登录

  • 用Redis来实现登录的话,就需要考虑如何使用Redis存储用户的登录信息
  • 1.首先,给用户发送短信验证码之后,如何将用户的手机号和验证码存储下来,原先的session是将验证码存放在session的code中,但是在Redis中,需要实现数据共享的话,所有用户的code将会产生冲突,那么我们需要考虑Redis的key用什么结构来存储,可以避免冲突,并可以更高效的访问
  • 使用string类型存储不同用户的验证码并使用用户的手机号作为key
  • 当用户输入手机号获取验证码时,我们就将生成的验证码以:phone: code => 133xxxxxxxx: 123456的形式博保存到Redis中,那么当验证登录时,只需要根据用户的手机号去Redis中查找是否存在这个key,以及输入的code是否能对上即可
  • 2.当登录验证通过后,需要从UserHolder中获取UserDTO类型的用户信息并返回,那么这里在验证登录成功之后就需要将用户保存到Redis中
  • 那么这里的用户信息以什么形式保存到Redis中
    • 方式一:将用户信息封装成JSON字符串的形式
    • 方式二:使用key用token来保证唯一性,并且后续用户访问时会携带给token,直接通过token去查询保存在Hash结构中存储的用户信息(Hash结构可以单独访问某个字段,或者动态的修改字段的值)

在这里插入图片描述

补充:前端如何实现每次请求都携带token:
  • 1.从后端获取到token之后,会将token通过sessionStorage.setItem("token", data),将传过去的data保存在浏览器中
  • 2.每次发送请求,会通过sessionStorage.getItem("token")得到浏览器中缓存的token,然后设置拦截器,再每次发送请求之前,会给请求加上一个请求头:headers['authroization'] = token
    在这里插入图片描述
实现
  • 1.发送验证码:将用户输入的手机号作为key,产生的验证码作为value存放到redis中
  • 这里注意需要给验证码设置一个有效期LOGIN_CODE_TTL = 2L,并且不同的业务可能都会使用手机号作为key,到时候会出现值的改写,所以,需要给key加入一个前缀,比如:LOGIN_CODE_KEY = "login:code:"
  • 2.登录验证:用户提交手机号和验证码。在验证手机号的合法性之后,需要根据LOGIN_CODE_KEY+phone作为key去Redis中查询对应的code,看是否与用户输入的code相同
  • 当验证通过之后,说明该用户可以登录了,需要先通过手机号去MySQL中查询该用户是否存在,不存在则创建,如果存在则获取该用户,此时用户是User类型的,为了屏蔽一些敏感信息需要转成UserDTO类型,然后需要存放到Redis中,用于后续的登录状态的维护
  • 使用UUID.RandomUUID()生成一个随机的UUID,并且为了区分业务,同时加上前缀LOGIN_USER_KEY = "login:token:",作为key,而UserDTO类型的用户信息则通过BeanUtil.beanToMap(userDTO)转成map集合,作为value存放到Redis中,同时也是需要设置有效期LOGIN_USER_TTL = 36000L

UserServiceImpl:

@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result sendCode(String phone, HttpSession session) {
        //1.验证手机号
        if (RegexUtils.isPhoneInvalid(phone)) {
            //2.如果不合法则直接返回错误提示
            return Result.fail("手机号格式不正确!");
        }
        //3.生成验证码
        String code = RandomUtil.randomNumbers(6);

        //4.将手机号和验证码保存到redis中,并设置验证码有效期
        stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone,code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
        //5.发送验证码
        log.debug("成功发送短信验证码,验证码为:{}", code);  //模拟发送
        //6.发送成功
        return Result.ok();
    }

    @Override
    public Result codeLogin(LoginFormDTO loginForm, HttpSession session) {
        //1.验证手机号是否合法
        String phone = loginForm.getPhone();
        String code = loginForm.getCode();
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("手机号格式不正确,请重新输入!");
        }
        //1.1根据手机号去查找到redis中对应key的value是否和用户输入的code一致
        if (code != null && !code.equals(stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone))) {
            //3.如果不一致则返回错误信息
            return Result.fail("验证码错误!");
        }
        //4.根据手机号判断用户是否存在
        User user = query().eq("phone", phone).one(); //使用mybatisplus的单表查询,根据手机号进行查询
        //5.如果用户不存在,则根据手机号创建用户
        if (user == null) {
            user = createUserByPhone(phone);
        }

        //1.2.如果存在,将用户保存到redis中
        //1.2.1生成一个随机token作为key ,加一个特定前缀
        String token = UUID.randomUUID().toString(true);

        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        //1.2.2将user对象转换成hashmap存储
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);
        //1.2.3存放,一次性存放一个key下的多个value
        String tokenKey = LOGIN_USER_KEY + token;
        stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);

        //1.2.4设置过期时间
        stringRedisTemplate.expire(token, LOGIN_USER_TTL, TimeUnit.MINUTES);

        //7.返回登录结果
        return Result.ok(token);
    }

    private User createUserByPhone(String phone) {
        //创建用户
        User user = new User();
        user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(6));
        user.setPhone(phone);
        //保存用户
        save(user);
        return user;
    }
}
  • 3.校验登录状态:从用户发送请求的请求头headers中获取携带的token信息,然后加上前缀LOGIN_USER_KEY,去Redis中查询key为该值的用户信息(此时是hashmap结构,需要转成userDTO类型),然后判断该用户是否还存在(如果token有效期到了,会删除该用户信息,也就是不再维护登录许可),如果还存在则将用户信息保存到ThreadLocal中便于获取
  • 这里登录状态的维护是看用户的操作,如果用户在有效期内没有操作则会直接失效,否则的话每次操作会刷新有效期,也就是每次发送请求会根据该token去Redis中找到该数据将数据的有效期重新设置为LOGIN_USER_TTL = 36000L

LoginIntercptor:

public class LoginInterceptor implements HandlerInterceptor {
    private StringRedisTemplate stringRedisTemplate;
    //定义自定义拦截器的构造方法,接收StringRedisTemplate,并赋值给当前的局部变量
    public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //1.从请求头中获取token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            //不存在,拦截,返回401未授权状态码
            response.setStatus(401);
            return false;
        }
        String key = LOGIN_USER_KEY + token;
        //2.根据token查找到对应的用户(hashmap结构)
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
        //3.判断用户是否存在
        if (userMap.isEmpty()) {
            //4.如果不存在则拦截,返回401状态码
            response.setStatus(401);
            return false;
        }
        //5.将hashmap结构的用户转成userDTO
        UserDTO user = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        //6.将用户添加到ThreadLocal中
        UserHolder.saveUser(user);
        //7.刷新token有效期
        stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
        //8.放行
        return true;
    }
    
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserHolder.removeUser(); //移除用户
    }
}

注意点:使用构造方法注入StringRedisTemplate
  • 我们是使用拦截器在每次请求前去校验登录状态
  • 但是这个拦截器是我们自己创建的实例对象,并添加到WebMvcConfig实现类中的增加拦截器方法addInterceptors()中,这个过程并没有交给Spring的IoC容器管理,所以在自定义拦截器中需要使用到StringRedisTemplate时则需要使用构造方法的形式在WebMvcConfig实现类注入StringRedisTemplate,然后将StringRedisTemplate类型的变量传给LoginInterceptor(stringRedisTemplate),因为WebMvcConfig实现类是配置了@Configuration注解的

MvcConfig:

@Configuration
public class MvcConfig implements WebMvcConfigurer {
		//获取RedisTemplate
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    			//构造函数方式将RedisTemplate注入给LoginInterceptor使用
        registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
                .excludePathPatterns(   //排除一些不需要拦截的路径
                        "/user/code",
                        "/user/login",
                        "/user/me",
                        "/blog/hot",
                        "/shop/**",
                        "/shop-type/**",
                        "/voucher/**",
                        "/upload/**"
                );
    }
}

补一个知识点:Redis的密码设置

  • 通过redis-cli命令进去客户端中,然后使用config set requirepass 123456, 123456是自己给Redis设置的密码
  • 其次就是需要设置Redis可以远程访问
  • 然后才可以正确通过配置将数据写到Redis中

在这里插入图片描述

还存在的小问题:
  • 由于我们使用的是StringRedisTemplate,而在将userDTO的信息通过BeanToMap()转成userMap之后,userId是为Long类型的,在将Long类型的userId放到要求全部使用String的StringRedisTemplate中时就会产生错误。
    在这里插入图片描述

  • 于是我们需要在放入之前将所有类型全部转成String类型

  • 这里使用比较笨的办法就是不使用hutool包的BeanToMap(),而是手动new一个HashMap,然后将user.id转成String,其余的字段也是手动放入,这样就能解决问题了

@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public Result codeLogin(LoginFormDTO loginForm, HttpSession session) {
        //1.验证手机号是否合法
        String phone = loginForm.getPhone();
        String code = loginForm.getCode();
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("手机号格式不正确,请重新输入!");
        }
        //1.1根据手机号去查找到redis中对应key的value是否和用户输入的code一致
        if (code != null && !code.equals(stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone))) {
            //3.如果不一致则返回错误信息
            return Result.fail("验证码错误!");
        }
        //4.根据手机号判断用户是否存在
        User user = query().eq("phone", phone).one(); //使用mybatisplus的单表查询,根据手机号进行查询
        //5.如果用户不存在,则根据手机号创建用户
        if (user == null) {
            user = createUserByPhone(phone);
        }

        //1.2.如果存在,将用户保存到redis中
        //1.2.1生成一个随机token作为key ,加一个特定前缀
        String token = UUID.randomUUID().toString(true);

        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        //1.2.2将user对象转换成hashmap存储
        Map<String, String> userMap = new HashMap<>();
        userMap.put("id", userDTO.getId().toString());
        userMap.put("nickName", userDTO.getNickName());
        userMap.put("icon", userDTO.getIcon());
        //1.2.3存放,一次性存放一个key下的多个value
        String tokenKey = LOGIN_USER_KEY + token;
        stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);

        //1.2.4设置过期时间
        stringRedisTemplate.expire(token, LOGIN_USER_TTL, TimeUnit.MINUTES);

        //7.返回登录结果
        return Result.ok(token);
    }

    private User createUserByPhone(String phone) {
        //创建用户
        User user = new User();
        user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(6));
        user.setPhone(phone);
        //保存用户
        save(user);
        return user;
    }
}


5.自己给自己挖的坑排除——登录之后跳转首页并且需要再次登录(排了半天一晚)

  • 1.检查了前端:发现登录之后是跳转到首页index.html,改成了info.html,还是没反应,我以为是要重启nginx,于是在cmd下通过tasklist | findstr "nginx"找到对应的nginx进程,并通过taskkill /PID xxxx /F杀掉了该进程,然后通过start nginx.exe再次拉起服务,结果还是一样跳转
  • 2.通过postman测试login接口:携带phone 和 code,封装成JSON发送POST请求,发现login返回给前端的数据有问题:长这样
{
  "success": true,
  "data": {              // 这里才是 Result.ok(...) 返回的对象
    "success": true,
    "data": "xxxxxxxxxxxxxx"   // ⚠️ 真正的 token 在这里
  }
}
  • 原因是我在UserContorller中又将UserServiceImpl中返回的Result.ok()塞到了Result.ok()里面,就导致前端拿是直接获取data复制给token,这肯定是不对的,这种情况下想要正常拿到token,就要使用data.data才能获取到真正的token,但是一般不会这么拿

  • 3.通过postman测试/me接口:想要携带请求头,也就是将token作为headers,使用GET请求进行授权验证,我们设置的拦截器会获取headers中的authorization——token,然后去验证是否存在该用户

  • 但是我这里使用该token去请求时,发现并没有返回用户信息,按理来说应该返回user的信息,然后前端可以通过获取user中的信息完成个人信息页面的填充,但是现在里面没有数据,于是就判断为未登录,会跳转到登录界面。

  • 前端逻辑:
    在这里插入图片描述

  • 访问后端接口得到的结果:
    在这里插入图片描述

  • 当在/user/me中返回user时,就会返回用户信息,前端获取到就渲染到页面中:

  • 修改后的后端请求结果:
    在这里插入图片描述

  • 修改后前端的获取结果:
    在这里插入图片描述

  • 至此,这个bug就解决了,虽然最终也是依靠chatgpt才有点头绪,但是还是能慢慢摸清思路,顺着请求流程排查过去,希望以后多注意。


6.登录有效期刷新的完善

  • 当前实现的有效期刷新是拦截器将需要登录授权(获取用户信息)的请求拦截下来,然后才去刷新token的有效期

  • 产生的问题:当用户一直访问比如首页这种不需要登录验证的页面时,不会触发token的刷新,那么就会导致用户即使在操作,也会到期被清除掉(需要重新登录)

  • 解决的办法:在前面加一层拦截器,拦截所有路径,获取请求里面的token,然后查询Redis中的对应用户,有就保存到ThreadLocal中,没有的话也同样放行,而第二层拦截器(也就是原有的拦截器)就只负责

  • **总述:拦截器一:拦截所有路径,不管访问的什么路径都获取到当前用户,保存起来,并对用户的token进行刷新;拦截器二:登录拦截,从TreahLocal中获取用户
    在这里插入图片描述

  • 1.第一层拦截器:拦截所有请求
    RefreshTokenInterceptor:

public class RefreshTokenInterceptor implements HandlerInterceptor {


    private StringRedisTemplate stringRedisTemplate;

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

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //1.从请求头中获取token
        String token = request.getHeader("authorization");

        System.out.println(token);
        if (StrUtil.isBlank(token)) {

            return true;  //放行
        }
        String key = LOGIN_USER_KEY + token;
        //2.根据token查找到对应的用户(hashmap结构)
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
        System.out.println(userMap);
        //3.判断用户是否存在
        if (userMap.isEmpty()) {

            return true; //放行
        }
        //5.将hashmap结构的用户转成userDTO
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        //6.将用户添加到ThreadLocal中
        UserHolder.saveUser(userDTO);
        //7.刷新token有效期
        stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
        //8.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserHolder.removeUser(); //移除用户
    }
}
  • 2.第二层拦截器:登录拦截,只有登录的用户才能访问
    LoginInterceptor:
public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        //1.判断是否需要拦截(ThreadLocal中是否有用户)
        if (UserHolder.getUser() == null) {
            //没有则需要拦截
            response.setStatus(401);
            //拦截
            return false;
        }
        //放行
        return true;
    }
}
  • 3.拦截器配置:拦截器执行顺序控制
    • 1.默认安装添加顺序来执行
    • 2.使用order()控制执行顺序,order越小,优先级越高,order越大,优先级越低

MvcConfig:

@Configuration
public class MvcConfig implements WebMvcConfigurer {
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(   //排除一些不需要拦截的路径
                        "/user/code",
                        "/user/login",
                        "/blog/hot",
                        "/shop/**",
                        "/shop-type/**",
                        "/voucher/**",
                        "/upload/**"
                ).order(1);
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);  //拦截所有请求
    }
}

三、商户查询缓存

  • 缓存: 就是数据交换的缓冲区(称为Cache),是存储数据的临时地方,一般读写性能较高。
  • 比如:前端的css样式和js代码一般只需要第一次加载时刷新到浏览器的缓存中即可,需要用到的数据则从后端动态获取填充

1.缓存的利与弊

  • 缓存的作用:

    • 降低后端负载:将常用数据加载到缓存中,就不需要每次请求都向后端获取,如果有的则直接使用,减轻后端的压力
    • 提高读写效率:缓存中的数据读写性能较高,可以降低响应时间
  • 缓存的成本:

    • 数据一致性保证:由于是将数据加载了一份到缓存中,当数据库的数据发生改变时,就应该同时也更新缓存中的数据,这一过程的维护成本就可能较高
    • 代码维护成本:需要使用额外的代码去维护数据的同步和加载更新,同时为了解决缓存击穿、缓存穿透等问题,代码的复杂度一般较大,使得维护成本提高。
    • 运维成本:为了使得缓存高可用,就需要将redis等搭建成集群等模式,一些硬件上的编排也增加,加大了运维的成本。

2.添加redis缓存

  • 原本的请求:客户端发送请求,后端接收到请求之后直接去数据库中查找对应的数据并返回给前端
  • 缓存作用下的模型:
    • 1.客户端发送请求,先去缓存(Redis)中查询看是否有对应的数据,如果有则直接返回,没有则进一步从数据库中读取并返回
    • 2.但是这里基于缓存下的话,查到数据会存一份到缓存(Redis)中,以便下一次查询时可以直接从缓存中获取到对应的数据

在这里插入图片描述


1)根据商户的id查询商品缓存(Redis)
  • 1.根据前端传入的商铺id,去Redis中查询对应的商铺是否存在,如果有则直接返回,如果Redis中没有该id的商铺,则去数据库中查询,如果数据库中也没有该id的商铺,则说明该商铺不存在

  • 2.在数据库中查询到对应id的商铺信息之后,先将其存入到Redis中一份,以便下次查询时直接使用,同时将该商铺的信息返回给前端

  • 后端实现:

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryShop(Long id) {
        String shopKey = CACHE_SHOP_KEY + id;
        //1.根据id去redis中查询是否存在该商铺
        String shopStr = stringRedisTemplate.opsForValue().get(shopKey);
        //2.判断是否存在
        if (StrUtil.isNotBlank(shopStr)) {
            //如果存在则直接返回
            Shop shop = JSONUtil.toBean(shopStr, Shop.class);
            return Result.ok(shop);
        }
        //3.不存在则需要到MySQL中进行查询
        Shop shop = getById(id);
        //4.如果MySQL中不存在则返回错误信息
        if (shop == null) {
            return Result.fail("商铺不存在!");
        }
        //5.如果存在则需要保存一份到Redis中
        String shopJSON = JSONUtil.toJsonStr(shop);
        stringRedisTemplate.opsForValue().set(shopKey, shopJSON);
        //6.返回商铺对象
        return Result.ok(shop);
    }
}
  • postman接口测试:

在这里插入图片描述

2)给店铺类型查询业务添加缓存(Redis)
  • 也就是访问"http://localhost:8081/shop-type/list"时需要返回所有的用户列表,前端获取之后刷新在首页的商铺列表中
  • 这里使用JSON字符串的形式保存所有商铺的信息
  • 首先也是同样的,前端发送该请求后,首先先去Redis中查询key=cache:shopList的数据是否存在,如果存在则直接该代表所有商铺对象的JSON字符串转成List<Shop>,然后返回给前端
  • 如果不存在则需要到数据库中进行查询,通过query().orderByAsc("sort").list()查询到商铺对象列表,然后首先转成所有商铺对象的JSON字符串,存到key=cache:shopList的value中,并返回商铺对象列表给前端。
  • 后端/shop-type/list接口编写:
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryShopTypeList() {
        //1.从缓存中查找店铺列表
        String shopListKey = "cache:shopList";
        String shopTypeList = stringRedisTemplate.opsForValue().get(shopListKey);
        //2.判断是否为空
        if (StrUtil.isNotBlank(shopTypeList)) {
            //不为空则直接返回
            List<ShopType> shopList = JSONUtil.toList(shopTypeList, ShopType.class);
            return Result.ok(shopList);
        }

        //3.为空的话则需要去数据库中查找
        List<ShopType> typeList = query().orderByAsc("sort").list();
        //4.查找到的店铺列表保存到redis中
        String shopTypeListStr = JSONUtil.toJsonStr(typeList);
        stringRedisTemplate.opsForValue().set(shopListKey, shopTypeListStr);

        //5.返回店铺列表
        return Result.ok(typeList);
    }
}
  • 使用postman进行接口测试:成功返回商铺对象列表

3.缓存更新策略

1.内存淘汰机制:
  • Redis中内存是有限的,当内存不足时会自动淘汰一部分数据,从而使得那一部分数据能保持一定的一致性。
  • 缺点:Redis内容一般较大,不太容易自动淘汰,并且淘汰时也不可控,无法得知是淘汰的是哪些数据,对于需要保持一致性的数据可能没有被淘汰,从而导致不一致。
2.超时剔除:
  • 当设置缓存时,通过expire设置TTL,使得缓存在经过一定时间之后自动删除,下次查询时就可以更新缓存。
  • 缺点:TTL时间不好掌握,如果太长,则一致性保障较低,如果太短也会增加服务器的负担
3.主动更新:
  • 当更新数据库数据时,同时更新缓存,这样就能保障缓存中的数据是修改后的新数据
  • 缺点:维护成本较高,需要编写复杂的逻辑来保障同步更新
    在这里插入图片描述
  • 在不同的业务场景下使用不同的策略:
    • 低一致性需求:使用内存淘汰机制,比如店铺类型的查询缓存
    • 高一致性需求:自动更新,并且使用超时剔除作为保底,如果主动更新失败,也能通过超时剔除完成一致性同步,比如商铺的详情(菜品的库存查询等)
主动更新策略的三种实现模式:
  • 1.Cache Aside Pattern:由缓存的调用者,在更新数据库的同时更新缓存(一般使用)
  • 2.Read/Write Through Pattern:缓存和数据库整合成一个服务,由服务维护一致性,调用者调用该服务,无需关心缓存一致性问题。
  • 缺点:服务复用性不强,需要针对不同的场景实现不同的服务,而且代码难度较大。
  • 3.Write Behind Caching Pattern:调用者只操作缓存,由其他线程异步间更缓存数据持久化到数据库,保障一致性。(比如多次的数据更新,最后只需要更新最后的数据即可)
  • 缺点:一致性难以稳定保障,如果缓存更新了,但是中间宕机了,就会导致数据更新丢失。
Cache Aside Pattern模式:

在这里插入图片描述

  • 缓存的更新方法:
    • 1.在操作数据库的同时,更新缓存,问题是每次操作数据库都会更新缓存,如果并未查询缓存就会导致缓存的写操作冗余
    • 2.删除缓存,更新数据库时,删除缓存,这样下次去缓存中查找时,并未命中就会到数据库中查询并更新到缓存中,从而实现一致性(建议使用)
  • 怎么保证缓存的更新和数据库更新的一致性:
    • 1.单体系统:使用事务保证缓存更新和数据库更新的同成功同失败
    • 2.分布式系统:使用分布式事务(这块不太了解)
  • 先操作缓存还是操作数据库(多线程并发情况下的问题):
    • 1.方案一:先操作缓存再操作数据库:线程1缓存删除之后,需要到数据库中查询,并写入到缓存中,这一过程耗时比较长,中间可能会出现线程2来查询缓存,由于缓存已经删除,所以未命中,去数据库中查询到之前数据库中旧的值保存到缓存中,查询旧的值之后数据库数据被线程1修改为新的值,就导致线程2读取的缓存数据是与数据库数据不一致的
    • 2.方案二:先操作数据库再删除缓存:线程1更新数据库之后,删除缓存,如果在更新数据库之前线程2由于某种原因导致缓存丢失,就会去数据库中查找,找到了之后准备写入缓存时,线程1更新了数据库,并删除对应缓存,但是此时由于缓存丢失,于是相当于执行了个鸡毛,但是线程2在线程1更新数据库之后将旧的数据读取到了缓存中,导致数据不一致。
      在这里插入图片描述
  • 相比之下,先操作数据库再删除缓存出现并发问题导致不一致的可能性是比较低的
  • 因为方案一删除缓存之后从数据库读取对应数据并写入到缓存的过程是比较耗时的,期间发生其他线程读取到原有的值,导致数据不一致的可能性是较高的
  • 而方案二,因为缓存失效而从数据库读取数据写入到缓存中,这一过程是较快速的,要在这一短期内发生其他线程来修改数据库并删除原本失效的缓存,导致数据不一致的发生概率远小于方案一。这种情况下再加超时剔除即可。

在这里插入图片描述

4.商铺信息数据的数据库与缓存同步(数据一致性)

  • 当查询商铺信息时,未命中缓存则会从数据库中读取,读取并保存到缓存中,而且会给该记录设置TTL,作为兜底方案
  • 当更新商铺信息时,首先会更新数据库,然后将缓存中对应的商铺信息删除(主动更新策略),这样下次查询时,缓存中由于没有该商铺的信息,就会从数据库中读取最新的数据并保存到缓存中
  • ShopServiceImpl:其实变动不是很大,就是从数据库中读取最新信息到缓存中时设置一个过期时间,并在更新数据库之后删除掉缓存中对应的数据,保证一致性。
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryShop(Long id) {
        String shopKey = CACHE_SHOP_KEY + id;
        //1.根据id去redis中查询是否存在该商铺
        String shopStr = stringRedisTemplate.opsForValue().get(shopKey);
        //2.判断是否存在
        if (StrUtil.isNotBlank(shopStr)) {
            //如果存在则直接返回
            Shop shop = JSONUtil.toBean(shopStr, Shop.class);
            return Result.ok(shop);
        }
        //3.不存在则需要到MySQL中进行查询
        Shop shop = getById(id);
        //4.如果MySQL中不存在则返回错误信息
        if (shop == null) {
            return Result.fail("商铺不存在!");
        }
        //5.如果存在则需要保存一份到Redis中
        String shopJSON = JSONUtil.toJsonStr(shop);
        stringRedisTemplate.opsForValue().set(shopKey, shopJSON, CACHE_SHOP_TTL, TimeUnit.MINUTES);  //设置缓存过期时间
        //6.返回商铺对象
        return Result.ok(shop);
    }

    @Override
    public Result updateShop(Shop shop) {
        //保存到数据库
        Long id = shop.getId();
        if (id == null) {
            return Result.fail("商铺id不能为空");
        }
        updateById(shop);

        //店铺对应的删除缓存
        stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
        return Result.ok();
    }
}
  • postman接口测试:
    在这里插入图片描述
  • 前端数据变更:
    在这里插入图片描述
    在这里插入图片描述

5.缓存穿透

  • 是指客户端请求的数据在缓存中和数据库中都没有不存在,这样的缓存永远不会生效,导致请求一直打到数据库,当出现恶意攻击时(短期内使用大量不存在的数据请求去访问,就会导致数据库崩坏。
常用的解决方案:
1.缓存空对象:(一般使用)
  • 实现方式:当第一次请求的数据在缓存和数据库中都不存在时,将一个null值保存到缓存中并设置短期的TTL(比如两分钟),那么两分钟内不存在的数据请求都会返回null
  • 优点:实现简单,维护方便
  • 缺点:不同类型的请求可能会缓存不同的null值对象,造成额外的内存消耗,也有可能造成短期的不一致,比如两分钟内,该请求对应的数据被新增到数据库中了,而查询是直接返回缓存中的null
2.布隆过滤器:
  • 实现方式:使用一个二进制(bite数组)数组,将存在的数据经过某种算法计算出对应的hash值,再将hash值转换成二进制位保存再数组中。查询时首先经过该过滤器,如果存在则放行,使其到缓存中查找,如果不存在则直接拒绝
  • 优点:内存占用较少,只需要使用一个较小的二进制数组即可,没有多余的key
  • 缺点:实现复杂,并且存在误判,不一定准确
    在这里插入图片描述
解决缓存穿透问题

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryShop(Long id) {
        String shopKey = CACHE_SHOP_KEY + id;
        //1.根据id去redis中查询是否存在该商铺
        String shopStr = stringRedisTemplate.opsForValue().get(shopKey);
        //2.判断是否存在
        if (StrUtil.isNotBlank(shopStr)) {
            //如果存在则直接返回
            Shop shop = JSONUtil.toBean(shopStr, Shop.class);
            return Result.ok(shop);
        }
        //之前判断的是缓存中是否存在该信息,如果不存在则返回的是空字符串,已经被排除了,现在判断的是
        //如果id不存在,缓存中的字符串是空的,所以如果字符串是空,则返回查询的id商铺是不存在的
        if (shopStr != null) {
            return Result.fail("店铺信息不存在");
        }

        //3.不存在则需要到MySQL中进行查询
        Shop shop = getById(id);
        //4.如果MySQL中不存在则返回错误信息
        if (shop == null) {
            //如果查询的id是不存在的,则将它对应的value设置为null,下一次进行该id的查询就会名字该null值
            stringRedisTemplate.opsForValue().set(shopKey, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return Result.fail("商铺不存在!");
        }
        //5.如果存在则需要保存一份到Redis中
        String shopJSON = JSONUtil.toJsonStr(shop);
        stringRedisTemplate.opsForValue().set(shopKey, shopJSON, CACHE_SHOP_TTL, TimeUnit.MINUTES);  //设置缓存过期时间
        //6.返回商铺对象
        return Result.ok(shop);
    }

    @Override
    public Result updateShop(Shop shop) {
        //保存到数据库
        Long id = shop.getId();
        if (id == null) {
            return Result.fail("商铺id不能为空");
        }
        updateById(shop);

        //店铺对应的删除缓存
        stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
        return Result.ok();
    }
}
  • 这里是因为StrUtil.isNotBlank()去判断时,只有字符串有字符才会判断为true,所以会忽略字符串为""的情况,于是我们这里再对字符串的情况进行判断,如果shopStr != null,说明字符串是"",即对应id不存在的情况,此时就需要返回商铺不存在的信息
    在这里插入图片描述
  • 当id不存在时就会将对应的value设置为"",并且设置了TTL
    在这里插入图片描述

6.缓存雪崩

  • 是指在同一时段大量的缓存key同时失效或者Redis宕机,导致大量请求直接到达数据库,使得数据库负载突然巨大。
  • 情况一:大量缓存key失效
    • 当进行缓存预热(将一些热门的数据预加载到缓存中)时,key的TTL起始时间是一样的,如果TTL设置的一致,那么就会导致大量key同时失效
  • 情况二:Redis服务宕机
    • 运行过程中Redis服务挂了,就会导致大量请求直接冲击数据库
      在这里插入图片描述
解决方案
  • 1.给不同的key的TTL增加随机值:当增加随机值之后,就不会同时出现大量的key失效
  • 2.利用Redis集群提高服务的可用性:利用集群来降低某个Redis突然宕机带来的风险(哨兵模式+主从复制,监控所有Redis的运行情况,如果某个主宕机了,就在从机当中选出一个来当主,保证集群下的数据一致性)
  • 3.给缓存业务添加降级限流策略:当所有Redis都宕机(机房挂了)时,采用某些请求做快速失败,直接拒绝策略,而不是持续将请求打到服务器数据库上。
  • 4.给业务添加多级缓存:请求从浏览器发出,在nginx建立缓存,Redis建立缓存,JVM本地缓存,MySQL建立缓存,使得缓存雪崩带来的直接冲击。

7.缓存击穿

  • 也被称为热点key问题,指的是一个高并发访问并且缓存重建业务较复杂的key突然失效了,导致的大量请求直接发送到数据库中,带来巨大冲击。
    在这里插入图片描述

  • 多线程并发下,当一个热点key失效时,会先去重建缓存,那么如果该过程比较长(涉及联合查询,条件运算等),当其他线程同一时间来请求时,都会因为没有命中而去重建缓存,那么大量的请求就会涌入数据库,进行大量的IO操作,可能使数据库崩溃。

常用解决方案:
1.互斥锁
  • 当某个线程查询时发现缓存失效了,需要去数据库中查找,就尝试去获取一个互斥锁,只有当获取到互斥锁之后才会去重建缓存,而在这段时间内,没有获取到互斥锁的线程就需要尝试去获取互斥锁,等待,再尝试,直到持有互斥锁的线程重建缓存成功并释放锁,其他线程再去请求时,就能命中更新之后的缓存数据。

  • 优点:没有额外的内存消耗,因为不要维护旧的数据;一致性高;实现简单。

  • 缺点:当某个线程重建时,其他所有线程都需要等待这一个线程完成,并且有死锁风险(比如对多个缓存查询有需求,当获取到当前缓存涉及的锁,而需要获取另外一个缓存的锁时,发现在其他业务里被持有,就会导致陷入互相等待的状态)

2.逻辑过期
  • 当创建缓存时,根据当前时间+过期时间,组成一个逻辑过期的字段,当线程来查询时,首先去判断该逻辑字段是否在有效期内,如果过期则会获取一个互斥锁,然后将缓存重建工作交给单独的线程来进行,而自身则是直接返回旧的过期缓存数据,当其他线程来请求时,也先尝试获取锁,未能获取则返回旧的过期数据,直到缓存重建线程完成缓存重建之后,会重置逻辑过期时间并释放锁,然后其他线程再去请求时,返回的就是更新之后的数据。
  • 优点:所有线程无需等待,即刻返回数据,性能较好
  • 缺点:数据一致性不强,并且需要额外的内存来维护过期数据,实现起来也较为复杂。

在这里插入图片描述

  • 通俗:互斥锁,由一个人重建,我不能重建就等已经在更新的人更新完再获取——高一致性,可用性较差
  • 逻辑删除:先尝试重建,我不能重建就获取原本的数据进行使用——牺牲了一致性,提高了可用性
    在这里插入图片描述
使用互斥锁解决商铺id查询下的缓存击穿
  • 1.首先前端提交商铺id进行查询请求,后端接收id之后,需要根据商铺id去缓存中查找,是否存在该商铺id的缓存数据
  • 2.如果命中,则直接返回数据,如果未命中,则需要去获取互斥锁,尝试重建缓存
  • 3.如果没有成功获取,则说明当前缓存击穿情况下,有其他线程已经在重建缓存,那么本线程只需要等待一会,然后再尝试去根据id查询对应商铺的缓存数据,如果命中则直接返回,如果未命中则尝试重建
  • 4.如果成功获取,则说明需要靠本线程去重建缓存,那么需要根据id去数据库中查询对应的商铺,如果不存在则返回异常信息,存在则需要将数据写到缓存中,释放锁并返回数据(删除对应的锁字段)。
业务逻辑:

在这里插入图片描述

代码实现:
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryShop(Long id) {

        //缓存穿透时,通过返回空对象解决
        //Shop shop =  queryShopWithThrough(id);

        //缓存击穿时,使用互斥锁进行缓存重建,避免大量的请求进入到数据库中
        Shop shop = queryShopWithPass(id);
        if (shop == null) {
            return Result.fail("店铺不存在!");
        }
        return Result.ok(shop);
    }

    public Shop queryShopWithPass(Long id) {
        String shopKey = CACHE_SHOP_KEY + id;
        //1.根据id去redis中查询是否存在该商铺
        String shopStr = stringRedisTemplate.opsForValue().get(shopKey);
        //2.判断是否存在
        if (StrUtil.isNotBlank(shopStr)) {
            //如果存在则直接返回
            Shop shop = JSONUtil.toBean(shopStr, Shop.class);
            return shop;
        }
        //之前判断的是缓存中是否存在该信息,如果不存在则返回的是空字符串,已经被排除了,现在判断的是
        //如果id不存在,缓存中的字符串是空的,所以如果字符串是空,则返回查询的id商铺是不存在的
        if (shopStr != null) {
            return null;
        }
        //未命中缓存
        String lockKey = "lock:shop:" + id;
        Shop shop = null;
        try {
            //4.缓存重建
            //4.1 尝试获取互斥锁
            if (!setLock(lockKey)) {
                //如果获取锁失败,则休眠一会,然后再尝试查询
                Thread.sleep(100);
                 return queryShopWithThrough(id);
            }
            //4.2 获取锁成功则进行缓存重建
            shop = getById(id);
            //4.3 如果MySQL中不存在则返回错误信息
            if (shop == null) {
                //如果查询的id是不存在的,则将它对应的value设置为null,下一次进行该id的查询就会名字该null值
                stringRedisTemplate.opsForValue().set(shopKey, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                return null;
            }
            //5.如果存在则需要保存一份到Redis中
            String shopJSON = JSONUtil.toJsonStr(shop);
            stringRedisTemplate.opsForValue().set(shopKey, shopJSON, CACHE_SHOP_TTL, TimeUnit.MINUTES);  //设置缓存过期时间
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            //释放锁
            unLock(lockKey);
        }
        //6.返回商铺对象
        return shop;
    }
    //设置锁
    public boolean setLock(String key) {
        //设置一个锁字段,并给锁设置有效期,避免死锁
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10L, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }
    //释放锁
    public void unLock(String key) {
        stringRedisTemplate.delete(key);
    }
}
使用逻辑过期方式解决商铺id查询下的缓存击穿问题
  • 逻辑过期: 不是设置真正的有效期来更新数据,而是由程序员手动设置并判断该数据是否过期
  • 也就是对于一些热点key,如果需要做逻辑过期,我们会将其预加载到缓存中进行预热,当用户发送请求时,该数据一般来说肯定存在于缓存中,并且一直会存在
业务逻辑:

代码实现:
  • 问题一:要给对应id的商铺信息设置过期字段,在原本的shop对象上加一个逻辑过期属性
  • 解决方式:
    • 1.新建对象,使用继承的方式使得新建的对象拥有额外的逻辑过期属性
    • 2.使用‘对象嵌套‘,新建对象,使得该对象包含shop对象,和逻辑过期属性
@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}
  • 问题二:需要提前给热点key和对应的value存入缓存中,并设置逻辑过期时间
  • 解决方式:
    //根据id,将对应的商铺信息和逻辑过期时间封装到RedisData对象中
    public void saveShopInfoToRedis(Long id, Long expireSeconds) {
        //1.根据id在数据库中查询对应的商铺信息
        Shop shop = getById(id);
        //2.获取当前时间,并设置逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        //3.封装到RedisData对象中
        redisData.setData(shop);
        //4.写入到Redis中
        String lockKey = LOCK_SHOP_KEY + id;
        stringRedisTemplate.opsForValue().set(lockKey, JSONUtil.toJsonStr(redisData));
    }
  • 写入方式:使用JUnit单元测试调用ShopService中编写的上述方法将数据写入到Redis中
@SpringBootTest
class HmDianPingApplicationTests {
    @Resource
    private ShopServiceImpl shopService;
    @Test
    void testSaveShop() {
        shopService.saveShopInfoToRedis(1L, 10L);
    }
}
  • 写入的数据效果:
    在这里插入图片描述

  • 这样,我们在缓存中既可以保存对应id的商铺信息,并且有该缓存数据的逻辑过期时间,接下来我们只需要根据该逻辑过期时间来判断该缓存数据需不需要从数据库更新过来即可。

  • 业务代码编写:

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryShop(Long id) {

        //缓存穿透时,通过返回空对象解决
        //Shop shop =  queryShopWithThrough(id);

        //缓存击穿时,使用互斥锁进行缓存重建,避免大量的请求进入到数据库中
        //Shop shop = queryShopWithPass(id);

        //使用logic过期解决缓存击穿问题
        Shop shop = queryWithLogic(id);

        if (shop == null) {
            return Result.fail("店铺不存在!");
        }
        return Result.ok(shop);
    }

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

    public Shop queryWithLogic(Long id) {
        //1.根据id去缓存中查找是否有对应的商铺信息
        String shopKey = CACHE_SHOP_KEY + id;
        String redisDataStr = stringRedisTemplate.opsForValue().get(shopKey);
        //2.如果没有则直接返回null
        if (StrUtil.isBlank(redisDataStr)) {
            return null;
        }
        //3.如果有对应的商铺信息
        //将JSON发序列化为对象
        RedisData redisData = JSONUtil.toBean(redisDataStr, RedisData.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        JSONObject data = (JSONObject) redisData.getData();
        Shop shop = JSONUtil.toBean(data, Shop.class);
        //4.判断该缓存是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            //判断过期时间是不是在当前时间之后
            //4.1 没有过期则直接返回该商铺信息
            return shop; //是则说明未过期
        }
        //4.2过期,则尝试进行缓存重建
        //获取锁
        String lockKey = LOCK_SHOP_KEY+ id;
        if (setLock(lockKey)) {
            //5.获取成功,开启新线程更新该缓存的商铺有效期
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    //重建缓存
                    this.saveShopInfoToRedis(id, 20L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    //释放锁
                    unLock(lockKey);
                }
            });
        }
        //6.未获取成功则直接返回旧的商铺信息
        return shop;
    }
    //设置锁
    public boolean setLock(String key) {
        //设置一个锁字段,并给锁设置有效期,避免死锁
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10L, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }
    //释放锁
    public void unLock(String key) {
        stringRedisTemplate.delete(key);
    }

    //根据id,将对应的商铺信息和逻辑过期时间封装到RedisData对象中
    public void saveShopInfoToRedis(Long id, Long expireSeconds) throws InterruptedException {
        //1.根据id在数据库中查询对应的商铺信息
        Shop shop = getById(id);
        
        Thread.sleep(200); //模拟缓存重建耗时

        //2.获取当前时间,并设置逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        //3.封装到RedisData对象中
        redisData.setData(shop);
        //4.写入到Redis中
        String lockKey = CACHE_SHOP_KEY + id;
        stringRedisTemplate.opsForValue().set(lockKey, JSONUtil.toJsonStr(redisData));
    }
}
  • 当更新了一个商铺的信息之后,模拟100个线程同时去做该商铺信息的查询,由于设置了缓存重建线程200ms的睡眠,所以会过一段时间才更新缓存
  • 所以当前200ms的查询都是返回的旧的数据:

在这里插入图片描述

  • 而当200ms之后,重建缓存的线程会判断是否到了过期时间,如果过期则会去数据库中将更新之后的数据读取到缓存中,更新彻底更新该数据。

  • 而服务器中对缓存的重建也只执行了一次,减少了对数据库的频繁IO以及不必要的IO。

8.缓存穿透和缓存击穿工具类封装

cacheClient.java:

@Configuration
@Slf4j
public class CacheClient {


    private final StringRedisTemplate stringRedisTemplate;
    public CacheClient(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

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

    public void setKey (String key, Object value, Long time, TimeUnit timeUnit) {
        //1.将object对象转换成JSON字符串
        String jsonStr = JSONUtil.toJsonStr(value);
        //2.将value保存到key中,并设置过期时间
        stringRedisTemplate.opsForValue().set(key, jsonStr, time, timeUnit);
    }

    public void setLogicExpier(String key, Object value, Long time, TimeUnit timeUnit) {
        //将传入的对象和过期时间,封装到一个对象并保存到redis中
        RedisData redisData = new RedisData();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(time)));
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    public <R, ID>  R queryWithPassThrough (String keyPrefix, ID id, Function<ID, R> function, Class<R> type, Long time, TimeUnit timeUnit) {
        //1.根据id去缓存中查找
        String key = keyPrefix + id;
        String jsonStr = stringRedisTemplate.opsForValue().get(key);
        //2.判断是否存在
        if (StrUtil.isNotBlank(jsonStr)) {  //只有当"abc"时才返回true,所以不会判断""为true
            return JSONUtil.toBean(jsonStr, type);
        }
        //判断为""时,说明该对象是缓存穿透的null值
        if (jsonStr != null) {
            return null;
        }
        //3.需要到数据库中进行查询
        R r = function.apply(id); //传入一个id,该function则利用该id返回对应类型为R的对象
        //4.如果查询的id对应的对象不存在,则需要将""存入到Redis中
        if (r == null) {
            stringRedisTemplate.opsForValue().set(key, "", time, timeUnit);
            return null;
        }
        //5.如果查询的id对应的对象存在,则将该对象写入到Redis中
//        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(r), time, timeUnit);
        this.setKey(key, r, time, timeUnit);
        //6.返回指定对象
        return r;
    }

    public <ID, R> R queryWithLogicalExpier (String keyPrefix, ID id, Function<ID, R> function, Class<R> type, Long time, TimeUnit timeUnit) {
        //1.根据id查询缓存中的对象
        String key = keyPrefix + id;
        //2.判断是否命中缓存
        String jsonStr = stringRedisTemplate.opsForValue().get(key);
        //未命中则返回null
        if (StrUtil.isBlank(jsonStr)) {
            return null;
        }
        //3.如果命中则判断有效期
        // 3.1 将JSON字符串转换成对象
        RedisData redisData = JSONUtil.toBean(jsonStr, RedisData.class);
        // 3.2 获取有效时间
        LocalDateTime expireTime = redisData.getExpireTime();
        // 3.3 获取对象信息
        JSONObject data = (JSONObject) redisData.getData();
        R r = JSONUtil.toBean(data, type);
        //4.如果是在有效期内,直接返回命中的对象
        if (expireTime.isAfter(LocalDateTime.now())) {
            return r;
        }
        //5.如果过期了,则尝试获取锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean isLock  = setLock(lockKey);
        if (isLock) {
            //6.获取锁成功
            try {
                //开启一个新线程执行缓存重建
                //缓存重建:从数据库中读取数据,并重置有效期
                CACHE_REBUILD_EXECUTOR.submit(() -> {
                    R r1 = function.apply(id);
                    this.setLogicExpier(key, r1, time, timeUnit);
                });
            } catch (Exception e) {
                throw new RuntimeException(e);
            } finally {
                unLock(lockKey);
            }
        }
        //7.获取失败,则直接返回旧的数据
        return r;
    }
    //设置锁
    public boolean setLock(String key) {
        //设置一个锁字段,并给锁设置有效期,避免死锁
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10L, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }
    //释放锁
    public void unLock(String key) {
        stringRedisTemplate.delete(key);
    }
}
  • 使用时直接导入该工具类,调用里面的方法,传入指定的参数即可:选用指定的方法解决指定的问题
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    @Resource
    private CacheClient cacheClient;

    @Override
    public Result queryShop(Long id) {

        //缓存穿透时,通过返回空对象解决
        //Shop shop =  queryShopWithThrough(id);

        //缓存击穿时,使用互斥锁进行缓存重建,避免大量的请求进入到数据库中
        //Shop shop = queryShopWithPass(id);

        //使用logic过期解决缓存击穿问题
        //Shop shop = queryWithLogic(id);

        //使用工具类的缓存穿透解决方案
        Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, this::getById, Shop.class, CACHE_SHOP_TTL, TimeUnit.MINUTES);

        //使用工具类的缓存击穿解决方案
        //Shop shop = cacheClient.queryWithLogicalExpier(CACHE_SHOP_KEY, id, this::getById, Shop.class, 20L, TimeUnit.MINUTES);

        if (shop == null) {
            return Result.fail("店铺不存在!");
        }
        return Result.ok(shop);
    }
}

9.总结

  • 1.认识缓存:
    • 一种具备高效读写能力的数据暂存区域
    • 缓存作用:降低后端的负载,提高数据读写的效率
    • 弊端:代码变复杂、维护成本、数据可能出现不一致
  • 2.缓存更新策略:
    • 内存淘汰机制:当内存不足时,会自动剔除掉一些缓存数据,
      • 缺点:随机,可能常用的缓存数据被剔除,不常用的反而留下
    • 超时剔除:通过expire给缓存数据设置过期时间,到期自动删除
      • 缺点:TTL时间不好掌握,太长会导致数据的不一致,太短导致数据库的负载依旧很大
    • 主动更新:
      • cache aside: 由缓存的调用者,在操作数据库的同时更新缓存
      • write/read through:建立服务,当数据库的数据发送变化时,缓存随之变化
        • 缺点:服务代码实现复杂,且复用性不高
      • write behind cache:调用者只操作缓存,更新操作由单独的线程异步进行持久化操作
        • 缺点:难以保证数据一致性,当异步线程宕机时,就会导致数据的更新丢失,造成数据不一致。
    • 更新策略:当一致性需求较低时可以使用内存淘汰机制或者超时剔除,当数据一致性要求高,则使用主动更新策略,并加入超时剔除作为兜底,确保缓存数据能及时更新。
    • cache aside:
      • 删除缓存:更新数据库时,直接删除缓存,就不会出现缓存的冗余更新,当下次请求改数据时,由于缓存删除了,就会直接从数据库中读取并重建缓存,从而保证数据的一致性
      • 一致性保证:单体系统使用事务保证更新数据库和删除缓存的原子性,分布式系统则使用分布式事务
      • 先更新数据库再删除缓存:在更新完数据库删除缓存这一过程中,有其他线程来读取到了缓存中未来得及删除的数据的时间间隔远比在删除完缓存,再更新数据库的这一过程短,在删除完缓存之后,由于其他线程未命中,就容易在更新数据库前从数据库中直接读取旧值,数据不一致的可能性大大增加。
  • 3.缓存穿透:请求的数据在缓存中不存在,打到数据库中查询也不存在
    • 导致的问题:大量的不存在数据请求打到数据库,每次都要执行查询判断,数据库负载增大,容易崩溃
    • 解决方案:
      • 方案一:返回空对象:当第一次查询的数据在缓存中不存在,数据库也不存在时,将该数据存为一个空对象,并设置TTL,在TTL时间内的该请求都会命中缓存中的null值,减少了数据库的访问压力。
        • 优缺点:实现简单,但是内存占用可能较大,不同类型的请求可能对应不同的null值对象,导致大量的内存占用,并可能存在短期的不一致,比如两分钟内原本为null的值,插入了数据,但是在TTL时间内还是返回null。
      • 方案二:布隆过滤器:将数据通过一种hash算法得到hash值,存储在二进制位的数组中,在请求时先判断该数据在数据库中是否存在,如果不存在直接拒绝
        • 优缺点:内存占用少,但实现复杂,并且存在误判
      • 其他:1.做好数据的基础校验, 2.设置请求的用户权限, 3.给热点参数进行限流
  • 4.缓存雪崩:大量的key同时失效或者Redis宕机,导致大量请求直接打到数据库
    • 导致的问题:数据库短时间内的IO负载暴增,直接崩溃
    • 解决方案:
      • 1.如果是大量key同时失效:给key设置TTL时加上随机值
      • 2.如果是Redis宕机:使用Redis集群,设置多级缓存,给缓存业务添加降级限流策略
  • 5.缓存击穿:热点key(高访问的key)失效,导致大量的数据请求直接打到数据库
    • 导致的问题:多线程访问下发现该key缓存失效,都尝试重建缓存,导致大量的线程同时重建缓存,造成拥塞。
    • 解决方案:
      • 方案一:加互斥锁思路:限制对热点key的同时访问,只有拿到锁的线程才能尝试重建缓存,没有获取到锁的线程就等待,直到第一个获取到锁的线程重建完缓存并释放锁之后如果发现还是没有命中缓存才能重新尝试获取锁去重建缓存。
        • 优点:实现简单,没有额外的内存消耗,一致性好
        • 缺点:导致并发性能下降,并且有死锁风险
      • 方案二:逻辑过期思路:给热点key添加逻辑过期时间,实际上的热点key缓存永不过期,每次判断该缓存是否在有效时间内,有效则直接使用,过期则获取锁并单独使用一个线程去重建缓存,自己则先使用旧数据,其他线程在缓存过期之后也会尝试获取锁,如果未获取成功,则也使用旧数据。
        • 优点:所有线程无需等待,直接返回数据,并发性能好
        • 缺点:一致性不高,并且需要额外的内存,实现复杂

四、优惠劵秒杀

1.全局唯一ID

全局ID生成器
  • 使用数据库自增ID的弊处:

    • 1.id规律,会泄漏一些订单信息
    • 2.单表的数据量的限制:当数据量级较大时,数据可能会保存在不同的表和数据库中,那么使用自增id就会产生相同的ID,导致订单冲突,当需要查询单个订单时无法准确查询到对应订单数据
  • 全局ID生成器:是一种在分布式系统下用来生成全局唯一ID的工具

  • 需要满足的特性:

    • 1.唯一性:全局需要唯一
    • 2.高可用:任意时候都需要能使用
    • 3.高性能:ID生成响应速度快
    • 4.递增性:生成的id具有一定的递增规律,使得容易建立索引,增加查询效率
    • 5.安全性:保证id的安全性
  • 解决方案:Redis的String类型的INCR:让一个整形的key自增并指定步长,例如:incrby num 2让num值自增2

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

  • ID组成:
    在这里插入图片描述

RedisIdGenaretor:

@Component
public class RedisIdGenaretor {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 开始的时间戳
     */
    private static final long BEGIN_TIMESTAMP = 1758499200L; //2025/9/22的秒数

    /**
     * 序列号的位数
     */
    private static final int COUNT_BITS = 32;

    public long nextId(String keyPrefix) {
        //1. 生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSeconds = now.toEpochSecond(ZoneOffset.UTC); //当前的时间秒数
        long timestamp = nowSeconds - BEGIN_TIMESTAMP;

        //2. 生成自增id
        //2.1.获取当前日期,精确到天
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        //2.2.自增长
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
        //3. 拼接
        return timestamp << COUNT_BITS | count;
    }
}
  • 测试全局唯一ID生成:
@SpringBootTest
class HmDianPingApplicationTests {
    @Resource
    private RedisIdGenaretor redisIdGenaretor;
    private ExecutorService es = Executors.newFixedThreadPool(500);
    @Test
    void testRedisIdGenaretor() throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(100);
        Runnable task = () -> {
            for (int i = 0; i < 10; i++) {
                long id = redisIdGenaretor.nextId("order");
                System.out.println("id = " + id);
            }
            countDownLatch.countDown();
        };
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100; i++) {
            es.submit(task);
        }
        countDownLatch.await();
        long end = System.currentTimeMillis();
        System.out.println("time = " + (end - start));
    }
}

2.优惠劵

  • 1.普通优惠劵和秒杀优惠劵的数据结构

普通优惠劵:

-- auto-generated definition
create table tb_voucher
(
    id           bigint unsigned auto_increment comment '主键'
        primary key,
    shop_id      bigint unsigned                               null comment '商铺id',
    title        varchar(255)                                  not null comment '代金券标题',
    sub_title    varchar(255)                                  null comment '副标题',
    rules        varchar(1024)                                 null comment '使用规则',
    pay_value    bigint(10) unsigned                           not null comment '支付金额,单位是分。例如200代表2元',
    actual_value bigint(10)                                    not null comment '抵扣金额,单位是分。例如200代表2元',
    type         tinyint(1) unsigned default 0                 not null comment '0,普通券;1,秒杀券',
    status       tinyint(1) unsigned default 1                 not null comment '1,上架; 2,下架; 3,过期',
    create_time  timestamp           default CURRENT_TIMESTAMP not null comment '创建时间',
    update_time  timestamp           default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间'
)
    row_format = COMPACT;

秒杀优惠劵:

-- auto-generated definition
create table tb_seckill_voucher
(
    voucher_id  bigint unsigned                     not null comment '关联的优惠券的id'
        primary key,
    stock       int(8)                              not null comment '库存',
    create_time timestamp default CURRENT_TIMESTAMP not null comment '创建时间',
    begin_time  timestamp default CURRENT_TIMESTAMP not null comment '生效时间',
    end_time    timestamp default CURRENT_TIMESTAMP not null comment '失效时间',
    update_time timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间'
)
    comment '秒杀优惠券表,与优惠券是一对一关系' row_format = COMPACT;
  • 秒杀优惠劵作为优惠劵的一种,它的一个主键id是和普通优惠劵的id关联的,也就是通过普通优惠劵的id能找到对应的秒杀优惠劵
  • 优惠劵添加:
  • 通过postman发送添加请求:
    在这里插入图片描述
  • 添加成功:
    在这里插入图片描述

3.优惠劵购买

1)总体流程以及思维导图
  • 流程:点击抢购,根据优惠劵的id发送POST请求,后端接收到请求之后,生成订单数据,将用户id和优惠劵id绑定,存入到数据库中,用于后续的查询。

在这里插入图片描述
*
在这里插入图片描述

2)普通优惠劵购买功能实现:
  • 通过RedisIdGenaretor生成全局唯一订单ID
  • 使用MybatisPlus对sql的封装,组合sql对秒杀券的库存进行更新。
  • 由于涉及到多张表的数据更新,于是开启事务维护数据的一致性。
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdGenaretor redisIdGenaretor;

    @Override
    @Transactional  //加上事务,防止中间断开,导致数据不一致
    public Result seckillById(Long voucherId) {
        //1.根据id查找到对应的秒杀券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2.判断当前下单时间是否在秒杀券活动时间范围内
        //2.1 下单时间是否在开始之前
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀活动还未开始!");
        }
        //2.2 下单时间是否在结束之后
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀活动已结束!");
        }
        //3.判断库存是否剩余
        //3.1 库存不足
        if (voucher.getStock() < 1) {
            return Result.fail("库存不足!");
        }
        //4.减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId).update();
        if (!success) {
            return Result.fail("库存不足!");
        }
        //5.生成订单数据,插入订单表中
        VoucherOrder voucherOrder = new VoucherOrder();
        //5.1 使用RedisIdGenaretor生成订单id
        long orderId = redisIdGenaretor.nextId("order");
        voucherOrder.setId(orderId);
        //5.2 将用户id存入订单数据
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        //5.3 将秒杀券id存入订单数据
        voucherOrder.setVoucherId(voucherId);
        //5.4 写入数据库
        save(voucherOrder);
        //6.返回订单id
        return Result.ok(orderId);
    }
}
  • 数据库秒杀券库存更新:
    在这里插入图片描述
  • 订单数据生成:
    在这里插入图片描述

3)超卖问题
  • 基于上述代码实现的优惠劵下单代码,在高并发的情况下,就会出现库存超卖的情况
    在这里插入图片描述

  • 如果秒杀的商品是比较稀缺的,出现超卖问题就会导致商铺的利益受损

  • 出现超卖的原因:上述任务中是先执行查询,再去判断库存是否大于0,但是在高并发的情况下,线程A在查询库存到减去库存的过程中,可能会有其他线程来查询原有的库存,那么在线程A扣减库存之前,其他线程都会判断为还有库存,那么就会抢购成功,导致超卖。

在这里插入图片描述

4)解决方案——加锁
  • 悲观锁:
    • 认为线程安全问题是一定存在的,每次操作数据之前都会先获取锁,确保并发线程串行执行
    • synchronized, Lock都属于悲观锁
  • 乐观锁:
    • 认为线程安全问题是少数发送的,因此不加锁,只有在更新数据时判断是否有其他线程对数据进行了修改
    • 如果没有修改,则认为是安全的,自己才会更新数据
    • 如果已经被其他线程修改,则说明发送了安全问题,此时就重新获取数据或者抛出异常
乐观锁
1)版本号法
  • 给数据新增version字段,每次修改数据时必须将版本号+1
  • 修改数据时将version加入判断,如果修改时的version是和查询时的version一致的,说明该数据没有被修改过

在这里插入图片描述

2)CAS法(compare and switch)
  • 先比较再修改
  • 基于版本号法的优化,每次修改都要更新库存和版本号,那么直接利用库存来判断查询到的数据在修改之前是否被其他线程来修改过,只需要加一个查询库存为当前库存的条件即可。
  • 如果修改过,库存则会发生变化,说明当前查询到的数据是不安全的。
  • 用库存代替版本来判断数据是否发生修改。
    在这里插入图片描述
  • CAS法实现:其实实现很简单,只需要在更新时加入一个条件:stock = voucher.getStock(),主要是乐观锁的思想比较重要。
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdGenaretor redisIdGenaretor;

    @Override
    @Transactional  //加上事务,防止中间断开,导致数据不一致
    public Result seckillById(Long voucherId) {
        //1.根据id查找到对应的秒杀券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2.判断当前下单时间是否在秒杀券活动时间范围内
        //2.1 下单时间是否在开始之前
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀活动还未开始!");
        }
        //2.2 下单时间是否在结束之后
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀活动已结束!");
        }
        //3.判断库存是否剩余
        //3.1 库存不足
        if (voucher.getStock() < 1) {
            return Result.fail("库存不足!");
        }
        //4.减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") // set stock = stock - 1
                .eq("voucher_id", voucherId).eq("stock", voucher.getStock()) // where id = ? and stock = ?
                .update();
        if (!success) {
            return Result.fail("库存不足!");
        }
        //5.生成订单数据,插入订单表中
        VoucherOrder voucherOrder = new VoucherOrder();
        //5.1 使用RedisIdGenaretor生成订单id
        long orderId = redisIdGenaretor.nextId("order");
        voucherOrder.setId(orderId);
        //5.2 将用户id存入订单数据
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        //5.3 将秒杀券id存入订单数据
        voucherOrder.setVoucherId(voucherId);
        //5.4 写入数据库
        save(voucherOrder);
        //6.返回订单id
        return Result.ok(orderId);
    }

}
乐观锁的弊端改进
  • 乐观锁的弊端:只要数据发生改变就认为是不安全的,直接将该线程的修改停止了,导致在该业务中,库存仍有剩余时,多个线程查询到同一个库存时,某个线程先更新了库存,就会导致其他所有线程都判断为数据发送变化,导致不再去修改库存,也就产生了有库存但是卖不出去
    在这里插入图片描述

在这里插入图片描述

  • 解决方案:由于库存比较特殊,只要库存还有就能卖,所以这里添加的判断条件改为stock > 0即可。也就是只要库存还有就能继续修改。
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdGenaretor redisIdGenaretor;

    @Override
    @Transactional  //加上事务,防止中间断开,导致数据不一致
    public Result seckillById(Long voucherId) {
        //1.根据id查找到对应的秒杀券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2.判断当前下单时间是否在秒杀券活动时间范围内
        //2.1 下单时间是否在开始之前
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀活动还未开始!");
        }
        //2.2 下单时间是否在结束之后
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀活动已结束!");
        }
        //3.判断库存是否剩余
        //3.1 库存不足
        if (voucher.getStock() < 1) {
            return Result.fail("库存不足!");
        }
        //4.减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") // set stock = stock - 1
                .eq("voucher_id", voucherId) // voucher_id = ?
                .gt("stock", 0) // stock > 0
                .update();
        if (!success) {
            return Result.fail("库存不足!");
        }
        //5.生成订单数据,插入订单表中
        VoucherOrder voucherOrder = new VoucherOrder();
        //5.1 使用RedisIdGenaretor生成订单id
        long orderId = redisIdGenaretor.nextId("order");
        voucherOrder.setId(orderId);
        //5.2 将用户id存入订单数据
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        //5.3 将秒杀券id存入订单数据
        voucherOrder.setVoucherId(voucherId);
        //5.4 写入数据库
        save(voucherOrder);
        //6.返回订单id
        return Result.ok(orderId);
    }
}
5)一人一单
  • 秒杀券的初衷就是吸引不同的用户,所以需要限制同一个秒杀优惠劵一个用户只能下一单
  • 之前的设计中,同一个用户可以下多个秒杀券的订单。
    在这里插入图片描述
1.判断用户是否参与过该活动
  • 先根据用户id和秒杀券id去查询对应的订单数据,如果存在则不让用户继续再下单
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdGenaretor redisIdGenaretor;

    @Override
    @Transactional  //加上事务,防止中间断开,导致数据不一致
    public Result seckillById(Long voucherId) {
        //1.根据id查找到对应的秒杀券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2.判断当前下单时间是否在秒杀券活动时间范围内
        //2.1 下单时间是否在开始之前
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀活动还未开始!");
        }
        //2.2 下单时间是否在结束之后
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀活动已结束!");
        }
        //3.判断库存是否剩余
        //3.1 库存不足
        if (voucher.getStock() < 1) {
            return Result.fail("库存不足!");
        }

        //4.根据当前用户id和优惠券id查询订单中是否存在该用户对于该优惠劵的订单
        Long userId = UserHolder.getUser().getId();
        int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
        //4.1如果存在,则说明该用户已经参与过该活动
        if (count > 0) {
            return Result.fail("用户已经参与过一次活动,无法重复参与!");
        }

        // 5.减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") // set stock = stock - 1
                .eq("voucher_id", voucherId) // voucher_id = ?
                .gt("stock", 0) // stock > 0
                .update();
        if (!success) {
            return Result.fail("库存不足!");
        }
        //6.生成订单数据,插入订单表中
        VoucherOrder voucherOrder = new VoucherOrder();
        //6.1 使用RedisIdGenaretor生成订单id
        long orderId = redisIdGenaretor.nextId("order");
        voucherOrder.setId(orderId);
        //6.2 将用户id存入订单数据
        voucherOrder.setUserId(userId);
        //6.3 将秒杀券id存入订单数据
        voucherOrder.setVoucherId(voucherId);
        //6.4 写入数据库
        save(voucherOrder);
        //7.返回订单id
        return Result.ok(orderId);
    }
}
2.并发下的一人多单问题
  • 根据查询到的订单数是否大于0来判断用户是否参与过确实没问题,仍有与之前超卖问题一样的问题,就是先去查询,然后根据查询的数据再判断。

  • 并发下,如果查询到的是0,则该线程开始去下单减库存,但是同一时间其他线程查询到的也是0,就导致多个线程同时下单,还是会发生一人多单问题。

  • 如何解决:由于该订单数据是从无到有的,不能像库存那样一开始就控制数量,如果不对则不修改,

  • 于是我们使用悲观锁,当有线程在操作该数据时,其他线程只能等待

  • 方式一:

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

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdGenaretor redisIdGenaretor;

    @Override
    public Result seckillById(Long voucherId) {
        //1.根据id查找到对应的秒杀券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2.判断当前下单时间是否在秒杀券活动时间范围内
        //2.1 下单时间是否在开始之前
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀活动还未开始!");
        }
        //2.2 下单时间是否在结束之后
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀活动已结束!");
        }
        //3.判断库存是否剩余
        //3.1 库存不足
        if (voucher.getStock() < 1) {
            return Result.fail("库存不足!");
        }


        return createVoucherOrder(voucherId);
    }

    @Transactional  //加上事务,防止中间断开,导致数据不一致
    public  synchronized Result createVoucherOrder(Long voucherId) {
        //4.根据当前用户id和优惠券id查询订单中是否存在该用户对于该优惠劵的订单
        Long userId = UserHolder.getUser().getId();
        int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
        //4.1如果存在,则说明该用户已经参与过该活动
        if (count > 0) {
            return Result.fail("用户已经参与过一次活动,无法重复参与!");
        }

        // 5.减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") // set stock = stock - 1
                .eq("voucher_id", voucherId) // voucher_id = ?
                .gt("stock", 0) // stock > 0
                .update();
        if (!success) {
            return Result.fail("库存不足!");
        }
        //6.生成订单数据,插入订单表中
        VoucherOrder voucherOrder = new VoucherOrder();
        //6.1 使用RedisIdGenaretor生成订单id
        long orderId = redisIdGenaretor.nextId("order");
        voucherOrder.setId(orderId);
        //6.2 将用户id存入订单数据
        voucherOrder.setUserId(userId);
        //6.3 将秒杀券id存入订单数据
        voucherOrder.setVoucherId(voucherId);
        //6.4 写入数据库
        save(voucherOrder);
        //7.返回订单id
        return Result.ok(orderId);
    }
}
  • 弊端,锁加在方法上,当所有用户的该操作都加锁了,而且是同一把锁,那么不同用户等待相同的锁,效率极低,不推荐。

  • 方式二:锁用户id,对于同一个用户加同一把锁,缩小锁锁定的资源范围

  • 锁定用户id:

  • 注意事项:不同线程创建的userId是不同的对象,于是需要使用toString()转换成同一个字符串的值进行比较,但是toString()底层在将不同对象转换成字符串时,每次是new了一个新的字符串,所以,即使内容相同,两个相同的userId经过toString()转化之后并不指向同一个地址,也就无法实现将相同userId的用户进行锁定。
    在这里插入图片描述

  • 于是我们进一步使用intern()方法,它底层会去字符串常量池中找跟指定字符串内容相同的值,于是相同的userId会被指向到同一个地址中对应同一个东西,也就能将该同一个东西对应的资源锁定,不让并发操作。
    在这里插入图片描述

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

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdGenaretor redisIdGenaretor;

    @Override
    public Result seckillById(Long voucherId) {
        //1.根据id查找到对应的秒杀券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2.判断当前下单时间是否在秒杀券活动时间范围内
        //2.1 下单时间是否在开始之前
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀活动还未开始!");
        }
        //2.2 下单时间是否在结束之后
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀活动已结束!");
        }
        //3.判断库存是否剩余
        //3.1 库存不足
        if (voucher.getStock() < 1) {
            return Result.fail("库存不足!");
        }


        return createVoucherOrder(voucherId);
    }

    @Transactional  //加上事务,防止中间断开,导致数据不一致
    public  Result createVoucherOrder(Long voucherId) {
        //4.根据当前用户id和优惠券id查询订单中是否存在该用户对于该优惠劵的订单
        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()){
            int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
            //4.1如果存在,则说明该用户已经参与过该活动
            if (count > 0) {
                return Result.fail("用户已经参与过一次活动,无法重复参与!");
            }

            // 5.减库存
            boolean success = seckillVoucherService.update()
                    .setSql("stock = stock - 1") // set stock = stock - 1
                    .eq("voucher_id", voucherId) // voucher_id = ?
                    .gt("stock", 0) // stock > 0
                    .update();
            if (!success) {
                return Result.fail("库存不足!");
            }
            //6.生成订单数据,插入订单表中
            VoucherOrder voucherOrder = new VoucherOrder();
            //6.1 使用RedisIdGenaretor生成订单id
            long orderId = redisIdGenaretor.nextId("order");
            voucherOrder.setId(orderId);
            //6.2 将用户id存入订单数据
            voucherOrder.setUserId(userId);
            //6.3 将秒杀券id存入订单数据
            voucherOrder.setVoucherId(voucherId);
            //6.4 写入数据库
            save(voucherOrder);
            //7.返回订单id
            return Result.ok(orderId);
        }
    }
}
  • 方式三:将锁和事务的层次关系颠倒
  • 上述的情况中还是存在明显的问题:就是锁是在事务内部的,当锁内部的流程执行完毕,就会释放,而此时事务还未提交,此时,其他线程已经可以获取锁并进行下单操作了,这样还是会导致一人多单
  • 于是我们需要将事务放在锁的内部,即事务完成并提交之后,锁才能释放,确保数据已经完全更新到数据库中。
  • 于是我们稍微放大锁的范围,将其整个事务进行加锁。
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdGenaretor redisIdGenaretor;

    @Override
    public Result seckillById(Long voucherId) {
        //1.根据id查找到对应的秒杀券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2.判断当前下单时间是否在秒杀券活动时间范围内
        //2.1 下单时间是否在开始之前
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀活动还未开始!");
        }
        //2.2 下单时间是否在结束之后
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀活动已结束!");
        }
        //3.判断库存是否剩余
        //3.1 库存不足
        if (voucher.getStock() < 1) {
            return Result.fail("库存不足!");
        }
        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()) {
            return createVoucherOrder(voucherId);
        }
    }

    @Transactional  //加上事务,防止中间断开,导致数据不一致
    public  Result createVoucherOrder(Long voucherId) {
        //4.根据当前用户id和优惠券id查询订单中是否存在该用户对于该优惠劵的订单
        Long userId = UserHolder.getUser().getId();
        int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
        //4.1如果存在,则说明该用户已经参与过该活动
        if (count > 0) {
            return Result.fail("用户已经参与过一次活动,无法重复参与!");
        }

        // 5.减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") // set stock = stock - 1
                .eq("voucher_id", voucherId) // voucher_id = ?
                .gt("stock", 0) // stock > 0
                .update();
        if (!success) {
            return Result.fail("库存不足!");
        }
        //6.生成订单数据,插入订单表中
        VoucherOrder voucherOrder = new VoucherOrder();
        //6.1 使用RedisIdGenaretor生成订单id
        long orderId = redisIdGenaretor.nextId("order");
        voucherOrder.setId(orderId);
        //6.2 将用户id存入订单数据
        voucherOrder.setUserId(userId);
        //6.3 将秒杀券id存入订单数据
        voucherOrder.setVoucherId(voucherId);
        //6.4 写入数据库
        save(voucherOrder);
        //7.返回订单id
        return Result.ok(orderId);
    }
}
  • 方式四:其实还是存在问题,那就是事务失效。
  • 此时由于createVoucherOrder()加了事务,而实际调用它时,是通过this.createVoucherOrder()来调用的,而此时的this是指当前的VoucherOrderServiceImpl对象,而不是Spring动态代理的对象
  • 而事务之所以生效,也就是能对原本的createVoucherOrder()方法进行事务处理,是因为Spring拿到了VoucherOrderServiceImpl的动态代理对象,然后去进行事务处理。
  • 但是这里通过this拿到的并不是Spring中的动态代理对象。
  • 那我们就需要拿到Spring中关于VoucherOrderServiceImpl的代理对象: AopContext.currentProxy(),然后调用createVoucherOrder()
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdGenaretor redisIdGenaretor;

    @Override
    public Result seckillById(Long voucherId) {
        //1.根据id查找到对应的秒杀券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2.判断当前下单时间是否在秒杀券活动时间范围内
        //2.1 下单时间是否在开始之前
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀活动还未开始!");
        }
        //2.2 下单时间是否在结束之后
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀活动已结束!");
        }
        //3.判断库存是否剩余
        //3.1 库存不足
        if (voucher.getStock() < 1) {
            return Result.fail("库存不足!");
        }
        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()) {
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }
    }

    @Transactional  //加上事务,防止中间断开,导致数据不一致
    public  Result createVoucherOrder(Long voucherId) {
        //4.根据当前用户id和优惠券id查询订单中是否存在该用户对于该优惠劵的订单
        Long userId = UserHolder.getUser().getId();
        int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
        //4.1如果存在,则说明该用户已经参与过该活动
        if (count > 0) {
            return Result.fail("用户已经参与过一次活动,无法重复参与!");
        }

        // 5.减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") // set stock = stock - 1
                .eq("voucher_id", voucherId) // voucher_id = ?
                .gt("stock", 0) // stock > 0
                .update();
        if (!success) {
            return Result.fail("库存不足!");
        }
        //6.生成订单数据,插入订单表中
        VoucherOrder voucherOrder = new VoucherOrder();
        //6.1 使用RedisIdGenaretor生成订单id
        long orderId = redisIdGenaretor.nextId("order");
        voucherOrder.setId(orderId);
        //6.2 将用户id存入订单数据
        voucherOrder.setUserId(userId);
        //6.3 将秒杀券id存入订单数据
        voucherOrder.setVoucherId(voucherId);
        //6.4 写入数据库
        save(voucherOrder);
        //7.返回订单id
        return Result.ok(orderId);
    }
}
  • 添加依赖:——动态代理模式
    <dependencies>
        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjweaver</artifactId>
        </dependency>
    </dependencies>
  • 启动类设置暴露代理对象:——默认为不暴露,不暴露的话就无法获取
@EnableAspectJAutoProxy(exposeProxy = true)
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {

    public static void main(String[] args) {
        SpringApplication.run(HmDianPingApplication.class, args);
    }
}
  • 以上方式中,只有方式四才是完整的方式,其他都是有缺陷的。

4.集群下的一人一单并发安全问题

  • IDEA中多开服务(将同一个服务启动两份):Ctrl+D复制配置: 添加程序实参:--server.port=8082
    在这里插入图片描述
    在这里插入图片描述

  • 使用修改nginx的conf目录下的nginx.conf文件,配置反向代理和负载均衡:

  • 负载均衡 其实就是一个算法将所有请求分配到不同的服务上,减小单个服务器端的压力。

worker_processes  1;
events {
    worker_connections  1024;
}
http {
    include       mime.types;
    default_type  application/json;

    sendfile        on;
    
    keepalive_timeout  65;

    server {
        listen       8080;
        server_name  localhost;
        # 指定前端项目所在的位置
        location / {
            root   html/hmdp;
            index  index.html index.htm;
        }

        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }


        location /api {  
            default_type  application/json;
            #internal;  
            keepalive_timeout   30s;  
            keepalive_requests  1000;  
            #支持keep-alive  
            proxy_http_version 1.1;  
            rewrite /api(/.*) $1 break;  
            proxy_pass_request_headers on;
            #more_clear_input_headers Accept-Encoding;  
            proxy_next_upstream error timeout;  
            #proxy_pass http://127.0.0.1:8081;
            proxy_pass http://backend; #负载均衡配置
        }
    }
    upstream backend {  #轮询的负载均衡规则——服务器地址
        server 127.0.0.1:8081 max_fails=5 fail_timeout=10s weight=1;
        server 127.0.0.1:8082 max_fails=5 fail_timeout=10s weight=1;
    }  
}

  • 通过nginx.exe -s reload命令重新加载nginx服务
  • 再次重新拉起两个服务就能够实现两个服务中间的负载均衡了,同时发送两次请求,发现每个服务均收到一个请求

1)分布式系统引起的问题
  • 原本的单体系统中,我们是根据用户Id设定了一个锁的监视器,当某个线程获取到锁时,就会标注该监视器监视的线程名称,其他线程发现同一把锁下的监视器已经有监视的线程就会等待,从而避免并发引起一人多单。
  • 但是在分布式系统中,由于JVM不同,对于相同用户Id设定的锁其实不是同一把,所以即使JVM1中已经有线程获取到了用户Id指定的锁,在JVM2中仍可以获取用户Id指定的锁,这就导致分布式系统下仍会出现一人多单问题。
    在这里插入图片描述
2)分布式锁的工作原理
  • 那么为了在不同的JVM下所有相同业务线程共用一把锁,就要考虑将锁的监视器放在所有线程都可以访问的公共空间中。这样当某个JVM下的一个线程获取到锁,锁监视器就会记录该线程的id,其他JVM或者同JVM下的线程去尝试获取时就会获取失败。
  • 分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁

在这里插入图片描述

分布式锁的三种实现方式:

在这里插入图片描述

3)基于Redis实现分布式锁
  • 获取锁:
    • 互斥:确保只能有一个线程获取锁
    • setnx lock thread1 ——利用setnx的互斥特性
    • 添加锁的过期时间:expire lock 10——避免服务宕机引起死锁
    • 为了进一步防止因为分步执行命令导致的死锁(设置过期时间时宕机),将两个命令放到一起执行:setnx lock thread1 nx ex 10(nx是互斥,ex是设置过期时间)
  • 释放锁:
    • 手动释放:delete lock
    • 超时释放:设置锁时设置了超时时间,超过指定时间即自动释放锁

在这里插入图片描述

1.定义锁接口规范不同锁的实现方式:
package com.hmdp.utils;

public interface ILock {
    public boolean tryLock(long timeout);

    public void unlock();
}
2.实现一个初级分布式锁:

SimpleRedisLock:

public class SimpleRedisLock implements  ILock{

    private String lockName;
    private StringRedisTemplate redisTemplate;

    //提供构造方法,将锁的名字和redis注入进来,方便使用
    public SimpleRedisLock(String lockName, StringRedisTemplate redisTemplate) {
        this.lockName = lockName;
        this.redisTemplate = redisTemplate;
    }

    private static final String LOCK_PROFIX = "lock:";

    @Override
    public boolean tryLock(long timeout) {
        long threadId = Thread.currentThread().getId();
        Boolean success = redisTemplate.opsForValue()
                .setIfAbsent(LOCK_PROFIX + lockName, threadId + "", timeout, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success); //防止自动拆箱报空指针异常,因为success可能是null
    }

    @Override
    public void unlock() {
        redisTemplate.delete(LOCK_PROFIX + lockName); //删除对应key
    }
}
  • 需要注意的点:
自动拆箱的注意事项:

将一个包装类型的值赋值给基本数据类型时,会发生自动拆箱,而当包装类型的变量为null时,就会报空指针异常
在这里插入图片描述

  • 这里的User().idInteger类型,默认为null,而返回时会转为int类型,所以返回时会报空指针异常。
3.分布式锁应用:

VoucherOrderServiceImpl:

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

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdGenaretor redisIdGenaretor;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result seckillById(Long voucherId) {
        //1.根据id查找到对应的秒杀券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2.判断当前下单时间是否在秒杀券活动时间范围内
        //2.1 下单时间是否在开始之前
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀活动还未开始!");
        }
        //2.2 下单时间是否在结束之后
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀活动已结束!");
        }
        //3.判断库存是否剩余
        //3.1 库存不足
        if (voucher.getStock() < 1) {
            return Result.fail("库存不足!");
        }
        Long userId = UserHolder.getUser().getId();

        //创建自己定义的分布式锁对象
        SimpleRedisLock simpleRedisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
        //根据当前用户去尝试获取锁
        boolean isLock = simpleRedisLock.tryLock(1200);
        if (!isLock) {
            //如果获取失败则直接返回错误信息
            //这里是为了避免相同用户重复下单
            return Result.fail("不允许重复下单!");
        }

        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        return proxy.createVoucherOrder(voucherId);
    }

    @Transactional  //加上事务,防止中间断开,导致数据不一致
    public  Result createVoucherOrder(Long voucherId) {
        //4.根据当前用户id和优惠券id查询订单中是否存在该用户对于该优惠劵的订单
        Long userId = UserHolder.getUser().getId();
        int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
        //4.1如果存在,则说明该用户已经参与过该活动
        if (count > 0) {
            return Result.fail("用户已经参与过一次活动,无法重复参与!");
        }

        // 5.减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") // set stock = stock - 1
                .eq("voucher_id", voucherId) // voucher_id = ?
                .gt("stock", 0) // stock > 0
                .update();
        if (!success) {
            return Result.fail("库存不足!");
        }
        //6.生成订单数据,插入订单表中
        VoucherOrder voucherOrder = new VoucherOrder();
        //6.1 使用RedisIdGenaretor生成订单id
        long orderId = redisIdGenaretor.nextId("order");
        voucherOrder.setId(orderId);
        //6.2 将用户id存入订单数据
        voucherOrder.setUserId(userId);
        //6.3 将秒杀券id存入订单数据
        voucherOrder.setVoucherId(voucherId);
        //6.4 写入数据库
        save(voucherOrder);
        //7.返回订单id
        return Result.ok(orderId);
    }
}
  • 当相同的用户使用多个线程来同时下单,先获取到锁的就会设置锁字段为当前的线程id,其他相同的用户id的线程就无法拿到该锁,从而无法下单。
4.存在的弊端
  • 基于上述方法实现的分布式锁仍是有线程安全问题的

  • 也就是当线程1获取到锁之后,由于中间任务阻塞导致线程1持有的锁被超时释放了,而此时其他线程,比如线程2则能够获取到锁,并执行对应业务,那么当线程1任务执行完之后,仍会执行释放锁的动作,此时由于没有判断,线程1会直接释放对应的锁,导致线程2的锁被释放,而线程3此时又能够获取到锁并执行业务,就导致还是会有两个线程同时在进行下单任务。
    在这里插入图片描述

  • 解决的方案:在释放锁之前先判断现在的锁是否是自己的,如果不是则说明自己的锁已经被释放了,如果是则才执行释放锁的动作。
    在这里插入图片描述

  • 业务流程:
    获取锁时,存放锁的标识(存入线程标识),释放时则判断锁的标识是否是自己的,如果是则才释放。

  • 代码实现:

public class SimpleRedisLock implements  ILock{
    private String lockName;
    private StringRedisTemplate stringRedisTemplate;

    //提供构造方法,将锁的名字和redis注入进来,方便使用
    public SimpleRedisLock(String lockName, StringRedisTemplate stringRedisTemplate) {
        this.lockName = lockName;
        this.stringRedisTemplate = stringRedisTemplate;
    }

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

    @Override
    public boolean tryLock(long timeout) {
        long threadId = Thread.currentThread().getId();
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(LOCK_PROFIX + lockName, ID_PROFIX + threadId, timeout, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success); //防止自动拆箱报空指针异常,因为success可能是null
    }

    @Override
    public void unlock() {
        //获取线程标识
        long threadId = Thread.currentThread().getId();
        String currentValue = stringRedisTemplate.opsForValue().get(LOCK_PROFIX + lockName);
        //获取锁中的标识
        //如果标识一致,则释放对应锁
        if (currentValue.equals(ID_PROFIX + threadId)) {
            stringRedisTemplate.delete(LOCK_PROFIX + lockName); //删除对应key
        }
    }
}
  • 在设置锁的时候,将UUID+线程id存入value,作为当前线程获取到的锁的标识,其他线程需要释放同一个锁时,需要判断它的标识是否能对应,也就是是否是属于自己的锁。

5.仍存在的弊端
  • 由于这里的判断锁是否为自己的锁以及释放锁还是分步执行的
  • 所以当中间出现不可控因素时,还是会发生误删锁的问题:
  • 比如线程1执行完,在校验了锁标识,准备释放锁时,发生了阻塞,导致自己的锁超时释放了,而此时其他线程比如线程2就可以直接获取锁去执行业务,而线程1恢复之后由于已经校验过锁标识了就会直接释放锁,也就是把线程2的锁又给误删了。
    在这里插入图片描述
  • 解决方法:校验锁和释放锁的原子性
4)使用Lua脚本实现Redis命令的原子性
  • Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。
    在这里插入图片描述
    在这里插入图片描述

  • lua脚本:

--比较线程标识与锁中的标识是否一致

if(redis.call('get', KEYS[1]) == ARGV[1]) then
    --释放锁
    return redis.call('del', KEYS[1]);
end
return 0
  • 调用lua脚本:
public class SimpleRedisLock implements  ILock{

    private String lockName;
    private StringRedisTemplate stringRedisTemplate;

    //提供构造方法,将锁的名字和redis注入进来,方便使用
    public SimpleRedisLock(String lockName, StringRedisTemplate stringRedisTemplate) {
        this.lockName = lockName;
        this.stringRedisTemplate = stringRedisTemplate;
    }

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

    //静态代码块初始化加载脚本,类一加载就将脚本加载好了,不用每次调用都加载
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

    @Override
    public boolean tryLock(long timeout) {
        long threadId = Thread.currentThread().getId();
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(LOCK_PROFIX + lockName, ID_PROFIX + threadId, timeout, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success); //防止自动拆箱报空指针异常,因为success可能是null
    }

    @Override
    public void unlock() {
        //调用lua脚本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(LOCK_PROFIX + lockName),
                ID_PROFIX + Thread.currentThread().getId());
    }
}
  • 将脚本通过ClassPathResource()注入lua脚本,并通过静态代码块在类加载时就加载lua脚本,避免每次都需要加载lua脚本。
  • 在lua脚本中,判断了线程标识与锁中的标识是否一致,如果一致就继续释放锁,实现了Redis分布式锁的原子性。
实现思路

在这里插入图片描述


*注:上述内容基于B站黑马程序员视频学习笔记,仅作为学习交流,不用作商业用途,如有侵权,联系删除。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值