为什么需要使用全局唯一ID,订单id采用自增长方式有什么问题?
1)订单规律性太过明显(今天下一单订单编号是10,明天下一单订单编号是100,就可以推出这个商店一天销售量是90!!)
2)订单量庞大时,需要多张表来存储,采用id值自增长的方式,订单id可能不唯一
<全局唯一id的实现>
redis的increament命令可以使key自增长,而且redis是全局中唯一的一个,所以无论数据库中订单在多少个表中,都开始生成一个全局的唯一id。
使用redis生成全局唯一id,满足了高可用、高性能、唯一性、递增性(数据库索引创建方便)。
可是自增性带来的问题如何解决?->得到的自增序列号与其它信息进行拼接
这样,这样一个全局id生成器可以每秒生成2^32个不同的订单id
直接以"icr" + ":" + keyPrefix 作为key可以吗? -> 不可以!
如果这样命名,说明订单业务的id自增会一直与这一个key绑定在一起,几年或者十年都是这个key在自增,而reids中单个key自增的上限是2^64,虽然订单量可能无法达到这个数值,但是上图已经规定了,订单编号是用32个bit来存储的,订单数量很可能会超过2^32
所以我们应该这样命名key:"icr" + ":" + keyPrefix +日期(yyyy:MM:dd)
这样一来,既可以解决32bit存不下订单编号的问题,还可以方便统计某一天或者某一月以及某一年的销量
<优惠券秒杀的下单功能实现流程>
<多线程并发下的“超卖”问题>
为什么会出现超卖问题?
假设现在库存有一件商品,A线程先查询库存为1,按理说A线程接着会判断剩余库存是否大于0,大于0对库存进行扣减,但是在A线程执行扣减操作前,又有B线程进行了库存查询,这时查到的库存还是1,后续,A、B两个线程都会执行库存扣减操作,最后剩余库存为-1,也就出现了超卖问题。
<如何解决“超卖”这一多线程安全问题?>
常见的解决方案有两种:
1)悲观锁:认为线程安全问题一定会发生,因此在进行数据操作之前先获取锁,确保线程串行执行。
常见的悲观锁:java中的Synchronized、数据库中的互斥锁
优点:简单粗暴
缺点:加锁导致线程串行,性能较差
2)乐观锁:认为线程安全问题不一定会发生,因此不加锁,而是在进行数据更新的时候去判断有没有其它线程对我之前查询的数据进行了修改,如果没有,则认为是安全的,才更新数据;如果有,则说明发生了安全问题,此时可以重试或者报异常。
优点:线程不需要串行执行,性能较好
缺点:存在成功率低的问题(后面会讲到)
乐观锁如何在更新的时候判断有没有其它线程对之前查询的数据进行了修改?
1)版本号法
在进行更新操作的时候,先检查我之前执行查询操作时候查到的数据版本号是否与当前数据的版本号一致:
如果一致,说明没有其它线程对数据进行修改,可以更新数据,这时更新数据会将版本号+1。
如果不一致,说明有其它线程对数据进行修改,则不能更新数据,报异常。
2)CAS(Compare and swap)法
每次执行更新操作之前, 先比较要更新的数据当前值是否与之前查询的时候一致,一致则说明没有其它线程对其进行修改操作,可以继续更新。
乐观锁的问题——成功率太低
比如现在有100个库存,100个线程,其中A线程执行了查询操作后,在它进行更新操作前,其它99个线程都执行了查询操作,接着A线程进行更新操作后,不论了版本号法还是CAS法,其它99个线程后面都进无法进行更新操作,因为版本号不同或者查询到的库存发生了更改。但是其实这99个线程是可以正常执行的,毕竟还有99个库存,这就是乐观锁的问题所在,成功率太低了。
如果提高超卖问题下的乐观锁的成功率?
超卖问题比较特殊,在每次执行更新操作的时候,库存不一定要刚好等于原来查询时的库存值,只要大于0即可。
商家为了提高知名度在秒杀活动中对于同一个商品一般只允许一个用户抢一单
之前的优惠券秒杀下单流程:
现在的优惠券秒杀下单流程:
库存充足的情况下,不会直接扣减库存,而是先检查是否存在该用户id购买这个优惠券的订单信息。
<可是在多线程情况下,会出现什么问题?>
比如现在A用户没有下过单,但是A用户在短时间内迅速进行多次下单操作,在还未进行库存扣减的情况下,这些线程都发现数据库订单表没有自己相关的订单信息,也就是都允许下单,这样一来,就会出现一人多单的情况。
如何解决多线程并发下的一人多单问题?
采用悲观锁,加锁的方式,使得线程串行执行
为什么不能用乐观锁?
乐观锁是在更新操作的时候判断数据是否被其它线程修改来确定是否出现线程安全问题,而这里并不存在数据的修改,只是单纯查询判断数据是否存在。
所以,我们应该使用悲观锁解决多线程并发下的一人一单问题
<使用悲观锁锁住用户id的相关代码分析>
userId.toString()将每次产生的不同Long对象转换为字符串对象,而这个字符串对象其实在底层是被new 出来的,所以即使是同一个userId,userId.toString()也不是一个对象,synchronized就不能锁住同一个用户。因此,让新new出来的字符串对象调用intern()方法,最终返回的是字符串常量池中,与这个字符串对象equal的字符串对象的引用。这样一来,synchronized就可以锁住同一个用户了。
为什么不能先释放悲观锁再提交事务?
悲观锁释放后,事务还没提交也就是还没有将用户已经下单的信息记录在订单表中,如果该用户很快地再次下单,这个时候一查还是count = 0,又可以下单了,这样一来还是会出现一个用户下单多次的情况。
只有先提交事务,用户的下单信息记录在订单表后,再释放悲观锁,才能够实现一人一单。
<代码实现过程中事务的实现方法>
在VoucherOrderServiceImpl中涉及到以下两个方法:
@Override
public Result seckillVoucher(Long voucherId) {
//1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//2.判断秒杀是否开始
if(voucher.getBeginTime().isAfter(LocalDateTime.now())){
//尚未开始
return Result.fail("秒杀尚未开始!");
}
//3.判断秒杀是否已经结束
if(voucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束!");
}
//4.判断库存是否充足
if(voucher.getStock() < 1){
//库存不足
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
//调用了createVoucherOrder()方法
return proxy.createVoucherOrder(voucherId);
}
}
该方法会调用本类中的createVoucherOrder()方法:
@Transactional
public Result createVoucherOrder(Long voucherId) {
//5.一人一单
Long userId = UserHolder.getUser().getId();
//userId.toString().intern()确保了相同的userId过来,锁住的是一个String对象
//这里的toString()方法实际会创建一个新的字符串对象
//intern()则会返回字符串常量池中与新创建的字符串对象equal的字符串对象的引用
//从而保证相同的userId进行了toString().intern()返回的是同一个字符串对象的引用
//5.1查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
//5.2判断是否存在该订单
if (count > 0) {
//用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
//6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId).gt("stock", 0)//更新的条件:where id = ? stock > 0
.update();
if (!success) {
//扣减失败
return Result.fail("库存不足!");
}
//7.创建顶单
VoucherOrder voucherOrder = new VoucherOrder();
//7.1订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
//7.2用户id
voucherOrder.setUserId(userId);
//7.3代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//8.返回订单id
return Result.ok(orderId);
}
但是createVoucherOrder()事务的实现,是由代理类完成的,所以我们需要在seckillVoucher()中用代理类的方式去调用createVoucherOrder()。
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
//调用了createVoucherOrder()方法
return proxy.createVoucherOrder(voucherId);
}
同时,还需要在IVoucherOrderService 接口(由Spring管理)中创建createVoucherOrder()方法。
public interface IVoucherOrderService extends IService {
Result seckillVoucher(Long voucherId);
Result createVoucherOrder(Long voucherId);
}
在pom.xml中添加aspectj依赖:
org.aspectj
aspectjweaver
最后,还需要在启动类添加注解@EnableAspectJAutoProxy(exposeProxy = true)去暴露代理对象
<集群模式下线程并发带来的问题>
前面我们通过加悲观锁的方式 已经解决了单机模式下的线程并发问题:
可是,在集群模式下,单靠悲观锁还是会发生线程安全问题:
在集群模式下,每个集群节点对应了一个tomcat,也就是对应了一个JVM,而锁实现的原理,就是靠JVM中的锁监视器来实现的,当上图处于JVM2的线程3访问同一个userId时,由于与JVM1中锁监视器不是同一个,也就是JVM2中的锁监视器还是空的,这个时候就会出现线程的并发安全问题。
如何解决这个问题呢?->请看下一篇文章:
黑马点评项目学习笔记--(3)分布式锁