优惠券秒杀
# 优惠券秒杀
# 3. 你在实现优惠券秒杀有出现哪些问题?
- 设计优惠券
- 实现抢优惠券逻辑
- 超卖问题
- 一人一单问题
- 集群情况下,并发问题
# 1. 设计优惠券
当用户抢购商品时,生成的订单会保存到 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 功能封装起来:
@Component
public class RedisIdWorker {
// 开始时间戳
private static final long BEGIN_TIMESTAMP = 1640995200L;
// 时间戳位数位移数,也就是序列号的位数
private static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
// 传入一个key前缀,在不同程序下生成的全局Id不一样
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.自增长
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3.拼接并返回
return timestamp << COUNT_BITS | count;
}
}
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
# 2. 实现抢优惠券逻辑
- 首先提交优惠券 id,然后查询优惠券信息
- 之后判断秒杀时间是否开始
- 开始了,则判断是否有剩余库存
- 有库存,那么扣减一个库存
- 然后创建订单
- 无库存,则返回一个错误信息
- 有库存,那么扣减一个库存
- 没开始,则返回一个错误信息
- 开始了,则判断是否有剩余库存
- 创建成功,返回订单 id

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)
.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);
}
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
# 3. 超卖问题
导致超卖问题的原因:假设现在只剩下一张优惠券,线程 1 过来查询库存,判断库存数大于 1,但还没来得及去扣减库存,此时线程 2 也过来查询库存,发现库存数也大于 1,那么这两个线程都会进行扣减库存操作,最终相当于是多个线程都进行了扣减库存,那么此时就会出现超卖问题。
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁。而对于加锁,我们通常有两种解决方案:
- 悲观锁
- 悲观锁认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。
- 例如
Synchronized、Lock等都是悲观锁。
- 乐观锁
- 乐观锁认为线程安全问题不一定会发生,因此不加锁,只是在更新数据的时候再去判断有没有其他线程对数据进行了修改。
- 如果没有修改,则认为自己是安全的,自己才可以更新数据。
- 如果已经被其他线程修改,则说明发生了安全问题,此时可以重试或者异常。
- 乐观锁认为线程安全问题不一定会发生,因此不加锁,只是在更新数据的时候再去判断有没有其他线程对数据进行了修改。
悲观锁:悲观锁可以实现对于数据的串行化执行,比如 syn 和 lock 都是悲观锁的代表。同时,悲观锁中又可以再细分为公平锁、非公平锁、可重入锁等等。
乐观锁:乐观锁会有一个版本号,每次操作数据会对版本号 +1,再提交回数据时,会去校验是否比之前的版本大 1。如果大 1,则进行操作成功。这套机制的核心逻辑在于:如果在操作过程中,版本号只比原来大 1,那么就意味着操作过程中没有人对它进行过修改,它的操作就是安全的;如果不大 1,则数据被修改过。当然乐观锁还有一些变种的处理方式比如 CAS。
这里并不需要真的来指定一下版本号,完全可以使用 stock 来充当版本号,在扣减库存时,比较查询到的优惠券库存和实际数据库中优惠券库存是否相同。
在扣减库存的语句里添加一段判断语句 eq("stock", seckillVoucher.getStock()):
// 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("库存不足");
}
2
3
4
5
6
7
8
9
10
11
12
13
不过这样也会出现一个问题:以上逻辑的核心含义是,只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的。但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设 100 个线程同时都拿到了 100 的库存,然后大家一起去进行扣减,但是 100 个人中只有 1 个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败。
所以继续完善代码:在这种场景,我们可以只判断是否有剩余优惠券,即只要数据库中的库存大于 0,都能顺利完成扣减库存操作。
去掉原来的判断,改成 gt("stock", 0):
// 5. 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!success) {
return Result.fail("库存不足");
}
2
3
4
5
6
7
8
9
# 4. 一人一单问题
- 需求:修改秒杀业务,要求同一个优惠券,一个用户只能抢一张。
- 具体操作逻辑:我们在判断库存是否充足之后,根据我们保存的订单数据,判断用户订单是否已存在
- 如果已存在,则不能下单,返回错误信息
- 如果不存在,则继续下单,获取优惠券
在扣减库存前,加上判断该用户是否抢过优惠券:
// 一人一单逻辑
Long userId = UserHolder.getUser().getId();
int count = query().eq("voucherId", voucherId).eq("userId", userId).count();
if (count > 0) {
return Result.fail("你已经抢过优惠券了哦");
}
2
3
4
5
6
- 存在问题:还是和之前一样,如果这个用户故意开多线程抢优惠券,那么在判断库存充足之后、执行一人一单逻辑之前,在这个区间如果进来了多个线程,还是可以抢多张优惠券的。那我们这里使用悲观锁来解决这个问题。
- 初步代码:我们把一人一单逻辑之后的代码都提取到一个
createVoucherOrder方法中,然后给这个方法加锁。不管哪一个线程(例如线程 A),运行到这个方法时,都要检查有没有其它线程 B(或者 C、D 等)正在用这个方法(或者该类的其他同步方法),有的话要等正在使用 synchronized 方法的线程 B(或者 C、D)运行完这个方法后再运行此线程 A,没有的话,锁定调用者,然后直接运行。
private Result createVoucherOrder(Long voucherId) {
// 一人一单逻辑
Long userId = UserHolder.getUser().getId();
int count = query().eq("voucherId", voucherId).eq("userId", userId).count();
if (count > 0) {
return Result.fail("你已经抢过优惠券了哦");
}
// 略......
}
2
3
4
5
6
7
8
9
但是这样加锁,锁的细粒度太粗了。在使用锁的过程中,控制锁粒度是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会被锁住。现在的情况就是所有用户都公用这一把锁,串行执行,效率很低。我们现在要完成的业务是一人一单,所以这个锁应该只加在单个用户上,用户标识可以用 userId:
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 一人一单逻辑
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
int count = query().eq("voucherId", voucherId).eq("userId", userId).count();
if (count > 0) {
return Result.fail("你已经抢过优惠券了哦");
}
// 略....
}
// 执行到这里,锁已经被释放了,但是可能当前事务还未提交,如果此时有线程进来,不能确保事务不出问题
}
2
3
4
5
6
7
8
9
10
11
12
13
由于 toString 的源码是 new String,所以如果我们只用 userId.toString() 拿到的也不是同一个用户,需要使用 intern()。如果字符串常量池中已经包含了一个等于这个 String 对象的字符串(由 equals(Object) 方法确定),那么将返回池中的字符串。否则,将此 String 对象添加到池中,并返回对此 String 对象的引用。
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);
}
2
3
4
5
6
7
8
但是以上代码还是存在问题,问题的原因在于当前方法被 Spring 的事务控制,如果你在内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放了,这样也会导致问题。所以我们选择将当前方法整体包裹起来,确保事务不会出现问题:
@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();
synchronized (userId.toString().intern()) {
return createVoucherOrder(voucherId);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
但是以上做法依然有问题,因为你调用的方法其实是 this. 的方式调用的,事务想要生效,还得利用代理来生效。所以这个地方,我们需要获得原始的事务对象来操作事务,这里可以使用 AopContext.currentProxy() 来获取当前对象的代理对象,然后再用代理对象调用方法。记得要去 IVoucherOrderService 中创建 createVoucherOrder 方法:
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
2
3
4
5
但是该方法会用到一个依赖,我们需要导入一下:
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
2
3
4
同时在启动类上加上 @EnableAspectJAutoProxy(exposeProxy = true) 注解:
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)
public class HmDianPingApplication {
public static void main(String[] args) {
SpringApplication.run(HmDianPingApplication.class, args);
}
}
2
3
4
5
6
7
8