秒杀券功能

乐观锁解决超卖问题

​ 秒杀系统的难点在于解决高并发、多请求,在以上情况下非常容易出现超卖现象,即卖出去商品的数量大于商品的库存数量。出现这种问题的原因在于,我们判断一个用户能否购买该券的前提是从数据库中查到的券的库存数量是否大于0,某一时刻同时来了大量请求假设为100,此时它们查到的数据都大于0假设为5,于是每个线程都会去更新数据库,导致数据库中库存的值为-95,这就造成了超卖。

image-20240309192938068

​ 解决该问题的常见方法就是加锁,这里介绍两种比较常用的锁。

  1. 悲观锁:这种锁的特点就是对于每个请求都需要拿到锁之后才能继续执行,并且同一时刻只能有一个请求拿到锁。其优点就是成功率高,缺点是由于每个线程都需要进行排队,效率低下。
  2. 乐观锁:这种锁的特点它认为线程安全问题不一定会发生,只在数据库更新数据时判断此时是否有人修改过数据,如果数据被修改过,则不更新数据。这种锁的优点是支持线程并发,性能高,缺点是请求成功率低——只要数据不对就需要不断重试。

​ 针对乐观锁的设计,一般有两种方案。

  1. 版本号法:在数据库表字段中再增加一个版本字段,查询时同时查数据和版本号,更新时判断此时版本号和之前的版本是否一致
  2. 直接使用要查询的数据作为版本字段,更新时判断此时的数据和之前查询的数据是否一致

​ 本项目采用的是乐观锁并且使用的是第二种设计方案,为了提高请求的成功率,在更新数据时我们只需要判断库存是否大于0即可(只要此时库存大于0,即使和之前查询到的结果不一致依然不会出现超卖现象)。

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
package com.hmdp.service.impl;

import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.SeckillVoucherMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.UniqueIdGenerator;
import com.hmdp.utils.UserHolder;
import org.springframework.stereotype.Service;
import org.springframework.util.IdGenerator;

import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.UUID;

/**
* <p>
* 秒杀优惠券表,与优惠券是一对一关系 服务实现类
* </p>
*
* @author dch
* @since 2024-03-08
*/
@Service
public class SeckillVoucherServiceImpl extends ServiceImpl<SeckillVoucherMapper, SeckillVoucher> implements ISeckillVoucherService {
@Resource
private UniqueIdGenerator uniqueIdGenerator;
public Result seckillVoucher(Long id){
//1.根据id从数据库中查询对应的优惠券
SeckillVoucher seckillVoucher = getById(id);
//2.判断该优惠券是否开始以及结束
if(seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("活动未开始");
}
if(seckillVoucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("活动已结束");
}
//3.查询库存是否充足
Integer stock = seckillVoucher.getStock();
if(stock < 1){
return Result.fail("库存不足");
}
//4.更新库存并使用乐观锁防止超卖问题
boolean success = update().setSql("stock = stock - 1").eq("voucher_id", id).gt("stock",0).update();
if(!success){
return Result.fail("库存不足");
}
//5.生成订单
VoucherOrder voucherOrder = new VoucherOrder();
//5.1 填入订单id,使用全局唯一id生成器生成
long voucherOrderId = uniqueIdGenerator.generatorId("voucherOrder");
voucherOrder.setId(voucherOrderId);
//5.2 填入用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
//5.3 填入优惠券id
voucherOrder.setVoucherId(id);

return Result.ok(voucherOrder);
}
}

​ 其实还有另一种方案,既可以提高使用悲观锁的性能又能提高乐观锁的成功率——分库分表,不同的请求可以访问不同数据库。

一人一单功能

​ 由于是秒杀券,商家肯定不希望某个人获得全部的券,而是保证一个用户只能获得一张券。

​ 一种简单的实现方案为查询数据库中是否已存在该用户购买这张券的记录,如果存在则报错。

image-20240309195812264

​ 但是这种做法也存在和超卖现象一样的问题。因此,也必须使用锁机制来预防该问题,和超卖问题不同的是该问题不能使用乐观锁,只能使用悲观锁即snychronized等工具。那么这就有两个问题:以什么作为锁?把锁加在何处?

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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
package com.hmdp.service.impl;

