秒杀项目学习总结

秒杀项目学习总结

2022/5/15完成, 跟着视频简单的学习了一遍秒杀项目,视频内容循序渐进,先开发简单功能后逐渐优化,很适合对0基础的小白,学习内容来自乐字节,流程图如下。
在这里插入图片描述

  1. 项目搭建

后端使用springboot框架,持久化使用mybatis-plus(此部分还需学习),前端使用Thymeleaf模板引擎。
开发功能前先创建好RespBean状态对象,和以前自己做的菜鸡电商项目相比,使用RespBean对象+枚举类显然会使整个项目更易读。

//状态对象 主要记录状态代码,信息,以及要返回的对象
public class RespBean {

    private long code;
    private String message;
    private Object object;
    }
//状态枚举 code+message
public enum RespBeanEnum {
    //登录模块
    LOGIN_ERROR(500210, "用户名或者密码不正确"),
    MOBILE_ERROR(500211, "手机号码格式不正确"),
    BIND_ERROR(500212, "参数校验异常"),
    MOBILE_NOT_EXIST(500213, "手机号码不存在"),
    PASSWORD_UPDATE_FAIL(500214, "更新密码失败"),
    SESSION_ERROR(500215, "用户SESSION不存在"),
    ;
    private final Integer code;
    private final String message;
  1. 分布式session和分布式锁

为什么要使用分布式session?
以往Web应用单机部署的情况下,Session存储在同一服务器,所有的用户请求也是通过该服务器处理,所以很容易实现会话跟踪。但是在分布式场景下,应用会被部署到多台服务器上,用户的请求也会根据负载均衡转发到某台服务器上执行(如nginx),还按照以往的session存储可能存在这样的情况,用户第一次请求由服务器A处理,服务器A记录了用户的session,当再次发起请求时由服务器B处理,这时用户会成为未登录状态,显然不符合要求,所以必须保证一台服务器保存session后,其他服务器同时保存该用户的session。

分布式session的具体实现有很多种,本次只学习了两种解决方案,springsession和直接使用redis,其实两种方案都是基于redis实现的,第一种导入spring-session-data-redis依赖,会自动创建session在redis,第二种是自己操作redis,该方法其实并不是session,而是将用户信息存入redis中,以cookie为key,用户信息为value,这样就可以根据用户本地的cookie获得到用户的信息。

  1. 功能实现

登录:

登录主要做了两个小功能点,MD5加密和参数校验,MD5加密使用盐值进行二次加密避免数据库中存储明文密码,参数校验主要使用正则表达式,但是为了降低代码耦合,可通过validation组件创建注解实现参数校验(validation组件详细使用有待学习),该部分主要用来判断是否符合手机号格式,具体使用方法就是在字段上加@IsMobile注解,并且在controller中该对象前加@Valid注解.

@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(
        validatedBy = {
            IsMobileValidator.class
        }
)
public @interface IsMobile {

    boolean required() default true;

    String message() default "手机号码格式错误";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}
public class IsMobileValidator implements ConstraintValidator<IsMobile, String> {

    private boolean required = false;

    @Override
    public void initialize(IsMobile constraintAnnotation) {
//        ConstraintValidator.super.initialize(constraintAnnotation);
        required = constraintAnnotation.required();
    }

    @Override
    public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {

        if (required) {
            return ValidatorUtil.isMobile(s);
        } else {
            if (StringUtils.isEmpty(s)) {
                return true;
            } else {
                return ValidatorUtil.isMobile(s);
            }
        }
    }
}

登录后,每次调用方法都需要通过cookie获得redis中的用户信息,有大量重复代码,通过在webConfig中配置addArgumentResolvers()此方法可避免大量重复代码,具体代码如下:

//该方法实现HandlerMethodArgumentResolver,通过判断方法的参数类型,如果是TUser类型则执行resolveArgument
//写好该方法后将其注入到WebConfig中
@Component
public class UserArgumentResolver implements HandlerMethodArgumentResolver {
    @Autowired
    ITUserService userService;
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        Class<?> parameterType = parameter.getParameterType();
        return parameterType == TUser.class;

    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        HttpServletRequest nativeRequest = webRequest.getNativeRequest(HttpServletRequest.class);
        HttpServletResponse nativeResponse = webRequest.getNativeResponse(HttpServletResponse.class);
        String userTicket = CookieUtil.getCookieValue(nativeRequest, "userTicket");
        if (StringUtils.isEmpty(userTicket)) {
            return null;
        }
        return userService.getUserByCookie(userTicket,nativeRequest,nativeResponse);

    }
}
//WebConfig中重写此方法
	@Autowired
    UserArgumentResolver userArgumentResolver;
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(userArgumentResolver);
    }
