优惠券秒杀
添加优惠卷
店铺发布优惠券又分为平价券和特价券
, 平价券可以任意购买而特价券需要秒杀抢购(限制数量和时间)
tb_voucher(平价券)
: 优惠券的基本信息
tb_seckill_voucher(秒杀券)
: 有voucher_id
字段表示具有优惠卷的基本信息,此外还有库存,开始抢购时间,结束抢购时间等特殊字段
在VoucherController
提供了一个接口方法用来添加普通优惠券
/**
* 新增普通券
* @param voucher 优惠券信息
* @return 优惠券id
*/
@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {
// 直接将普通券的信息保存到普通券表中
voucherService.save(voucher);
return Result.ok(voucher.getId());
}
在VoucherController
提供了一个接口方法用来添加秒杀券,在VoucherService
中的addSeckillVoucher方法
实现添加秒杀券的业务逻辑
/**
* 新增秒杀券
* @param voucher 优惠券信息,包含秒杀券信息(库存,生效时间,失效时间)
* @return 优惠券id
*/
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
voucherService.addSeckillVoucher(voucher);
return Result.ok(voucher.getId());
}
// 新增秒杀券就是在数据库中新增普通卷和秒杀券的信息
@Override
@Transactional// 因为是操作两张表所以需要添加事务
public void addSeckillVoucher(Voucher voucher) {
// 将秒杀券的基本信息保存到普通券表中,如果没有指定Id会自动生成
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
// 关联普通券id
seckillVoucher.setVoucherId(voucher.getId());
// 设置库存
seckillVoucher.setStock(voucher.getStock());
// 设置开始时间
seckillVoucher.setBeginTime(voucher.getBeginTime());
// 设置结束时间
seckillVoucher.setEndTime(voucher.getEndTime());
// 保存信息到秒杀券表中
seckillVoucherService.save(seckillVoucher);
}
添加秒杀券
: 由于没有后台管理页面,使用Postman模拟发送POST请求http://localhost:8081/voucher/seckill来新增秒杀券(截止日期要超过当前日期否则不显示)
{
"shopId":1,
"title":"100元代金券",
"subTitle":"周一至周五可用",
"rules":"全场通用\\n无需预约\\n可无限叠加",
// 数据库中金额的单位是分
"payValue":8000,
"actualValue":10000,
"type":1,
"stock":100,
"beginTime":"2022-01-01T00:00:00",
"endTime":"2023-10-31T23:59:59"
}
实现秒杀下单
当我们点击抢购时会触发右侧的请求,我们只需要在VoucherOrderController
编写对应的Controller处理请求即可
当用户开始进行下单我们应当提交优惠券Id
去查询优惠卷信息,然后判断是否满足秒杀条件即秒杀是否开始和库存是否充足
@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);
}
}
public interface IVoucherOrderService extends IService<VoucherOrder> {
Result seckillVoucher(Long voucherId);
}
@Slf4j
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Override
@Transactional// 操作两张表应该加上事务
public Result seckillVoucher(Long voucherId) {
//1.查询优惠券的基本信息
//SeckillVoucher seckillVouche = seckillVoucherService.getById(voucherId)
LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();
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");
voucherOrder.setId(orderId);
//6.2 设置用户id
Long id = UserHolder.getUser().getId();
voucherOrder.setUserId(id);
//6.3 设置代金券id
voucherOrder.setVoucherId(voucherId);
//7. 将订单数据保存到订单表中
save(voucherOrder);
//8. 返回订单id
return Result.ok(orderId);
}
库存超卖问题
当遇到高并发场景时会出现超卖现象,我们可以用Jmeter开200个线程来模拟抢100张优惠券的场景,结果优惠券库存为负数表示出现了超卖现象
添加请求的信息头管理器
携带我们登录的token(可能会过期),然后发起POST请求http://localhost:8081/voucher-order/seckill/voucher_id
乐观锁的两种实现方式
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁(悲观锁或乐观锁)
悲观锁
: 悲观锁比较适合插入数据,简单粗暴但是性能一般乐观锁
: 比较适合更新数据, 性能好但是成功率低(多个线程同时执行时只有一个可以执行成功),还需要访问数据库造成数据库压力过大
版本号法
: 给数据库表增加一个版本号version字段,每次操作表中的数据时会查询版本号,修改数据时再次验证版本号有没有变化,没有变化才可以更新数据
CAS
(Compare-And-Switch): 首先查询要修改字段的值,在修改数据时再次验证字段值有没有发生变化(或满足某种条件),没有变化(或满足条件)才会更新字段的值
乐观锁解决超卖问题
使用stock
来充当版本号,VoucherOrderServiceImpl在扣减库存时比较查询到的优惠券库存和实际数据库中优惠券库存是否相同
- 假设100个线程同时都拿到了100的库存, 但是100个人中只有1个人能扣减成功(其他的人在扣减时库存发现库存已经被修改过了,所以不再执行扣减操作)
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1") //set stock = stock -1
.eq("voucher_id", voucherId).eq("stock",voucher.getStock()).update(); //where id = ? and stock = ?
使用stock>0
充当判断条件, 在扣减库存时只要判断是否有剩余优惠券,即只要数据库中的库存大于0都能顺利完成扣减库存操作
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).gt("stock",0).update(); where voucher_id = ? and stock > 0
单机环境下一人一单(悲观锁)
需求:修改秒杀业务要求同一个优惠券一个用户只能下一单
- 如果时间和库存都充足,还需要根据优惠卷id和用户id查询是否已经下过这个订单,如果下过这个订单则不能再下单
在VoucherOrderServiceImpl中库存和时间都充足时即将扣减库存之前再增加一人一单逻辑
一个用户开了多个线程抢优惠券,在判断库存充足之后和执行一人一单逻辑之前间如果进来了多个线程,此时它们都在数据库中查询不到订单然后都会执行扣减操作
// 4. 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
}
// 5.根据用户id查询用户对应的订单是否存在
Long userId = UserHolder.getUser().getId();
int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
if (count > 0) {
// 用户已经下过单
return Result.fail("您已经抢过优惠券了哦");
}
// 6.使用stock>0充当判断条件,在扣减库存时只要判断是否有剩余优惠券,即只要数据库中的库存大于0都能顺利完成扣减库存操作
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).gt("stock",0).update(); //where voucher_id = ? and stock > 0
if (!success) {
return Result.fail("库存不足");
}
把一人一单逻辑之后生成订单记录的代码都提取到一个createVoucherOrder方法中(ctrl + alt + m)然后加悲观锁synchronized(悲观锁适合插入数据)
- 不管哪一个线程运行到这个方法时都要检查有没有其它线程正在用这个方法(或者该类的其他同步方法),有的话要等正在使用synchronized方法的线程结束
- 把锁加在createVoucherOrder方法上锁的范围太大(粒度太粗)会导致每个线程进来都会锁住,锁的对象是
this
所有用户都公用一把锁串行执行会导致效率很低
@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
// 5.根据用户id查询用户对应的订单是否存在
Long userId = UserHolder.getUser().getId();
int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
// 判断用户是否下过单
if (count > 0) {
return Result.fail("您已经抢过优惠券了哦");
}
// 6.使用stock>0充当判断条件,在扣减库存时只要判断是否有剩余优惠券,即只要数据库中的库存大于0都能顺利完成扣减库存操作
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).gt("stock",0).update(); //where voucher_id = ? and stock > 0
if (!success) {
return Result.fail("库存不足");
}
//7. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//7.1 设置订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
//7.2 设置用户id
Long id = UserHolder.getUser().getId();
voucherOrder.setUserId(id);
//7.3 设置代金券id
voucherOrder.setVoucherId(voucherId);
//8. 将订单数据保存到表中
save(voucherOrder);
//9. 返回订单id
return Result.ok(orderId);
}
要完成一人一单的业务应该把这个锁只加在单个用户上(用户标识可以用userId), 如果我们直接使用userId.toString()每次锁住的都不是同一个String对象
方法名 | 功能 |
---|---|
String intern() | 从常量池中拿数据,如果字符串常量池中已经包含了一个等于这个String对象的字符串(由equals方法确定)将返回池中的字符串 如果没有则将此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);
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// toString的源码是new String所以userId.toString()拿到的也不是同一个String对象即不是同一个用户/不是同一把锁
synchronized (userId.toString().intern()) {
// 5.根据用户id查询用户对应的订单是否存在
int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
// 判断用户是否下过单
if (count > 0) {
return Result.fail("您已经抢过优惠券了哦");
}
// 6.使用stock>0充当判断条件,在扣减库存时只要判断是否有剩余优惠券,即只要数据库中的库存大于0都能顺利完成扣减库存操作
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).gt("stock",0).update(); //where voucher_id = ? and stock > 0
if (!success) {
return Result.fail("库存不足");
}
//7. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//7.1 设置订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
//7.2 设置用户id
Long id = UserHolder.getUser().getId();
voucherOrder.setUserId(id);
//7.3 设置代金券id
voucherOrder.setVoucherId(voucherId);
//8. 将订单数据保存到表中
save(voucherOrder);
//9. 返回订单id
return Result.ok(orderId);
}
//执行到这里锁已经被释放了但是可能当前事务还未提交,如果此时有线程进来不能确保事务不出问题
}
createVoucherOrder方法被Spring的事务控制,如果你在方法内部加锁可能会导致当前方法事务还没有提交但是锁已经释放了,此时新增的订单还没有写入数据库
- 在seckillVoucher方法中将createVoucherOrder方法整体包裹起来, 保证事务提交之后才会释放锁确保数据库中有订单存在,同时控制锁的粒度
@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("优惠券已被抢光了哦,下次记得手速快点");
}
// 获取用户id
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
return createVoucherOrder(voucherId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
// toString的源码是new String所以userId.toString()拿到的也不是同一个String对象即不是同一个用户/不是同一把锁
synchronized (userId.toString().intern()) {
// 5.根据用户id查询用户对应的订单是否存在
int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
// 判断用户是否下过单
if (count > 0) {
return Result.fail("您已经抢过优惠券了哦");
}
// 6.使用stock>0充当判断条件,在扣减库存时只要判断是否有剩余优惠券,即只要数据库中的库存大于0都能顺利完成扣减库存操作
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).gt("stock",0).update(); //where voucher_id = ? and stock > 0
if (!success) {
return Result.fail("库存不足");
}
//7. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//7.1 设置订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
//7.2 设置用户id
Long id = UserHolder.getUser().getId();
voucherOrder.setUserId(id);
//7.3 设置代金券id
voucherOrder.setVoucherId(voucherId);
//8. 将订单数据保存到表中
save(voucherOrder);
//9. 返回订单id
return Result.ok(orderId);
}
//执行到这里锁已经被释放了但是可能当前事务还未提交,如果此时有线程进来不能确保事务不出问题
}
由于seckillVoucher方法没有加事务注解,所以调用createVoucherOrder方法是this.
的方式调用的,this此时是VoucherOrderServiceImpl没有事务功能
-
事务想要生效需要利用VoucherOrderServiceImpl的代理对象,所以我们需要获得原始的事务对象来操作事务
-
使用
AopContext.currentProxy()
获取当前对象的代理对象(具有事务功能),然后再用代理对象调用方法底层需要使用aspectjweaver依赖 -
获取事务的代理对象需要在
IVoucherOrderService
中创建createVoucherOrder
方法 -
在启动类上加上
@EnableAspectJAutoProxy(exposeProxy = true)
注解
public interface IVoucherOrderService extends IService<VoucherOrder> {
Result seckillVoucher(Long voucherId);
Result createVoucherOrder(Long voucherId);
}
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)
public class HmDianPingApplication {
public static void main(String[] args) {
SpringApplication.run(HmDianPingApplication.class, args);
}
}