reids-优惠券秒杀

0 目录

image-20240225111852122

1 全局ID生成器

1.1 背景

使用数据库自增ID会存在两个问题:

  • id的规律性太明显(如果我们的id具有太明显的规则,用户或者说商业对手很容易猜测出来我们的一些敏感信息,比如商城在一天时间内,卖出了多少单,这明显不合适)
  • 受单表数据量的限制(随着我们商城规模越来越大,mysql的单表的容量不宜超过500W,数据量过大之后,我们要进行拆库拆表,但拆分表了之后,他们从逻辑上讲他们是同一张表,所以他们的id是不能一样的, 于是乎我们需要保证id的唯一性。)

因此就需要就一个全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:

  • 唯一性:想到了关键字incrby
  • 高可用:集群方案、主从方案、哨兵方案
  • 高性能:内存存储性能好
  • 递增:采用递增
  • 安全性:自增然后再拼接一些其它信息,让规律不要那么明显

1.2 组成

数据库的id选择数值类型(占用空间更小),对应Java的Long(8字节,64比特位)

在这里插入图片描述

  • 符号位:1bit,永远为0
  • 时间戳:31bit,以秒为单位,可以使用69年
  • 序列号:32bit,秒内的计数器,支持每秒产生232个不同ID

1.3 代码

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
@Component
public class RedisWorker {
/**
* 把距离当前时间的偏移量作为时间戳
*/
private static final long BEGIN_TIMESTAMP = 计算出当前的时间戳;
/**
* 序列号的长度(位数)
*/
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 timestamp = nowSecond - BEGIN_TIMESTAMP;

// 2.生成序列号
// 2.1 获取当前日期,精确到天,保证一天生成一个key
// 2.2 自增长
String date = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
Long sequence = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);

//3.拼接返回
long id = (timestamp << COUNT_BITS) | sequence;
return id;
}
}

1.4 多线程测试

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
@Test
public void getId() throws InterruptedException {
ExecutorService pools = CacheClient.newFixedThreadPool(500);
// 程序计数器 设置的数量和循环数量一致
CountDownLatch latch = new CountDownLatch(300);

Runnable runnable = new Runnable() {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
long id = redisWorker.nextID("order");
System.out.println("id:" + id);
}
// 每一个线程跑完,就剪掉一次计数(倒计时)
latch.countDown();
}
};

runnable.run();

long begin = System.currentTimeMillis();
for (int i = 0; i < 300; i++) {
pools.submit(runnable);
}
latch.await(); //等待所有的执行完毕

long end = System.currentTimeMillis();
System.out.println("总时间是:" + (end - begin));
}

2 优惠券秒杀下单

2.1 接口

在这里插入图片描述

考虑的点:

  1. 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
  2. 库存是否充足,不足则无法下单

2.2 分析及代码实现

在这里插入图片描述

controller