全局异常处理:

如果异常不在后端捕获处理,那么出现的异常只会在后端报错,不会展示到前端,所以需要在后端手动处理异常,最基础的处理方式就是try-catch或者通过throw抛出异常,在SpringMVC中可以将各处的异常处理从各处处理中解耦出来,可以保证相关处理异常的功能单一、实现异常信息的统一处理和维护。SpringMVC全局异常处理主要有两种,第一种是@ControllerAdvice和 @ExceptionHandler注解的组合使用,第二种是使用ErrorController接口实现,两者的区别如下:
1.@ControllerAdvice处理控制器内的异常,ErrorController类可处理全部异常(包括未进入控制器的异常,比如404)
2.如果两者一起使用,会由@ControllerAdvice处理控制器内的异常,ErrorController类处理未进入控制器的异常
3.相比于ErrorController,@ControllerAdvice自由度更高,可定义多个拦截方法,在注解中传入不同的异常类可以对不同异常类采取不同的拦截方法。

该项目中使用@ControllerAdvice和@ExceptionHandler做异常处理,主要为了了解使用,自定义GlobalException ,在处理时判断异常类型,BindException是在使用validation组件时出现的异常,当使用自定义注解@IsMobile时,如果账号格式不符合要求则会出现该异常,为了在前端显示信息,所以对BindException做了额外的处理.

//自定义GlobalException 继承 RuntimeException 
public class GlobalException extends RuntimeException {

    private RespBeanEnum respBeanEnum;

    public RespBeanEnum getRespBeanEnum() {
        return respBeanEnum;
    }

    public void setRespBeanEnum(RespBeanEnum respBeanEnum) {
        this.respBeanEnum = respBeanEnum;
    }

    public GlobalException(RespBeanEnum respBeanEnum) {
        this.respBeanEnum = respBeanEnum;
    }
}

//通过 @RestControllerAdvice 以及  @ExceptionHandler
@RestControllerAdvice
public class GlobalExceptionHandler {

//Exception.class 可以自定义异常 处理不同的异常类
    @ExceptionHandler(Exception.class)
    public RespBean ExceptionHandler(Exception e) {
        if (e instanceof GlobalException) {
            GlobalException exception = (GlobalException) e;
            return RespBean.error(exception.getRespBeanEnum());
        } else if (e instanceof BindException) {
            BindException bindException = (BindException) e;
            RespBean respBean = RespBean.error(RespBeanEnum.BIND_ERROR);
            respBean.setMessage("参数校验异常:" + bindException.getBindingResult().getAllErrors().get(0).getDefaultMessage());
            return respBean;
        }
        System.out.println("异常信息" + e);
        return RespBean.error(RespBeanEnum.ERROR);
    }
}

商品列表:

商品列表实现较为简单,在Mapper中写一个两表联查的Sql即可,将返回的结果通过model.addAttribute()传入到前端,在前端展示即可,为了提高该功能效率,可做一些简单的优化,将渲染好的html以goodslist为key值,以html内容为value值存入redis中,每次获取商品列表时首先从redis中获取,提高效率。

商品详情:

从数据库中根据goodsId获取到商品的信息,但是这部分需要做一个秒杀状态的校验,通过当前时间与商品秒杀表中秒杀开始时间和结束时间比较,获得商品的秒杀状态,分为未开始、进行中、已结束三个状态,如果是未开始状态还需要向前端返回一个倒计时的时间,用于前端展示,和商品列表类似,该部分也可以通过将html存入redis中,提高效率。

