高并发场景在现场的日常工作中很常见,特别是在互联网公司中,这篇文章就来通过秒杀商品来模拟高并发的场景。
本文环境:
SpringBoot 2.5.7 + MySQL 8.0 X + MybatisPlus + Swagger2.9.2
1. 商品秒杀-超卖
在开发中,对于下面的代码,可能很熟悉:在Service里面加上
@Transactional
事务注解和Lock锁。
控制层:Controller
@ApiOperation(value="秒杀实现方式——Lock加锁" ) @PostMapping("/start/lock" ) public Result startLock(long skgId){ try { log.info("开始秒杀方式一..." ); final long userId = (int) (new Random().nextDouble() * (99999 - 10000 + 1)) + 10000; Result result = secondKillService.startSecondKillByLock(skgId, userId); if (result != null){ log.info("用户:{}--{}" , userId, result.get("msg" )); }else { log.info("用户:{}--{}" , userId, "哎呦喂,人也太多了,请稍后!" ); } } catch (Exception e) { e.printStackTrace(); } finally { } return Result.ok(); }
业务层:Service
@Override @Transactional(rollbackFor = Exception.class) public Result startSecondKillByLock(long skgId, long userId) { lock.lock(); try { // 校验库存 SecondKill secondKill = secondKillMapper.selectById(skgId); Integer number = secondKill.getNumber(); if (number > 0) { // 扣库存 secondKill.setNumber(number - 1); secondKillMapper.updateById(secondKill); // 创建订单 SuccessKilled killed = new SuccessKilled(); killed.setSeckillId(skgId); killed.setUserId(userId); killed.setState((short) 0); killed.setCreateTime(new Timestamp(System.currentTimeMillis())); successKilledMapper.insert(killed); // 模拟支付 Payment payment = new Payment(); payment.setSeckillId(skgId); payment.setSeckillId(skgId); payment.setUserId(userId); payment.setMoney(40); payment.setState((short) 1); payment.setCreateTime(new Timestamp(System.currentTimeMillis())); paymentMapper.insert(payment); } else { return Result.error(SecondKillStateEnum.END); } } catch (Exception e) { throw new ScorpiosException("异常了个乖乖" ); } finally { lock.unlock(); } return Result.ok(SecondKillStateEnum.SUCCESS); }
对于上面的代码应该没啥问题吧,业务方法上加事务,在处理业务的时候加锁。
但上面这样写法是有问题的,会出现超卖的情况,看下测试结果:模拟1000个并发,抢100商品。
img
img
这里在业务方法开始加了锁,在业务方法结束后释放了锁。但这里的事务提交却不是这样的,有可能在事务提交之前,就已经把锁释放了,这样会导致商品超卖现象。所以加锁的时机很重要!
2. 解决商品超卖
对于上面超卖现象,主要问题出现在事务中锁释放的时机,事务未提交之前,锁已经释放。(事务提交是在整个方法执行完)。如何解决这个问题呢,就是把加锁步骤提前
2.1 方式一(改进版加锁)
@ApiOperation(value="秒杀实现方式——Lock加锁" ) @PostMapping("/start/lock" ) public Result startLock(long skgId){ // 在此处加锁 lock.lock(); try { log.info("开始秒杀方式一..." ); final long userId = (int) (new Random().nextDouble() * (99999 - 10000 + 1)) + 10000; Result result = secondKillService.startSecondKillByLock(skgId, userId); if (result != null){ log.info("用户:{}--{}" , userId, result.get("msg" )); }else { log.info("用户:{}--{}" , userId, "哎呦喂,人也太多了,请稍后!" ); } } catch (Exception e) { e.printStackTrace(); } finally { // 在此处释放锁 lock.unlock(); } return Result.ok(); }
上面这样的加锁就可以解决事务未提交之前,锁释放的问题,可以分三种情况进行压力测试:
对于并发量大于商品数的情况,商品秒杀一般不会出现少卖的请况,但对于并发数小于等于商品数的时候可能会出现商品少卖情况,这也很好理解。
对于没有问题的情况就不贴图了,因为有很多种方式,贴图会太多
img
2.2 方式二(AOP版加锁)
对于上面在控制层进行加锁的方式,可能显得不优雅,那就还有另一种方式进行在事务之前加锁,那就是AOP
自定义AOP注解
@Target({ElementType.PARAMETER, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface ServiceLock { String description() default "" ; }
定义切面类
@Slf4j @Component @Scope @Aspect @Order(1) //order越小越是最先执行,但更重要的是最先执行的最后结束 public class LockAspect { /** * 思考:为什么不用synchronized * service 默认是单例的,并发下lock只有一个实例 */ private static Lock lock = new ReentrantLock(true ); // 互斥锁 参数默认false ,不公平锁 // Service层切点 用于记录错误日志 @Pointcut("@annotation(com.scorpios.secondkill.aop.ServiceLock)" ) public void lockAspect () { } @Around("lockAspect()" ) public Object around(ProceedingJoinPoint joinPoint) { lock.lock(); Object obj = null; try { obj = joinPoint.proceed(); } catch (Throwable e) { e.printStackTrace(); throw new RuntimeException(); } finally{ lock.unlock(); } return obj; } }
在业务方法上添加AOP注解
@Override @ServiceLock // 使用Aop进行加锁 @Transactional(rollbackFor = Exception.class) public Result startSecondKillByAop(long skgId, long userId) { try { // 校验库存 SecondKill secondKill = secondKillMapper.selectById(skgId); Integer number = secondKill.getNumber(); if (number > 0) { //扣库存 secondKill.setNumber(number - 1); secondKillMapper.updateById(secondKill); //创建订单 SuccessKilled killed = new SuccessKilled(); killed.setSeckillId(skgId); killed.setUserId(userId); killed.setState((short) 0); killed.setCreateTime(new Timestamp(System.currentTimeMillis())); successKilledMapper.insert(killed); //支付 Payment payment = new Payment(); payment.setSeckillId(skgId); payment.setSeckillId(skgId); payment.setUserId(userId); payment.setMoney(40); payment.setState((short) 1); payment.setCreateTime(new Timestamp(System.currentTimeMillis())); paymentMapper.insert(payment); } else { return Result.error(SecondKillStateEnum.END); } } catch (Exception e) { throw new ScorpiosException("异常了个乖乖" ); } return Result.ok(SecondKillStateEnum.SUCCESS); }
控制层:
@ApiOperation(value="秒杀实现方式二——Aop加锁" ) @PostMapping("/start/aop" ) public Result startAop(long skgId){ try { log.info("开始秒杀方式二..." ); final long userId = (int) (new Random().nextDouble() * (99999 - 10000 + 1)) + 10000; Result result = secondKillService.startSecondKillByAop(skgId, userId); if (result != null){ log.info("用户:{}--{}" , userId, result.get("msg" )); }else { log.info("用户:{}--{}" , userId, "哎呦喂,人也太多了,请稍后!" ); } } catch (Exception e) { e.printStackTrace(); } return Result.ok(); }
这种方式在对锁的使用上,更高阶、更美观!
2.3 方式三(悲观锁一)
除了上面在业务代码层面加锁外,还可以使用数据库自带的锁进行并发控制。
悲观锁,什么是悲观锁呢?通俗的说,在做任何事情之前,都要进行加锁确认。这种数据库级加锁操作效率较低。
使用for update一定要加上事务,当事务处理完后,for update才会将行级锁解除
如果请求数和秒杀商品数量一致,会出现少卖
@ApiOperation(value="秒杀实现方式三——悲观锁" ) @PostMapping("/start/pes/lock/one" ) public Result startPesLockOne(long skgId){ try { log.info("开始秒杀方式三..." ); final long userId = (int) (new Random().nextDouble() * (99999 - 10000 + 1)) + 10000; Result result = secondKillService.startSecondKillByUpdate(skgId, userId); if (result != null){ log.info("用户:{}--{}" , userId, result.get("msg" )); }else { log.info("用户:{}--{}" , userId, "哎呦喂,人也太多了,请稍后!" ); } } catch (Exception e) { e.printStackTrace(); } return Result.ok(); }
业务逻辑
@Override @Transactional(rollbackFor = Exception.class) public Result startSecondKillByUpdate(long skgId, long userId) { try { // 校验库存-悲观锁 SecondKill secondKill = secondKillMapper.querySecondKillForUpdate(skgId); Integer number = secondKill.getNumber(); if (number > 0) { //扣库存 secondKill.setNumber(number - 1); secondKillMapper.updateById(secondKill); //创建订单 SuccessKilled killed = new SuccessKilled(); killed.setSeckillId(skgId); killed.setUserId(userId); killed.setState((short) 0); killed.setCreateTime(new Timestamp(System.currentTimeMillis())); successKilledMapper.insert(killed); //支付 Payment payment = new Payment(); payment.setSeckillId(skgId); payment.setSeckillId(skgId); payment.setUserId(userId); payment.setMoney(40); payment.setState((short) 1); payment.setCreateTime(new Timestamp(System.currentTimeMillis())); paymentMapper.insert(payment); } else { return Result.error(SecondKillStateEnum.END); } } catch (Exception e) { throw new ScorpiosException("异常了个乖乖" ); } finally { } return Result.ok(SecondKillStateEnum.SUCCESS); }
Dao层
@Repository public interface SecondKillMapper extends BaseMapper { /** * 将此行数据进行加锁,当整个方法将事务提交后,才会解锁 * @param skgId * @return */ @Select(value = "SELECT * FROM seckill WHERE seckill_id=#{skgId} FOR UPDATE" ) SecondKill querySecondKillForUpdate(@Param("skgId" ) Long skgId); }
上面是利用for update进行对查询数据加锁,加的是行锁。
2.4 方式四(悲观锁二)
悲观锁的第二种方式就是利用update更新命令来加表锁
/** * UPDATE锁表 * @param skgId 商品id * @param userId 用户id * @return */ @Override @Transactional(rollbackFor = Exception.class) public Result startSecondKillByUpdateTwo(long skgId, long userId) { try { // 不校验,直接扣库存更新 int result = secondKillMapper.updateSecondKillById(skgId); if (result > 0) { //创建订单 SuccessKilled killed = new SuccessKilled(); killed.setSeckillId(skgId); killed.setUserId(userId); killed.setState((short) 0); killed.setCreateTime(new Timestamp(System.currentTimeMillis())); successKilledMapper.insert(killed); //支付 Payment payment = new Payment(); payment.setSeckillId(skgId); payment.setSeckillId(skgId); payment.setUserId(userId); payment.setMoney(40); payment.setState((short) 1); payment.setCreateTime(new Timestamp(System.currentTimeMillis())); paymentMapper.insert(payment); } else { return Result.error(SecondKillStateEnum.END); } } catch (Exception e) { throw new ScorpiosException("异常了个乖乖" ); } finally { } return Result.ok(SecondKillStateEnum.SUCCESS); }
Dao层
@Repository public interface SecondKillMapper extends BaseMapper { /** * 将此行数据进行加锁,当整个方法将事务提交后,才会解锁 * @param skgId * @return */ @Select(value = "SELECT * FROM seckill WHERE seckill_id=#{skgId} FOR UPDATE" ) SecondKill querySecondKillForUpdate(@Param("skgId" ) Long skgId); @Update(value = "UPDATE seckill SET number=number-1 WHERE seckill_id=#{skgId} AND number > 0" ) int updateSecondKillById(@Param("skgId" ) long skgId); }
2.5 方式五(乐观锁)
乐观锁,顾名思义,就是对操作结果很乐观,通过利用version字段来判断数据是否被修改。
乐观锁,不进行库存数量的校验,直接做库存扣减。
这里使用的乐观锁会出现大量的数据更新异常(抛异常就会导致购买失败)、如果配置的抢购人数比较少、比如120:100(人数:商品) 会出现少买的情况,不推荐使用乐观锁。
@ApiOperation(value="秒杀实现方式五——乐观锁" ) @PostMapping("/start/opt/lock" ) public Result startOptLock(long skgId){ try { log.info("开始秒杀方式五..." ); final long userId = (int) (new Random().nextDouble() * (99999 - 10000 + 1)) + 10000; // 参数添加了购买数量 Result result = secondKillService.startSecondKillByPesLock(skgId, userId,1); if (result != null){ log.info("用户:{}--{}" , userId, result.get("msg" )); }else { log.info("用户:{}--{}" , userId, "哎呦喂,人也太多了,请稍后!" ); } } catch (Exception e) { e.printStackTrace(); } return Result.ok(); } @Override @Transactional(rollbackFor = Exception.class) public Result startSecondKillByPesLock(long skgId, long userId, int number) { // 乐观锁,不进行库存数量的校验,直接 try { SecondKill kill = secondKillMapper.selectById(skgId); // 剩余的数量应该要大于等于秒杀的数量 if (kill.getNumber() >= number) { int result = secondKillMapper.updateSecondKillByVersion(number,skgId,kill.getVersion()); if (result > 0) { //创建订单 SuccessKilled killed = new SuccessKilled(); killed.setSeckillId(skgId); killed.setUserId(userId); killed.setState((short) 0); killed.setCreateTime(new Timestamp(System.currentTimeMillis())); successKilledMapper.insert(killed); //支付 Payment payment = new Payment(); payment.setSeckillId(skgId); payment.setSeckillId(skgId); payment.setUserId(userId); payment.setMoney(40); payment.setState((short) 1); payment.setCreateTime(new Timestamp(System.currentTimeMillis())); paymentMapper.insert(payment); } else { return Result.error(SecondKillStateEnum.END); } } } catch (Exception e) { throw new ScorpiosException("异常了个乖乖" ); } finally { } return Result.ok(SecondKillStateEnum.SUCCESS); } @Repository public interface SecondKillMapper extends BaseMapper { /** * 将此行数据进行加锁,当整个方法将事务提交后,才会解锁 * @param skgId * @return */ @Select(value = "SELECT * FROM seckill WHERE seckill_id=#{skgId} FOR UPDATE" ) SecondKill querySecondKillForUpdate(@Param("skgId" ) Long skgId); @Update(value = "UPDATE seckill SET number=number-1 WHERE seckill_id=#{skgId} AND number > 0" ) int updateSecondKillById(@Param("skgId" ) long skgId); @Update(value = "UPDATE seckill SET number=number-#{number},version=version+1 WHERE seckill_id=#{skgId} AND version = #{version}" ) int updateSecondKillByVersion(@Param("number" ) int number, @Param("skgId" ) long skgId, @Param("version" )int version); }
乐观锁会出现大量的数据更新异常(抛异常就会导致购买失败),会出现少买的情况,不推荐使用乐观锁。
2.6 方式六(阻塞队列)