Bootstrap

乐优商城(四十六)秒杀功能优化(库存更新原子操作)

目录

一、优化思路

二、具体实现

2.1 秒杀接口改造

2.1.1 系统初始化

2.1.2 库存更新

2.1.3 发消息

2.1.4 RabbitMQ配置

2.1.5 优化redis

2.2 订单服务监听

2.2.1 监听器配置

2.2.2 消息解析

2.2.3 库存判断

2.2.4 用户是否秒杀成功

2.2.5 下订单

三、Jmeter压测

3.1 数据准备

3.2 测试

3.3 错误分析

3.3.1 拒绝连接

3.3.2 地址正在使用

四、是否秒杀成功

4.1 Controller

4.2 Service


一、优化思路

主要就是减少数据库的访问:

1. 将商品的库存信息放入Redis中

2. 收到秒杀请求,Redis预减库存,库存不足直接返回,否则进行下一步操作

3. 请求入队,立即返回排队中

4. 请求出队,生成订单,减少库存

5. 客户轮询,是否秒杀成功

二、具体实现

2.1 秒杀接口改造

SeckillController中的seckillOrder方法

改造过程:

1. 系统初始化的时候将要秒杀的商品库存放入redis中

2. 秒杀开始时,先读取redis中的库存信息,判断是否还有剩余

3. 如果已经没有库存,那么直接返回,不再继续往下执行

4. 如果库存充足,那么先对redis中的库存进行更新,然后发消息到rabbitmq队列当中。

2.1.1 系统初始化

在这里,让SeckillController实现InitializingBean接口,然后在afterPropertiesSet方法中加载秒杀商品库存信息。

/**
     * 系统初始化,初始化秒杀商品数量
     * @throws Exception
     */
    @Override
    public void afterPropertiesSet() throws Exception {
        //1.查询可以秒杀的商品
        List<SeckillGoods> seckillGoods = this.seckillService.querySeckillGoods();
        if (seckillGoods == null || seckillGoods.size() == 0){
            return;
        }
        BoundHashOperations<String,Object,Object> hashOperations = this.stringRedisTemplate.boundHashOps(KEY_PREFIX);
        if (hashOperations.hasKey(KEY_PREFIX)){
            hashOperations.delete(KEY_PREFIX);
        }
        seckillGoods.forEach(goods -> {
            hashOperations.put(goods.getSkuId().toString(),goods.getStock().toString());
        });
    }

2.1.2 库存更新

        String result = "排队中";
        //1.读取库存,减一后更新缓存
        BoundHashOperations<String,Object,Object> hashOperations = this.stringRedisTemplate.boundHashOps(KEY_PREFIX);
        Long stock = hashOperations.increment(seckillGoods.getSkuId().toString(), -1);
        //2.库存不足直接返回
        if (stock < 0){
            localOverMap.put(seckillGoods.getSkuId(),true);
            return ResponseEntity.ok(result);
        }

2.1.3 发消息

        //3.库存充足,请求入队
        //3.1 获取用户信息
        UserInfo userInfo = LoginInterceptor.getLoginUser();
        SeckillMessage seckillMessage = new SeckillMessage(userInfo,seckillGoods);
        //3.2 发送消息
        this.seckillService.sendMessage(seckillMessage);

这里面要发送的消息内容包含两部分,第一个部分就是用户信息(通过登录拦截器从cookie中解析token获得),第二部分就是秒杀商品的信息,所以这里面将其封装为消息对象SeckillMessage。

SeckillMessage

package com.leyou.seckill.vo;

import com.leyou.auth.entity.UserInfo;
import com.leyou.user.pojo.User;

/**
 * @Author: 98050
 * @Time: 2018-11-15 20:19
 * @Feature: 秒杀信息
 */
public class SeckillMessage {
    /**
     * 用户信息
     */
    private UserInfo userInfo;

    /**
     * 秒杀商品
     */
    private SeckillGoods seckillGoods;

    public SeckillMessage() {
    }

    public SeckillMessage(UserInfo userInfo, SeckillGoods seckillGoods) {
        this.userInfo = userInfo;
        this.seckillGoods = seckillGoods;
    }

    public UserInfo getUserInfo() {
        return userInfo;
    }

    public void setUserInfo(UserInfo userInfo) {
        this.userInfo = userInfo;
    }

    public SeckillGoods getSeckillGoods() {
        return seckillGoods;
    }

    public void setSeckillGoods(SeckillGoods seckillGoods) {
        this.seckillGoods = seckillGoods;
    }
}

