概述
文章目录
- postman发送后台数据的时候,后台接收到的LocalDateTime为null
- 解决库存超卖问题
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("rn".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("rn".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种方式:
- 版本号法: 所谓的版本号法,就是在数据库中添加额外的字段version,这时候
在获取数据的时候old_data,我们还需要获取一个版本号数据old_version,然后进行数据更新的时候,只需要保证更新数据的id正确,以及数据库中的版本号字段的值就是当前old_version,那么就可以进行数据库的更新操作,否则,如果不相等,那么说明这个数据已经被其他的线程更新了,old_version已经发生了修改,因此这个线程就不操作了。对应的过程如下所示:
- 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张");
}
最后
以上就是爱笑小蘑菇为你收集整理的day3_redis学习_乐观锁解决库存超卖问题postman发送后台数据的时候,后台接收到的LocalDateTime为null解决库存超卖问题的全部内容,希望文章能够帮你解决day3_redis学习_乐观锁解决库存超卖问题postman发送后台数据的时候,后台接收到的LocalDateTime为null解决库存超卖问题所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复