前几年,火车票购票网站12306,每到放假高峰期,在线票刷不出来,购买困难,甚至出现了各种插件支持抢票,这样的场景,对于每个买过票的人,应该印象深刻。小米手机的抢购活动,同样异常火爆,在几分钟的时间内,卖出几十万部手机。当一个Web系统,在一秒钟内收到数以万计甚至更多请求时,系统的优化和稳定至关重要。
在面试中,面试官提出这样的问题,应该从哪些角度分析。在工作中,也许没有这么庞大复杂的应用场景,但是,针对网站的优化思路是一致的。本文从技术的角度,分析下应如何设计优化系统,才能保障如此大规模的并发访问。
秒杀系统主要解决三大问题:
一、瞬时的高并发访问。抢购和普通的电商销售有所不同,普通的电商销售,流量是比较平均的,虽然有波峰波谷,但不会特别突出。而抢购是在特定时间点进行的推销活动,抢购开始前,用户不断刷新页面,以获得购买按钮;抢购开始的一瞬间,集中并发购买。
二、数据正确性。抢购毕竟是一种购买行为,需要购买、扣减库存、支付等复杂的流程,在此过程中,要保证数据的正确性,防止超卖(卖出量超过库存)的发生。
三、防作弊。无论是火车票的购买,还是低价商品的促销,肯定不希望某些客户买到所有的商品,应尽量保证公平性。通过购票插件购买火车票,阿里巴巴抢月饼事件等,需要限制技术性用户绕过网站的限制,通过技术手段获得不良收益。
解决上述问题,主要有如下的三个思路:访问拦截,分流,限流。
主流的Web站点采用分层的架构设计,如果你的应用还没有采用分层的架构,那么先做分层设计吧。一般来说,浏览器采用了html/js/css技术,负责数据的展示;反向代理一般采用nginx,负责负载均衡;Web层是指Php,Tomcat等应用服务器,负责用户状态的维护,http协议处理等;service层一般是rpc调用,当然也有用http的,例如spring cloud;数据库存储一般是mongodb,mysql等持久化数据方案。用户的一次数据访问,例如查询商品库存,数据是从上层依次调用到DB,逐层返回数据。
所谓访问拦截,是指尽量把访问拦截在上层,减轻下一层的压力,即离用户访问更近的那一层。下面将从每一层讲解如何做访问拦截。
浏览器访问拦截:产品层面,当用户点击查询或购买按钮后,按钮置灰,防止用户重复提交数据。js层面,限制用户在限定时间内的接口调用次数,或者返回相同的值。例如,用户重复刷新,每秒访问10次接口,变成5秒钟访问一次,并发量将会降低50倍。此种方法,可以拦截90%的小白用户的访问,但是技术型的用户可以绕过js,通过脚本或其他自动化方式调用接口,当年出现的刷票神器,就属于这类范畴。用户量虽小,但是访问量很大。关于防作弊的问题,后续讨论。
CDN加速:CDN的全称是Content Delivery Network,即内容分发网络。其基本思路是尽可能避开互联网上有可能影响数据传输速度和稳定性的瓶颈和环节,使内容传输的更快、更稳定。简单的来说,就是把原服务器上数据复制到其他服务器上,用户访问时,那台服务器近访问到的就是那台服务器上的数据。CDN的劣势是内容的变更生效慢,所以仅适用于“几乎不变”的资源,例如引用的js包,图片等。
动静分离与反向代理层访问拦截:动态页面是指根据实时数据渲染的,需要组织数据、渲染页面;静态页面是存储在文件系统的文件,不会根据数据变化而变化,读取速度很快。为了提升效率,应尽可能的静态化,用静态页面,替换动态页面。例如,商品信息页,商品信息在发布后,是不会变化的,如果采用动态的方式,访问数据库读取数据,service组装数据,web渲染数据;如果发布商品信息时,就保存下商品信息的静态页面,访问时只需要读取一个文件就够了。
做了动静分离,静态文件的访问应在哪一层返回?无论是tomcat,还是apache,都支持静态文件的访问,很多时候我们也是这么做的,把静态文件作为web项目的一部分进行发布。Nginx也支持静态文件的访问,更高效的做法是,把静态文件交由nginx管理,访问nginx直接返回静态数据,减轻Web服务的压力。
Web层和Service层访问拦截:通过上述的访问拦截,进入到web层的,都是动态数据访问。这部分的访问拦截,主要采用缓存的策略,减少对下一层的数据访问。缓存又可分为本地缓存和redis、memcache等缓存中间件。关于缓存,重点关注缓存的淘汰策略。一般有三种方式:超时更新,定时更新,通知更新。
访问拦截,除了减少向下一层的访问,还大幅提高系统的支持用户数。访问拦截,大大减少了每次请求的处理时间,假设:每个请求原来需要200ms时间,10W的并发量,每秒钟可处理50W的请求;通过访问拦截,每个请求的处理时间下降到100ms,同样的并发量,每秒钟可处理100W的请求。
通过上述的分析,各层通过访问拦截,系统架构演变成如下的结构。
在并发量巨大的场景下,通过上述的优化远远不够的,因为单台服务器的处理能力是有限的,即便在当前硬件设备越来越便宜,也不可能无限扩容。分流就是指通过多台服务器,并发的处理请求,减轻单台服务的负载。
DNS轮询:Nginx的处理能力是有限的,单台服务器支持10W左右的并发访问,没有问题。如果更大的负载怎么办?Nginx是应用服务的入口,不能再应用服务这个层次增加服务器,提高并发处理能力。
通过浏览器输入域名访问某个服务,其过程如图所示。DNS轮询是ISP提供的一个服务,不同的用户访问同一个域名,获取到不同的IP地址。例如:给www.example.com配置4个IP地址,如果有40W的并发访问,每个IP将会获得10W的并发访问。当然,域名的IP地址配置,可以支持不同的策略,例如按照电信运营商分配,按照地域分配等。
Nginx负载均衡:Nginx可以支持10W的并发访问,而应用服务器却达不到这个水准,tomcat一般支持1W的并发访问就很好了。Nginx支持配置请求的代理策略,把请求路由到多个Web服务器处理。Nginx支持的负载均衡策略包括:轮询,权重,ip_hash,fair,url_hash等。
分布式架构的负载策略:Web层调用service,以及service之间的调用,每个service都需要部署多份。目前最常用的两个框架技术,spring cloud和dubbo,都采用客户端负载均衡策略,路由到service的不同实例。
Redis负载:redis是内存的缓存结构,非常高效,瓶颈在于网络IO,支持几十万的QPS。redis分流,可考虑分片的设计,把数据分配到多台服务器上,减轻每台机器的负载。一般情况下,分片策略多用户redis数据扩容方案。
Mysql读写分离:对写请求,不适合做分流,因为分流后的数据同步是大问题,导致数据不一致。对于写请求,一般采用读写分离的策略,并且可以多台读库。读库应用MyIsam引擎,单独设置合适的索引,提高读性能。从库并不是越多越好,因为从库越多,数据延迟越严重,要保持好平衡。
通过上述的分析,各层通过分流策略,系统架构演变成如下的结构。
访问拦截和分流的策略,主要作用还是解决并发读的问题。购买、支付等这类“写请求”,不能像读缓存一样,写缓存提高效率,数据持久化成功,才算交易成功。尤其抢购这种模式下,商品数量少,如果多台服务同事写数据,将造成mysql严重的行锁冲突,执行效率远远不如顺序执行。并且大量的所等待,延长单个操作的时长,占用工作线程,产生服务雪崩现象,短时间内不能对外提供服务。解决此问题的思路是限流,限制写操作的流量,使其正常运行,不影响业务。
计数器:假设总共100个商品库存,供大家抢货,并发访问极大。可以在Web层做一个计数器,抢单一次计数器加1,计数器到达100后,直接返回抢购失败。同样的道理,计数器亦可在service层实现。这种情况下,假设有10台web服务器,也只会放行100 * 10 = 1000次抢购。
按商品路由:在Web层,把对同一品类商品的抢购路由到一台service处理。在service内,自定义mysql连接池,使对同一个商品的操作,使用同一个连接。这样就实现了对同一商品的顺序处理,避免了锁竞争。
异步化:是指把购买请求的接受和处理异步化。购买请求先放到队列中,这个过程非常高效,返回客户信息。抢购服务订阅消息队列,异步处理购买请求,处理成功给用户发消息。异步化主要解决成产和消费的速度不匹配问题,由此类场景都可以采用。
对于防作弊问题,是比较容易处理的。因为所有的购买,都是登陆用户的行为,可以很方便的根据用户ID进行过滤,只允许一个客户购买一次。在分布式环境下,要解决如何记录用户ID的问题,因为同一个用户可能被不同的web,不同的service处理。
全局Cache:在redis中开辟一个空间,记录所有用户的商品购买,处理用户购买请求是,校验缓存中是否已记录此商品的购买,如果已经购买,则不允许。要解决重复提交的问题,可考虑分布式锁。
用户ID路由:参考上一节的按商品路由,我们同样可以把对一个用户的处理,路由到同一个Service处理,只需要做本地缓存就够了。此种方案最大的问题是,如果服务挂了,数据就错乱了。
网友评论