面试的时候有时候会问到秒杀系统如何设计,今天,来总结一下。
秒杀系统其实不单纯是一个电商抢购系统,涉及到大并发的场景都适合使用到该套秒杀系统的方案。
特点就是瞬时大并发、库存少、业务流程简单
主要是产生大并发请求、产生超卖的现象和性能问题
其实,并发的流量实际上都是直接穿透让MYSQL自己去抗,比如说库存是否卖完以及用户是否重复秒杀都完全是靠查询数据库去判断,造成数据库不必要的负担非常大,然而这些都可以放在缓存做一个标记在服务层进行拦截,对于中小规模的并发还可以,但是真正的超高并发,显然这个还不完善。
主要还是通过缓存、异步、限流来保证系统的高并发和高可用。
削峰:利用缓存和消息中间件,异步处理也是削峰的一种实现方式
除了上图四点之外,另外,系统注意设计成弹性可扩展的。流量过大时,扩展机器就好了;还有就是消息队列:消息队列可以削峰,将拦截大量并发请求,这也是一个异步处理过程,后台业务根据自己的处理能力,从消息队列中主动的拉取请求消息进行业务处理。
使用数学公式验证码:
好处: 防止恶意机器人or爬虫;分散用户的请求
实现:
1)通过把商品id作为参数调用服务端创建验证码接口
2)服务端根据前端传过来的商品id和用户id生成验证码,并将商品id+用户id作为key,生成的验证码作为value存入redis,同时将生成的验证码输入图片写入imageIO让前端展示。
3)将用户输入的验证码与根据商品id+用户id从redis查询到的验证码对比,相同就返回验证成功,进入秒杀;不同或从redis查询的验证码为空都返回验证失败,刷新验证码重试
禁止重复提交:提交后按钮置为灰色
可利用负载均衡(例如反响代理Nginx等)使用多个服务器并发处理请求,减小服务器压力。
限制同一个userID访问频率:尽量拦截浏览器请求,但针对某些恶意攻击或其它插件,在服务端控制层需要针对同一个访问uid,限制访问频率。
有两个方法:
业务分离:将秒杀业务系统和其他业务分离,单独放在高配服务器上,可以集中资源对访问请求抗压。——应用的拆分
采用消息队列缓存请求:将大流量请求写到消息队列缓存,利用服务器根据自己的处理能力主动到消息缓存队列中抓取任务处理请求,数据库层订阅消息减库存,减库存成功的请求返回秒杀成功,失败的返回秒杀结束。
利用缓存应对读请求:对于读多写少业务,大部分请求是查询请求,所以可以读写分离,利用缓存分担数据库压力。
利用缓存应对写请求:缓存也是可以应对写请求的,可把数据库中的库存数据转移到Redis缓存中,所有减库存操作都在Redis中进行,然后再通过后台进程把Redis中的用户秒杀请求同步到数据库中。
可以将缓存和消息中间件 组合起来,缓存系统负责接收记录用户请求,消息中间件负责将缓存中的请求同步到数据库。
方案:本地标记 + redis预处理 + RabbitMQ异步下单 + 客户端轮询
描述:通过三级缓冲保护,1、本地标记 2、redis预处理 3、RabbitMQ异步下单,最后才会访问数据库,这样做是为了最大力度减少对数据库的访问。
实现:
在秒杀阶段使用本地标记对用户秒杀过的商品做标记,若被标记过直接返回重复秒杀,未被标记才查询redis,通过本地标记来减少对redis的访问
抢购开始前,将商品和库存数据同步到redis中,所有的抢购操作都在redis中进行处理,通过Redis预减少库存减少数据库访问
为了保护系统不受高流量的冲击而导致系统崩溃的问题,使用RabbitMQ用异步队列处理下单,实际做了一层缓冲保护,做了一个窗口模型,窗口模型会实时的刷新用户秒杀的状态。
client端用js轮询一个接口,用来获取处理状态
数据库层是最脆弱的一层,一般在应用设计时在上游就需要把请求拦截掉,数据库层只承担“能力范围内”的访问请求。所以,上面通过在服务层引入队列和缓存,让最底层的数据库高枕无忧。
但依然可以进行如下方向的优化:
对于秒杀系统,直接访问数据库的话,存在一个【事务竞争优化】问题,可使用存储过程(或者触发器)等技术绑定操作,整个事务在MySQL端完成,把整个热点执行放在一个过程当中一次性完成,可以屏蔽掉网络延迟时间,减少行级锁持有时间,提高事务并发访问速度。
MQ 排队服务,只要 MQ 排队服务顶住,后面下订单与扣减库存的压力都是自己能控制的,根据数据库的压力,可以定制化创建订单消费者的数量,避免出现消费者数据量过多,导致数据库压力过大或者直接宕机。
库存服务专门为秒杀的商品提供库存管理,实现提前锁定库存,避免超卖的现象。同时,通过超时处理任务发现已抢到商品,但未付款的订单,并在规定付款时间后,处理这些订单,将恢复订单商品对应的库存量。
8. SOA服务层优化:后端进行流量控制:通过消息队列、异步处理、提高并发等方式解决。对于超过系统水位线的请求,直接采取 **「Fail-Fast」**原则,拒绝掉。
因为秒杀系统对应的是高并发的场景,这类场景最大的特征就是活动周期短,瞬时流量大(高并发),大量的用户涌入但是只有少数人能抢到有限的产品。
所以我们针对高并发的情况:
首先要优化程序,单次请求时间变短,性能增加(系统的处理速度:程序内数据读写 > redis > mysql > 磁盘;单机网络请求 > 局域网内请求 > 跨机房请求);
然后可以增加服务器,用集群处理,设计一个可靠且能够灵活扩充的分布式系统;
我们还要选取一个好的语言( 高并发和性能方面,golang、ngx_lua与java和PHP相比更具有优势);
提前估计系统的容量,是在一个什么样的量级下,关注系统的瓶颈,尽量优化程序,使用最简短的逻辑(把速度快且提前中断的逻辑放在最前面,例如验证登录、验证问答等);
然后分布式方案中,资源要放在最近的地方,前端服务器依赖的数据尽量放在局域网中,尽量不要出现跨机房的网络请求。
只有10个库存,但是一秒钟有1k个订单,怎么能不超卖呢?
核心思想就是保证库存递减是原子性操作,10–返回9,9–返回8,8–返回7。
而**不能是读取出来库存10,10-1=9再更新回去。**因为这个读取和更新是并发执行的,很可能就会有1k个订单都成功了,而库存实际只有10。
实际上也是缓存和数据库出现不一致的问题!但是我们不是非得解决不一致的问题,本身使用缓存就难以保证强一致性:
在redis中设置库存比真实库存多一些就行。
少卖的情况:1)数据库那边出现非库存原因比如网络等造成减库存失败,而这时redis已经减了。2)如果一个用户发出多个请求,而且这些请求恰巧比别的请求更早到达服务器,如果库存足够,redis就会减多次,redis提前进入卖空状态,并拒绝。不过这两种情况出现的概率都是非常低的。
不要做多集群的数据同步,而是用散列,每个集群的数据是独立存在的。
假设,有10个商品,每个商品有1w库存,规划用10个集群,那么每个集群有10个商品,每个商品是1k库存。
每个集群只需要负责把自己的库存卖掉即可,至于说,会不会有用户知道有10个集群,然后每个集群都去抢。
这种情况就不要用程序来处理了,利用运营规则,活动结束后汇总订单的时候再去处理就好了。
如果担心散列的不合理,比如:某个集群用户访问量特别少,那么可以引入一个中控服务,来监控各个集群的库存,然后再做平衡。
在其他一般读大于写的场景,一般处理的原则是:缓存只做失效,不做更新。
采用Cache-Aside pattern:
失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
更新:先把数据存到数据库中,成功后,再让缓存失效。
没什么太好的办法,类似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中是否有对应于该用户的该商品的秒杀订单对象,如果有,则表明秒杀成功,并在前台给出提示。