1. 添加优惠券
该项目没有后台管理的界面,所以采用postman发送请求
http://localhost:8081/voucher/seckill
注意end时间要大于当前系统时间
{
"shopId": 2,
"title": "100元代金券",
"subTitle": "周一至周五均可使用",
"rules": "全场通用\\n无需预约\\n可无限叠加\\不兑现、不找零\\n仅限堂食",
"payValue": 8000,
"actualValue": 10000,
"type": 1,
"stock": 100,
"beginTime": "2023-07-05T10:09:17",
"endTime": "2023-07-05T23:09:04"
}
2. 用户实现秒杀下单
controller层
@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {
@Resource
private IVoucherOrderService voucherOrderService;
@PostMapping("seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
return voucherOrderService.seckillVoucher(voucherId);
}
}
service层
public interface IVoucherOrderService extends IService<VoucherOrder> {
Result seckillVoucher(Long voucherId);
}
实现类
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
//1. 查询优惠券(MybatisPlus运用)
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//2. 判断秒杀是否开始
if(voucher.getBeginTime().isAfter(LocalDateTime.now())){
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
//3. 判断秒杀是否结束
if(voucher.getEndTime().isBefore(LocalDateTime.now())){
// 已经结束
return Result.fail("秒杀已经结束!");
}
//4. 判断库存是否充足
if(voucher.getStock() < 1){
// 库存不足
return Result.fail("已经抢完啦~");
}
//5. 扣减库存(MybatisPlus运用)
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 userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
//6.3 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//7. 返回订单id
return Result.ok(orderId);
}
}
3. 问题分析和优化
超卖问题
多线程并发安全问题,在之前代码的基础上采用JMeter测试,发现出现超卖的情况,库存出现了负数;
正常的线程执行,顺序执行不会出现问题;
但是线程的执行是并发抢占式的,所以很可能会变成交叉执行,如下图的情况;
只要在线程1进行数据库操作之前,有线程已经做了查询的操作,那就存在超卖的风险;
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId).update();
最直观解决方案: 加锁,有两种锁,悲观锁和乐观锁;
对版本号法的一种简化,stock和version都要做相同的判断,那就直接简化为判断stock即可,只要在做修改的时候判断stock是否和自己查询的stock相同就行;
采用CAS法对代码进行优化
//5. 扣减库存(MybatisPlus运用)
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")// set stock = stock - 1
.eq("voucher_id", voucherId).eq("stock", voucher.getStock())// where id = ? and stock = ?
.update();
重点: 采用JMeter测试,发现没有出现超卖的情况,但是出现了很多优惠券卖不出的情况,可以极端假设有100个线程同时查询完了库存,线程1进行数据库的修改之后,stock的值变成了99,此时另外99个线程进行对比的时候会发现stock的值与它们之前查询的stock的值不相同,此时就会走下面代码;
if(!success){
// 扣减失败
return Result.fail("已经抢完啦~");
}
修改的方案变成stock>0而不是判断之前的stock和现在stock是否相等;
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")// set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", 0)// where id = ? and stock = ?
.update();
4. 一人一单问题
关键点
在实现一人一单的过程中,锁对象如果采用下面的代码,会出现问题,因为toString()方法的源码中产生的都是新的对象,所以不能这么写;
userId.toString().intern()
方法可以确保返回的字符串对象是常量池中的对象,从而确保在多线程中使用相同的字符串对象。intern()方法会将字符串添加到常量池中(如果常量池中不存在该字符串),并返回常量池中的对象引用;
因此,改为synchronized(userId.toString().intern())
来确保在多线程中使用相同的字符串对象。这样做可以避免不必要的锁竞争;
但是,intern()方法的使用可能会增加内存的消耗,因为它会将字符串对象添加到常量池中,尤其是当有大量不同的字符串被调用时。所以在使用intern()方法时应该谨慎使用,确保在适当的情况下使用它;
synchronized (userId.toString())
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);
}
修改为
synchronized (userId.toString().intern())
关键点
Spring提交事务是在函数结束之后,也就意味着锁释放之后事务才会去做提交操作,此时其它线程可能会趁虚而入;
所以要将锁放在函数外边;
示例代码:
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
//1. 查询优惠券(MybatisPlus运用)
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//2. 判断秒杀是否开始
if(voucher.getBeginTime().isAfter(LocalDateTime.now())){
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
//3. 判断秒杀是否结束
if(voucher.getEndTime().isBefore(LocalDateTime.now())){
// 已经结束
return Result.fail("秒杀已经结束!");
}
//4. 判断库存是否充足
if(voucher.getStock() < 1){
// 库存不足
return Result.fail("已经抢完啦~");
}
// 一人一单
// 用户id
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
return createVoucherOrder(voucherId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 一人一单
// 用户id
Long userId = UserHolder.getUser().getId();
// 查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
return Result.fail("已经抢过咯~");
}
//5. 扣减库存(MybatisPlus运用)
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")// set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", 0)// where id = ? and stock = ?
.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
voucherOrder.setUserId(userId);
//6.3 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//7. 返回订单id
return Result.ok(orderId);
}
}
关键点
建议补充知识点:事务失效的几种情况
事务管理问题:createVoucherOrder方法上使用了@Transactional注解,但是在seckillVoucher方法中调用该方法时,并未通过代理方式调用,因此事务并不会起作用;
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
return createVoucherOrder(voucherId);
}
其中的返回值也可以是
return this.createVoucherOrder(voucherId);
所以,这是一个内部方法调用,而不是通过代理对象调用。在这种情况下,Spring事务管理器无法正确地拦截该方法调用并应用事务;
所以我们需要获取代理对象;
// 一人一单
// 用户id
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>
配置类中做对应修改
// 默认暴露代理对象
@EnableAspectJAutoProxy(exposeProxy = true)
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {
public static void main(String[] args) {
SpringApplication.run(HmDianPingApplication.class, args);
}
}