2023.12.8
本章将用redis实现优惠劵秒杀下单的功能。
构建全局唯一ID
我们都有在店铺中抢过优惠券,优惠券也是一种商品,当用户抢购时,就会生成订单并保存到数据库对应的表中,而订单表如果使用数据库自增ID就存在一些问题:
- ID的规律性太明显:如果简单地使用数据库自增ID,很容易被人看出规律,比如今天ID是10,明天ID是110,那么就可以猜出这一天的订单量是100,这明显不合适。
- 受单表数据量的限制:随着订单量的增加,一张表终究是存不下那么多订单的,需要将数据库的表拆分成多张表,但是这几张表的id不能重复,因为用户可能需要凭着订单id查询售后相关的业务,所以这里id还需要保证唯一性。
这里就引出要介绍的全局ID生成器了。全局ID生成器是一种在分布式系统下用来生成全局唯一ID的工具,满足唯一性、高可用、高性能、递增性、安全性的特点。
这里我们使用 redis自增+拼接其他信息 的策略来生成全局唯一ID,即使用一个64bit的二进制数来充当全局ID,这64bit分为以下三部分:
- 符号位:1bit,永远为0,代表ID为正值。
- 时间戳:31bit,以秒为单位,可以使用69年。(2的31次方秒大概有68年多)
- 序列号:32bit,秒内的计数器,最大可以支持每秒产生2^32个不同ID,就算每秒全中国人一起生成id也是足够的。
下面根据该策略来生成全局唯一ID:
public class RedisIdWorker {
//开始时间戳
private static final long BEGIN_TIMESTAMP = 1640995200L;
//序列号的位数
private static final int COUNT_BITS = 32;
@Resource
private StringRedisTemplate stringRedisTemplate;
public long nextId(String keyPrefix){
//1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestemp = nowSecond-BEGIN_TIMESTAMP;
//2.生成序列号
//2.1获取当天日期,精确到天
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
//2.2自增长
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
//3.拼接并返回
return timestemp << COUNT_BITS | count;
}
}
因为时间戳返回值是long,所以最后拼接是用位运算拼接的,不能简单的用字符串拼接。
另外全局ID生成策略还有:UUID、雪花算法等等... 等有时间再去补。
实现秒杀下单
实现秒杀下单时需要考虑两个点:
- 秒杀活动是否开始或者结束,如果不在秒杀活动范围期间则无法下单。
- 秒杀券是否有库存,没库存了也不允许下单。
下面看一下整个代码的流程图:
即先判断一下满不满足下单要求,满足则扣减库存并创建订单,代码如下:
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private RedisIdWorker redisIdWorker;
@Resource
private ISeckillVoucherService seckillVoucherService;
@Override
@Transactional
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("库存不足");
}
//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代金券
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//7.返回订单id
return Result.ok(orderId);
}
}
超卖问题及解决办法
上述秒杀下单存在线程安全问题,在高并发场景下,可能会有多个线程同时对临界资源进行操作,这里的临界资源就是秒杀券的库存,这里使用jmeter来模拟一下高并发的场景:
首先秒杀券的库存为100,我们定义200个线程进行秒杀券的下单:
jmeter启动! 观察一下秒杀券的库存,发现是-9,这就是超卖问题。
这就是并发场景存在的安全问题,多个线程同时对临界资源进行访问就会存在这种问题,所以我们可以对临界资源加锁来解决此线程安全问题,锁又可以分为两种锁:
- 悲观锁:悲观锁比较悲观,认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。常见的悲观锁有Synchronized、Lock等。
- 乐观锁:乐观锁认为线程安全问题不一定会发生,因此不加锁,只是在更新数据的时候判断一下有没有其他线程对数据进行了修改,如果没有修改的话自己才能操作。
悲观锁比较简单粗暴,但是性能比乐观锁要差,这里我们只实现乐观锁。
乐观锁典型的实现方案:乐观锁会维护一个版本号字段,每次操作数据都会对版本号+1,再提交回数据时,会去校验版本号是否比之前大1 ,如果大1 说明除了自己没有其他人操作数据,则操作成功。否则就是其他人也在修改数据,操作失败。
在本项目中,可以直接使用stock(库存)充当版本号字段,只要stock发生改变了就相当于有其他线程在操作数据。
在jmeter的并发场景验证过程中,发现库存还有残余,并且大量线程的请求操作都失败了,这就是这种方案的弊端:成功率太低。 于是我们可以进一步的优化代码:只判断是否有剩余优惠券,即只要数据库中的库存大于0,都能顺利完成扣减库存操作。只需要改动库存扣减的代码:
//5.满足条件,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId).gt("stock",0)//乐观锁,查询一下stock有无变化,变化了就不能做修改操作
.update();
这下就能完美解决超卖问题了。
一人一单
这一节信息量有点大,有点难顶。
实际情况抢秒杀券的时候,通常是希望同一个用户对同一种秒杀券只能抢一次的,抢很多次的话那大概率就是黄牛了,所以我们需要限制一个用户只能下一单。
策略就是在判断库存充足的情况下:根据券id和用户id查询订单,如果订单存在,就需要限制该用户下单;不存在则可以下单。流程图更改为下图:
在判断库存充足之后添加一人一单的代码:
//一人只能下一单
Long userId = UserHolder.getUser().getId();
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();//查询订单数目
if(count > 0){
//用户已经购买过该秒杀券
return Result.fail("用户已经购买过一次!");
}
上述存在线程安全问题:由于一人一单代码和扣减库存代码之间是有间隙的,如果黄牛开多线程抢优惠券,可能有多个线程同时通过一人一单的代码,那么同一用户依然可以抢多张优惠券,这显然不能解决问题。
这里可以将一人一单代码和扣减库存代码提取到一个新方法createVoucherOrder中,然后使用悲观锁synchronized将其锁住确保这段方法一次只能有一个线程执行。
createVoucherOrder代码为:
@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
//一人只能下一单
Long userId = UserHolder.getUser().getId();
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();//查询订单数目
if(count > 0){
//用户已经购买过该秒杀券
return Result.fail("用户已经购买过一次!");
}
//5.满足条件,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId).gt("stock",0)//乐观锁,查询一下stock有无变化,变化了就不能做修改操作
.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(userId);
//6.3代金券
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//7.返回订单id
return Result.ok(orderId);
}
此时,这个锁的粒度太粗了,相当于所有线程都是串行执行,效率太低。我们希望的是锁住相同用户即可,不同用户没必要被锁住。因此我们可以使用用户id来加锁,减小加锁的范围:
@Transactional
public Result createVoucherOrder(Long voucherId) {
//一人只能下一单
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString()) {
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();//查询订单数目
if (count > 0) {
//用户已经购买过该秒杀券
return Result.fail("用户已经购买过一次!");
}
//5.满足条件,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId).gt("stock", 0)//乐观锁,查询一下stock有无变化,变化了就不能做修改操作
.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(userId);
//6.3代金券
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//7.返回订单id
return Result.ok(orderId);
}
}
此处有个小细节:我们希望同一用户id才加锁,但toString()函数底层其实是新new了一个对象的,也就是说就算两个用户id是一样的,tostring之后也是不同的对象,因此没法对其加锁。
为了解决这个问题,可以使用字符串的一个方法:intern,它能够返回字符串对象的规范表示,它会去字符串常量池里寻找值相同的字符串,确保能够锁住相同的用户id:
@Transactional
public Result createVoucherOrder(Long voucherId) {
//一人只能下一单
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();//查询订单数目
if (count > 0) {
//用户已经购买过该秒杀券
return Result.fail("用户已经购买过一次!");
}
//5.满足条件,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId).gt("stock", 0)//乐观锁,查询一下stock有无变化,变化了就不能做修改操作
.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(userId);
//6.3代金券
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//7.返回订单id
return Result.ok(orderId);
}
}
这里我们将锁定义在了方法内部,又会出并发问题:此处事务是在方法结束时提交,而锁在synchronized结束之后就释放了,无法保证在这短暂的时间里面不会有线程窜进来,此时由于事务还未提交,该线程查询订单数量依然为0,依然可以下单。
所以我们应该将整个函数锁起来:
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private RedisIdWorker redisIdWorker;
@Resource
private ISeckillVoucherService seckillVoucherService;
@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()) {
return createVoucherOrder(voucherId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
//一人只能下一单
Long userId = UserHolder.getUser().getId();
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();//查询订单数目
if (count > 0) {
//用户已经购买过该秒杀券
return Result.fail("用户已经购买过一次!");
}
//5.满足条件,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId).gt("stock", 0)//乐观锁,查询一下stock有无变化,变化了就不能做修改操作
.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(userId);
//6.3代金券
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//7.返回订单id
return Result.ok(orderId);
}
}
这样子就能保证锁一定是在事务提交之后才释放。
但还是有个小问题,这里调用的方法,其实是this.的方式调用的,事务想要生效,还得利用代理来生效,所以这个地方,我们需要获得原始的事务对象, 来操作事务,这里可以使用AopContext.currentProxy()
来获取当前对象的代理对象,然后再用代理对象调用方法,需要更改的代码如下:
synchronized (userId.toString().intern()) {
//获取代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
这里注意需要去IVoucherOrderService
中创建createVoucherOrder
方法,pom文件加入相关依赖,启动类加入相关注解,就不一一实现了。
最后使用jmeter来测试一下,黄牛还能不能使用多线程抢到多张优惠券了:
异常率高达99.5,说明黄牛的大量下单请求都失效了,再来看看数据库:
库存只少了一张优惠券,问题基本得到了解决。
你以为这就结束了吗?并没有,这里还存在集群条件下的线程安全问题,需要使用分布式锁来解决,这部分留到下一章继续学习。