• day3_redis学习_乐观锁解决库存超卖问题


    postman发送后台数据的时候,后台接收到的LocalDateTime为null

    当我们需要添加一个优惠券的时候,这时候由于没有设置管理员的界面,所以需要通过postman来模拟后台进行修改,当在postman发送完毕之后,并且接收到的响应是200状态码,但是当我们在后台通过日志,却看到属性beginTime是一个null.
    对应的postman的数据如下所示:

    {
        "shopId": 4,
        "title": "50元代金券",
        "subTitle": "周一到周日均可使用",
        "rules": "全场通用\\n无需预约\\n可无限叠加\\不兑现、不找零\\n仅限堂食",
        "payValue": 8000,
        "actualValue": 10000,
        "type": 1,
        "stock": 100,
        "beginTime": "2022-11-05T00:00:00",
        "endTime": "2022-11-26T00:00:00",
        "createTime": "2022-11-06T00:00:00"
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    后台需要接收的代码如下所示:

    /**
         * 新增秒杀券 ---> 路径是localhost:8081/voucher/seckill
         * @param voucher 优惠券信息,包含秒杀信息
         * @return 优惠券id
         */
        @PostMapping("seckill")
        @Transactional
        public Result addSeckillVoucher(@RequestBody Voucher voucher) {
            log.info("voucherController addSeckillVoucher = {}",voucher);
            voucherService.addSeckillVoucher(voucher);
            return Result.ok(voucher.getId());
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    voucher实体类: 在这个实体类中,有使用了注解@TableField(exist = false),它的作用是这个字段在数据库的表中是不存在的,但是实体类中却是需要使用这个属性。也正因为这个属性,Voucher也可以是秒杀优惠券,而不仅仅是一个普通的优惠券。所以上面的发送beginTime,endTime是映射得到的。

    @Data
    @EqualsAndHashCode(callSuper = false)
    @Accessors(chain = true)
    @TableName("tb_voucher")
    public class Voucher implements Serializable {
    
        private static final long serialVersionUID = 1L;
    
        /**
         * 主键
         */
        @TableId(value = "id", type = IdType.AUTO)
        private Long id;
    
        /**
         * 商铺id
         */
        private Long shopId;
    
        /**
         * 代金券标题
         */
        private String title;
    
        /**
         * 副标题
         */
        private String subTitle;
    
        /**
         * 使用规则
         */
        private String rules;
    
        /**
         * 支付金额
         */
        private Long payValue;
    
        /**
         * 抵扣金额
         */
        private Long actualValue;
    
        /**
         * 优惠券类型
         */
        private Integer type;
    
        /**
         * 优惠券类型
         */
        private Integer status;
        /**
         * 库存
         */
        @TableField(exist = false)
        private Integer stock;
    
        /**
         * 生效时间
         */
        @TableField(exist = false)
        private LocalDateTime beginTime;
    
        /**
         * 失效时间
         */
        @TableField(exist = false)
        private LocalDateTime endTime;
    
        /**
         * 创建时间
         */
        private LocalDateTime createTime;
    
    
        /**
         * 更新时间
         */
        private LocalDateTime updateTime;
    
    
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85

    但是得到的数据中,voucher的属性beginTime,endTime,createTime都是null的,哪怕最后在postman中修改为beginTim,也是可以封装成为Voucher类,并且这个类中的beginTime还是null.

    在通过百度搜索,发现来来去去都是说字段名字的问题,或者在参数的前面加一个注解@RequestBody来解决,但是这些方法都没有解决我的问题。

    后来我才想起来了,因为在前面测试添加商品的时候,我有多写了一个类LocalDateTimeSerializerConfig,它的作用是将LocalDateTime进行序列化和反序列化的,对应的代码如下所示:

    public class LocalDateTimeSerializerConfig {
    
        @Bean
        public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
            return builder -> {
                builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer());
                builder.deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer());
            };
        }
    
        /**
         * 序列化
         */
        public static class LocalDateTimeSerializer extends JsonSerializer<LocalDateTime> {
            @Override
            public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers)
                    throws IOException {
                if (value != null) {
                    long timestamp = value.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
                    gen.writeNumber(timestamp);
                }
            }
        }
    
        /**
         * 反序列化
         */
        public static class LocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {
            @Override
            public LocalDateTime deserialize(JsonParser p, DeserializationContext deserializationContext)
                    throws IOException {
                log.info("反序列化,jsonString = " + p.getValueAsString() + ", timestamp = " + p.getValueAsLong());
                long timestamp = p.getValueAsLong();
                if (timestamp > 0) {
                    return LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault());
                } else {
                    return null;
                }
            }
        }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42

    那么这时候我们是需要将postman中的字符串格式反序列化为LocalDateTime,所以我们只需要看反序列化的代码即可,当我们在postman提交数据的时候,发现的确是进入了这一步进行反序列化,然后通过日志发现,p.parseValueAsString的值就是我们的发送的数据,而p.parseValueAsLong的值则直接就是0,所以导致返回的就是null.终于真相大白了,所以要解决问题,我们只需要将这个反序列化的代码去掉即可,然后再次在postman中发送的时候,就可以看到这个数据了,并且在前端中,可以看到对应的优惠券了。

    解决库存超卖问题

    在我们完成了上面优惠券的问题之后,这时候我们就可以去实现秒杀商品的功能了,对应的步骤为:
    在这里插入图片描述
    根据上面的步骤,对应的代码为:

    @GetMapping("/list/{shopId}")
     public Result queryVoucherOfShop(@PathVariable("shopId") Long shopId) {
        return voucherService.queryVoucherOfShop(shopId);
     }
    
    • 1
    • 2
    • 3
    • 4

    VoucherOrderServiceImpl代码:

    @Override
        @Transactional
        public Result seckillVoucher(Long voucherId) {
            //1、获取优惠券
            SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
            //1.1 获取开始时间以及结束时间
            LocalDateTime beginTime = seckillVoucher.getBeginTime();
            LocalDateTime endTime = seckillVoucher.getEndTime();
            if(beginTime.isAfter(LocalDateTime.now())){
                return Result.fail("秒杀尚未开始");
            }
            if(endTime.isBefore(LocalDateTime.now())){
                return Result.fail("秒杀已经结束");
            }
            //2、获取这个优惠券的库存
            Integer stock = seckillVoucher.getStock();
            if(stock < 1){
                return Result.fail("优惠券剩余0张");
            }
    
            //3、更新库存,防止在库存变成负数的时候,先生成订单,然后在更新库存
            boolean isUpdate = seckillVoucherService.update()
                    .setSql("stock = stock - 1")
                    .eq("voucher_id", voucherId).
                            update();
            //4、进行秒杀操作,生成订单
            VoucherOrder voucherOrder = new VoucherOrder();
            Long orderId = redisWorker.nextId("order");
            voucherOrder.setId(orderId);
            voucherOrder.setVoucherId(voucherId);
            voucherOrder.setUserId(UserHolder.getUser().getId());
            voucherOrderService.save(voucherOrder);
            return Result.ok(orderId);
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    全局ID生成器RedisWorker代码:

    @Component
    public class RedisWorker {
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
        //以2022-1-1 00:00:00为基准
        private static final Long BEGIN_TIMESTAMP = 1640995200L;
        private static final Long BITE_OFFSET = 32L;
        /**
         * 生成prefixKey的下一个id:
         * 1、获取时间戳
         * 2、获取计数器
         * 3、最后的id就是 时间戳<32 | 计数器
         * @param prefixKey
         * @return
         */
        public Long nextId(String prefixKey){
            //1、获取时间戳
            Long current_timestamp = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC);
            long timestamp = current_timestamp - BEGIN_TIMESTAMP;
            //2、获取计数
            String time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
            Long count = stringRedisTemplate.opsForValue().increment("irc:" + prefixKey + time);
            //3、生成真正的下一个id: 时间戳<<32 | count
            return timestamp << BITE_OFFSET | count;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    当我们运行的时候,的确可以解决问题,但是如果在高并发的环境下就会导致库存超卖的问题,例如我们先生成2000个用户,然后再利用JMeter进行压测这个秒杀接口的时候,再次查看数据库,就会发现对应的商品的库存数量变成了负数。

    而在这里,生成2000个随机用户,主要是在测试中进行生成的,对应的代码为:

    @Test
        public void createUser() throws IOException {
            List<User> users = new ArrayList<>();
            for(int i = 0; i < 2000; ++i){
                User user = new User();
                user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
                user.setPhone(18315400000L + i + "");
                users.add(user);
            }
    
            //将用户插入到数据库中
            userService.saveBatch(users);
            //发送登录请求,从而将用户保存到了redis中,并生成cookie的值,然后保存到压测的文件中
            String codeUrlString = "http://localhost:8081/user/code";
            String loginUrlString = "http://localhost:8081/user/login";
            File file = new File("F:\\JMeter\\redis压力测试用户code.txt");
            File file1 = new File("F:\\JMeter\\redis压力测试用户token.txt");
            if(file.exists()) {
                file.delete();
            }
            RandomAccessFile raf = new RandomAccessFile(file, "rw");
            file.createNewFile();
            raf.seek(0);
            if(file1.exists()) {
                file1.delete();
            }
            RandomAccessFile raf1 = new RandomAccessFile(file1, "rw");
            file1.createNewFile();
            raf1.seek(0);
            for(User user : users) {
                //获取验证码
                String params = "phone=" + user.getPhone();
                Result result = doRequest(codeUrlString, params);
                String code = (String)result.getData();
                //将每一行以 用户的id,cookie的id 的形式(2个值以逗号分隔)写入到要进行压测的code文件中
                String row = user.getId()+","+code;
                raf.seek(raf.length());
                raf.write(row.getBytes());
                raf.write("\r\n".getBytes());
                //将执行登录操作
                params += "&code=" + code;
                result = doRequest(loginUrlString, params);
                String token = (String)result.getData();
                //将生成的token保存到对应的压测的token文件中
                row = user.getId()+"," + token;
                raf1.seek(raf1.length());
                raf1.write(row.getBytes());
                raf1.write("\r\n".getBytes());
            }
            raf.close();
            raf1.close();
            System.out.println("over");
        }
    
    
        public Result doRequest(String urlString, String params) throws IOException {
            //发送请求,方法是POST
            URL url = new URL(urlString);
            HttpURLConnection co = (HttpURLConnection)url.openConnection();
            co.setRequestMethod("POST");
            co.setDoOutput(true);
            OutputStream out = co.getOutputStream();
            //设置发送的请求参数
            out.write(params.getBytes());
            out.flush();
            //读取服务端发送的响应
            InputStream inputStream = co.getInputStream();
            ByteArrayOutputStream bout = new ByteArrayOutputStream();
            byte buff[] = new byte[1024];
            int len = 0;
            while((len = inputStream.read(buff)) >= 0) {
                bout.write(buff, 0 ,len);
            }
            inputStream.close();
            bout.close();
            String response = new String(bout.toByteArray());
            ObjectMapper objectMapper = new ObjectMapper();
            return objectMapper.readValue(response, Result.class);
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79

    通过上面的代码,发现我们需要请求2次,是因为我们第一次发送请求是为了获取验证码,第二次请求则是提交表单数据进行登录操作,如果登录成功,就将token保存到对应的文件中(在进行压测的时候需要用到这个文件的token),然后我们在JMeter中配置如下所示:
    在这里插入图片描述
    并且线程组设置有5000个,从而最后压测完毕之后,查看数据库,发现库存数量变成了-9,也即是存在库存超卖的问题。

    而要解决库存超卖的问题,我们可以通过加锁的方法解决,而可以添加的锁主要有以下几种:
    ① 悲观锁:认为线程一定不安全,那么就会直接添加锁,这时候线程请求就是一个串行请求,只要有一个线程在进行秒杀,那么其他的线程由于没有获得锁,所以只能在方法外面等待释放锁,例如我们学过的synchronized,lock就属于悲观锁。虽然实现了线程安全,但是性能却下降了。
    ② 乐观锁: 认为线程不一定是不安全的,因此他没有直接在方法上加锁,而是在线程请求更新数据的时候(也即当前有一个线程请求秒杀商品,之后需要更新商品的库存数量),判断是否需要继续执行操作,如果发现已经有其他的线程修改了库存数量,那么这个线程就会重试,否则,如果没有线程修改库存,那么这个线程就去执行更新库存的操作。

    而实现乐观锁的方式主要2种方式:

    1. 版本号法: 所谓的版本号法,就是在数据库中添加额外的字段version,这时候
      在获取数据的时候old_data,我们还需要获取一个版本号数据old_version,然后进行数据更新的时候,只需要保证更新数据的id正确,以及数据库中的版本号字段的值就是当前old_version,那么就可以进行数据库的更新操作,否则,如果不相等,那么说明这个数据已经被其他的线程更新了,old_version已经发生了修改,因此这个线程就不操作了。对应的过程如下所示:
      在这里插入图片描述
    2. CAS(Compare And Swap)方法: 这个方法是在版本号法的基础上进行修改的,因为我们发现操作一次,就需要将version + 1,stock - 1,这时候就可以明显发现stock也可以实现版本号的作用,所以这时候我们不需要修改数据库表的结构了,而是直接利用stock这个字段来充当version,效果是一样的,对应的步骤如下所示:
      在这里插入图片描述
      这里基于CAS方法进行修改,对应的代码为:
    @Override
        @Transactional
        public Result seckillVoucher(Long voucherId) {
            //1、获取优惠券
            SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
            //1.1 获取开始时间以及结束时间
            LocalDateTime beginTime = seckillVoucher.getBeginTime();
            LocalDateTime endTime = seckillVoucher.getEndTime();
            if(beginTime.isAfter(LocalDateTime.now())){
                return Result.fail("秒杀尚未开始");
            }
            if(endTime.isBefore(LocalDateTime.now())){
                return Result.fail("秒杀已经结束");
            }
            //2、获取这个优惠券的库存
            Integer stock = seckillVoucher.getStock();
            if(stock < 1){
                return Result.fail("优惠券剩余0张");
            }
    
            //3、更新库存,防止在库存变成负数的时候,先生成订单,然后在更新库存
            boolean isUpdate = seckillVoucherService.update()
                    .setSql("stock = stock - 1")
                    .eq("voucher_id", voucherId)
                    .eq("stock", stock)
                    .update();
            if(!isUpdate){
                return Result.fail("已经有线程修改了,此线程无法操作");
            }
            //4、进行秒杀操作,生成订单
            VoucherOrder voucherOrder = new VoucherOrder();
            Long orderId = redisWorker.nextId("order");
            voucherOrder.setId(orderId);
            voucherOrder.setVoucherId(voucherId);
            voucherOrder.setUserId(UserHolder.getUser().getId());
            voucherOrderService.save(voucherOrder);
            return Result.ok(orderId);
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38

    这时候已经解决了库存超卖的问题,但是却还可以进一步优化,因为在stock大于1的时候,那么虽然线程2获取的stock不等于数据库中的stock,此时但是只要数据库中的库存stock还是大于0的时候,那么线程2是可以操作,而不是像上面步骤一样,一旦当前线程查到的stock和数据库中的stock不相等,就直接返回错误信息了。所以只需要将数据库操作的代码修改成下面的代码即可:

    boolean isUpdate = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherId)
            .gt("stock", 0) //只要stock大于0,就进行操作
            .update();
    if(!isUpdate){
        return Result.fail("优惠券剩余0张");
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    但是这个语句可以修改成下面的样子的话,会出现问题订单数量不等于库存数量的情况:

    seckillVoucher.setStock(stock - 1);
    boolean isUpdate = seckillVoucherService.update(seckillVoucher, new UpdateWrapper<SeckillVoucher>().gt("stock", 0));  //在库存数量大于0的时候,才更新数据库,更新的实体数据就是seckillVoucher
    if(!isUpdate){
        return Result.fail("优惠券剩余0张");
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    之所以会出现订单数量远大于库存数量,是因为当stock= 8的时候,那么有多个线程执行操作seckillVoucher.setStock(stock - 1),此时这多个线程更新之后的stock = 7,此时线程1在更新数据库,发现数据库中的stock依旧是大于0的,所以就更新数据库,此时数据库中的stock应该是7才对,但是并发的线程2也已经执行了seckillVoucher.setStock(stock - 1),所以线程2更新seckillVoucher实体之后,stock也等于7,那么再次去更新数据库的时候,数据库中的库存数量还是7.但是实际上应该是6才对,因为这时候已经生成了2个订单

    所以上面的代码还需要修正,需要在判断库存数量之后,才可以进行更新stock,而不是先更新了seckillVoucher的stock,然后才执行数据库操作,所以正确的代码应该是下面的样子:

    boolean isUpdate = seckillVoucherService.update(new UpdateWrapper<SeckillVoucher>()
                                                  .setSql("stock = stock - 1")
                                                  .eq("voucher_id", voucherId)
                                                  .gt("stock", 0));
    if(!isUpdate){
        return Result.fail("优惠券剩余0张");
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
  • 相关阅读:
    千兆以太网传输层 UDP 协议原理与 FPGA 实现(UDP回环)
    小车PWM调速-模式选择
    0基础学习PyFlink——用户自定义函数之UDAF
    部署云 SIEM 解决方案的 5 大优势
    Delphi 取消与设置CDS本地排序
    (附源码)app校园购物网站 毕业设计 041037
    NOIP2023模拟1联测22 黑暗料理
    JVM性能调优-二.流程步骤实践
    Pytorch深度学习——线性回归实现 04(未完)
    springMVC异常处理的知识点+异常处理案例
  • 原文地址:https://blog.csdn.net/weixin_46544385/article/details/127717680