Linfinity

Never say never.
  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

miaosha2:高并发抢购方案

Posted on 2021-08-06 19:51  Linfinity  阅读(291)  评论(0编辑  收藏  举报

写在前面

最近参考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配置