商品列表和商品详情的上述提到的优化是将html存入redis中,但是每次从后台向前端传输时还是需要传输整个html页面,而整个页面有些内容是固定不变的,为了进一步优化可将页面静态化,页面静态化的优化过程也就是使用Ajax,实现局部刷新(AJAX 最大的优点是在不重新加载整个页面的情况下,可以与服务器交换数据并更新部分网页内容),这部分内容和前端相关,简单的学习实现了一遍,但是目前没深入研究Ajax具体的执行原理。还有一些其他的优化,主要还是通过将内容存入redis中,不进行更多阐述。

秒杀:

该功能是学习内容的核心部分,秒杀功能的简单描述就是一个减库存生成订单的过程,其中主要存在两个问题,一个是商品超卖,一个是重复下单。
##更正上面的思路,之前看别人的博客,以为主要原因是原子性问题,但是不然,但是根本原因还是因为线程的同步性问题,尽管整个操作是原子性,但还是可能存在多个请求同时判断库存的情况,导致的结果就是库存减了一个,实际下了多个订单。

商品超卖问题
存在超卖问题的原因是判断内存、减库存操作不是原子性的,在并发场景下可能存在多个请求同时判断库存>0,将库存减为负的情况。
(1)第一次实现该功能:直接查数据库减库存itSeckillGoodsService.update(new UpdateWrapper()
.setSql("stock_count = " + “stock_count-1”)
.eq(“goods_id”, goodsVo.getId())
.gt(“stock_count”, 0)
);
每次减库存前判断一次商品库存是否为0,但是我觉得这里并不能解决超卖问题,并不确定的原因是目前不清楚mybatis-plus的update内的全部操作是否是原子性的,如果是那可以解决,如果不是,在并发场景下还是可能存在超卖问题。
##更正上面错误想法,利用update解决超卖是可行的,因为mysql在update时会加写锁,也就是说如果一百个线程同时修改库存未1的商品,只有一个线程可以修改成功,但是使用mysql的排他锁会影响性能,所以有了第二次的优化。
(2)第二次实现该功能,通过redis预减库存:将Controller实现InitializingBean接口,重写afterPropertiesSet()方法,该接口作用是在初始化Bean前调用afterPropertiesSet()方法,通过这种方式可以将库存加载到Redis中,利用redis中decrement的原子性,解决超卖问题,一方面redis中decrement是原子性操作,另一方redis是单线程,所以多个线程执行时只会有一个线程成功,在redis中预减之后再对数据库进行更新,这样就不会存在超卖问题了。(其实这种方式也就相当于应用分布式锁)>> 使用redis防止超卖的主要原因:redis单线程,线程安全。

		//2.预减库存
        Long stock = valueOperations.decrement("seckillGoods" + goodsId);
		//库存小于0说明超卖,返回error信息
        if(stock < 0){
            EmptyStockMap.put(goodsId,true);
            valueOperations.increment("seckillGoods" + goodsId);
            return RespBean.error(RespBeanEnum.EMPTY_STOCK);
        }
        //商品可下单,通过消息队列异步处理
        SeckillMessage seckillMessage = new SeckillMessage(user, goodsId);
        mqSender.sendSeckillMessage(JsonUtil.object2JsonStr(seckillMessage));
		return RespBean.success(0);

//初始化 将商品数量库存加载到redis
    @Override
    public void afterPropertiesSet() throws Exception {
        List<GoodsVo> goodsVos = goodsService.findGoodsVo();
        if(goodsVos.isEmpty())
            return ;
        //初始化 将商品数量加载到redis
        goodsVos.forEach(goodsVo ->{
                    redisTemplate.opsForValue().set("seckillGoods"+goodsVo.getId(),goodsVo.getStockCount());
                    EmptyStockMap.put(goodsVo.getId(),false);
                }


        );
    }

(3)第三次,使用lua脚本,主要原因是lua脚本在redis是原子性执行的。(有空详细学习lua)
第二种方法对于库存为0的商品每次都需要访问redis,可通过内存标记优化,降低redis访问次数,建立一个set,将库存为0的商品加入到set中,每次执行秒都先判断set中是否包含此商品