1
2
3
4
5
6
7
8
9
10
11
@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {
@Autowired
private VoucherOrderServiceImpl voucherOrderService;

@PostMapping("seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
return voucherOrderService.seckillVoucher(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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

@Autowired
private ISeckillVoucherService iSeckillVoucherService;

@Autowired
private RedisWorker redisWorker;

@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1.查询秒杀优惠券信息
// select * from tb_seckill_voucher where voucher_id = ?
SeckillVoucher seckillVoucher = iSeckillVoucherService.getById(voucherId);

// 2.判断秒杀是否开始
LocalDateTime beginTime = seckillVoucher.getBeginTime();
LocalDateTime endTime = seckillVoucher.getEndTime();
LocalDateTime now = LocalDateTime.now();

if (now.isBefore(beginTime)) {
// 当前时间早于秒杀开始时间,说明秒杀没有开始
return Result.fail("秒杀尚未开始,请耐心等待!秒杀开始时间:" + beginTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
}
// 3.判断秒杀是否已经结束
if (now.isAfter(endTime)) {
// 当前时间晚于秒杀结束时间,说明秒杀结束了
return Result.fail("秒杀已经结束,感谢支持!");
}


// 4.判断库存是否充足
Integer stock = seckillVoucher.getStock();
if (stock <= 0) {
return Result.fail("商品已经售罄!");
}

// 5.扣减库存
boolean result = iSeckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherId).update();

if (!result) {
return Result.fail("商品已经售罄!");
}

// 6.创建订单
/**
* 获取订单id
*/
long orderId = redisWorker.nextID("order");

VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(orderId);
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(UserHolder.getUser().getId());

// 将订单信息保存到数据库
// insert into tb_voucher_order values ()
save(voucherOrder);

// 7.返回订单id
return Result.ok(orderId);
}
}

3 超卖问题

3.1 背景

如果只有同一时间只有一个用户下单没有任何的问题,但是此时如果统一时间有多个用户下单,QPS较高的情况此时就会出现并发的问题

3.2 超卖模拟

将库存恢复(tb_seckill_voucher),清空订单数据(tb_voucher_order),使用Jemeter

在这里插入图片描述

  • post请求设置

在这里插入图片描述

  • 配置头

在这里插入图片描述

  • 设置断言

  • 启动

在这里插入图片描述

  • 结果

在这里插入图片描述

在这里插入图片描述

3.3 原因分析

当库存还有1的时候,线程1查询库存的时候,得到库存为大于0,正准备扣减库存,此时正好线程2过来也进行查询,库存也是大于0,线程2同样准备扣减库存,当两个线程结束的时候,库存被-2了。

在这里插入图片描述

3.4 锁

在这里插入图片描述

3.4.1 悲观锁

悲观锁可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等。

3.4.2 乐观锁

乐观锁:增加一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功。

这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过。

当然乐观锁还有一些变种的处理方式比如cas:compare and set

3.5 解决

3.5.1 方案一

在这里插入图片描述

根据乐观锁分析,只需要比对和上次的version相同即可,如果版本相同那么证明是没有被修改过,此时扣减库存,同时version++,如果不同,那么证明已经被修改了。

但是发现库存和版本号同样也可以做到这个功能,因此无需多增加版本信息。

1
2
3
4
5
6
7
8
// 5.2扣减库存(针对超卖问题用乐观锁CAS解决)
// update tb_seckill_voucher set stock = stock -1 where voucher_id = ? and stock = ?
boolean result = iSeckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.eq("stock", stock)
.update();

即:比对库存是否相同即可。

问题:有很多失败的,售卖出去的很少,没有实现超卖

原因:这样修改只保证了同一时间查询出来的库存相同的时候,多个线程只有一个会成功,其他的都会失败。

3.5.2 方案二

针对方案1的问题,只需要将库存设置为大于0即可,不需要严格的按照cas去做

1
2
3
4
5
6
7
// 5.3扣减库存(针对使用乐观锁CAS,没卖完解决)
// update tb_seckill_voucher set stock = stock -1 where voucher_id = ? and stock > 0
boolean result = iSeckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();

4 一人一单

4.1 背景

在优惠券不超卖的情况下需要考虑一个问题:每个用户只能领取一个优惠券,不能多次重复的领取

在这里插入图片描述

4.2 修改

增加对优惠券id和用户id进行判断

在这里插入图片描述

1
2
3
4
5
6
7
8
9
Long userID = UserHolder.getUser().getId();
// 5.实现1人1单加入逻辑:根据优惠券id和用户id查询订单
// 5.1查询订单,并不用查询出具体的值,而是查询出数量即可
Integer count = query().eq("user_id", userID).eq("voucher_id", voucherId).count();
// 5.2判断订单是否存在
if (count > 0) {
// 5.2.1 存在就返回异常结果
return Result.fail("秒杀优惠券每人限购1张,感谢配合,本优惠券最终解释权归ty公司所有!");
}

Jemeter测试:

在这里插入图片描述

4.3 分析

此时问题出现在了查询至修改库存的地方,多线程过来之后,查询到都不存在订单,因此某几个线程还是会一起去创建订单

4.4 解决

使用悲观锁进行解决

对代码的分析,此时只需要对创建订单这个方法进行抽取,使用sychronized加锁

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
49
50
51
52
53
54
55
56
57
58
@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
Long userID = UserHolder.getUser().getId();
// 5.实现1人1单加入逻辑:根据优惠券id和用户id查询订单
// 5.1查询订单,并不用查询出具体的值,而是查询出数量即可
Integer count = query().eq("user_id", userID).eq("voucher_id", voucherId).count();
// 5.2判断订单是否存在
if (count > 0) {
// 5.2.1 存在就返回异常结果
return Result.fail("秒杀优惠券每人限购1张,感谢配合,本优惠券最终解释权归ty公司所有!");
}

// 5.2.2 不存在再减少库存

// 6.1扣减库存(会出现超卖问题)
// update tb_seckill_voucher set stock = stock -1 where voucher_id = ?
/*boolean result = iSeckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherId).update();*/

// 6.2扣减库存(针对超卖问题用乐观锁CAS解决)
// update tb_seckill_voucher set stock = stock -1 where voucher_id = ? and stock = ?
/*boolean result = iSeckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.eq("stock", stock)
.update();*/

// 6.3扣减库存(针对使用乐观锁CAS,没卖完解决)
// update tb_seckill_voucher set stock = stock -1 where voucher_id = ? and stock > 0
boolean result = iSeckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();

if (!result) {
return Result.fail("商品已经售罄!");
}

// 7.创建订单
/**
* 获取订单id
*/
long orderId = redisWorker.nextID("order");

VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(orderId);
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(userID);

// 将订单信息保存到数据库
// insert into tb_voucher_order values ()
save(voucherOrder);

//8.返回订单id
return Result.ok(orderId);
}