import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.SeckillVoucherMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.UniqueIdGenerator;
import com.hmdp.utils.UserHolder;
import org.springframework.aop.framework.AopContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.IdGenerator;

import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.UUID;

/**
* <p>
* 秒杀优惠券表,与优惠券是一对一关系 服务实现类
* </p>
*
* @author dch
* @since 2024-03-08
*/
@Service
public class SeckillVoucherServiceImpl extends ServiceImpl<SeckillVoucherMapper, SeckillVoucher> implements ISeckillVoucherService {
@Resource
private UniqueIdGenerator uniqueIdGenerator;
public Result seckillVoucher(Long id){
//1.根据id从数据库中查询对应的优惠券
SeckillVoucher seckillVoucher = getById(id);
//2.判断该优惠券是否开始以及结束
if(seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("活动未开始");
}
if(seckillVoucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("活动已结束");
}
//3.查询库存是否充足
Integer stock = seckillVoucher.getStock();
if(stock < 1){
return Result.fail("库存不足");
}

Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()){
//获取代理对象,避免自身调用导致事务失效
ISeckillVoucherService currentProxy = (ISeckillVoucherService) AopContext.currentProxy();
return currentProxy.createVoucherOrder(id);
}
}

@Transactional
public Result createVoucherOrder(Long id){
//4.一人一单判断
Long userId = UserHolder.getUser().getId();
Integer count = query().eq("user_id", userId).eq("voucher_id", id).count();
if(count > 0){
//该用户已经下过单
return Result.fail("每个用户只能购买一次");
}
//5.更新库存并使用乐观锁防止超卖问题
boolean success = update().setSql("stock = stock - 1").eq("voucher_id", id).gt("stock",0).update();
if(!success){
return Result.fail("库存不足");
}
//6.生成订单
VoucherOrder voucherOrder = new VoucherOrder();
//6.1 填入订单id,使用全局唯一id生成器生成
long voucherOrderId = uniqueIdGenerator.generatorId("voucherOrder");
voucherOrder.setId(voucherOrderId);
//6.2 填入用户id
voucherOrder.setUserId(userId);
//6.3 填入优惠券id
voucherOrder.setVoucherId(id);

return Result.ok(voucherOrder);
}
}

​ 这里的做法是将操作数据库的操作剥离出来形成一个方法,并以用户名作为锁,因为我们只需要拦截同一个用户的多次请求。并且可以发现我们是将锁加在调用者中,因为如果把锁加在剥离出来的方法上(由于我们规定了锁对象,因此只能使用代码块的方式加锁),可能会出现锁已释放但事务还未提交(因为Spring中事务需要在方法执行完毕后才提交),依然有线程安全问题,为了避免该情况的发生,我们将锁加在调用者上。

​ 从这里可以学习到:当想要给某个方法中某部分代码加锁并且这部分代码还要求事务时,可以将这部分代码单独抽离出来形成一个新的方法,并将锁加在调用者的对应代码上。

​ 这里还有一个值得我们注意的问题,那就是Spring事务失效的问题。(详细内容参考Spring事务@Transactional常见的8种失效场景(通俗易懂)_事务失效的8大场景-CSDN博客)

自调用失效:同一个类中,一个方法调用另一个方法,被调用方法上的事务不生效

失效原因:spring事务底层使用动态代理,若同一个类中的方法吊用另一个方法,则不存在对象之间的代理关系,被调用方法的事务失效

解决方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
接口:HelloService,
接口实现类:HelloServiceImpl
方法:fun、fun2,fun2调用fun

# 新建类实现fun调用fun2
新建类HelloServiceImpl2,方法fun2在HelloServiceImpl2中
在类中注入HelloServiceImpl对象实例,方法fun在HelloServiceImpl中,
在HelloServiceImpl2的fun2中,调用HelloServiceImpl的fun方法

# application.getBean
在fun2中使用application.getBean(HelloService.class)获取对象helloService,
然后使用helloService.fun实现方法调用

# AopUtils.currentProxy()
@EnableAspectJAutoProxy(exposeProxy = true),将exposeProxy = true
在fun2中使用((HelloService)AopContext.currentProxy()).fun()实现方法调用

这里使用的是第三种方法

​ 通过本节需要掌握

  1. 乐观锁和悲观锁的使用
  2. 如何实现一个秒杀功能
  3. Spring事务失效的8种场景以及解决方案