sendMessage

    /**
     * 发送消息到秒杀队列当中
     * @param seckillMessage
     */
    @Override
    public void sendMessage(SeckillMessage seckillMessage) {
        String json = JsonUtils.serialize(seckillMessage);
        System.out.println(json);
        try {
            this.amqpTemplate.convertAndSend("order.seckill", json);
        }catch (Exception e){
            LOGGER.error("秒杀商品消息发送异常,商品id:{}",seckillMessage.getSeckillGoods().getSkuId(),e);
        }
    }

2.1.4 RabbitMQ配置

2.1.5 优化redis

为了减少对redis的无用访问,设置一个标记位,当库存不足时直接返回即可,不用再访问redis。

最终代码

    @PostMapping("seck")
    public ResponseEntity<String> seckillOrder(@RequestBody SeckillGoods seckillGoods){

        String result = "排队中";

        //内存标记,减少redis访问
        boolean over = localOverMap.get(seckillGoods.getSkuId());
        if (over){
            return ResponseEntity.ok(result);
        }

        //1.读取库存,减一后更新缓存
        BoundHashOperations<String,Object,Object> hashOperations = this.stringRedisTemplate.boundHashOps(KEY_PREFIX);
        String s = (String) hashOperations.get(seckillGoods.getSkuId().toString());
        if (s == null){
            return ResponseEntity.ok(result);
        }
        int stock = Integer.valueOf(s) - 1;
        //2.库存不足直接返回
        if (stock < 0){
            localOverMap.put(seckillGoods.getSkuId(),true);
            return ResponseEntity.ok(result);
        }
        //3.更新库存
        hashOperations.delete(seckillGoods.getSkuId().toString());
        hashOperations.put(seckillGoods.getSkuId().toString(),String.valueOf(stock));

        //4.库存充足,请求入队
        //4.1 获取用户信息
        UserInfo userInfo = LoginInterceptor.getLoginUser();
        SeckillMessage seckillMessage = new SeckillMessage(userInfo,seckillGoods);
        //4.2 发送消息
        this.seckillService.sendMessage(seckillMessage);


        return ResponseEntity.ok(result);
    }

2.2 订单服务监听

在订单微服务中设置消息队列监听器,读取消息队列的信息,然后创建订单。

2.2.1 监听器配置

在application.yml中配置rabbitmq

创建listener用来接收消息。

  • 创建队列:leyou.order.seckill.queue
  • 绑定交换机:leyou.order.exchange
  • 交换机类型:Topic
  • 接收消息类型:order.seckill

2.2.2 消息解析

        SeckillMessage seckillMessage = JsonUtils.parse(seck,SeckillMessage.class);
        UserInfo userInfo = seckillMessage.getUserInfo();
        SeckillGoods seckillGoods = seckillMessage.getSeckillGoods();

2.2.3 库存判断

        //1.首先判断库存是否充足
        Stock stock = stockMapper.selectByPrimaryKey(seckillGoods.getSkuId());
        if (stock.getSeckillStock() <= 0 || stock.getStock() <= 0){
            return;
        }

2.2.4 用户是否秒杀成功

默认规定一个用户只能秒杀一个商品,所以需要查询秒杀订单表tb_seckill_order。同时在创建tb_seckill_order表的时候要创建唯一索引:

双重保险。

//2.判断此用户是否已经秒杀到了
        Example example = new Example(SeckillOrder.class);
        example.createCriteria().andEqualTo("userId",userInfo.getId()).andEqualTo("skuId",seckillGoods.getSkuId());
        List<SeckillOrder> list = this.seckillOrderMapper.selectByExample(example);
        if (list.size() > 0){
            return;
        }

2.2.5 下订单