4.4.1 问题一

此时sychronized加锁的话,锁粒度很粗,直接锁住的是整个方法,导致性能较差,因此考虑缩小锁的范围

此时使用用户ID作为锁

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userID = UserHolder.getUser().getId();

// 通过悲观锁,锁住用户,实现一人一单
synchronized (userID.toString().intern()) { //如果不加Intern的话,那么在toString底层每次都是New一个新的对象,那么锁就失效了
// 5.实现1人1单加入逻辑:根据优惠券id和用户id查询订单
// 5.1查询订单,并不用查询出具体的值,而是查询出数量即可
Integer count = query().eq("user_id", userID).eq("voucher_id", voucherId).count();
// 5.2判断订单是否存在
if (count > 0) {
// 5.2.1 存在就返回异常结果
return Result.fail("秒杀优惠券每人限购1张,感谢配合,本优惠券最终解释权归ty公司所有!");
}

// 5.2.2 不存在再减少库存

// 6.1扣减库存(会出现超卖问题)
// update tb_seckill_voucher set stock = stock -1 where voucher_id = ?
/*boolean result = iSeckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherId).update();*/

// 6.2扣减库存(针对超卖问题用乐观锁CAS解决)
// update tb_seckill_voucher set stock = stock -1 where voucher_id = ? and stock = ?
/*boolean result = iSeckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.eq("stock", stock)
.update();*/

// 6.3扣减库存(针对使用乐观锁CAS,没卖完解决)
// update tb_seckill_voucher set stock = stock -1 where voucher_id = ? and stock > 0
boolean result = iSeckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();

if (!result) {
return Result.fail("商品已经售罄!");
}

// 7.创建订单
/**
* 获取订单id
*/
long orderId = redisWorker.nextID("order");

VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(orderId);
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(userID);

// 将订单信息保存到数据库
// insert into tb_voucher_order values ()
save(voucherOrder);

//8.返回订单id
return Result.ok(orderId);
}
}

4.4.2 问题二

当前方法加了@Transactional进行事务控制,在方法内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放也会导致问题,因此需要将方法整体包起来,确保事务不会出现问题

在这里插入图片描述

1
2
3
4
5
// 实现一人一单,锁住对象
Long userID = UserHolder.getUser().getId();
synchronized (userID.toString().intern()) {
return createVoucherOrder(voucherId);
}

4.4.3 问题三

此时事务的标志是在createVocherOrder方法上,并没有在seckillVoucher方法上,在调用createVocherOrder的时候使用的this方式里调用的,因此是VoucherOrderServiceImpl而不是VoucherOrderService的代理对象。因此我们需要获得原始的事务对象, 来操作事务。

在这里插入图片描述

VoucherOrderServiceImpl.java

1
2
3
4
5
6
7
8
// 实现一人一单,获取user对象锁
Long userID = UserHolder.getUser().getId();
synchronized (userID.toString().intern()) {
// 调用本类方法的时候,Spring事务是失效的,解决方案二:调用AopContext API
Object o = AopContext.currentProxy();
IVoucherOrderService proxy = (IVoucherOrderService) o;
return proxy.createVoucherOrder(voucherId);
}

IVoucherOrderService.java

1
2
3
4
5
6
7
public interface IVoucherOrderService extends IService<VoucherOrder> {

Result seckillVoucher(Long voucherId);

Result createVoucherOrder(Long voucherId);
}

pom依赖

1
2
3
4
5
6
<!-- Spring事务失效,采用AopContext API来处理 -->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>

HmDianPingApplication.java

1
@EnableAspectJAutoProxy(exposeProxy = true) //暴露代理对象,默认不暴露

5 集群模式下的一人一单

通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。

  1. 我们将服务启动两份,端口分别为8081和8082:

    在这里插入图片描述

  2. 重新指定新服务端口

  3. debug启动

    在这里插入图片描述

  4. nginx修改

    在这里插入图片描述

  5. 重启nginx

  6. 访问接口(http://localhost:8080/api/voucher/list/1),直至两个服务都有sql语句输出,这样模拟集群就生效了

  7. 使用postman配置2个http请求

    在这里插入图片描述

    在这里插入图片描述

5.1 问题

此时使用Postman发送完成后

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

此时发现2个请求在查询的时候count都是0,那么说明都是可以下单的,此时说明这个是有问题的。

5.2 分析

此时启动的另外一个服务,相当于是新一个JVM。如果在同一个应用下,JVM只有一个,此时锁监视器也只有一个,因此是锁可以实现互斥的,但是现在存在两个JVM,锁监视器在每个JVM中存在一个,因此无法实现互斥

在这里插入图片描述


reids-优惠券秒杀
https://baijianglai.cn/reids-优惠券秒杀/92ed990d85c4/
作者
Lai Baijiang
发布于
2024年2月25日
许可协议