重复下单
分为两种情况,一种是已生成订单,另一种是快速点击两次,这时未生成订单,
第一种情况解决方法:
1.查询一次数据库,查看订单是否存在
2.为订单添加唯一索引,如果已经存在订单会回滚,为订单添加唯一索引可以解决上述的两种情况,但是这样会有大量请求落在数据库,还需进一步优化(这个方法其实和第一个没太大区别)。
3.将预减检功能移到redis中,提高并发能力,具体操作:订单信息存到redis中,过期时间可以设定为秒杀活动持续时长,通过redis中是否存在订单判断是否重复下单。
总的来说就是秒杀成功后就将用户id及商品id预存到redis中,设置过期时间为秒杀结束,再挑选合适时间异步将订单真正写入数据库。

//1.使用redis判断是否重复抢购
        TSeckillOrder tSeckillOrder = (TSeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
        if (tSeckillOrder != null) {
            return RespBean.error(RespBeanEnum.REPEATE_ERROR);
        }

//service层下订单要包含如下操作
	redisTemplate.opsForValue().set("order"+user.getId()+":"+goodsVo.getId(),tSeckillOrder);

第二种情况的解决办法:
1.通过对订单建立索引,当重复的订单写到数据库时会回滚,但是会有大量请求落在数据库。
2.通过redis的setnx实现分布式锁,以用户id为key,设置value为1,当第二个请求进入后,key存在且value等于1说明上一个请求并没有下单,该请求可下单,将value设置为2,如果key存在且value大于1说明当前请求是重复下单,抛出异常。

    /**
     * 代码来自csdn
     * 使用redis拦截同一用户快速二次请求
     * @param userId 请求用户
     * @return 是否放行
     */
    private boolean healthRequest(Integer userId){
        String key = "sec:req:"+userId;
        long count = redisTemplate.opsForValue().increment(key, 1);
        if (count == 1) {
        //设置有效期2秒
            redisTemplate.expire(key, 2, TimeUnit.SECONDS);
        }
        if (count > 1) {
           //重复提交订单
            //抛出异常
            Assert.isTrue(false,ResponseEnum.REPEAT_req);
        }
        return true;
    }

订单详情:

订单详情在最开始是直接从秒杀Controller跳转页面,传入model实现内容的渲染,但是这样是一个同步的流程,这部分通过使用RabbitMQ优化,该学习视频使用RabbitMQ中的Topic模式,实现异步处理,在前端则在goodsDeatils页面使用Ajax通过轮询的方式判断结果,主要是查询数据库中是否存在订单,如果存在则返回订单id,不存在则返回0代表正在排队,这里通过redis优化,当库存为空时在redis中存入(“isStockEmpty:” + goodsId)key值,降低数据库的访问量。

  1. 压测

使用JMeter进行压力测试,通过工具类在数据库中生成5000个用户,并将用户cookie记录在本地,配置JMeter线程组等信息,进行压测,在第一次压测出现超卖问题,并且没经过redis优化的QPS对比优化后的QPS大概是两倍的差距,对于JMeter只进行了简单的使用,不投入过多时间学习。

  1. RabbitMQ

RabbitMQ是采用erlang实现的 AMQP(Advanced Message Queuing Protocol),本次学习对于原理了解不深,更多的是学习了如何使用RabbitMQ以及RabbitMQ的几种交换机模式,主要学习三种:Fanout Exchange(广播式交换机),Direct Exchange(直连交换机),Topic Exchange(通配符交换机)
Fanout Exchange(广播式交换机)下将队列绑定到交换机上,一旦有消息加入,所有绑定的队列都可以接收到消息。
Direct Exchange(直连交换机)相比于 Fanout Exchange 增加了一个RoutingKey,消息根据RoutingKey判断进入哪个队列,但是这里的RoutingKey必须完全相同才可。
Topic Exchange(通配符交换机)相比于 Direct Exchange 提供了对RoutingKey的模糊匹配,#” 表示0个或多个关键字,“*”表示匹配一个关键字。

这部分只对消息队列进行了简单的学习,后续会再记录一篇学习消息队列的内容。

  1. 简单总结

本次学习主要是熟悉springboot中如何集成redis和消息队列,对于Redis主要学习如何使用redisTemplate,对于消息队列本次使用的是RabbitMQ消息队列。另外就是对并发场景以及分布式场景可能存在的问题有了更深入的理解。

源码链接:https://gitee.com/yang1998010/seckill-demo.git

  • 6
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值