ToB企服应用市场:ToB评测及商务社交产业平台
标题:
黑马点评3——优惠券秒杀—全局唯一ID、秒杀下单、超卖问题(乐观锁)、一
[打印本页]
作者:
守听
时间:
2024-9-22 05:25
标题:
黑马点评3——优惠券秒杀—全局唯一ID、秒杀下单、超卖问题(乐观锁)、一
全局唯一ID
全局ID天生器,是一种在分布式系统下用来天生全局唯一ID的工具,一般要满足一下特性:
基本格式如下:
UUID
返回的是16进制的
Redis自增
根据上面图示的格式,我们实现一个Redis自增的全局ID,注册成bean,交给Spring管理
@Component
public class RedisIdWorker {
/**
* 开始时间戳
*/
private static final long BEGIN_TIMESTAMP = 1704067200L;
/**
* 序列号位数
*/
private static final long COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public long nextId(String keyPrefix){
// 1. 生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestep = nowSecond - BEGIN_TIMESTAMP;
// 2. 生成序列号
// 2.1 获取当前日期,精确到天,好处1: 避免超过2^32, 2:方便统计
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
long count = stringRedisTemplate.opsForValue().increment("icr:"+keyPrefix + ":" + date);
// 3. 拼接并返回
return timestep << COUNT_BITS | count;
}
}
复制代码
单位测试:
@Autowired
private RedisIdWorker redisIdWorker;
private ExecutorService es = Executors.newFixedThreadPool(500);
@Test
void testIdWorker() throws InterruptedException {
// 使用 CountDownLatch 同步 300 个异步任务。
// 确保所有任务完成后,计算并输出总的执行时间。
// 通过这种方式,可以准确地测量所有任务的总执行时间,而不会因为异步执行导致时间计算不准确。
CountDownLatch latch = new CountDownLatch(300); // 因为这里是异步执行的,所以统计时间的话不能使用普通的打印时间
Runnable task = () ->{
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id = " + id);
}
latch.countDown();
};
long begin = System.currentTimeMillis();
for(int i=0; i<300; i++){
es.submit(task); // 使用线程池 es 提交 300 个相同的任务。每个任务都会执行上面定义的操作。
}
latch.await(); // 调用 latch.await() 方法,使当前线程等待,直到计数器的值变为 0。这确保了所有 300 个任务都已完成。
long end = System.currentTimeMillis();
System.out.println("time = " + (end - begin));
}
复制代码
snowflake算法(雪花算法)
数据库自增
这里不是说使用数据库的自增字段,而是说单独使用一张表,专门用来做自增,订单表需要id,就从这张表里获取(redis自增的数据库版)
Redis自增id策略
天天一个Key,方便统计订单量
ID构造是时间戳+计数器
实现优惠券秒杀下单
主要是针对特价券这种需要抢的,普通券就没须要了。
优惠券秒杀下单时要判断两点:
秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
库存是否富足,不敷无法下单
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2. 判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 2/ 判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已经结束
return Result.fail("秒杀已经结束!");
}
// 4. 判断库存是否充足
if (voucher.getStock()<1) {
// 库存不足
return Result.fail("库存不足!");
}
// 5.扣减库存
boolean success = seckillVoucherService.update().setSql("stock = stock - 1")
.eq("voucher_id", voucherId).update();
if(!success){
return Result.fail("库存不足!");
}
// 6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2 用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7. 返回订单id
return Result.ok(orderId);
}
复制代码
超卖问题
正常情况下没问题:
高并发环境下就有问题,不同线程的动作会交叉
如果同一时候有许多的线程同时来查询,就出现了这个并发安全问题
多个线程在操作共享的资源,并且操作资源的代码有好几行,这几行代码执行的中心,多个线程相互穿插,就出现了安全问题。
悲观锁很简朴暴力,直接加锁就行,我们演示乐观锁:
乐观锁的关键是
判断之前查询得到的数据是否有被修改过
, 常见的方式有两种:
乐观锁——版本号法
线程2在扣减库存的时间发现版本不一致就无法更新了
那现在我们想,我们即用库存,又用版本,比力版本的变化,是不是可以使用库存的变化代替呢?固然可以,于是乎,有了新方案:
cas
乐观锁——CAS法
我们只需要把刚刚代码在扣减的时间加上关于库存的比力
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2. 判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 2/ 判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已经结束
return Result.fail("秒杀已经结束!");
}
// 4. 判断库存是否充足
if (voucher.getStock()<1) {
// 库存不足
return Result.fail("库存不足!");
}
// 5.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.eq("stock",voucher.getStock()) // CAS锁
.update();
if(!success){
return Result.fail("库存不足!");
}
// 6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2 用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7. 返回订单id
return Result.ok(orderId);
}
复制代码
但我们使用jmeter进行测试,设置库存100件,并发线程200个,测试结果如下:
错误率高达68.5%
啊。不应该啊。
在看看数据库的库存:
还剩下79件???
订单也只有21个,没有超卖啊,安全问题确实解决了,那为什么出现了许多失败的情况呢?
还没卖完就结束了?怎么回事?
乐观锁的毛病
太小心了,他认为只要有人修改了就不执行,失败率大大提高
其实只要库存大于0, 就没问题。
这就是乐观锁的问题——成功率太低
其实解决方案很简朴,就是把扣减库存的时间的
// 5.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.eq("stock",voucher.getStock()) // CAS锁
.update();
复制代码
这个严苛的判断条件改成只要库存大于0,就没须要大惊小怪的。
// 5.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock",0) // 把判断条件改成库存大于0就可以避免乐观锁的弊端
.update();
复制代码
@Override @Transactional public Result seckillVoucher(Long voucherId) { // 1. 查询优惠券 SeckillVoucher voucher = seckillVoucherService.getById(voucherId); // 2. 判断秒杀是否开始 if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { // 尚未开始 return Result.fail("秒杀尚未开始!"); } // 2/ 判断秒杀是否结束 if (voucher.getEndTime().isBefore(LocalDateTime.now())) { // 已经结束 return Result.fail("秒杀已经结束!"); } // 4. 判断库存是否富足 if (voucher.getStock()<1) { // 库存不敷 return Result.fail("库存不敷!"); } // 5.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock",0) // 把判断条件改成库存大于0就可以避免乐观锁的弊端
.update();
if(!success){ return Result.fail("库存不敷!"); } // 6.创建订单 VoucherOrder voucherOrder = new VoucherOrder(); // 6.1 订单id long orderId = redisIdWorker.nextId("order"); voucherOrder.setId(orderId); // 6.2 用户id Long userId = UserHolder.getUser().getId(); voucherOrder.setUserId(userId); // 6.3 代金券id voucherOrder.setVoucherId(voucherId); save(voucherOrder); // 7. 返回订单id return Result.ok(orderId); }
复制代码
100的库存,200的线程并发访问,jmeter测试结果:
数据库也淘汰到0了
固然这个解决方案不是所有情况都行的,还有其他的解决方案
比如:如果有的问题中没有库存怎么办?
接纳分批加锁的方案,或者是分段锁的方案,也就是说把数据库中的资源分成几份,比如说把数据分成十份,那用户在抢的时间可以去10张表里面分别去抢,这样一来成功率就提高了10倍。这种思想在ConcurrentHashMap中有应用。
总结
一人一单
以前我们的优惠券下单业务是这样的:
现在修改业务流程
修改我们的业务代码如下:
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2. 判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 2/ 判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已经结束
return Result.fail("秒杀已经结束!");
}
// 4. 判断库存是否充足
if (voucher.getStock()<1) {
// 库存不足
return Result.fail("库存不足!");
}
// 5. 一人一单
Long userId = UserHolder.getUser().getId();
// 5.1 查询订单
Integer 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) // 把判断条件改成库存大于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);
}
复制代码
100的库存,200的线程并发访问,但是只配置一个用户的请求头,jmeter测试
理论上应该只能下一单才对,库存应该还剩下99个
看看数据库库存
订单表里面也有10个订单:
什么情况?里面的user_id和voucher_id竟然也是一样的!
说明我们做了一人一单的业务逻辑判断,但是并没有解决问题。
现在的问题就是由于先查询,在判断,在扣减,就是由于多个线程并发访问,多个线程一起查询count都是0,都说我可以扣减,那就堕落了,那怎么解决呢?——加锁呀!
先暴力加个悲观锁,把查询订单和扣减库存的方法抽取出来:
Transactional注解也要换到扣减库存的方法上。
@Override
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2. 判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 2/ 判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已经结束
return Result.fail("秒杀已经结束!");
}
// 4. 判断库存是否充足
if (voucher.getStock()<1) {
// 库存不足
return Result.fail("库存不足!");
}
return createVoucherOrder(voucherId);
}
@Transactional
public synchronized Result createVoucherOrder(Long voucherId){
// 5. 一人一单
Long userId = UserHolder.getUser().getId();
// 5.1 查询订单
Integer 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) // 把判断条件改成库存大于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);
}
复制代码
锁加在方法上肯定可以解决,但是不建议,由于synchronized加在方法上,就酿成了锁整个方法,锁的对象是this,也就意味着不管任何一个用户来了,都要加这个锁,而且大家是同一把锁,整个方法就串行执行了。我们想要的是只有同一个用户来了在加锁,不同用户来了就不消管。各做各就行,应该对用户id加锁。缩小加锁的范围。
注意两点:
开释锁的时机:我们的锁也不能加载createVoucherOrder方法里面,由于锁要是加载createVoucherOrder方法里面,会出现spring的事务还没提交就开释锁的问题。
事务失效问题
我们在createVoucherOrder函数加了事务,没有给seckillVoucher函数加事务,而seckillVoucher函数调用的时间使用this调用的,事务失效,详细表明看代码里
@Override
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2. 判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 2/ 判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已经结束
return Result.fail("秒杀已经结束!");
}
// 4. 判断库存是否充足
if (voucher.getStock()<1) {
// 库存不足
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
/**
* 每一个请求过来,这个id对象都是一个全新的id对象,因为要是对userId加锁的话,对象变了锁就变了,那不行
* 我们希望id的值一样,所以用了toString(),但是toString()依旧不能保证是对对象的值加锁的
* toString底层是new 一个String数组,还是new了一个新对象,同一个用户id在不同的请求中过来,每次都new一个,还是不能把锁加载同一个用户上
* 于是用intern() ,intern()方法可以去字符串常量池中找字符串值一样的引用返回
* 这样一来,如果你的userId是5,不管你new了多少个字符串,只要值是一样的,返回的结果也一样。这样就可以锁住同一个用户
* 不同的用户不会被锁住
*/
synchronized (userId.toString().intern()) {
// 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy(); // 拿到当前对象的代理对象,其实就是IVoucherOrderService这个接口的代理对象,返回的是Object,做个强转
return proxy.createVoucherOrder(voucherId); // 如果报错了是因为我们的接口中没有这个方法,那我们就在接口中创建一下这个方法就行
}
}
/**
* 事务加在这,就失效了,为什么呢?
* 加载这是对createVoucherOrder函数加了事务,没有给seckillVoucher函数加事务,而seckillVoucher函数调用的时候
* createVoucherOrder(voucherId);
* 这样使用this调用的,这个this拿到的是当前的VoucherOrderServiceImpl对象
* 而不是VoucherOrderServiceImpl的代理对象
* 而事务要想生效,是spring对当前这个类做了动态代理,拿到代理对象做的事务处理
* 而我们当前的this是非代理对象,这就是事务失效的几种可能性之一
* 解决方法之一:
* AopContext.currentProxy()拿到代理对象来调用createVoucherOrder
*
* 当然这样解决还得做两件事:
* 1. 引入aspectj的依赖
* <dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.7</version>
</dependency>
* 2. 启动类添加注解@EnableAspectJAutoProxy(exposeProxy = true)暴露代理对象
*/
@Transactional
public Result createVoucherOrder(Long voucherId){
// 5. 一人一单
Long userId = UserHolder.getUser().getId();
// 5.1 查询订单
Integer 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) // 把判断条件改成库存大于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);
}
复制代码
100的库存,200的线程并发访问,但是只配置一个用户的请求头,jmeter测试
理论上应该只能下一单才对,库存应该还剩下99个
看看数据库库存对的。YES!!!
一人一单的并发安全问题
上面的解决方案在单机模式下不会有问题,但是在集群模式下就有问题了,什么问题呢?我们来测试
我们用postman使用同一个用户发送两个请求,在锁后打上断点,发现两个集群下两个服务都进入断点了,这一个锁在集群模式下没有锁住,放开后也发现数据库的数据被同一个用户扣减了两个。为什么呢?
来捋一下:
之前是单体项目,正常情况下:
多线程并发下,要是没有加锁,会出现并发执行:
这就出现了线程安全问题。于是我们加了锁
在集群情况下就出问题了:
现在我们是多台JVM下,锁的原理是,在JVM内部维护一个锁监视器对象,这个监视器对象用的userId,userId在常量池中。在这个JVM内部维护了一个常量池,当userId相同的情况下,永远是同一个锁,也就是锁的监视器就能记录不同线程的情况。
但是当集群的时间,那就各自有各自的JVM,那各自的JVM都有各自的堆、栈、方法区之类的。JVM2也会有自己的常量池,JVM2 的锁监视器只能在当前的JVM内部见识线程,实现互斥。
这就有一次出现了并发安全问题,每一个JVM都有自己的锁,就导致并行运行下,就出现问题了,那就得让多个JVM只能使用同一把锁,但这样的锁不是JDK提供的,于是乎跨JVM,或者跨进程的锁就出现了——分布式锁
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
欢迎光临 ToB企服应用市场:ToB评测及商务社交产业平台 (https://dis.qidao123.com/)
Powered by Discuz! X3.4