全局唯一ID
唯一ID的必要性
每个店铺都可以发布优惠券:
当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:
-
id的规律性太明显,容易被用户根据id的间隔来猜测到销量等商业信息,不够保密
-
受单表数据量的限制,mysql的id自增长有数值约束,且数据量大的情况下会进行分库分表,表不同自增长id可能相同,在分布式系统中是不允许的
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:
Redis恰好满足以上特性,为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:
ID的组成部分:符号位:1bit,永远为0
时间戳:31bit,以秒为单位,可以使用69年
序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID,这个序列号足够大,几乎不可能到达极限
redis实现全局唯一ID
获取当前时间戳的秒数
LocalDateTime time = LocalDateTime.of(2023, 9, 2, 0, 0, 0);
long of = time.toEpochSecond(ZoneOffset.UTC);
生成序列号,自增长的key为了防止一直使用该key,最后导致达到redis的上限,故需要拼接上日期,既防止达到上限又能方便统计同一天的下单量
//开始时间戳秒数
private static final long BEGIN_TIMESTAMP = 1693612800L;
//序列号位数
private static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public long nextId(String keyPrefix) {
//生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
//利用redis的自增生成序列号
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
Long increment = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
//拼接
return timestamp << COUNT_BITS | increment;
}
添加优惠券
每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购:
平价卷由于优惠力度并不是很大,所以是可以任意领取
而代金券由于优惠力度大,所以像第二种卷,就得限制数量,特价卷除了具有优惠卷的基本信息以外,还具有库存,抢购时间,结束时间等等字段
添加特价券
{"shopId":1,
"title":"100元代金券",
"subTitle":"周一至周五均可使用",
"rules":"全场通用\\n无需预约\\n可无限叠加\\不兑现、不找零\\n仅限堂食",
"payValue":8000,
"actualValue":10000,
"type":1,
"stock":100,
"beginTime":"2023-09-02T10:09:17",
"endTime":"2023-09-26T12:09:04"
}
由于没有后台管理系统,故使用postman进行post请求添加,需要关闭拦截器,同时设置有效的开始时间和结束时间,优惠券才会显示
实现秒杀下单
下单核心思路:当我们点击抢购时,会触发右侧的请求,我们只需要编写对应的controller即可,service层编写对应的代码操作数据库即可
下单时需要判断两点:
-
秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
-
库存是否充足,不足则无法下单
下单核心逻辑分析:
当用户开始进行下单,我们应当去查询优惠卷信息,查询到优惠卷信息,判断是否满足秒杀条件
比如时间是否充足,如果时间充足,则进一步判断库存是否足够,如果两者都满足,则扣减库存,创建订单,然后返回订单id,如果有一个条件不满足则直接结束
代码实现
由于涉及到优惠券表和优惠券订单表两张表的dml操作,需要加上@Transactional声明事务
@Transactional
@Override
public Result seckillVoucher(Long voucherId) {
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
//判断是否开始,开始时间如果在当前时间之后就是尚未开始
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始");
}
//判断是否结束,结束时间如果在当前时间之前就是已经结束
if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已经结束");
}
//判断库存是否充足
if (seckillVoucher.getStock() < 1) {
return Result.fail("库存不足");
}
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update();
if (!success) {
return Result.fail("库存不足");
}
VoucherOrder voucherOrder = new VoucherOrder();
//订单id
long orderId = redisIdWorker.nextId("order");
//用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
voucherOrder.setId(orderId);
save(voucherOrder);
return Result.ok(orderId);
}
超卖问题
模拟实现
使用jmeter模拟实现,注意带上请求头authorization,值为登录时的token的key
从数据库的库存中我们可以看到已经出现了超卖现象,库存出现了负数
超卖原因
我们原有的代码是这么写的
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("库存不足!");
}
假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。
解决方案
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:而对于加锁,我们通常有两种解决方案:见下图:
悲观锁:
悲观锁可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等
乐观锁:
乐观锁:会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过,当然乐观锁还有一些变种的处理方式比如cas,即查值进行比对,发现值没有被修改,认为线程安全进行修改值,解决线程安全问题
解决方案实现
采用乐观锁方案,对于优惠券库存我们并需要设置版本号,因为查询到的库存和最后修改数据时再查第二遍库存后,我们只需要将这两次库存量进行比较,就能知道库存是否被修改过即线程是否安全,且为了性能,我们会将修改数据时设置的条件,并不需要两次库存完全相同,只需要在进行修改时,加上库存大于0的条件即可,上面代码只需要修改此处即可
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).gt("stock",0).update();
开了两百个线程之后,异常率达到完美的50%,同时数据库数据正常
一人一单
优惠卷是为了引流,但是目前的情况是,一个人可以无限制的抢这个优惠卷,所以我们应当增加一层逻辑,让一个用户只能下一个单,而不是让一个用户下多个单
具体操作逻辑如下:比如时间是否充足,如果时间充足,则进一步判断库存是否足够,然后再根据优惠卷id和用户id查询是否已经下过这个订单,如果下过这个订单,则不再下单,否则进行下单
初步实现,在扣减库存前查询订单表,该用户是否已经下过单
@Transactional
@Override
public Result seckillVoucher(Long voucherId) {
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
//判断是否开始,开始时间如果在当前时间之后就是尚未开始
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始");
}
//判断是否结束,结束时间如果在当前时间之前就是已经结束
if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已经结束");
}
//判断库存是否充足
if (seckillVoucher.getStock() < 1) {
return Result.fail("库存不足");
}
Long userId = UserHolder.getUser().getId();
// 5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
//扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).gt("stock",0).update();
if (!success) {
return Result.fail("库存不足");
}
VoucherOrder voucherOrder = new VoucherOrder();
//订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
voucherOrder.setId(orderId);
save(voucherOrder);
return Result.ok(orderId);
}
还是出现了一人多张优惠券订单的情况
存在问题:现在的问题还是和之前一样,并发过来,查询数据库,都不存在订单,所以我们还是需要加锁,但是乐观锁比较适合更新数据,而现在是插入数据,所以我们需要使用悲观锁操作,可以直接在方法上直接加上synchronized 锁来解决
这样添加锁,锁的粒度太粗了,在使用锁过程中,控制锁粒度 是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会锁住,所以我们需要去控制锁的粒度,可以将用户下单的代码封装成一个方法,对该业务进行上锁,将锁的范围缩小,同时由于spring的事务必须等到锁释放之后才会提交,如果锁释放之后,有别的线程进入下单业务,而此时spring事务尚未提交,这就会造成订单尚未写入数据库,该线程仍会查到无订单,继续进行下单操作无法解决线程安全问题,所以我们要先提交事务才能释放锁,就能避免该问题。
最终实现
由于createVoucherOrder()要受事务控制,要注入IVoucherOrderService拿到代理对象,通过该代理对象调用该方法,事务才能生效,为了使事务提交在释放锁之前,可以将锁直接锁死事务方法。
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Autowired
private IVoucherOrderService voucherOrderService;
@Override
public Result seckillVoucher(Long voucherId) {
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
//判断是否开始,开始时间如果在当前时间之后就是尚未开始
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始");
}
//判断是否结束,结束时间如果在当前时间之前就是已经结束
if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已经结束");
}
//判断库存是否充足
if (seckillVoucher.getStock() < 1) {
return Result.fail("库存不足");
}
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
return voucherOrderService.createVoucherOrder(voucherId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
//扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).gt("stock", 0).update();
if (!success) {
return Result.fail("库存不足");
}
VoucherOrder voucherOrder = new VoucherOrder();
//订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
voucherOrder.setId(orderId);
save(voucherOrder);
return Result.ok(orderId);
}