缪啥
难点&& 特点
特点就是瞬时大并发、库存少、业务流程简单
主要是产生大并发请求、产生超卖的现象和性能问题
- 瞬时大并发:抢购场景中系统经常会有10w+的用户同时访问一个商品页面去抢购手机,这就是一个典型的瞬时大并发,如果系统没有经过限流或者熔断处理,那么系统瞬间就会崩掉,就好像被DDos攻击一样;
- 在大并发的场景最容易发生的就是超卖,不同线程读取到的当前库存数据可能下个毫秒就被其他线程修改了,如果没有一定的锁库存机制那么库存数据必然出错,都不用上万并发,几十并发就可以导致商品超卖;
- 性能:当遇到大并发和超卖问题后,必然会引出另一个问题,那就是性能问题,如何保证在大并发请求下,系统能够有好的性能,让用户能够有更好的体验,不然每个用户都等几十秒才能知道结果,那体验必然是很糟糕的;
其实,并发的流量实际上都是直接穿透让MYSQL自己去抗,比如说库存是否卖完以及用户是否重复秒杀都完全是靠查询数据库去判断,造成数据库不必要的负担非常大,然而这些都可以放在缓存做一个标记在服务层进行拦截,对于中小规模的并发还可以,但是真正的超高并发,显然这个还不完善。
解决
主要还是通过缓存、异步、限流来保证系统的高并发和高可用。
削峰:利用缓存和消息中间件,异步处理也是削峰的一种实现方式
除了上图四点之外,另外,系统注意设计成弹性可扩展的。流量过大时,扩展机器就好了;还有就是消息队列:消息队列可以削峰,将拦截大量并发请求,这也是一个异步处理过程,后台业务根据自己的处理能力,从消息队列中主动的拉取请求消息进行业务处理。
优化的主要思路:
1. 将请求尽量拦截在系统的上游 :
- 限流(屏蔽掉无用的流量,允许少部分流量流向后端);
- 削峰(避免瞬时大流量:异步、缓存、消息中间件等技术)
2.前端优化 :
- 静态资源进行缓存:
- 页面静态化
商品详情和订单详情进行一个静态化处理,静态的HTML无需打开数据库连接;动态数据通过接口从服务端获取;实现前后端分离。 - 页面缓存
通过CDN(内容分发网络)缓存静态资源来抗峰值。也可以通过 在手动渲染得到的HTML页面缓存到redis。
- 页面静态化
- 限流
-
使用数学公式验证码:
好处: 防止恶意机器人or爬虫;分散用户的请求
实现:
1)通过把商品id作为参数调用服务端创建验证码接口
2)服务端根据前端传过来的商品id和用户id生成验证码,并将商品id+用户id作为key,生成的验证码作为value存入redis,同时将生成的验证码输入图片写入imageIO让前端展示。
3)将用户输入的验证码与根据商品id+用户id从redis查询到的验证码对比,相同就返回验证成功,进入秒杀;不同或从redis查询的验证码为空都返回验证失败,刷新验证码重试 -
禁止重复提交:提交后按钮置为灰色
-
3. 负载均衡 中间代理层
可利用负载均衡(例如反响代理Nginx等)使用多个服务器并发处理请求,减小服务器压力。
4. 后端优化
1.网关层(控制层)
限制同一个userID访问频率:尽量拦截浏览器请求,但针对某些恶意攻击或其它插件,在服务端控制层需要针对同一个访问uid,限制访问频率。
有两个方法:
- 利用缓存:设置 缓存的有效时间,在缓存中计数,若缓存的有效时间内请求次数超了,就返回请求访问太频繁;
- 利用RateLimiter
RateLimiter是guava提供的基于令牌桶算法的限流实现类,通过调整生成token的速率来限制用户频繁访问秒杀页面,从而达到防止超大流量冲垮系统。(令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。
2.服务层
-
业务分离:将秒杀业务系统和其他业务分离,单独放在高配服务器上,可以集中资源对访问请求抗压。——应用的拆分
-
采用消息队列缓存请求:将大流量请求写到消息队列缓存,利用服务器根据自己的处理能力主动到消息缓存队列中抓取任务处理请求,数据库层订阅消息减库存,减库存成功的请求返回秒杀成功,失败的返回秒杀结束。
-
利用缓存应对读请求:对于读多写少业务,大部分请求是查询请求,所以可以读写分离,利用缓存分担数据库压力。
-
利用缓存应对写请求:缓存也是可以应对写请求的,可把数据库中的库存数据转移到Redis缓存中,所有减库存操作都在Redis中进行,然后再通过后台进程把Redis中的用户秒杀请求同步到数据库中。
可以将缓存和消息中间件 组合起来,缓存系统负责接收记录用户请求,消息中间件负责将缓存中的请求同步到数据库。
方案:本地标记 + redis预处理 + RabbitMQ异步下单 + 客户端轮询
描述:通过三级缓冲保护,1、本地标记 2、redis预处理 3、RabbitMQ异步下单,最后才会访问数据库,这样做是为了最大力度减少对数据库的访问。
- 1
实现:
在秒杀阶段使用本地标记对用户秒杀过的商品做标记,若被标记过直接返回重复秒杀,未被标记才查询redis,通过本地标记来减少对redis的访问
抢购开始前,将商品和库存数据同步到redis中,所有的抢购操作都在redis中进行处理,通过Redis预减少库存减少数据库访问
为了保护系统不受高流量的冲击而导致系统崩溃的问题,使用RabbitMQ用异步队列处理下单,实际做了一层缓冲保护,做了一个窗口模型,窗口模型会实时的刷新用户秒杀的状态。
client端用js轮询一个接口,用来获取处理状态
3.数据库层
数据库层是最脆弱的一层,一般在应用设计时在上游就需要把请求拦截掉,数据库层只承担“能力范围内”的访问请求。所以,上面通过在服务层引入队列和缓存,让最底层的数据库高枕无忧。
但依然可以进行如下方向的优化:
对于秒杀系统,直接访问数据库的话,存在一个【事务竞争优化】问题,可使用存储过程(或者触发器)等技术绑定操作,整个事务在MySQL端完成,把整个热点执行放在一个过程当中一次性完成,可以屏蔽掉网络延迟时间,减少行级锁持有时间,提高事务并发访问速度。
其他
- 一般秒杀活动时,运营会配置静态的活动页面,配置静态活动页面(尽量减少动态元素)的主要目的是:一方面,为了便于在各种社交媒体进行转发;另一方面,因为秒杀活动页的流量在大促期间是最大的,通过配置为静态页面可以将页面发布在公有云上动态的横向扩展;
- 将秒杀活动的静态页面提前刷新到CDN节点,通过CDN节点的页面缓存来缓解访问压力和公司网络带宽,CDN中缓存JS、css和图片。
- 将活动H5页面部署在公有云的web server上,使用公有云最大的好处就是能够根据活动的火爆程度动态扩容而且成本较低,同时将访问压力隔离在公司系统外部;
- 防止提前下单:防止提前下单主要是在静态化页面中加入一个 JS 文件引用,该 JS 文件包含活动是否开始的标记以及开始时的动态下单页面的 URL 参数。同时,这个 JS 文件是不会被 CDN 系统缓存的,会一直请求后端服务的,所以这个 JS 文件一定要很小。当活动快开始的时候(比如提前),通过后台接口修改这个 JS 文件使之生效。
- 在提供真正商品秒杀业务功能的app server上,需要进行交易限流、熔断控制,防止因为秒杀交易影响到其他正常服务的提供,我们在限流和熔断方面使用了hystrix,在核心交易的controller层通过hystrix进行交易并发限流控制,当交易流量超出我们设定的限流最大值时,会对新交易进行熔断处理固定返回静态失败报文。
- 服务降级处理,除了上面讲到的限流和熔断控制,我们还设定了降级开关,对于首页、购物车、订单查询、大数据等功能都会进行一定程度的服务降级,例如我们会对首页原先动态生成的大数据页面布局降级为所有人看到的是一样的页面、购物车也会降级为不在一级页面的tabbar上的购物车图标上显示商品数量、历史订单的查询也会提供时间周期较短的查询、大数据商品推荐也会提供一样的商品推荐,通过这样的降级处理能够很好的保证各个系统在大促期间能够正常的提供最基本的服务,保证用户能够正常下单完成付款。
- 上面介绍的都是如何保证能扛住高并发,下面介绍下整个方案中如何防止超卖现象的发生,我们日常的下单过程中防止超卖一般是通过在数据库上实施乐观锁来完成,使用乐观锁虽然比for update这种悲观锁方式性能要好很多,但是还是无法满足秒杀的上万并发需求,我们的方案其实也很简单实时库存的扣减在缓存中进行,异步扣减数据库中的库存,保证缓存中和数据库中库存的最终一致性。
秒杀系统核心在于层层过滤,逐渐递减瞬时访问压力,减少最终对数据库的冲击。
MQ 排队服务,只要 MQ 排队服务顶住,后面下订单与扣减库存的压力都是自己能控制的,根据数据库的压力,可以定制化创建订单消费者的数量,避免出现消费者数据量过多,导致数据库压力过大或者直接宕机。
库存服务专门为秒杀的商品提供库存管理,实现提前锁定库存,避免超卖的现象。同时,通过超时处理任务发现已抢到商品,但未付款的订单,并在规定付款时间后,处理这些订单,将恢复订单商品对应的库存量。
8. SOA服务层优化:后端进行流量控制:通过消息队列、异步处理、提高并发等方式解决。对于超过系统水位线的请求,直接采取 **「Fail-Fast」**原则,拒绝掉。
架构
整体流程如下:
详细的秒杀流程见文章末尾。
因为秒杀系统对应的是高并发的场景,这类场景最大的特征就是活动周期短,瞬时流量大(高并发),大量的用户涌入但是只有少数人能抢到有限的产品。
高并发
所以我们针对高并发的情况:
首先要优化程序,单次请求时间变短,性能增加(系统的处理速度:程序内数据读写 > redis > mysql > 磁盘;单机网络请求 > 局域网内请求 > 跨机房请求);
然后可以增加服务器,用集群处理,设计一个可靠且能够灵活扩充的分布式系统;
我们还要选取一个好的语言( 高并发和性能方面,golang、ngx_lua与java和PHP相比更具有优势);
提前估计系统的容量,是在一个什么样的量级下,关注系统的瓶颈,尽量优化程序,使用最简短的逻辑(把速度快且提前中断的逻辑放在最前面,例如验证登录、验证问答等);
然后分布式方案中,资源要放在最近的地方,前端服务器依赖的数据尽量放在局域网中,尽量不要出现跨机房的网络请求。
问题1. 关于超卖:
只有10个库存,但是一秒钟有1k个订单,怎么能不超卖呢?
核心思想就是保证库存递减是原子性操作,10–返回9,9–返回8,8–返回7。
而**不能是读取出来库存10,10-1=9再更新回去。**因为这个读取和更新是并发执行的,很可能就会有1k个订单都成功了,而库存实际只有10。
怎么保证原子性操作?
- 数据库 update product set num=num-1 where num>0; 但是数据库使用乐观锁的性能相对较差,不建议使用;
- 分布式锁 用redis来做一个分布式锁,reids->setnx(‘lock’, 1) 设置一个锁,程序执行完成再del这个锁。
锁定的过程,不利于并发执行,大家都在等待锁解开,不建议使用。 - 消息队列将订单请求全部放入消息队列,然后另外一个后台程序一个个处理队列中的订单请求。
并发不受影响,但是用户等待的时间较长,进入队列的订单也会很多,体验上并不好,也不建议使用。而且如果队列涌入消息的速度远远大于消息处理的速度的话,消息都在队列中堆积,整个系统的性能也很低。 - redis递减 通过 redis->incrby(‘product’, -1) 得到递减之后的库存数。
问题2:如何解决少卖的问题
实际上也是缓存和数据库出现不一致的问题!但是我们不是非得解决不一致的问题,本身使用缓存就难以保证强一致性:
在redis中设置库存比真实库存多一些就行。
少卖的情况:1)数据库那边出现非库存原因比如网络等造成减库存失败,而这时redis已经减了。2)如果一个用户发出多个请求,而且这些请求恰巧比别的请求更早到达服务器,如果库存足够,redis就会减多次,redis提前进入卖空状态,并拒绝。不过这两种情况出现的概率都是非常低的。
问题3:多个集群的数据怎么保持一致性
不要做多集群的数据同步,而是用散列,每个集群的数据是独立存在的。
假设,有10个商品,每个商品有1w库存,规划用10个集群,那么每个集群有10个商品,每个商品是1k库存。
每个集群只需要负责把自己的库存卖掉即可,至于说,会不会有用户知道有10个集群,然后每个集群都去抢。
这种情况就不要用程序来处理了,利用运营规则,活动结束后汇总订单的时候再去处理就好了。
如果担心散列的不合理,比如:某个集群用户访问量特别少,那么可以引入一个中控服务,来监控各个集群的库存,然后再做平衡。
问题4:
在其他一般读大于写的场景,一般处理的原则是:缓存只做失效,不做更新。
采用Cache-Aside pattern:
失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
更新:先把数据存到数据库中,成功后,再让缓存失效。
问题5:机器人抢购怎么办:
没什么太好的办法,类似DDOS攻击,只能是让自身更强大才是王道。
运营策略上,可以严格控制用户注册,必须登录,提交订单的时候引入图像验证码,问答,交互式验证等。
一个秒杀系统,500用户同时登陆访问服务器A,服务器B如何快速利用登录名(假设是电话号码或者邮箱)做其他查询?
主从复制,读写分离
资料:https://www.jianshu.com/p/d789ea15d060
https://yq.aliyun.com/articles/618443
http://www.mamicode.com/info-detail-2383504.html
优化秒杀流程
秒杀活动开始之前有个活动倒计时,时间到了则会放开秒杀的权限,并生成一个验证码展示在前面页面,并把验证结果存在redis中,这里利用redis有过期时间的特性,也给验证码的缓存加了个过期时间。这里的redis缓存用的是redis的string类型。
在秒杀之前先要填一个验证码verifyCode,点击秒杀按钮时,先发送ajax请求到后台获取真实的秒杀地址path,这里秒杀地址是隐藏的,目的是防止有人恶意刷秒杀接口。所谓隐藏地址,其实是在请求地址中加一段随机字符串,这段字符串是变化的,因此秒杀请求地址是动态的;
先说下如何获取真实的秒杀地址,后台先访问redis,验证一下这个验证码有没有过期以及这个verifyCode是不是正确,验证码验证通过后,先删除这个验证码缓存,然后生成真实地址;
真实地址随机字符串由uuid以及md5加密生成,并且保存在redis中,并且设置了有效期;
从浏览器端向秒杀地址发起请求,带上path参数去后台调用真正的秒杀接口,下面是秒杀接口的逻辑;
访问redis,验证path有没有过期,以及是不是正确。这里验证path以及上面的校验验证码,都是用userId对应生成的一个key值去取redis中的数据;
path验证通过后,先访问内存标识,看秒杀的这个商品有没有卖完,减少对redis的不必要访问。每一种参与秒杀活动的商品都在内存里用HashMap设置了一个标识,标识某个商品id商品是否卖完了。这里的是否卖完的内存标识设置以及每种参与秒杀商品的库存存入redis是在系统启动时做的;
如果内存标识中这个商品没有卖完,则要看这个用户在这次活动中是否重复秒杀,因为我们的秒杀规则是一个用户id对于某个商品id的商品只能秒杀一件。如何判断该用户有没有秒杀过这件商品呢,秒杀记录也保存在redis缓存中;
如果判断秒杀过则返回提示,如果没有秒杀过,继续;
上面说过系统加载时redis中保存了各商品对应的库存,这里用到redis的原子操作的方法decr,将对应商品的库存减1,此时数据库时的库存还没有减,因此是预减库存;
desc方法返回该商品此时的库存,如果小于0,说明商品已经卖完了,此次秒杀无效,并且设置该商品的内存标识为true,表示已卖完;
正确地预减库存后,然后就要真正操作数据库了,数据库一般是性能瓶颈,比较耗时,因此决定用异步方式处理。对于每一条秒杀请求存入消息队列RabbitMQ中,消息体中要包含哪个用户秒杀哪个商品的信息,这里是封装了一个消息体类,这样一个秒杀请求就进入了消息队列,一个秒杀请求还没有完成,真正的秒杀请求的完成得要持久化到数据库,生成订单,减了数据库的库存才能算数,这时在客户端显示的一般是排队中,比如以前在抢购小米手机时,我就看到这样的展示,过一会再刷新页面就显示没抢到;
**消息队列处理秒杀请求。**先从消息体中解析出用户id和商品id,查数据库看这个商品是否卖完了,查数据库看该用户对于这个商品是否有过秒杀记录;数据库减库存,数据库生成订单,这两项持久化地写数据库操作放在同一个事务中,要么都执行成功,要么都失败。并把秒杀记录对象,包括秒杀单号、订单号、用户id、商品id,存入redis中。如果数据库减库存失败,表明商品卖完了,则要在redis中设置该商品已卖完的标识。消息队列处理秒杀请求。先从消息体中解析出用户id和商品id,查数据库看这个商品是否卖完了,查数据库看该用户对于这个商品是否有过秒杀记录;
数据库减库存,数据库生成订单,这两项持久化地写数据库操作放在同一个事务中,要么都执行成功,要么都失败。并把秒杀记录对象,包括秒杀单号、订单号、用户id、商品id,存入redis中。如果数据库减库存失败,表明商品卖完了,则要在redis中设置该商品已卖完的标识。
ajax发起秒杀请求,秒杀请求的处理逻辑最后也只是把这条请求放入消息队列,并不能返回是否秒杀成功的结果。因此,当秒杀请求正确响应后,即请求放入消息队列后,需要另外一个请求去轮询秒杀结果,秒杀成功的标志是生成秒杀订单,并把秒杀订单对象放入redis中。所以轮询秒杀结果,只用去轮询redis中是否有对应于该用户的该商品的秒杀订单对象,如果有,则表明秒杀成功,并在前台给出提示。