总览:
1、全局ID生成器
观察到未使用AUTO_INCRE
当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:
* id的规律性太明显
* 受单表数据量的限制
场景分析:如果我们的id具有太明显的规则,用户或者说商业对手很容易猜测出来我们的一些敏感信息,比如商城在一天时间内,卖出了多少单,这明显不合适。
场景分析二:随着我们商城规模越来越大,mysql的单表的容量不宜超过500W,数据量过大之后,我们要进行拆库拆表,但拆分表了之后,他们从逻辑上讲他们是同一张表,所以他们的id是不能一样的, 于是乎我们需要保证id的唯一性。
为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:
ID:long型,8字节,64bit
ID的组成部分:符号位:1bit,永远为0,正数
时间戳:31bit,以秒为单位,可以使用69年
序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID
RedisWorker.java:
/**
* 由正常年月日(20220101)转换为时间戳
*/
public Long makeTime(){
LocalDateTime now = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
long second=now.toEpochSecond(ZoneOffset.UTC);
return second;
}
/**
* 开始时间戳
*/
private static final long BEGIN_TIMESTAMP = 1640995200L;
/**
* 序列号的位数
*/
private static final int COUNT_BITS = 32;
@Autowired
private RedisUtil redisUtil;
public long nextId(String keyPrefix) {
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2.生成序列号
// 2.1.获取当前日期,精确到天
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 2.2.redis中自增长记录总数量(条)
long count=redisUtil.incr("icr:" + keyPrefix + ":" + date,1);
// 3.拼接并返回生成的ID
return timestamp << COUNT_BITS | count;
}
}
2、实现优惠券秒杀下单
2.1代码实现
流程:
voucherOrderServiceImpl.java:
@Servicepublic
class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired private RedisIdWorker redisIdWorker;
@Transactional
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始");
}
// 3.判断秒杀是否已经结束
if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已经结束
return Result.fail("秒杀已经结束"); }
// 4.判断库存是否充足
if (seckillVoucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足"); }
//5,扣减库存
boolean success=this.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
voucherOrder.setUserId(UserHolder.getUser().getId());
// 6.3.优惠券id
voucherOrder.setVoucherId(voucherId);
this.save(voucherOrder);
return Result.ok(orderId);
}
}
超卖问题分析:
问题:
高并发情况下出现超卖
假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。
示例:
JMeter设置:
200个线程同时访问:
我们这里使用一个用户token来模拟高并发
这里由于代码设置了refreshInterceptor检查,所以需要携带token:
设置JSON断言,表示如果success==true则为请求成功
结果:
观察到200个线程中存在失败
查看数据库:
共有108条订单,且stock值 == -8,表示超卖了 8 个
悲观锁&&乐观锁:
悲观锁:插入数据时
乐观锁:更新数据时
选用乐观锁实现超卖解决:
选用乐观锁原因:因为此处主要是对stock数据做判断操作,为更新数据,则使用乐观锁
乐观锁两种方式:
方式一:version法
查询库存的同时查询版本号,关键在于减库操作时同时判断version与先前查找的是否一致,如果一致,表明中间过程没有其他线程修改version
方式二:cas法
(改进版本号法)CAS法:compare and switch
因为方式一库存与版本均被查找和跟新 因此 可使用库存代替版本
修改扣减库存方法:
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1") //set stock = stock -1
.eq("voucher_id", voucherId).eq("stock",voucher.getStock()).update(); //where id = ? and stock = ?
JMeter测试,观察到异常达到90%,仅生成21条订单,stock远>0
以上逻辑的核心含义是:只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设100个线程同时都拿到了100的库存,然后大家一起去进行扣减,但是100个人中只有1个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败
个人理解:若200个线程同时拿到stock的值,则任意线程stock值均一致,此时后续只有同一时间点开始的一批线程成功比较前后stock值一致,后续均不可满足条件,故仅卖出21条
乐观锁弊端:成功率可能会低
之前的方式要修改前后都保持一致,但是这样我们分析过,成功的概率太低,所以我们的乐观锁cas法需要变一下,改成stock大于0 即可
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update().gt("stock",0); //where id = ? and stock > 0
JMeter测试,观察到异常为50%,仅生成100条订单,stock=0
实现200个线程只有100(stock=100)个可以实现,并保证了stock=0,不会出现超卖
总结:
3、保证一人一单问题
流程:
具体操作逻辑如下:比如时间是否充足,如果时间充足,则进一步判断库存是否足够,然后再根据优惠卷id和用户id查询是否已经下过这个订单,如果下过这个订单,则不再下单,否则进行下单
直接修改(不加锁):
直接修改seckillvoucher方法:加入如下代码,但这样仍然无法确保一人一单,原因是若一个用户同时发起100条购买请求,这样查找出来的count都等于0,那这样100条线程都可以买了,仍无法实现一人一单
// 5.一人一单逻辑
// 5.1.用户id
Long userId = UserHolder.getUser().getId();
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
**存在问题:**现在的问题还是和之前一样,并发过来,查询数据库,都不存在订单,所以我们还是需要加锁,但是乐观锁比较适合更新数据,而现在是插入数据,所以我们需要使用悲观锁操作
**注意:**在这里提到了非常多的问题,我们需要慢慢的来思考,首先我们的初始方案是封装了一个createVoucherOrder方法,同时为了确保他线程安全,在方法上添加了一把synchronized 锁
改进版1:
修改voucherOrderServiceImpl:
seckillVoucher():
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始");
}
// 3.判断秒杀是否已经结束
if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已经结束
return Result.fail("秒杀已经结束");
}
// 4.判断库存是否充足
if (seckillVoucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足");
}
return this.createVoucherOrder(voucherId);
}
封装判断一人一单、修改库存和创建订单 为 createVoucherOrder():
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 5.0一人一单
// 5.1.用户id
Long userId = UserHolder.getUser().getId();
/*这里锁不放在方法上,这样做是因为若放在方法上,表明每当有用户来就会加锁,不管是不是同一个用户
*而是放在userId上,表明只对同一用户(查找UserId值一样的)加锁,避免资源过度消耗,锁粒度更细
*/
/*
*这里使用userId转为string,目的是因为字符串常量池的对象是唯一的,及时代码上new一个对象,但只要值相同,不会再重复
* userId(Long)转string 源码上是new了一个string对象,故要使用String.intern()表明先去常量池找到若equals相等(值一样),则返回其对象
* 保证了一个userId对应一个String对象
*/
/*
*这里有问题:
* 锁释放在synchorionzed{}执行完毕,而方法事务提交在@Transactional注解的方法执行完
* 如果在方法内部加锁,会导致锁释放了,而事务仍然没有提交,一但锁释放就会有线程进入,会导致问题发生,所以我们需要选择将当前方法整体包裹起来,确保事务不会出现问题,由此使用改进版2
*/
synchronized (userId.toString().intern()) {
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
//5,扣减库存
boolean success = this.seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherId)
.gt("stock", 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
voucherOrder.setUserId(UserHolder.getUser().getId());
// 6.3.优惠券id
voucherOrder.setVoucherId(voucherId);
this.save(voucherOrder);
return Result.ok(orderId);
}
}
改进版2:
基于改进版1做出修改:
修改seckillVoucher():
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始");
}
// 3.判断秒杀是否已经结束
if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已经结束
return Result.fail("秒杀已经结束");
}
// 4.判断库存是否充足
if (seckillVoucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足");
}
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
return this.createVoucherOrder(voucherId);}
/*
*改进版2在此处加锁,表明只有在事务方法createVoucherOrder执行完提交后后,才释放锁
* 保证了不会有锁释放前不会有其他线程 (锁的唯一性)
*/
/*异步,事务这些注解生效的原理,在于通过切面创建了代理类,通过操作代理类实现了异步,事务
*在同一类中声明异步、事务时,则不会创建代理类,故事务不会生效
*此处存在问题,我们只对createVoucherOrder加了事务,未对外部方法seckillVoucher添加事务
*因此采用this直接调用同类方法导致事务无法生效
*/
}
由于this调用会导致事务失效,故采用注入自身方法
声明后注入类后,调用方法(需要在Service接口中定义方法)
结果测试:
JMeter测试:200个请求同时发起,只有一个成功
数据库只有一个订单,且stock=99,表明用户只可购买一次
集群部署下出现并发安全问题:
观察到:
使用postman携带同一用户token验证一人一单:
数据库生成两个订单,证明在集群部署情况下无法保证一人一单
多台jvm:
产生安全问题原因:集群部署下或在分布式系统中,有多个jvm的存在,每个jvm都有自己的锁,导致每个锁都有线程获取。
需要实现多个jvm使用同一把(套)锁:跨jvm锁、跨进程锁