和普通订单的创建没有什么区别,但是此时缺少对地址的管理,所以这里面就采用默认地址,这个可以再后期进行业务逻辑的优化(根据用户id查询用户的默认地址,又增加了一次数据库的访问)

 //3.下订单
        //构造order对象
        Order order = new Order();
        order.setPaymentType(1);
        order.setTotalPay(seckillGoods.getSeckillPrice());
        order.setActualPay(seckillGoods.getSeckillPrice());
        order.setPostFee(0+"");
        order.setReceiver("李四");
        order.setReceiverMobile("15812312312");
        order.setReceiverCity("西安");
        order.setReceiverDistrict("碑林区");
        order.setReceiverState("陕西");
        order.setReceiverZip("000000000");
        order.setInvoiceType(0);
        order.setSourceType(2);

        OrderDetail orderDetail = new OrderDetail();
        orderDetail.setSkuId(seckillGoods.getSkuId());
        orderDetail.setNum(1);
        orderDetail.setTitle(seckillGoods.getTitle());
        orderDetail.setImage(seckillGoods.getImage());
        orderDetail.setPrice(seckillGoods.getSeckillPrice());
        orderDetail.setOwnSpec(this.skuMapper.selectByPrimaryKey(seckillGoods.getSkuId()).getOwnSpec());

        order.setOrderDetails(Arrays.asList(orderDetail));

        //3.1 生成orderId
        long orderId = idWorker.nextId();
        //3.2 初始化数据
        order.setBuyerNick(userInfo.getUsername());
        order.setBuyerRate(false);
        order.setCreateTime(new Date());
        order.setOrderId(orderId);
        order.setUserId(userInfo.getId());
        //3.3 保存数据
        this.orderMapper.insertSelective(order);

        //3.4 保存订单状态
        OrderStatus orderStatus = new OrderStatus();
        orderStatus.setOrderId(orderId);
        orderStatus.setCreateTime(order.getCreateTime());
        //初始状态未未付款:1
        orderStatus.setStatus(1);
        //3.5 保存数据
        this.orderStatusMapper.insertSelective(orderStatus);

        //3.6 在订单详情中添加orderId
        order.getOrderDetails().forEach(od -> {
            //添加订单
            od.setOrderId(orderId);
        });

        //3.7 保存订单详情,使用批量插入功能
        this.orderDetailMapper.insertList(order.getOrderDetails());

        //3.8 修改库存
        order.getOrderDetails().forEach(ord -> {
            Stock stock1 = this.stockMapper.selectByPrimaryKey(ord.getSkuId());
            stock1.setStock(stock1.getStock() - ord.getNum());
            stock1.setSeckillStock(stock1.getSeckillStock() - ord.getNum());
            this.stockMapper.updateByPrimaryKeySelective(stock1);

            //新建秒杀订单
            SeckillOrder seckillOrder = new SeckillOrder();
            seckillOrder.setOrderId(orderId);
            seckillOrder.setSkuId(ord.getSkuId());
            seckillOrder.setUserId(userInfo.getId());
            this.seckillOrderMapper.insert(seckillOrder);

        });

三、Jmeter压测

3.1 数据准备

Jmeter所需数据:5000个已经申请号的用户,然后循环10次。具体数据参考上一篇。

数据库:

然后清空tb_order、tb_order_detail、tb_order_status和tb_seckill_order四个表。

3.2 测试

查看数据库中的数据:

3.3 错误分析

在聚合报告中会发现有错误,接下来进行分析

3.3.1 拒绝连接

系统配置文件:{JMeter 主目录}\bin\system.properties,将 java.net.preferIPv4Stack 设置为 true 即可

3.3.2 地址正在使用

报错:JAVA.NET.BINDEXCEPTION: ADDRESS ALREADY IN USE: CONNECT

解决方案为:

1.cmd中,用regedit命令打开注册表

2.在 HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters下,

  1 .右击parameters,添加一个新的DWORD,名字为MaxUserPort

  2 .然后双击MaxUserPort,输入数值数据为65534,基数选择十进制(如果是分布式运行的话,控制机器和负载机器都需要这样操作哦)

3.修改配置完毕之后记得重启机器才会生效

通过上述修改,问题不能得到全部解决,因为机器承受不了这么大的访问,没得办法,有其它解决办法的小伙伴请留言。

四、是否秒杀成功

增加接口,判断当前用户是否秒杀成功,即根据用户id从tb_seckill_order表中查询订单号。

4.1 Controller

    /**
     * 根据userId查询订单号
     * @param userId
     * @return
     */
    @GetMapping("orderId")
    public ResponseEntity<Long> checkSeckillOrder(Long userId){
        Long result = this.seckillService.checkSeckillOrder(userId);
        if (result == null){
            return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
        }
        return ResponseEntity.ok(result);

    }

4.2 Service

    /**
     * 根据用户id查询秒杀订单
     * @param userId
     * @return
     */
    @Override
    public Long checkSeckillOrder(Long userId) {
        Example example = new Example(SeckillOrder.class);
        example.createCriteria().andEqualTo("userId",userId);
        List<SeckillOrder> seckillOrders = this.seckillOrderMapper.selectByExample(example);
        if (seckillOrders == null || seckillOrders.size() == 0){
            return null;
        }
        return seckillOrders.get(0).getOrderId();
    }

然后在前端不断轮询即可。

;