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"
}

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

/**
     * 新增秒杀券 ---> 路径是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());
    }

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;


}

但是得到的数据中,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;
            }
        }
    }
}

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

解决库存超卖问题

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

@GetMapping("/list/{shopId}")
 public Result queryVoucherOfShop(@PathVariable("shopId") Long shopId) {
    
    
    return voucherService.queryVoucherOfShop(shopId);
 }

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);
    }

全局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;
    }
}

当我们运行的时候,的确可以解决问题,但是如果在高并发的环境下就会导致库存超卖的问题,例如我们先生成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);
    }

通过上面的代码,发现我们需要请求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);
    }

这时候已经解决了库存超卖的问题,但是却还可以进一步优化,因为在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张");
}

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

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

之所以会出现订单数量远大于库存数量,是因为当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张");
}

猜你喜欢

转载自blog.csdn.net/weixin_46544385/article/details/127717680