优惠券秒杀 Redis实现全局唯一ID
在各类购物App中,都会遇到商家发放的优惠券
当用户抢购商品时,生成的订单会保存到tb_voucher_order表中,而订单表如果使用数据库自增ID就会存在一些问题
id规律性太明显
受单表数据量的限制
如果我们的订单id有太明显的规律,那么对于用户或者竞争对手,就很容易猜测出我们的一些敏感信息,例如商城一天之内能卖出多少单,这明显不合适
随着我们商城的规模越来越大,MySQL的单表容量不宜超过500W,数据量过大之后,我们就要进行拆库拆表,拆分表了之后,他们从逻辑上讲,是同一张表,所以他们的id不能重复,于是乎我们就要保证id的唯一性
那么这就引出我们的全局ID生成器了
全局ID生成器是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足一下特性
为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其他信息
ID组成部分
符号位:1bit,永远为0
时间戳:31bit,以秒为单位,可以使用69年(2^31秒约等于69年)
序列号:32bit,秒内的计数器,支持每秒传输2^32个不同ID
那我们就根据我们分析的ID生成策略,来编写代码
1 2 3 4 5 6 7 public static void main (String[] args) { LocalDateTime tmp = LocalDateTime.of(2022 , 1 , 1 , 0 , 0 , 0 ); System.out.println(tmp.toEpochSecond(ZoneOffset.UTC)); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @Component public class RedisIdWorker { @Autowired private StringRedisTemplate stringRedisTemplate; public static final Long BEGIN_TIMESTAMP = 1640995200L ; public static final Long COUNT_BIT = 32L ; public long nextId (String keyPrefix) { LocalDateTime now = LocalDateTime.now(); long currentSecond = now.toEpochSecond(ZoneOffset.UTC); long timeStamp = currentSecond - BEGIN_TIMESTAMP; String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd" )); long count = stringRedisTemplate.opsForValue().increment("inc:" +keyPrefix+":" +date); return timeStamp << COUNT_BIT | count; } }
添加优惠券
每个店铺度可以发布优惠券,分为平价券和特价券,平价券可以任意购买,而特价券需要秒杀抢购
tb_voucher:优惠券的基本信息,优惠金额、使用规则等
Field
Type
Collation
Null
Key
Default
Extra
Comment
id
bigint unsigned
(NULL)
NO
PRI
(NULL)
auto_increment
主键
shop_id
bigint unsigned
(NULL)
YES
(NULL)
商铺id
title
varchar(255)
utf8mb4_general_ci
NO
(NULL)
代金券标题
sub_title
varchar(255)
utf8mb4_general_ci
YES
(NULL)
副标题
rules
varchar(1024)
utf8mb4_general_ci
YES
(NULL)
使用规则
pay_value
bigint unsigned
(NULL)
NO
(NULL)
支付金额,单位是分。例如200代表2元
actual_value
bigint
(NULL)
NO
(NULL)
抵扣金额,单位是分。例如200代表2元
type
tinyint unsigned
(NULL)
NO
0
0,普通券;1,秒杀券
status
tinyint unsigned
(NULL)
NO
1
1,上架; 2,下架; 3,过期
create_time
timestamp
(NULL)
NO
CURRENT_TIMESTAMP
DEFAULT_GENERATED
创建时间
update_time
timestamp
(NULL)
NO
CURRENT_TIMESTAMP
DEFAULT_GENERATED on update CURRENT_TIMESTAMP
更新时间
tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息
Field
Type
Collation
Null
Key
Default
Extra
Comment
voucher_id
bigint unsigned
(NULL)
NO
PRI
(NULL)
关联的优惠券的id
stock
int
(NULL)
NO
(NULL)
库存
create_time
timestamp
(NULL)
NO
CURRENT_TIMESTAMP
DEFAULT_GENERATED
创建时间
begin_time
timestamp
(NULL)
NO
CURRENT_TIMESTAMP
DEFAULT_GENERATED
生效时间
end_time
timestamp
(NULL)
NO
CURRENT_TIMESTAMP
DEFAULT_GENERATED
失效时间
update_time
timestamp
(NULL)
NO
CURRENT_TIMESTAMP
DEFAULT_GENERATED on update CURRENT_TIMESTAMP
更新时间
平价券由于优惠力度并不是很大,所以是可以任意领取
而代金券由于优惠力度大,所以像第二种券,就得限制数量,从表结构上也能看出,特价券除了具有优惠券的基本信息以外,还具有库存,抢购时间,结束时间等等字段
添加优惠券的代码已经提供好了
1 2 3 4 5 6 7 8 9 10 @PostMapping public Result addVoucher (@RequestBody Voucher voucher) { voucherService.save(voucher); return Result.ok(voucher.getId()); }
新增秒杀券
新增秒杀券主要看addSeckillVoucher中的业务逻辑
1 2 3 4 5 6 7 8 9 10 @PostMapping("seckill") public Result addSeckillVoucher (@RequestBody Voucher voucher) { voucherService.addSeckillVoucher(voucher); return Result.ok(voucher.getId()); }
新增秒杀券业务逻辑
秒杀券可以看做是一种特殊的普通券,将普通券信息保存到普通券表中,同时将秒杀券的数据保存到秒杀券表中,通过券的ID进行关联
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Override @Transactional public void addSeckillVoucher (Voucher voucher) { save(voucher); SeckillVoucher seckillVoucher = new SeckillVoucher (); seckillVoucher.setVoucherId(voucher.getId()); seckillVoucher.setStock(voucher.getStock()); seckillVoucher.setBeginTime(voucher.getBeginTime()); seckillVoucher.setEndTime(voucher.getEndTime()); seckillVoucherService.save(seckillVoucher); }
1 2 3 4 5 6 7 8 9 10 11 12 13 { "shopId" : 1 , "title" : "100元代金券" , "subTitle" : "周一至周五可用" , "rules" : "全场通用\\n无需预约\\n可无限叠加" , "payValue" : 8000 , "actualValue" : 10000 , "type" : 1 , "stock" : 100 , "beginTime" : "2022-01-01T00:00:00" , "endTime" : "2022-10-31T23:59:59" }
效果如下
实现秒杀下单
1 2 3 请求网址: http://localhost:8080/api/voucher-order/seckill/13 请求方法: POST
看样子是VoucherOrderController里的方法
1 2 3 4 5 6 7 8 9 @RestController @RequestMapping("/voucher-order") public class VoucherOrderController { @PostMapping("seckill/{id}") public Result seckillVoucher (@PathVariable("id") Long voucherId) { return Result.fail("功能未完成" ); } }
那我们现在来分析一下怎么抢优惠券
首先提交优惠券id,然后查询优惠券信息
之后判断秒杀时间是否开始
开始了,则判断是否有剩余库存
有库存,那么删减一个库存
无库存,则返回一个错误信息
没开始,则返回一个错误信息
对应的流程图如下
那现在我们就根据我们刚刚的分析和流程图,来编写对应的代码
VoucherOrderController
具体的业务逻辑我们还是放到Service层里写,在Service层创建seckillVoucher方法
1 2 3 4 5 6 7 8 9 10 @RestController @RequestMapping("/voucher-order") public class VoucherOrderController { @Autowired private IVoucherOrderService voucherOrderService; @PostMapping("/seckill/{id}") public Result seckillVoucher (@PathVariable("id") Long voucherId) { return voucherOrderService.seckillVoucher(voucherId); } }
1 2 3 public interface IVoucherOrderService extends IService <VoucherOrder> { Result seckillVoucher (Long voucherId) ; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 @Autowired private ISeckillVoucherService seckillVoucherService;@Autowired private RedisIdWorker redisIdWorker;@Override public Result seckillVoucher (Long voucherId) { LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper <>(); queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId); SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper); if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) { return Result.fail("秒杀还未开始,请耐心等待" ); } if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) { 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 (); long orderId = redisIdWorker.nextId("order" ); Long id = UserHolder.getUser().getId(); voucherOrder.setVoucherId(voucherId); voucherOrder.setId(orderId); voucherOrder.setUserId(id); save(voucherOrder); return Result.ok(orderId); }
超卖问题
我们之前的代码其实是有问题的,当遇到高并发场景时,会出现超卖现象,我们可以用Jmeter开200个线程来模拟抢优惠券的场景,URL为 localhost:8081/voucher-order/seckill/13,请求方式为POST
注意使用Jmeter进行压测时,需要携带我们登录的token
测试完毕之后,查看数据库中的订单表,我们明明只设置了100张优惠券,却有166条数据,去优惠券表查看,库存为-66,超卖了66张
那么如何解决这个问题呢?先来看看我们的代码中是怎么写的
1 2 3 4 5 6 7 8 9 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("库存不足" ); }
假设现在只剩下一张优惠券,线程1过来查询库存,判断库存数大于1,但还没来得及去扣减库存,此时库线程2也过来查询库存,发现库存数也大于1,那么这两个线程都会进行扣减库存操作,最终相当于是多个线程都进行了扣减库存,那么此时就会出现超卖问题
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:而对于加锁,我们通常有两种解决方案
悲观锁
悲观锁认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行
例如Synchronized、Lock等,都是悲观锁
乐观锁
乐观锁认为线程安全问题不一定会发生,因此不加锁,只是在更新数据的时候再去判断有没有其他线程对数据进行了修改
如果没有修改,则认为自己是安全的,自己才可以更新数据
如果已经被其他线程修改,则说明发生了安全问题,此时可以重试或者异常
悲观锁:悲观锁可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等
乐观锁:乐观锁会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过,当然乐观锁还有一些变种的处理方式比如CAS
乐观锁的典型代表:就是CAS(Compare-And-Swap),利用CAS进行无锁化机制加锁,var5 是操作前读取的内存值,while中的var1+var2 是预估值,如果预估值 == 内存值,则代表中间没有被人修改过,此时就将新值去替换 内存值
1 2 3 4 5 int var5;do { var5 = this .getIntVolatile(var1, var2); } while (!this .compareAndSwapInt(var1, var2, var5, var5 + var4)); return var5;
其中do while是为了操作失败时,再次进行自旋操作,即把之前的逻辑再操作一次
该项目中的具体解决方式
这里并不需要真的来指定一下版本号,完全可以使用stock来充当版本号,在扣减库存时,比较查询到的优惠券库存和实际数据库中优惠券库存是否相同
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 @Override public Result seckillVoucher(Long voucherId) { LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>(); //1. 查询优惠券 queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId); SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper); //2. 判断秒杀时间是否开始 if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) { return Result.fail("秒杀还未开始,请耐心等待"); } //3. 判断秒杀时间是否结束 if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) { return Result.fail("秒杀已经结束!"); } //4. 判断库存是否充足 if (seckillVoucher.getStock() < 1) { return Result.fail("优惠券已被抢光了哦,下次记得手速快点"); } //5. 扣减库存 boolean success = seckillVoucherService.update() .setSql("stock = stock - 1") .eq("voucher_id", voucherId) + .eq("stock",seckillVoucher.getStock()) .update(); if (!success) { return Result.fail("库存不足"); } //6. 创建订单 VoucherOrder voucherOrder = new VoucherOrder(); //6.1 设置订单id long orderId = redisIdWorker.nextId("order"); //6.2 设置用户id Long id = UserHolder.getUser().getId(); //6.3 设置代金券id voucherOrder.setVoucherId(voucherId); voucherOrder.setId(orderId); voucherOrder.setUserId(id); //7. 将订单数据保存到表中 save(voucherOrder); //8. 返回订单id return Result.ok(orderId); }
以上逻辑的核心含义是:只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设100个线程同时都拿到了100的库存,然后大家一起去进行扣减,但是100个人中只有1个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败
那么我们继续完善代码,修改我们的逻辑,在这种场景,我们可以只判断是否有剩余优惠券,即只要数据库中的库存大于0,都能顺利完成扣减库存操作
重启服务器,继续使用Jmeter进行测试,这次就能顺利将优惠券刚好抢空了
一人一单
需求:修改秒杀业务,要求同一个优惠券,一个用户只能抢一张
具体操作逻辑如下:我们在判断库存是否充足之后,根据我们保存的订单数据,判断用户订单是否已存在
如果已存在,则不能下单,返回错误信息
如果不存在,则继续下单,获取优惠券
初步代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 @Override public Result seckillVoucher(Long voucherId) { LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>(); //1. 查询优惠券 queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId); SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper); //2. 判断秒杀时间是否开始 if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) { return Result.fail("秒杀还未开始,请耐心等待"); } //3. 判断秒杀时间是否结束 if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) { return Result.fail("秒杀已经结束!"); } //4. 判断库存是否充足 if (seckillVoucher.getStock() < 1) { return Result.fail("优惠券已被抢光了哦,下次记得手速快点"); } + // 一人一单逻辑 + Long userId = UserHolder.getUser().getId(); + int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count(); + if (count > 0){ + return Result.fail("你已经抢过优惠券了哦"); + } //5. 扣减库存 boolean success = 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"); //6.2 设置用户id Long id = UserHolder.getUser().getId(); //6.3 设置代金券id voucherOrder.setVoucherId(voucherId); voucherOrder.setId(orderId); voucherOrder.setUserId(id); //7. 将订单数据保存到表中 save(voucherOrder); //8. 返回订单id return Result.ok(orderId); }
存在问题:还是和之前一样,如果这个用户故意开多线程抢优惠券,那么在判断库存充足之后,执行一人一单逻辑之前,在这个区间如果进来了多个线程,还是可以抢多张优惠券的,那我们这里使用悲观锁来解决这个问题
初步代码,我们把一人一单逻辑之后的代码都提取到一个createVoucherOrder方法中,然后给这个方法加锁
不管哪一个线程(例如线程A),运行到这个方法时,都要检查有没有其它线程B(或者C、 D等)正在用这个方法(或者该类的其他同步方法),有的话要等正在使用synchronized方法的线程B(或者C 、D)运行完这个方法后再运行此线程A,没有的话,锁定调用者,然后直接运行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 private Result createVoucherOrder (Long voucherId) { Long userId = UserHolder.getUser().getId(); int count = query().eq("voucher_id" , voucherId).eq("user_id" , userId).count(); 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 (); long orderId = redisIdWorker.nextId("order" ); Long id = UserHolder.getUser().getId(); voucherOrder.setVoucherId(voucherId); voucherOrder.setId(orderId); voucherOrder.setUserId(id); save(voucherOrder); return Result.ok(orderId); }
但是这样加锁,锁的细粒度太粗了,在使用锁的过程中,控制锁粒度是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会被锁住,现在的情况就是所有用户都公用这一把锁,串行执行,效率很低,我们现在要完成的业务是一人一单,所以这个锁,应该只加在单个用户上,用户标识可以用userId
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 @Transactional public Result createVoucherOrder (Long voucherId) { Long userId = UserHolder.getUser().getId(); synchronized (userId.toString().intern()) { int count = query().eq("voucher_id" , voucherId).eq("user_id" , userId).count(); 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 (); long orderId = redisIdWorker.nextId("order" ); Long id = UserHolder.getUser().getId(); voucherOrder.setVoucherId(voucherId); voucherOrder.setId(orderId); voucherOrder.setUserId(id); save(voucherOrder); return Result.ok(orderId); } }
由于toString的源码是new String,所以如果我们只用userId.toString()拿到的也不是同一个用户,需要使用intern(),如果字符串常量池中已经包含了一个等于这个string对象的字符串(由equals(object)方法确定),那么将返回池中的字符串。否则,将此String对象添加到池中,并返回对此String对象的引用。
1 2 3 4 5 6 7 8 public static String toString (long i) { if (i == Long.MIN_VALUE) return "-9223372036854775808" ; int size = (i < 0 ) ? stringSize(-i) + 1 : stringSize(i); char [] buf = new char [size]; getChars(i, size, buf); return new String (buf, true ); }
但是以上代码还是存在问题,问题的原因在于当前方法被Spring的事务控制,如果你在内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放了,这样也会导致问题,所以我们选择将当前方法整体包裹起来,确保事务不会出现问题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Override public Result seckillVoucher (Long voucherId) { LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper <>(); queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId); SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper); if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) { return Result.fail("秒杀还未开始,请耐心等待" ); } if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) { return Result.fail("秒杀已经结束!" ); } if (seckillVoucher.getStock() < 1 ) { return Result.fail("优惠券已被抢光了哦,下次记得手速快点" ); } Long userId = UserHolder.getUser().getId(); synchronized (userId.toString().intern()) { return createVoucherOrder(voucherId); } }
但是以上做法依然有问题,因为你调用的方法,其实是this.的方式调用的,事务想要生效,还得利用代理来生效,所以这个地方,我们需要获得原始的事务对象, 来操作事务,这里可以使用AopContext.currentProxy()来获取当前对象的代理对象,然后再用代理对象调用方法,记得要去IVoucherOrderService中创建createVoucherOrder方法
1 2 3 4 5 Long userId = UserHolder.getUser().getId();synchronized (userId.toString().intern()) { IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); return proxy.createVoucherOrder(voucherId); }
1 2 3 4 <dependency > <groupId > org.aspectj</groupId > <artifactId > aspectjweaver</artifactId > </dependency >
同时在启动类上加上@EnableAspectJAutoProxy(exposeProxy = true)注解
1 2 3 4 5 6 7 8 9 @MapperScan("com.hmdp.mapper") @SpringBootApplication @EnableAspectJAutoProxy(exposeProxy = true) public class HmDianPingApplication { public static void main (String[] args) { SpringApplication.run(HmDianPingApplication.class, args); } }
重启服务器,再次使用Jmeter测试,200个线程并发,但是只能抢到一张优惠券,目的达成
集群环境下的并发问题
通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了
我们将服务启动两份,端口分别为8081和8082
然后修改nginx的config目录下的nginx.conf文件,配置反向代理和负载均衡(默认轮询就行)
具体操作,我们使用POSTMAN发送两次请求,header携带同一用户的token,尝试用同一账号抢两张优惠券,发现是可行的。
失败原因分析:由于我们部署了多个Tomcat,每个Tomcat都有一个属于自己的jvm,那么假设在服务器A的Tomcat内部,有两个线程,即线程1和线程2,这两个线程使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的。但是如果在Tomcat的内部,又有两个线程,但是他们的锁对象虽然写的和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2互斥
这就是集群环境下,syn锁失效的原因,在这种情况下,我们需要使用分布式锁来解决这个问题,让锁不存在于每个jvm的内部,而是让所有jvm公用外部的一把锁(Redis)