共计 16146 个字符,预计需要花费 41 分钟才能阅读完成。
1. 引言
高并发场景在现场的日常工作中很常见,特地是在互联网公司中,这篇文章就来通过秒杀商品来模仿高并发的场景。文章开端会附上文章的所有代码、脚本和测试用例。
- 本文环境: SpringBoot 2.5.7 + MySQL 8.0 X + MybatisPlus + Swagger2.9.2
- 模仿工具: Jmeter
- 模仿场景: 减库存 -> 创立订单 -> 模仿领取
2. 商品秒杀 - 超卖
在开发中,对于上面的代码,可能很相熟:在 Service 外面加上 @Transactional
事务注解和 Lock 锁。
Spring Boot 根底就不介绍了,举荐看这个收费教程:
https://github.com/javastacks/spring-boot-best-practice
管制层: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 商品。
这里在业务办法开始加了锁,在业务办法完结后开释了锁。但这里的事务提交却不是这样的,有可能在事务提交之前,就曾经把锁开释了,这样会导致商品超卖景象。所以加锁的机会很重要!
3. 解决商品超卖
对于下面超卖景象,次要问题呈现在事务中锁开释的机会,事务未提交之前,锁曾经开释。(事务提交是在整个办法执行完)。如何解决这个问题呢,就是把加锁步骤提前
- 能够在 controller 层进行加锁
- 能够应用 Aop 在业务办法执行之前进行加锁
3.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();}
下面这样的加锁就能够解决事务未提交之前,锁开释的问题,能够分三种状况进行压力测试:
- 并发数 1000,商品 100
- 并发数 1000,商品 1000
- 并发数 2000,商品 1000
对于并发量大于商品数的状况,商品秒杀个别不会呈现少卖的请况,但对于并发数小于等于商品数的时候可能会呈现商品少卖状况,这也很好了解。
对于没有问题的状况就不贴图了,因为有很多种形式,贴图会太多
3.2 形式二(AOP 版加锁)
对于下面在管制层进行加锁的形式,可能显得不优雅,那就还有另一种形式进行在事务之前加锁,那就是 AOP。
举荐一个开源收费的 Spring Boot 最全教程:
https://github.com/javastacks/spring-boot-best-practice
自定义 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();}
这种形式在对锁的应用上,更高阶、更好看!
3.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<SecondKill> {
/**
* 将此行数据进行加锁,当整个办法将事务提交后,才会解锁
* @param skgId
* @return
*/
@Select(value = "SELECT * FROM seckill WHERE seckill_id=#{skgId} FOR UPDATE")
SecondKill querySecondKillForUpdate(@Param("skgId") Long skgId);
}
下面是利用 for update 进行对查问数据加锁,加的是行锁
3.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<SecondKill> {
/**
* 将此行数据进行加锁,当整个办法将事务提交后,才会解锁
* @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);
}
3.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<SecondKill> {
/**
* 将此行数据进行加锁,当整个办法将事务提交后,才会解锁
* @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);
}
乐观锁会呈现大量的数据更新异样(抛异样就会导致购买失败),会呈现少买的状况,不举荐应用乐观锁
3.6 形式六(阻塞队列)
利用阻塞队类,也能够解决高并发问题。其思维就是把接管到的申请按程序寄存到队列中,消费者线程逐个从队列里取数据进行解决,看下具体代码。
阻塞队列:这里应用动态外部类的形式来实现单例模式,在并发条件下不会呈现问题。
// 秒杀队列(固定长度为 100)
public class SecondKillQueue {
// 队列大小
static final int QUEUE_MAX_SIZE = 100;
// 用于多线程间下单的队列
static BlockingQueue<SuccessKilled> blockingQueue = new LinkedBlockingQueue<SuccessKilled>(QUEUE_MAX_SIZE);
// 应用动态外部类,实现单例模式
private SecondKillQueue(){};
private static class SingletonHolder{
// 动态初始化器,由 JVM 来保障线程平安
private static SecondKillQueue queue = new SecondKillQueue();}
/**
* 单例队列
* @return
*/
public static SecondKillQueue getSkillQueue(){return SingletonHolder.queue;}
/**
* 生产入队
* @param kill
* @throws InterruptedException
* add(e) 队列未满时,返回 true;队列满则抛出 IllegalStateException(“Queue full”)异样——AbstractQueue
* put(e) 队列未满时,直接插入没有返回值;队列满时会阻塞期待,始终等到队列未满时再插入。* offer(e) 队列未满时,返回 true;队列满时返回 false。非阻塞立刻返回。* offer(e, time, unit) 设定期待的工夫,如果在指定工夫内还不能往队列中插入数据则返回 false,插入胜利返回 true。*/
public Boolean produce(SuccessKilled kill) {return blockingQueue.offer(kill);
}
/**
* 生产出队
* poll() 获取并移除队首元素,在指定的工夫内去轮询队列看有没有首元素有则返回,否者超时后返回 null
* take() 与带超时工夫的 poll 相似不同在于 take 时候如果以后队列空了它会始终期待其余线程调用 notEmpty.signal()才会被唤醒
*/
public SuccessKilled consume() throws InterruptedException {return blockingQueue.take();
}
/**
* 获取队列大小
* @return
*/
public int size() {return blockingQueue.size();
}
}
生产秒杀队列:实现 ApplicationRunner 接口
// 生产秒杀队列
@Slf4j
@Component
public class TaskRunner implements ApplicationRunner{
@Autowired
private SecondKillService seckillService;
@Override
public void run(ApplicationArguments var){new Thread(() -> {log.info("队列启动胜利");
while(true){
try {
// 过程内队列
SuccessKilled kill = SecondKillQueue.getSkillQueue().consume();
if(kill != null){Result result = seckillService.startSecondKillByAop(kill.getSeckillId(), kill.getUserId());
if(result != null && result.equals(Result.ok(SecondKillStateEnum.SUCCESS))){log.info("TaskRunner,result:{}",result);
log.info("TaskRunner 从音讯队列取出用户,用户:{}{}",kill.getUserId(),"秒杀胜利");
}
}
} catch (InterruptedException e) {e.printStackTrace();
}
}
}).start();}
}
@ApiOperation(value="秒杀实现形式六——音讯队列")
@PostMapping("/start/queue")
public Result startQueue(long skgId){
try {log.info("开始秒杀形式六...");
final long userId = (int) (new Random().nextDouble() * (99999 - 10000 + 1)) + 10000;
SuccessKilled kill = new SuccessKilled();
kill.setSeckillId(skgId);
kill.setUserId(userId);
Boolean flag = SecondKillQueue.getSkillQueue().produce(kill);
// 尽管进入了队列,然而不肯定能秒杀胜利 进队出队有工夫间隙
if(flag){log.info("用户:{}{}",kill.getUserId(),"秒杀胜利");
}else{log.info("用户:{}{}",userId,"秒杀失败");
}
} catch (Exception e) {e.printStackTrace();
}
return Result.ok();}
留神:在业务层和 AOP 办法中,不能抛出任何异样,throw new RuntimeException()这些抛异样代码要正文掉。因为一旦程序抛出异样就会进行,导致生产秒杀队列过程终止!
应用阻塞队列来实现秒杀,有几点要留神:
- 生产秒杀队列中调用业务办法加锁与不加锁状况一样,也就是
seckillService.startSecondKillByAop()
、seckillService.startSecondKillByLock()
办法后果一样,这也很好了解 - 当队列长度与商品数量统一时,会呈现少卖的景象,能够调大数值
- 上面是队列长度 1000,商品数量 1000,并发数 2000 状况下呈现的少卖
3.7. 形式七(Disruptor 队列)
Disruptor 是个高性能队列,研发的初衷是解决内存队列的提早问题,在性能测试中发现居然与 I / O 操作处于同样的数量级,基于 Disruptor 开发的零碎单线程能撑持每秒 600 万订单。
// 事件生成工厂(用来初始化预调配事件对象)public class SecondKillEventFactory implements EventFactory<SecondKillEvent> {
@Override
public SecondKillEvent newInstance() {return new SecondKillEvent();
}
}
// 事件对象(秒杀事件)public class SecondKillEvent implements Serializable {
private static final long serialVersionUID = 1L;
private long seckillId;
private long userId;
// set/get 办法略
}
// 应用 translator 形式生产者
public class SecondKillEventProducer {private final static EventTranslatorVararg<SecondKillEvent> translator = (seckillEvent, seq, objs) -> {seckillEvent.setSeckillId((Long) objs[0]);
seckillEvent.setUserId((Long) objs[1]);
};
private final RingBuffer<SecondKillEvent> ringBuffer;
public SecondKillEventProducer(RingBuffer<SecondKillEvent> ringBuffer){this.ringBuffer = ringBuffer;}
public void secondKill(long seckillId, long userId){this.ringBuffer.publishEvent(translator, seckillId, userId);
}
}
// 消费者(秒杀处理器)
@Slf4j
public class SecondKillEventConsumer implements EventHandler<SecondKillEvent> {private SecondKillService secondKillService = (SecondKillService) SpringUtil.getBean("secondKillService");
@Override
public void onEvent(SecondKillEvent seckillEvent, long seq, boolean bool) {Result result = secondKillService.startSecondKillByAop(seckillEvent.getSeckillId(), seckillEvent.getUserId());
if(result.equals(Result.ok(SecondKillStateEnum.SUCCESS))){log.info("用户:{}{}",seckillEvent.getUserId(),"秒杀胜利");
}
}
}
public class DisruptorUtil {
static Disruptor<SecondKillEvent> disruptor;
static{SecondKillEventFactory factory = new SecondKillEventFactory();
int ringBufferSize = 1024;
ThreadFactory threadFactory = runnable -> new Thread(runnable);
disruptor = new Disruptor<>(factory, ringBufferSize, threadFactory);
disruptor.handleEventsWith(new SecondKillEventConsumer());
disruptor.start();}
public static void producer(SecondKillEvent kill){RingBuffer<SecondKillEvent> ringBuffer = disruptor.getRingBuffer();
SecondKillEventProducer producer = new SecondKillEventProducer(ringBuffer);
producer.secondKill(kill.getSeckillId(),kill.getUserId());
}
}
@ApiOperation(value="秒杀实现形式七——Disruptor 队列")
@PostMapping("/start/disruptor")
public Result startDisruptor(long skgId){
try {log.info("开始秒杀形式七...");
final long userId = (int) (new Random().nextDouble() * (99999 - 10000 + 1)) + 10000;
SecondKillEvent kill = new SecondKillEvent();
kill.setSeckillId(skgId);
kill.setUserId(userId);
DisruptorUtil.producer(kill);
} catch (Exception e) {e.printStackTrace();
}
return Result.ok();}
通过测试,发现应用 Disruptor 队列队列,与自定义队列有着同样的问题,也会呈现超卖的状况,但效率有所提高。
4. 小结
对于下面七种实现并发的形式,做一下总结:
- 一、二形式是在代码中利用锁和事务的形式解决了并发问题,次要解决的是锁要加载事务之前
- 三、四、五形式次要是数据库的锁来解决并发问题,形式三是利用 for upate 对表加行锁,形式四是利用 update 来对表加锁,形式五是通过减少 version 字段来管制数据库的更新操作,形式五的成果最差
- 六、七形式是通过队列来解决并发问题,这里须要特地留神的是,在代码中不能通过 throw 抛异样,否则生产线程会终止,而且因为进队和出队存在工夫间隙,会导致商品少卖
下面所有的状况都通过代码测试,测试分一下三种状况:
- 并发数 1000,商品数 100
- 并发数 1000,商品数 1000
- 并发数 2000,商品数 1000
思考:分布式状况下如何解决并发问题呢?下次持续试验。
版权申明:本文为 CSDN 博主「止步前行」的原创文章,遵循 CC 4.0 BY-SA 版权协定,转载请附上原文出处链接及本申明。原文链接:https://blog.csdn.net/zxd1435513775/article/details/122643285
近期热文举荐:
1.1,000+ 道 Java 面试题及答案整顿(2022 最新版)
2. 劲爆!Java 协程要来了。。。
3.Spring Boot 2.x 教程,太全了!
4. 别再写满屏的爆爆爆炸类了,试试装璜器模式,这才是优雅的形式!!
5.《Java 开发手册(嵩山版)》最新公布,速速下载!
感觉不错,别忘了顺手点赞 + 转发哦!