• miaosha2:高并发抢购方案


    写在前面

    最近参考github上的著名java秒杀项目,自己写了一个高并发秒杀商品项目,项目涉及springboot、redis、rabbitmq等,实现了异步下单还有安全防范等一些功能,并对优化前后做了性能对比。

    参考项目链接:https://github.com/qiurunze123/miaosha

    参考慕课课程链接:https://coding.imooc.com/class/168.html  ( ps: miaosha项目的基础思路也是来自该课程)

    我实现的miaosha链接:https://gitee.com/linfinity29/miaosha.git

    一、优化前项目

    1.1登录

    本项目登录使用的是jwt令牌登录,密码加密采用两次md5加密。

    1、controller

        @Resource
        MiaoshaUserService miaoshaUserService;
    
    
        @ApiOperation("登录")
        @PostMapping("login")
        public R login(@Valid LoginVO loginVO){
            MiaoshaUser miaoshaUser = miaoshaUserService.login(loginVO);
            return R.ok().data("userInfo", miaoshaUser);
        }

    2、service

       @Transactional( rollbackFor = {Exception.class})
        @Override
        public MiaoshaUser login(LoginVO loginVO) {
            String nickname = loginVO.getNickname();
            String password = loginVO.getPassword();
    
            //获取会员
            QueryWrapper<MiaoshaUser> queryWrapper = new QueryWrapper<>();
            queryWrapper.eq("nickname", nickname);
            MiaoshaUser user= baseMapper.selectOne(queryWrapper);
    
            //用户不存在
            //LOGIN_MOBILE_ERROR(-208, "用户不存在"),
            Assert.notNull(user, ResponseEnum.LOGIN_MOBILE_ERROR);
    
            //校验密码
            //LOGIN_PASSWORD_ERROR(-209, "密码不正确"),
            Assert.equals(MD5Util.inputPassToDbPass(loginVO.getPassword(), user.getSalt()), user.getPassword(), ResponseEnum.LOGIN_PASSWORD_ERROR);
    
            //记录登录日志
            LocalDateTime now = LocalDateTime.now();
            MiaoshaUser miaoshaUser = new MiaoshaUser();
            miaoshaUser.setLastLoginDate(now);
            miaoshaUser.setLoginCount(user.getLoginCount()+1);
            baseMapper.updateById(miaoshaUser);
    
            //生成token
            String token = JwtUtils.createToken(user.getId(), user.getNickname());
            user.setToken(token);
    
            return user;
        }

    3、MD5Util

    /**
     * 两次md5加密
     * inputPass = md5(明文密码+固定salt)
     * dbPass = md5(inputPass + 随机salt)
     */
    public class MD5Util {

    1.2秒杀接口

    1、controller

        @Resource
        MiaoShaService miaoShaService;
        
    
        /**
         * QPS:119    5000*10   优化前
         * @return
         */
        @ApiOperation("商品秒杀")
        @GetMapping("/{id}")
        public R miaosha(@ApiParam("商品id") @PathVariable("id") Long id,  HttpServletRequest request){
            String token = request.getHeader("token");
            Long userId = JwtUtils.getUserId(token);
            OrderInfo orderInfo = miaoShaService.miaosha(id, userId);
            return R.ok().data("orderInfo", orderInfo);
        }

    2、miaoShaService

        @Resource
        GoodsService goodsService;
        @Resource
        MiaoshaGoodsService miaoshaGoodsService;
        @Resource
        MiaoshaOrderService miaoshaOrderService;
        @Resource
        MiaoShaService miaoShaService;
        @Resource
        OrderInfoService orderInfoService;
        
    
        @Override
        public OrderInfo miaosha(Long id, Long userId) {
    
            //判断库存
            GoodsVO goodsVO = goodsService.getGoodsById(id);
            int stock = goodsVO.getStockCount();
            Assert.isTrue(stock > 0, ResponseEnum.GOODS_STOCK_EMPTY);
    
            //判断是否已经秒杀到了
            MiaoshaOrder order = miaoshaOrderService.getMiaoshaOrderByUserIdGoodsId(userId, id);
            Assert.isTrue(order == null, ResponseEnum.MIAOSHA_ORDER_EXIST);
    
            //减库存 下订单 写入秒杀订单
            miaoshaGoodsService.reduceStock(id);
            OrderInfo orderInfo = orderInfoService.createOrder(goodsVO, userId);
    
    
            return orderInfo;
    
        }

    3、orderInfoService

    
    
    @Resource
    MiaoshaOrderService miaoshaOrderService;

    @Override
    public OrderInfo createOrder(GoodsVO goodsVO, Long userId) {
    //插入订单信息
    OrderInfo orderInfo = new OrderInfo();
    orderInfo.setCreateDate(LocalDateTime.now());
    orderInfo.setDeliveryAddrId(0L);
    orderInfo.setGoodsCount(1);
    orderInfo.setGoodsId(goodsVO.getId());
    orderInfo.setGoodsName(goodsVO.getGoodsName());
    orderInfo.setGoodsPrice(goodsVO.getMiaoshaPrice());
    orderInfo.setOrderChannel(1);
    orderInfo.setStatus(0);
    orderInfo.setUserId(userId);
    baseMapper.insert(orderInfo);

    //插入用户订单一对一记录
    MiaoshaOrder miaoshaOrder = new MiaoshaOrder();
    miaoshaOrder.setGoodsId(goodsVO.getId());
    miaoshaOrder.setOrderId(orderInfo.getId());
    miaoshaOrder.setUserId(userId);
    miaoshaOrderService.save(miaoshaOrder);

    return orderInfo;
    }
     

    1.3压测

    实验环境:

    在本地计算机使用压测工具jmeter对接口进行并发压测

    java项目运行在本地计算机(8核16线程16G内存)

    其他软件,mysql、redis、rabbimq等均部署在一台腾讯云服务器(1核2G)上以确保模拟实际生产环境性能。

    1、创建线程组(5000个线程共并发50000个请求) 

     2、参加http请求默认值

     3、使用UserUtil创建5000个用户,并登录获取token

    /**
     * 该类用于生成user,并登录拿到token,把token存储下来,用于jmeter压测
     */
    public class UserUtil {
    
        private static void createUser(int count) throws Exception{

     4、在请求头添加动态token

     

     

    5、添加聚合报告查看结果

    分析:

    1)可以看到QPS只有119,即在50000个并发请求下每秒只能处理119个请求

    2)查看数据库可以看到库存变成了负数,说明存在超卖现象

     3)设置秒杀商品库存为10个,结果卖出了420个,数据完全对不上

    二、优化一

    2.1防止出现库存负数

    更新库存时加条件 stock_count>0

    MiaoshaGoodsService
        @Override
        public void reduceStock(Long id) {
    //        MiaoshaGoods miaoshaGoods = baseMapper.selectById(id);
    //        miaoshaGoods.setStockCount(miaoshaGoods.getStockCount() - 1);
    //        baseMapper.updateById(miaoshaGoods);
    
            baseMapper.reduceStock(id);
        }
    public interface MiaoshaGoodsMapper extends BaseMapper<MiaoshaGoods> {
    
        @Update("UPDATE miaosha_goods SET stock_count=stock_count-1 WHERE goods_id=#{id} AND stock_count>0")
        void reduceStock(Long id);
    }

    miaoShaService

    ......
          
            //减库存 下订单 写入秒杀订单
            OrderInfo orderInfo = orderInfoService.createOrder(goodsVO, userId);
    
    ......    

    orderInfoService

       @Transactional(rollbackFor = Exception.class)
        @Override
        public OrderInfo createOrder(GoodsVO goodsVO, Long userId) {
    
            boolean b = miaoshaGoodsService.reduceStock(goodsVO.getId());
            Assert.isTrue(b, ResponseEnum.GOODS_STOCK_EMPTY);
    
            //插入订单信息
            OrderInfo orderInfo = new OrderInfo();
            ........

    2.2、防止一个人重复下单

    数据库加唯一索引,service添加事务

    miaosha_order表

     

    2.3、优化效率

    判断是否下过单时从redis判断

     

            //判断是否已经秒杀到了
            MiaoshaOrder miaoshaOrder = (MiaoshaOrder) redisTemplate.opsForValue().get("miaoshaOrder:" + userId + "_" + id);
            if (miaoshaOrder == null){
                miaoshaOrder = miaoshaOrderService.getMiaoshaOrderByUserIdGoodsId(userId, id);
                if(miaoshaOrder != null){
                    redisTemplate.opsForValue().set("miaoshaOrder:" + userId + "_" + id, miaoshaOrder);
                }
            }
            Assert.isTrue(miaoshaOrder == null, ResponseEnum.MIAOSHA_ORDER_EXIST);

     

     

     

    2.4、压测

    聚合报告结果

     

    分析:

    1)可以看到QPS有109

    2)查看数据库可以看到库存变成负数没有了

     3)重复下单问题没有

     4)下单信息只有10条,超卖问题解决

     

    三、优化二

    3.1Redis预减库存减少数据库访问

    1、实现初始化bean接口

    @Service
    public class MiaoShaServiceImpl implements MiaoShaService, InitializingBean {

    2、实现初始化方法(此方法在bean的生命周期bean的自动填充后执行)

        /**
         * 预热加载数据库库存到redis
         * @throws Exception
         */
        @Override
        public void afterPropertiesSet() throws Exception {
            List<MiaoshaGoods> miaoshaGoodsList = miaoshaGoodsService.list();
            if(miaoshaGoodsList == null) {
                return;
            }
            for(MiaoshaGoods miaoshaGoods : miaoshaGoodsList) {
                redisTemplate.opsForValue().set("miaoshaGoodsStock:"+miaoshaGoods.getId(), miaoshaGoods.getStockCount());
                localOverMap.put(miaoshaGoods.getId(), false);
            }
    
        }

    3、预减库存

     
         //预减库存 long stock = redisTemplate.opsForValue().decrement("miaoshaGoodsStock:"+id);//10 if(stock < 0) { localOverMap.put(id, true); throw new BusinessException(ResponseEnum.GOODS_STOCK_EMPTY); }

    3.2使用内存标记减少Redis访问

    MiaoShaServiceImpl
        //本地内存标记秒杀商品是否售空
        private HashMap<Long, Boolean> localOverMap =  new HashMap<Long, Boolean>();
           //内存标记,减少redis访问
            boolean over = localOverMap.get(id);
            Assert.isTrue(!over, ResponseEnum.GOODS_STOCK_EMPTY);

    3.3使用RabbitMQ将请求入队缓存,异步下单,增强用户体验

    1、MiaoShaServiceImpl
            //秒杀请求入队
            MiaoShaMessage mm = new MiaoShaMessage();
            mm.setUserId(userId);
            mm.setGoodsId(id);
            sender.send(mm);
            return null;//排队中

    2、异步完成下单

    @Service
    @Slf4j
    public class MQReceiver {
    
    
    
        @Resource
        MiaoShaService miaoshaService;
    
        @Resource
        GoodsService goodsService;
    
        @Resource
        RedisTemplate redisTemplate;
    
        @Resource
        OrderInfoService orderInfoService;
    
        @RabbitListener(queues= RabbitMQConfig.QUEUE_NAME)
        public void receive(MiaoShaMessage mm) {
            log.info("======================================================");
            log.info("receive message:"+mm);
            long userId = mm.getUserId();
            long goodsId = mm.getGoodsId();
    
            GoodsVO goodsVO = goodsService.getGoodsById(goodsId);
            int stock = goodsVO.getStockCount();
            if(stock <= 0) {
                return;
            }
            //判断是否已经秒杀到了
            MiaoshaOrder miaoshaOrder = (MiaoshaOrder) redisTemplate.opsForValue().get("miaoshaOrder:" + userId + "_" + goodsId);
            if(miaoshaOrder != null) {
                return;
            }
            //减库存 下订单 写入秒杀订单
            orderInfoService.createOrder(goodsVO, userId);
        }
    
    }

    3、MiaoShaController-》miaosha

        @ApiOperation("商品秒杀")
        @GetMapping("/{id}")
        public R miaosha(@ApiParam("商品id") @PathVariable("id") Long id,  HttpServletRequest request){
            String token = request.getHeader("token");
            Long userId = JwtUtils.getUserId(token);
            OrderInfo orderInfo = miaoShaService.miaosha(id, userId);
            if(orderInfo == null){//消息进队排队中。。。
                return R.ok().message("正在抢购中");
            }
    
            return R.ok().data("orderInfo", orderInfo);
        }

    4、MiaoShaController-》result

    @ApiOperation("获取商品秒杀结果")
        @GetMapping("/result/{id}")
        public R getMiaoshaResult(@ApiParam("商品id") @PathVariable("id") long goodsId,  HttpServletRequest request) {
            String token = request.getHeader("token");
            Long userId = JwtUtils.getUserId(token);
            MiaoshaOrder order = miaoshaOrderService.getMiaoshaOrderByUserIdGoodsId(userId, goodsId);
            if(order != null) {//秒杀成功
                return R.ok().data("orderId",order.getOrderId());
            }else {
                boolean over = miaoShaService.getGoodsOver(goodsId);
                if(over) {
                    return R.error().message("抢购失败");
                }else {
                    return R.ok().message("正在抢购中");
                }
            }
        }

    3.4压测

    1、聚合报告结果

    分析:

    1)可以看到QPS达到了819,相比之前增加了7倍多

    2)异常率也变为了0

    2)超卖现象依然没有

     

    小结:至此,我们的接口优化大体完成,效率提升还是挺大的,而且我们的msql、redis和rabbitmq都安装在一台1核2g的服务器上,因此也会对我们的压测结果准确性造成一定影响,实际生产环境下msql、redis和rabbitmq部署在多台服务器上,性能提升会更加明显。

    四、安全优化

    4.1隐藏秒杀接口地址

    1、新增获取path接口

        @ApiOperation("获取秒杀隐藏路径")
        @GetMapping(value="/path")
        public R getMiaoshaPath(
                HttpServletRequest request,
                @ApiParam("商品Id") @RequestParam("goodsId")long goodsId) {
            String token = request.getHeader("token");
            Long userId = JwtUtils.getUserId(token);
    
            String path  = miaoShaService.createMiaoshaPath(userId, goodsId);
            Assert.isTrue(path!=null, ResponseEnum.MIAOSHAPATH_GEN_FAIL);
            return R.ok().data("path", path);
        }

    2、修改miaosha接口

     @ApiOperation("商品秒杀")
        @GetMapping("/{path}/{id}")
        public R miaosha(
                @ApiParam("秒杀路径") @PathVariable("path") String path,
                @ApiParam("商品id") @PathVariable("id") Long id,
                HttpServletRequest request){
            String token = request.getHeader("token");
            Long userId = JwtUtils.getUserId(token);
    
            //验证path
            boolean check = miaoShaService.checkPath(userId, id, path);
            Assert.isTrue(check, ResponseEnum.MIAOSHAPATH_CHECK_FAIL);
    
         。。。。。。

    3、miaoshaService

        /**
         * 生成秒杀隐藏路径
         * @param userId
         * @param goodsId
         * @return
         */
        @Override
        public String createMiaoshaPath(Long userId, long goodsId) {
            if(goodsId <= 0) {
                return null;
            }
            String str = MD5Util.md5(UUID.randomUUID().toString()+"123456");
            redisTemplate.opsForValue().set("miaoShaPath:"+userId + "_"+ goodsId, str);
            return str;
        }
    
        /**
         * 验证秒杀隐藏路径
         * @param userId
         * @param goodsId
         * @return
         */
        public boolean checkPath(Long userId, long goodsId, String path) {
            if(path == null) {
                return false;
            }
            String pathOld = (String) redisTemplate.opsForValue().get("miaoShaPath:"+userId + "_"+ goodsId);
            return path.equals(pathOld);
        }

    4、测试

    4.2数学公式验证码

    效果:用户输入验证码并点击立即秒杀,先根据验证码获取到秒杀隐藏路径,再发送秒杀请求。相当于点击按钮后发送两次请求。

    本项目为了偷懒只用随机字符串验证码,理论上越复杂的验证码校验规则越安全。

    1、新增生成验证码接口

      @ApiOperation("获取验证码")
        @GetMapping("/code/{id}")
        public void getMiaoShaCode(
                @ApiParam("商品id") @PathVariable("id") long goodsId,
                HttpServletRequest request,
                HttpServletResponse response) {
    
            String token = request.getHeader("token");
            Long userId = JwtUtils.getUserId(token);
            //定义图形验证码的长、宽、验证码字符数、干扰线宽度
            ShearCaptcha captcha = CaptchaUtil.createShearCaptcha(200, 100, 4, 4);
            //把code存到redis
            String code = captcha.getCode();
            redisTemplate.opsForValue().set("miaoShaCode:"+userId+"_"+goodsId, code);
            //图形验证码写出,可以写出到文件,也可以写出到流
            try {
                captcha.write(response.getOutputStream());
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

    2、修改获取path接口

      @ApiOperation("获取秒杀隐藏路径")
        @GetMapping(value = "/path/{goodsId}/{code}")
        public R getMiaoshaPath(
                HttpServletRequest request,
                @ApiParam("商品Id") @PathVariable("goodsId") long goodsId,
                @ApiParam("验证码") @PathVariable("code") String code) {
            String token = request.getHeader("token");
            Long userId = JwtUtils.getUserId(token);
    
            //校验验证码
            String codeOld = (String) redisTemplate.opsForValue().get("miaoShaCode:" + userId + "_" + goodsId);
            Assert.isTrue((code != null) && code.equals(codeOld), ResponseEnum.MIAOSHAPCODE_CHECK_FAIL);
    
         。。。。。。

    3、测试

    4.3、接口防刷限流

    思路:使用redis缓存对需要登录的uri路径进行限流访问,设置一段时间内最大访问次数。如10秒内最多访问8次。

    实现:

    1、创建一个注解,在需要限流的接口上添加注解即可,方便使用。

    @Retention(RUNTIME)
    @Target(METHOD)
    public @interface AccessLimit {
        int seconds();  //几秒内
        int maxCount();  //最大访问次数
        boolean needLogin() default true;
    }

    2、创建拦截器,使注解具有实际意义

    @Service
    public class AccessInterceptor  extends HandlerInterceptorAdapter{
    
        @Resource
        MiaoshaUserService userService;
    
        @Autowired
        RedisTemplate redisTemplate;
    
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
                throws Exception {
            if(handler instanceof HandlerMethod) {//handler可以获取很多有用信息,如拦截方法上的注解
                HandlerMethod hm = (HandlerMethod)handler;
                AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);
                if(accessLimit == null) {//如果访问的是没有添加这个注解的请求直接放行
                    return true;
                }
                int seconds = accessLimit.seconds();
                int maxCount = accessLimit.maxCount();
                boolean needLogin = accessLimit.needLogin();
                
                String key = "miaoshaLimit:"+request.getRequestURI(); //要限流的路径 如 /api/core/miaosha
                if(needLogin) {
                    Long userId = getUserId(request);
                    if(userId == null) {
                        render(response, R.setResult(ResponseEnum.LOGIN_AUTH_ERROR));
                        return false;
                    }
             //将userId放进ThreadLocal当中 UserContext.setUserId(userId); key
    += "_" + userId; //需要登录的接口,对userId限制访问次数 }else { key += "_" + request.getRemoteAddr(); //不需要登录的接口,对ip限制访问次数 } //统计有限时间内访问次数 Integer count = (Integer) redisTemplate.opsForValue().get(key); if(count == null) { redisTemplate.opsForValue().set(key, 1, seconds, TimeUnit.SECONDS); }else if(count < maxCount) { redisTemplate.opsForValue().increment(key); }else { render(response, R.setResult(ResponseEnum.ACCESS_LIMIT_REACHED)); return false; } } return true; } //响应结果 private void render(HttpServletResponse response, R r)throws Exception { response.setContentType("application/json;charset=UTF-8"); OutputStream out = response.getOutputStream(); String str = JSON.toJSONString(r); out.write(str.getBytes("UTF-8")); out.flush(); out.close(); } private Long getUserId(HttpServletRequest request) { String token = request.getHeader("token"); //使用不抛出异常的实现接口,以便在本拦截器响应请求 Long userId = JwtUtils.getUserIdNotException(token); return userId; } }

    3、注册拦截器

    WebConfig

    @Configuration
    public class WebConfig  extends WebMvcConfigurerAdapter{
    
        @Resource
        AccessInterceptor accessInterceptor;
    
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            registry.addInterceptor(accessInterceptor);
        }
    
    }

    4、修改controller代码

    在需要登录的接口不再需要重复以下代码

      String token = request.getHeader("token");
      Long userId = JwtUtils.getUserId(token);

    现在只需要:

    1)添加注解

    @AccessLimit(seconds = 10, maxCount = 8)

    2)从ThreadLocal中获取userId

    UserContext.getUserId()

    5、测试

    五、其他问题

    在做这个项目过程中遇到一些与项目无关的小bug,做个笔记。

    5.1找不到依赖包中的类

    bug说明:明明依赖导入正确,但是启动时却报错找不到引用的包的类。

    解决:在项目根目录执行

    mvn idea:idea

    5.2、打包时报错找不到依赖

    bug说明:打包时报错找不到自己写的common包。

    解决:

    1)修改被被依赖common包的pom文件

        <build>
            <plugins>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                    <configuration>
                        <classifier>exec</classifier>
                    </configuration>
                </plugin>
            </plugins>
        </build>

    2)mvn install 

    5.3、突然莫名其妙找不到符号log

    bug说明:使用lombok包时,明明正确引入@slfj4但是启动时却报错。

    解决:修改idea配置

  • 相关阅读:
    ggplot2绘图入门系列之二:图层控制与直方图
    机器学习与数据挖掘中的十大经典算法
    mysql使用存储过程执行定时任务
    使用hbase-shaded-client解决google包冲突问题
    vue 表单校验及气泡清除
    druid配置
    如何修改maven jar包源码
    jar包冲突最新解决方式
    Hive安装
    Hbase
  • 原文地址:https://www.cnblogs.com/linfinity/p/15104411.html
Copyright © 2020-2023  润新知