因为做过电子商城类型的项目,所以经常问这个问题
首先这是一个具体的使用场景,角色:
1、用户(N)
2、商城系统-订单模块(1)
这边体现的首先是一个多对一关系
具体的功能点:
1、用户下单
1.1、锁住库存成功
1.2、已无库存
2、订单超时或取消,库存恢复
3、订单支付成功(这边认为在秒杀场景中,支付成功,可以权且当做一个结束点,不再考虑退款的问题)
可以看出库存是一个关键词
那么上面是一个比较理想和正常的业务场景,而实际生产环境中呢?
在互联网时代,面对的经常都是几亿的网民流量,也就是说我们可能要面临几万个人抢100个库存的场景
也就是说我们需要为秒杀考虑并发场景
并发一般需要考虑的有三个比较大的概念性问题
1、安全性问题:指错误的结果不会发生,这边对应的是超卖
2、活跃性问题:指正确的事情会发生,这边指用户可以在抢到库存后成功下单并支付
3、性能问题:指正确的事情可以尽快发生,这边指抢单不会像以前抢火车票一样,抢了一两个小时才抢到
一、在传统单机部署系统中
这边主要针对如何做库存管理做描述
1、利用AtomicInteger,原子变量做库存管理
如下:
package com.example.demo; import org.junit.jupiter.api.Test; import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; public class Test1 { static class StockManager { final int stockTotal; final AtomicInteger stockAtomic; public StockManager(int stock) { stockTotal = stock; this.stockAtomic = new AtomicInteger(stock);; } public boolean lockStock(int stock) { int currentStock = stockAtomic.get(); int newStock = currentStock - stock; if (newStock < 0) { // 如果库存不足,直接返回失败 return false; } else { return stockAtomic.compareAndSet(currentStock, newStock); } } public boolean unLock(int stock) { int currentStock = stockAtomic.get(); int newStock = currentStock + stock; if (newStock > stockTotal) { // 其实这个不严谨,如果存在售卖期间,自行调整库存的情况,就会有问题,但这个问题域会更复杂点,在这里先不考虑 // 这里更应该抛出异常 return false; } else { return stockAtomic.compareAndSet(currentStock, newStock); } } } @Test public void test() { final StockManager stockManager = new StockManager(100); ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(100, 150, 1, TimeUnit.MINUTES, new LinkedBlockingDeque()); for (int i = 0; i < 10000; i++) { int finalI = i; threadPoolExecutor.submit(() -> { if (stockManager.lockStock(1)) { System.out.println("客户" + finalI + "成功抢占库存"); if (finalI % 2 == 0) { stockManager.unLock(1); System.out.println("客户" + finalI + "放弃库存"); } } }); } // 不再接受新的任务,执行完之后关闭 threadPoolExecutor.shutdown(); while (!threadPoolExecutor.isShutdown()) { } } }
执行完之后会发现抢占成功的次数-放弃库存的次数等于100.
这边实际用到的是硬件级别的锁
首先我们要确定一点,原子变量在JVM、Java代码的层级是没有锁机制的,它应用的是一种CAS的机制,完整的单词是Compare And Set(也有做Compare And Swap,但我看Java中暴露的方法是前者)
意思大概就是先比较再设置,那么了解并发的人就会意识到,先比较后设置,和先检查再执行,都是属于两个操作,是没有原子性的。
那么为什么这个变量又叫做原子性变量呢。
如果我们到JVM的源码中去看的话(因为在JDK中,只能看到最终调用的是一个本地方法接口),应该能看到最终这个方法调用的是C++中的一个方法,那么重点是这个方法应用的是CPU原语操作。
简单来说的话,就是CPU保证这个操作是原子性的,现代CPU基本上都支持CAS操作的原子性
ps:由此衍生的另外一个问题是ABA问题,这边ABA只是随便取的,重点要描述的是:一个变量,比如从5 到 10 再到5,看起来值是没变的,但实际中中途是变过的。
也就是说理论上CAS存在过程不一致,只能保证最终一致。
另外我们可以看出CAS实际上是一种乐观锁机制,那么与我们常规关系型数据库中使用的版本式的乐观锁不同,这边的版本号取自变量原值,所以存在上面的CAS问题。也就是说一般要解决ABA问题,版本号需要相对独立(可以使用带版本号的AtomicStampedRefence)
使用带版本号的乐观锁的改进版本
package com.example.demo; import org.junit.jupiter.api.Test; import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicStampedReference; public class Test1 { static class StockManager { final Integer stockTotal; final AtomicStampedReference<Integer> stockAtomic; public StockManager(Integer stock) { stockTotal = stock; this.stockAtomic = new AtomicStampedReference(stock, 1);; } public boolean lockStock(Integer stock) { Integer stamp = stockAtomic.getStamp(); Integer currentStock = stockAtomic.getReference(); Integer newStock = currentStock - stock; if (newStock < 0) { // 如果库存不足,直接返回失败 return false; } else { return stockAtomic.compareAndSet(currentStock, newStock, stamp, stamp + 1); } } public boolean unLock(Integer stock) { Integer stamp = stockAtomic.getStamp(); Integer currentStock = stockAtomic.getReference(); Integer newStock = currentStock + stock; if (newStock > stockTotal) { // 其实这个不严谨,如果存在售卖期间,自行调整库存的情况,就会有问题,但这个问题域会更复杂点,在这里先不考虑 // 这里更应该抛出异常 return false; } else { return stockAtomic.compareAndSet(currentStock, newStock, stamp, stamp + 1); } } } @Test public void test() { final StockManager stockManager = new StockManager(100); ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(100, 150, 1, TimeUnit.MINUTES, new LinkedBlockingDeque()); for (Integer i = 0; i < 10000; i++) { Integer finalI = i; threadPoolExecutor.submit(() -> { if (stockManager.lockStock(1)) { if (finalI % 2 == 0) { stockManager.unLock(1); System.out.println("客户" + finalI + "放弃库存"); } else { System.out.println("客户" + finalI + "成功抢占库存"); } } }); } // 不再接受新的任务,执行完之后关闭 threadPoolExecutor.shutdown(); while (true) { } } }
** 需要注意的事抢占成功不一定是100。。因为可能在版本冲突过程中,线程已经全部执行完成。。。。这个就是和自旋锁不断重试的机制不同的地方(后者会不断取出当前值做比较)
与我们一般使用的乐观锁还有的不同是,这边的CAS实际上是一种自旋锁机制,即不断重试,由此衍生的问题也就是说,竞争情况很多的时候会导致CPU性能瓶颈(只能说适用场景不同,并不能说自旋锁不适用于高并发场景)
**************************************切割下,现代网站系统通常都是分布式的,所以理论上这些都没办法直接被应用***************************************
通常我们可能会采用一种单机服务机制,比如说像授权服务,我们会有统一的一个服务,虽然可能会有多机部署,但从使用上,会保证一个登陆记录的唯一性
所以分布式中,对于秒杀的一般思路,很多面试官提及或者想要问的是队列。我自己大概也有几种思路(还没参考过网上的,所以也不知道这几个思路是不是有可取的)
第一种:
1、类似于我去大丰收吃饭,高峰期,取个号等就是了,门口就不会被堵住。
那么如何实现取号呢?
- 在redis中预热加载库存信息,并初始化比如说100张的入场券
- 提交订单的时候先到叫号机服务中获取一个编号,这个叫号机采用的是无边界队列模式(另外可以参考有边界的,通常是100个长度,最多取100个号,其他人直接拒绝),这个号码被绑定到登录信息中
ps:这边其实可以在这个叫号机上面做扩展,相当于流量控制算法
- 服务器后台自动轮询这个队列,一但入场券池中有券,就绑定到具体的号码(其实相当于关联到某个用户);超过一定时间没有被响应(消费)就清除绑定关系,被取消排号的有效性,将入场券重新返回池中
ps:redis中使用lua脚本控制事务性,保证入场券的ACID特性
- 用户发现没有抢成功,但还显示有库存的时候,重新点击提交订单,此时叫号机发现已经交过号了,便不再排号,而是继续等待(等待服务器轮询入场券池,有空余的时候按照顺序绑定到叫号机的队列的具体元素中)
ps:如果此时用户第一次抢失败,但是第二次点击的时候,入场券池的后台服务已经给此用户分配了券,则可以提交成功成功
- 用户一旦取消订单,则入场券会重新回到池中
上面的这个做法大致体现了公平性(如果100个并发用户在第二轮抢占时,将会优先给之前排号在前的)
第二种:我觉得比较简单粗暴,就是生产和消费者的关系
1、用户提交订单的时候,把对应的信息放到消息队列中(这里是生产)
2、商城的后台系统直接从队列中轮询,一一消费,这里我可以直接操作关系型数据库了,因为队列的执行保证有序和一定时间内的处理数量
3、如果商城后台成功消费了前台用户的订单信息,则会在消息队列中另外推送一条订单创建成功的信息
4、前台用户通过同步(前端等待抢单成功中)或者异步(收到微信通知抢单成功)进入订单支付页面,相当于消费掉这条订单创建成功的消息
综合考虑:
个人认为一般在现代的分布式系统中会有如下几个考虑
1、利用网关的特性,限制指定时间内流量的数量,这个和服务没有任何直接关系,用的特性也是队列的机制;可以考虑线程池任务队列满的时候的执行策略(比如说限制1分钟内10000个访问量,那么多过的部分怎么处理,是直接拒绝,还是等待,还是分发一个类似于叫号的方式)
2、使用生产+消费的双工流程,保证操作的有序性,且拆解出不同的操作步骤;比如下单和库存锁定,我把它切割开,各自集中处理各自的逻辑(在这边的思路中可能下单的逻辑没有深入,所以看上去只是序列化下下单信息而已)