秒杀系统场景特点
1, 秒杀时大量用户会在同一时间同时进行抢购,网站瞬时访问流量激增。
2, 秒杀一般是访问请求数量远远大于库存数量,只有少部分用户能够秒杀成功。
3, 秒杀业务流程比较简单,一般就是下订单减库存。
秒杀系统技术要求
1, 高并发:秒杀时大量用户会在同一时间同时进行抢购,网站瞬时访问流量激增。是时间极短、瞬间用户量极大。面对瞬间的流量涌入,如何保证服务还能平稳运行。
2, 超卖:高效益的产品不可能无限量供应,在总额度有限的前提下,如果发生超卖的情况,不仅会损失金钱,也会引发用户投诉,降低用户口碑。
3, 恶意请求:对于恶意的请求,即便不法用户最后未能抢到商品,但在恶意请求的期间,这种行为也会给服务器、数据库、带宽等造成压力,导致其他用户体验下降,对于脚本等恶意行为应该迅速拦截。
4, 数据库:项目使用 MySQL 数据库,在高 QPS 的场景下,如果流量瞬间涌入数据库后让数据库挂掉,导致其他服务也无法使用,带来的灾难将是不可预估的。
5, 链接暴露:为了防止不法分子提前知道秒杀活动的地址并发起请求,需设计一个随机算法生成秒杀链接。
同时还有以下问题需要被考虑:
时间同步:多个客户端的时间保持同步,也就是让大家看到时间是一致的。
高可扩:随着时间的推移,系统可能对功能,性能等方面有新的要求。
技术方案
很明显,要让大规模用户能够同时打开抢货的网页,势必要用要到 CDN。同时利用我们分布式中限流、网关等知识,将请求层层筛选,降低最后连接到数据库的请求。即使用 CDN 的边缘结点来扛流量,然后过滤用户请求(限流用户请求),来保护数据中心的系统。
CDN 主要作用有两个:
-
将一些不会改变的静态资源放到离客户端较近的边缘服务器上。这样客户端请求数据的时候可以直接从边缘服务器获取,降低中心服务器的压力。
-
可以把小服务部署到 CDN 结点上去,这样,当前端页面来访问开没开始时,这个小服务除了告诉前端是否开始外,它还可以统计下有多少人在线。
每个小服务会把当前在线等待秒杀的人数每隔一段时间就回传给我们的数据中心,于是我们就知道全网总共在线的人数有多少。
利用 CDN 将静态资源分发在边缘服务器上,当进行服务请求时,先进行鉴权,鉴权主要是筛选机器人等非人工抢购,根据实际经验,鉴权可以筛选很大一部分用户,例如是否登录。
当鉴权确定是真实有效的用户之后,通过负载均衡,也就是 LVS+Keepalived 将请求分配到不同的 Nginx 上。
一般会建立 Nginx 集群,然后再通过网关集群,即使这样还是要增加一些限流措施。
如果到这一步还是有很多请求压到数据库势必撑不住,那么可以采取服务限流、服务降级等措施,进行削峰处理。
到这儿理论上流量就不高了,如果还是很高,后面就将热点数据放进缓存集群中进行预热,同时设置定时任务。
一方面关注数据库与缓存的一致性,另一方面关闭超时未支付的订单,当订单提交之后交给任务队列,生成订单、修改数据库、做好持久化工作。
架构图如下(可点击查看大图):
针对超卖问题
- 超卖的原因
假设某个抢购场景中,我们一共只有100个商品,在最后一刻,我们已经消耗了99个商品,仅剩最后一个。这个时候,系统发来多个并发请求,这批请求读取到的商品余量都是99个,然后都通过了这一个余量判断,最终导致超发。
在上面的这个图中,就导致了并发用户B也“抢购成功”,多让一个人获得了商品。这种场景,在高并发的情况下非常容易出现。
- 悲观锁思路
解决线程安全的思路很多,可以从“悲观锁”的方向开始讨论。
悲观锁,也就是在修改数据的时候,采用锁定状态,排斥外部请求的修改。遇到加锁的状态,就必须等待。虽然上述的方案的确解决了线程安全的问题,但是,别忘记,我们的场景是“高并发”。也就是说,会很多这样的修改请求,每个请求都需要等待“锁”,某些线程可能永远都没有机会抢到这个“锁”,这种请求就会死在那里。同时,这种请求会很多,瞬间增大系统的平均响应时间,结果是可用连接数被耗尽,系统陷入异常。
- 乐观锁思路
这个时候,我们就可以讨论一下“乐观锁”的思路了。乐观锁,是相对于“悲观锁”采用更为宽松的加锁机制,大都是采用带版本号(Version)更新。实现就是,这个数据所有请求都有资格去修改,但会获得一个该数据的版本号,只有版本号符合的才能更新成功,其他的返回抢购失败。这样的话,我们就不需要考虑队列的问题,不过,它会增大CPU的计算开销。但是,综合来说,这是一个比较好的解决方案。
有很多软件和服务都有“乐观锁”功能的支持,例如Redis中的watch就是其中之一。通过这个实现,我们保证了数据的安全。
秒杀实现方案
- MQ削峰
- 请求先放到MQ,给客户端返回:正在排队中…
- 客户端起定时任务,向服务端轮询执行结果
- Redis预减
//请求url:/miaosha/product/12234
//服务端controller:
@PostMapping("/product/miaosha/{productId}")
public boolean miaosha(@PathVariable("productId") long productId){
// 活动开始之前,把商品id和商品的库存数量加载到redis中。
// 查redis中的productId的库存数量,10
int count = redisTemplate.get(""+productId);
if(count <= 0){
return false;
}
// redis扣减,预减库存
redisTemplate.decr(""+productId);
// 放入MQ,返回排队中
kafkaTemplate.send(productId);
// 从MQ收消息,下单,乐观锁扣库存
select * from product where id = #{productId} //version
update product set stock=stock-1, version=version+1
where id = #{productId} and version = #{version} and stock>0
}
- 验证码,防止机器人刷接口,减少瞬间的并发
- 活动开始之前换接口,换页面,防止脚本暴力访问
- 未支付或取消的订单包含的商品需要进行回仓操作,用户可以重新下单
秒杀架构设计理念
限流: 鉴于只有少部分用户能够秒杀成功,所以要限制大部分流量,只允许少部分流量进入服务后端。
削峰:对于秒杀系统瞬时会有大量用户涌入,所以在抢购一开始会有很高的瞬间峰值。高峰值流量是压垮系统很重要的原因,所以如何把瞬间的高流量变成一段时间平稳的流量也是设计秒杀系统很重要的思路。实现削峰的常用的方法有利用缓存和消息中间件等技术。
异步处理:秒杀系统是一个高并发系统,采用异步处理模式可以极大地提高系统并发量,其实异步处理就是削峰的一种实现方式。
内存缓存:秒杀系统最大的瓶颈一般都是数据库读写,由于数据库读写属于磁盘IO,性能很低,如果能够把部分数据或业务逻辑转移到内存缓存,效率会有极大地提升。
可拓展:当然如果我们想支持更多用户,更大的并发,最好就将系统设计成弹性可拓展的,如果流量来了,拓展机器就好了。像淘宝、京东等双十一活动时会增加大量机器应对交易高峰。
设计思路
将请求拦截在系统上游,降低下游压力:秒杀系统特点是并发量极大,但实际秒杀成功的请求数量却很少,所以如果不在前端拦截很可能造成数据库读写锁冲突,甚至导致死锁,最终请求超时。
充分利用缓存:利用缓存可极大提高系统读写速度。
消息队列:消息队列可以削峰,将拦截大量并发请求,这也是一个异步处理过程,后台业务根据自己的处理能力,从消息队列中主动的拉取请求消息进行业务处理。
对秒杀过程进行简单设计
-
秒杀开始前,秒杀按钮灰掉为“未开始”,不可点击。
-
URL在活动开始前不可露出或者生效,否则容易被使用工具绕过浏览器提前下单。导致活动还未开始,已经开始下单这个大黑洞。我们的做法是在活动开始前,通过更新JS文件露出下单的URL。
-
在秒杀进行中,秒杀按钮才可以点击下单。
-
秒杀产品的介绍,详情,参数等等,全部静态化,将秒杀商品页面缓存在CDN上。
-
用户点击“下单”后,按钮置灰,禁止用户重复提交请求,限制用户在60秒之内只能提交一次请求。(防止DDOS攻击)
-
然后就发送请求了,请求统一发送到Nginx中,Nginx中限流。
-
Nginx限流后把请求分发到Tomcat的秒杀接口上面去。
-
然后到了秒杀接口上了,这个秒杀接口的项目在启动运行的时候就把商品id和数量保存到了Redis中。
-
再做唯一性判断,对uid进行请求计数和去重,发现发送过秒杀请求就结束秒杀。
-
然后请求到达时通过要秒杀的id号到Redis中去查询预减库存,只要商品库存不到0就执行下一步。(因为Redis的操作是具有原子性所以不会出现超卖的问题)
-
如果是分布式项目下单的接口在另外的微服务中的话,就把订单消息传MQ,这个MQ实现了流量削峰后在以先进先出的方式进行异步下单。(前期都没操作数据库,防止服务器压力太大会死机的,经过MQ之后才访问数据库)
-
如果就在此接口下单的话就只需要调用下单接口生成订单就好了,不需要中间插件了。
-
下单的过程中的SQL语句还使用了乐观锁进行操作的再次控制防止出现超卖现象。
-
秒杀结束后,秒杀按钮灰掉为“已结束”,不可点击。
前端操作
-
秒杀开始前,秒杀按钮灰掉为“未开始”,不可点击。
-
URL在活动开始前不可露出或者生效,否则容易被使用工具绕过浏览器提前下单。导致活动还未开始,已经开始下单这个大黑洞。我们的做法是在活动开始前,通过更新JS文件露出下单的URL。
-
在秒杀进行中,秒杀按钮才可以点击下单。
-
秒杀产品的介绍,详情,参数等等,全部静态化,将秒杀商品页面缓存在CDN上(资源没那么好其实可以放在Nginx中做动静分离)CDN服务器就是内容分发网络,把资源内容放在了全国各地的各服务器,通过中心平台的负载均衡、内容分发、调度等功能模块,使用户就近获取所需内容。(一般都是到阿里云买CDN服务器)
-
用户点击“下单”后,按钮置灰,禁止用户重复提交请求,限制用户在60秒之内只能提交一次请求。(防止DDOS攻击)
-
然后就发送请求了,请求统一发送到Nginx中。
后端操作
-
Nginx收到前端的请求后,在进行限流操作后才把前端秒杀的请求发送到Tomcat服务器上的秒杀接口上。也就是说:这Nginx转发请求到tomcat的层到途中:Nginx限制用户的重复提交,使用了限流在某一时间段内只允许用户提交一次请求到秒杀接口(重点)
为什么要Nginx限流:Nginx限流就是限制用户请求速度,防止服务器受不了(防止DDOS攻击)重点:Nginx配置的限流代码。 -
Nginx限流有3种
1、限制访问频率(正常流量)
2、限制访问频率(突发流量)
3、限制并发连接数 -
然后到了秒杀接口上了,这个秒杀接口的项目在启动运行的时候就把商品id和数量保存到了 Redis中,秒杀项目启动时的操作,把商品id和数量保存到了 Redis中。(我们考虑Redis来解压力,防止服务器和MySQL都受不了)
-
项目运行流程先做唯一性判断,对uid进行请求计数和去重,发现以经发送过秒杀请求就结束秒杀。万一人家不通过Nginx进来秒杀怎么办,直接访问秒杀接口(所以需要再次判断Uid)
再次(防止DDOS攻击) -
然后请求到达时通过要秒杀的id号到Redis中去查询预减库存,只要商品库存不到0就执 行下一步,(因为Redis的操作是具有原子性所以不会出现超卖的问题)(重点)
关键的decr方法(redis中的原子递减,根据key减value值)decr key将指定key的value原子性递减1,就相当于java中的–i。 如果该key不存在,其 初始值为0,在decr之后其值为-1。如果value的值不能转成整型,如helllo,该操作将执行失败并返回相应的错误信息。 -
然后再把订单消息传入MQ,MQ去通知下单接口生成订单,这个MQ实现了流量削峰后在以先进先出的方式进行异步下单(前期都没操作数据库,防止服务器压力太大会死机的,经过MQ之后才访问数据库)。。。注意:(可以不需要此MQ,此MQ只是在分布式项目中需要,就是下单接口在另外的项目就需要,要此MQ还不好,因为中间插件挂了秒杀系统也挂了,其实可以直接调用下单的方法了)
-
如果就在此接口下单的话就只需要调用下单接口生成订单就好了,不需要中间插件了。
-
下单的过程中的SQL语句还使用了乐观锁进行操作的再次控制防止出现超卖现象,(其实可以不需要,只是保证以下,其实可以直接生成下单的订单了)
乐观锁的标记值判断就是把redis中返回预减后的的值去和数据库的库存去判断,达到一个防止超卖的现象。 -
到此秒杀结束。
需要注意的问题
1、什么是漏桶流
NGINX限流使用漏桶算法(leaky bucket algorithm),该算法广泛应用于通信和基于包交换计算机网络中,用来处理当带宽被限制时的突发情况。和一个从上面进水,从下面漏水的桶的原理很相似;如果进水的速率大于漏水的速率,这个桶就会发生溢出。
在请求处理过程中,水代表从客户端来的请求,而桶代表了一个队列,请求在该队列中依据先进先出(FIFO)算法等待被处理。漏的水代表请求离开缓冲区并被服务器处理,溢出代表了请求被丢弃并且永不被服务。
2、什么是MQ的削峰
MQ的削峰也和漏桶流有点相似道理,只不过他是一个永远不会漏的桶,请求在多没关系,我先接着,我慢慢来,不管你多少请求我都按照我的速度慢慢来,保证消息不丢失。
3、Tomcat最多支持并发多少用户
Tomcat 默认配置的最大请求数是 150,也就是说同时支持 150 个并发,当然了,也可以将其改大。
当某个应用拥有 250 个以上并发的时候,应考虑应用服务器的集群。
具体能承载多少并发,需要看硬件的配置,CPU 越多性能越高,分配给 JVM 的内存越多性能也就越高,但也会加重 GC 的负担。
操作系统对于进程中的线程数有一定的限制:
Windows 每个进程中的线程数不允许超过 2000
Linux 每个进程中的线程数不允许超过 1000
另外,在 Java 中每开启一个线程需要耗用 1MB 的 JVM 内存空间用于作为线程栈之用。
Tomcat的最大并发数是可以配置的,实际运用中,最大并发数与硬件性能和CPU数量都有很大关系的。更好的硬件,更多的处理器都会使Tomcat支持更多的并发。
4、Nginx支持多少并发
单个Nginx并发尽量不要超过2万,如果超过了就要做集群了,虽然官方监测能支持5万并发。
5、MySql最大的并发
MySQL最大的并发量500-1000,好一点的服务可以支持1000-2000,超过了还是要搭建集群。
核心问题
库存超卖
只有10个库存,但是一秒钟有1k个订单,怎么能不超卖呢?
核心思想就是保证库存递减是原子性操作,10–返回9,9–返回8,8–返回7。
而不能是读取出来库存10,10-1=9再更新回去。因为这个读取和更新是并发执行的,很可能就会有1k个订单都成功了,而库存实际只有10。
那么,怎么保证原子性操作呢?
-
数据库判断库存不能小于0(乐观锁)
update product set left_num=left_num-1 where left_num>0;
-
分布式锁(set nx ex)
用redis来做一个分布式锁,reids->setnx(‘lock’, 1) 设置一个锁,程序执行完成再del这个锁。
锁定的过程,不利于并发执行,大家都在等待锁解开,不建议使用。 -
消息队列(暂存在队列)
将订单请求全部放入消息队列,然后另外一个后台程序依次处理队列中的订单请求。
并发不受影响,但是用户等待的时间较长,进入队列的订单也会很多,不建议使用。 -
redis递减(decr方法就是原子性递减)
通过 redis的:decr(‘key’)方法 以原子性的方式得到递减之后的库存数。
性能方面很好,同时体验上也很好。
集群怎么来规划
前端服务器不用管,集群的数量不受影响。
redis的性能可以达到每秒几万次响应,所以一个集群的规模,也就是redis服务可以承载的数量。
比如:一台前端服务器是1-2k的qps(有库存时),那么10台+1台redis就可以是一个独立的集群,可以支撑1-2w每秒订单量。
10个上述的集群就可以做到一秒钟处理10w~20w的有效订单。
如果秒杀活动的库存量在1w以内,预计参与的人数在百万左右,那么有一个集群也就可以搞定。
如果秒杀参与的人数超过千万,那么就要用到不止一个集群了。
多个集群的数据怎么保持一致性啊
不要做多集群的数据同步,而是用散列,每个集群的数据是独立存在的。
假设,有10个商品,每个商品有1w库存,规划用10个集群,那么每个集群有10个商品,每个商品是1k库存。
每个集群只需要负责把自己的库存卖掉即可,至于说,会不会有用户知道有10个集群,然后每个集群都去抢。
这种情况就不要用程序来处理了,利用运营规则,活动结束后汇总订单的时候再去处理就好了。
如果担心散列的不合理,比如:某个集群用户访问量特别少,那么可以引入一个中控服务,来监控各个集群的库存,然后再做平衡。
机器人抢购怎么办
机器人抢购类似DDOS攻击,可以在运营策略上严格控制用户注册,必须登录,提交订单的时候引入图像验证码,问答,交互式验证等方式。