Saturday, January 9, 2016

秒杀系统架构分析与实战



https://www.jianshu.com/p/cf82e1f44b2b
如何控制秒杀商品页面抢购按钮的可用/禁用。
购买按钮只有在秒杀开始的时候才能点亮,在此之前是灰色的,显示活动未开始。
如果页面是动态生成的,每次刷新都要请求服务器,那么势必造成服务端的负载压力。
如果页面是静态页面的话,可以将页面缓存在CDN,反向代理服务器上,甚至用户浏览器上。
但是这样,秒杀开始时,用户刷新页面,根本请求不到应用服务器。
解决方案:
使用JS脚本控制,在页面中引用一个JS文件(文件极小),但是该文件不要被缓存。
该JS的作用是,包含秒杀开始标志,修改样式,生成下单页面的URL及随机参数。
该JS文件不被缓存的做法:xxx.js?v=随机数。
会有一台服务器进行监控(定时上下架):
当秒杀活动开始时推送该文件。
当秒杀活动结束时推送文件,标示结束标志,修改样式。









http://www.infoq.com/cn/articles/flash-deal-architecture-optimization

一、秒杀业务为什么难做

IM系统,例如QQ或者微博,每个人都读自己的数据(好友列表、群列表、个人信息)。
微博系统,每个人读你关注的人的数据,一个人读多个人的数据。
秒杀系统,库存只有一份,所有人会在集中的时间读和写这些数据,多个人读一个数据。
例如小米手机每周二的秒杀,可能手机只有1万部,但瞬时进入的流量可能是几百几千万。又例如12306抢票,票是有限的,库存一份,瞬时流量非常多,都读相同的库存。读写冲突,锁非常严重,这是秒杀业务难的地方。那我们怎么优化秒杀业务的架构呢?
优化方向有两个:
  1. 将请求尽量拦截在系统上游(不要让锁冲突落到数据库上去)。传统秒杀系统之所以挂,请求都压倒了后端数据层,数据读写锁冲突严重,并发高响应慢,几乎所有请求都超时,流量虽大,下单成功的有效流量甚小。以12306为例,一趟火车其实只有2000张票,200w个人来买,基本没有人能买成功,请求有效率为0。
  2. 充分利用缓存,秒杀买票,这是一个典型的读多些少的应用场景,大部分请求是车次查询,票查询,下单和支付才是写请求。一趟火车其实只有2000张票,200w个人来买,最多2000个人下单成功,其他人都是查询库存,写比例只有0.1%,读比例占99.9%,非常适合使用缓存来优化。好,后续讲讲怎么个“将请求尽量拦截在系统上游”法,以及怎么个“缓存”法,讲讲细节。

四、各层次优化细节

第一层,客户端怎么优化(浏览器层,APP层)

问大家一个问题,大家都玩过微信的摇一摇抢红包对吧,每次摇一摇,就会往后端发送请求么?回顾我们下单抢票的场景,点击了“查询”按钮之后,系统那个卡呀,进度条涨的慢呀,作为用户,我会不自觉的再去点击“查询”,对么?继续点,继续点,点点点……有用么?平白无故的增加了系统负载,一个用户点5次,80%的请求是这么多出来的,怎么整?
  • 产品层面,用户点击“查询”或者“购票”后,按钮置灰,禁止用户重复提交请求;
  • JS层面,限制用户在x秒之内只能提交一次请求;
APP层面,可以做类似的事情,虽然你疯狂的在摇微信,其实x秒才向后端发起一次请求。这就是所谓的“将请求尽量拦截在系统上游”,越上游越好,浏览器层,APP层就给拦住,这样就能挡住80%+的请求,这种办法只能拦住普通用户(但99%的用户是普通用户)对于群内的高端程序员是拦不住的。
FireBug一抓包,HTTP长啥样都知道,JS是万万拦不住程序员写for循环,调用HTTP接口的,这部分请求怎么处理?

第二层,站点层面的请求拦截

怎么拦截?怎么防止程序员写for循环调用,有去重依据么?IP?cookie-id?…想复杂了,这类业务都需要登录,用uid即可。在站点层面,对uid进行请求计数和去重,甚至不需要统一存储计数,直接站点层内存存储(这样计数会不准,但最简单)。一个uid,5秒只准透过1个请求,这样又能拦住99%的for循环请求。
5s只透过一个请求,其余的请求怎么办?缓存,页面缓存,同一个uid,限制访问频度,做页面缓存,x秒内到达站点层的请求,均返回同一页面。同一个item的查询,例如车次,做页面缓存,x秒内到达站点层的请求,均返回同一页面。如此限流,既能保证用户有良好的用户体验(没有返回404)又能保证系统的健壮性(利用页面缓存,把请求拦截在站点层了)。
页面缓存不一定要保证所有站点返回一致的页面,直接放在每个站点的内存也是可以的。优点是简单,坏处是HTTP请求落到不同的站点,返回的车票数据可能不一样,这是站点层的请求拦截与缓存优化。
好,这个方式拦住了写for循环发HTTP请求的程序员,有些高端程序员(黑客)控制了10w个肉鸡,手里有10w个uid,同时发请求(先不考虑实名制的问题,小米抢手机不需要实名制),这下怎么办,站点层按照uid限流拦不住了。

第三层 服务层来拦截(反正就是不要让请求落到数据库上去)

服务层怎么拦截?大哥,我是服务层,我清楚的知道小米只有1万部手机,我清楚的知道一列火车只有2000张车票,我透10w个请求去数据库有什么意义呢?没错,请求队列!
对于写请求,做请求队列,每次只透有限的写请求去数据层(下订单,支付这样的写业务):
  • 1w部手机,只透1w个下单请求去db:
  • 3k张火车票,只透3k个下单请求去db。
如果均成功再放下一批,如果库存不够则队列里的写请求全部返回“已售完”。
对于读请求,怎么优化?Cache抗,不管是memcached还是redis,单机抗个每秒10w应该都是没什么问题的。如此限流,只有非常少的写请求,和非常少的读缓存mis的请求会透到数据层去,又有99.9%的请求被拦住了。
当然,还有业务规则上的一些优化。回想12306所做的,分时分段售票,原来统一10点卖票,现在8点,8点半,9点,...每隔半个小时放出一批:将流量摊匀。
其次,数据粒度的优化:你去购票,对于余票查询这个业务,票剩了58张,还是26张,你真的关注么,其实我们只关心有票和无票?流量大的时候,做一个粗粒度的 “有票”“无票”缓存即可。
第三,一些业务逻辑的异步:例如 下单业务与 支付业务的分离。这些优化都是结合 业务 来的,我之前分享过一个观点“一切脱离业务的架构设计都是耍流氓”架构的优化也要针对业务。
浏览器拦截了80%,站点层拦截了99.9%并做了页面缓存,服务层又做了写请求队列与数据缓存,每次透到数据库层的请求都是可控的。db基本就没什么压力了,闲庭信步,单机也能扛得住,还是那句话,库存是有限的,小米的产能有限,透这么多请求来数据库没有意义。
全部透到数据库,100w个下单,0个成功,请求有效率0%。透3k到数据,全部成功,请求有效率100%。
  1. 尽量将请求拦截在系统上游(越上游越好);
  2. 读多写少的常用多使用缓存(缓存抗读压力);
浏览器和APP:做限速。 站点层:按照uid做限速,做页面缓存。 服务层:按照业务做写请求队列控制流量,做数据缓存。 数据层:闲庭信步。 以及结合业务做优化

问题1、按你的架构,其实压力最大的反而是站点层,假设真实有效的请求数有1000万,不太可能限制请求连接数吧,那么这部分的压力怎么处理?
答:每秒钟的并发可能没有1kw,假设有1kw,解决方案2个:
  1. 站点层是可以通过加机器扩容的,最不济1k台机器来呗。
  2. 如果机器不够,抛弃请求,抛弃50%(50%直接返回稍后再试),原则是要保护系统,不能让所有用户都失败。

问题5:站点层过滤的话,是把uid请求数单独保存到各个站点的内存中么?如果是这样的话,怎么处理多台服务器集群经过负载均衡器将相同用户的响应分布到不同服务器的情况呢?还是说将站点层的过滤放到负载均衡前?
答:可以放在内存,这样的话看似一台服务器限制了5s一个请求,全局来说(假设有10台机器),其实是限制了5s 10个请求,解决办法:
  1. 加大限制(这是建议的方案,最简单)
  2. 在nginx层做7层均衡,让一个uid的请求尽量落到同一个机器上
问题6:服务层过滤的话,队列是服务层统一的一个队列?还是每个提供服务的服务器各一个队列?如果是统一的一个队列的话,需不需要在各个服务器提交的请求入队列前进行锁控制?
答:可以不用统一一个队列,这样的话每个服务透过更少量的请求(总票数/服务个数),这样简单。统一一个队列又复杂了。
问题7:秒杀之后的支付完成,以及未支付取消占位,如何对剩余库存做及时的控制更新 ?
答:数据库里一个状态,未支付。如果超过时间,例如45分钟,库存会重新会恢复(大家熟知的“回仓”),给我们抢票的启示是,开动秒杀后,45分钟之后再试试看,说不定又有票哟。
问题10:如果在站点层或者服务层处理后台失败的话,需不需要考虑对这批处理失败的请求做重放?还是就直接丢弃?
答:别重放了,返回用户查询失败或者下单失败吧,架构设计原则之一是“fail fast”。
问题11:对于大型系统的秒杀,比如12306,同时进行的秒杀活动很多,如何分流?
答:垂直拆分
问题12:额外又想到一个问题。这套流程做成同步还是异步的?如果是同步的话,应该还存在会有响应反馈慢的情况。但如果是异步的话,如何控制能够将响应结果返回正确的请求方?
答:用户层面肯定是同步的(用户的HTTP请求是夯住的),服务层面可以同步可以异步。
问题13:秒杀群提问:减库存是在那个阶段减呢?如果是下单锁库存的话,大量恶意用户下单锁库存而不支付如何处理呢?
答:数据库层面写请求量很低,还好,下单不支付,等时间过完再“回仓”,之前提过了。
https://www.zhihu.com/question/54895548
  • 读取加速:在秒杀活动中,数据需求一般都是读多写少。20万人抢2000个商品,最后提交的订单最多也就2000个,但是在秒杀过程中,这20万人会一直产生大量的读取请求。因此可以使用缓存服务对用户请求进行缓存优化,把一些高频访问的内容放到缓存中去。对于更大规模的系统,可以通过静态文件分离、CDN服务等把用户请求分散到外围设施中去,以此来分担系统压力。
  • 异步处理和排队:通过消息队列和异步调用的方式可以实现接口异步处理,快速响应用户请求,在后端有较为充足的时间来处理实际的用户操作,提高对用户请求的响应速度,从而提升用户体验。通过消息队列还可以隔离前端的压力,实现排队系统,在涌入大量压力的情况下保证系统可以按照正常速率来处理请求,不会被流量压垮。
  • 无状态服务设计:相对于有状态服务,无状态服务更容易进行扩展,实现无状态化的服务可以在秒杀活动前进行快速扩容。而云化的服务更是有着先天的扩容优势,一般都可以实现分钟级别的资源扩容。
实际上题主的思路是异步化的思路。
异步化在秒杀场景中是用来解决高并发写带来的性能问题的。假设处理一个秒杀订单需要1s,而将秒杀请求(或意向订单/预订单)加入队列(或消息系统等)可能只需要1ms,这样的场景就非常适合用异步化的方式来处理秒杀订单。异步化将用户请求和业务处理解耦,有两大好处:1是极大提升了用户感知到的性能,2是保护后端系统不会受到过高的并发冲击。
而在秒杀场景下采用异步化处理有一个前提,就是要确保进入队列的购买请求一定能处理成功。否则的话,用户看到了成功的提示却最终没有买到,这是不可接受的。所以说,对购买资格的校验是必须要同步进行的,说白了就是要在将用户购买请求推入队列前完成对购买资格的校验。
而在秒杀场景中,购买资格校验最重要的一步就是对库存的校验。这一步校验必须要同步完成,使用redis的递增或递减功能是比较合适的。于redis天然是单线程的,其INCR/DECR操作可以保证线程安全。假设有3个线程同时向redis中一个值为3的key发起DECR,那么redis可以保证3个线程得到的返回一定分别是2,1和0。所以对库存余量的校验可以简化为一次DECR操作,只要返回的结果>=0,那么库存余量校验就是通过的,在确保线程安全的同时还具备了很高的性能。

http://zxcpro.github.io/blog/2015/07/27/gao-bing-fa-miao-sha-xi-tong-de-she-ji/
1. 做得少,一方面是指在功能特性上有所为,有所不为,另一方面是指一次处理的信息量要少。
2. 做得巧,根据业务自身的特点,选择合理的业务实现方式,选择合理的缓存类型和缓存调用时机。
对于一个需要承受高并发的接口,在功能上,尽量不涉及一些难以缓存和预热的数据。 一个典型的例子,用户维度个性化的数据,用户和用户的信息不同,userId数量又很多,即使加上缓存,缓存命中率依然很低,压力还是会打到数据库,不光接口快不了,高并发的sql也会给数据库带来风险。
举一个例子,在点评电影早期的秒杀活动页上,展示了一个用户当前秒杀资格的信息,由于不同用户抢到秒杀资格的时间、优惠不同,每次都需要读数据库的来取,也就是每个用户进入主页都会产生一条sql。
还有一个例子,一般电商搞大促的时候,比如同时有多个优惠活动可以降低商品的价格,而一般只展示最低价的优惠,同时用户一个优惠只能参与一次,这样不同用户参与了不同活动之后可以享受的最低价就会随之改变,如果要在商品页面上展示这个动态价格,就免不了取到各个用户参加这些在线优惠的信息。
如果遇到这样的数据,要怎么解决呢?
一个办法是尝试转移数据的维度:刚才说的秒杀活动资格信息,如果以用户userId为key,会出现缓存命中率低,仍要sql读的情况,但是能够秒到的用户数量其实很少,所以如果以这次秒杀活动id为key,存储一个成功秒到用户的userid的list,就能够解决缓存命中率低的问题。
还有一个办法是可以把这些需要个性化数据的功能在业务流程上后移,流量漏斗,越往后流量越少,创建订单级的sql查询是可接受的。 刚才说的第二个例子,商品最优惠的价格,可以排除用户相关信息,只在商品列表/详情上展示只和优惠相关的最低价,而在提交订单的时候才真正去取用户参加活动情况,如果用户已经参加过给出提示并选择次优的优惠。商品的列表/详情页都在用户路径上相对靠前的位置,排除了用户个性化信息可以让商品列表/详情更容易缓存,响应速度更快,系统可承受的高并发量更高。


根据业务特性选择实现方式

平时涉及到的业务,总有属于它的特性,比如实时性要求多高,数据一致性要求多高,涉及什么维度的数据,量有多大等等,我们要根据这些特性来选择实现的方案,比如一些统计数据,如某类目下所有商品的最低价,按照逻辑需要遍历商品来获取,但这样每次实时读取所有的对象,涉及读取缓存数据库操作,接口会很耗时,但如果选择作业离线计算,把计算结果写表,加上缓存,搜索直接读取,显然会快很多了。

合适选择和调用缓存

除了业务特性方面,缓存是业务对抗高并发非常重要的一个环节,合理选择缓存的类型和调用缓存的时机非常重要。
我们知道内存运算速度快于远程连接,所以存储上来说效率如下 内存 <= ehcache < redis <= memcached < mysql 可以看出,尽量少的远程连接,常规覆盖数据库访问的缓存,都能提高程序的性能。
要根据不同缓存的特性和原理,才能根据业务选出最合适的,来看看几种常用的缓存 
1. varnish,可以作为反向代理,缓存一些资源,例如可以把struts,freemarker动态生成的页面存储起来,达到直接挡掉到达web服务器的请求。 
2. ehcache,主要存储在当前机器内存中,存取非常快,缺点是内存有限,各台机器内存中各存一份,失效时间不一致,数据就会出现不一致,一般用来缓存不常变化,且缓存个数较少的数据。 
3. memcached缓存,kv分布式缓存集群,可扩展性好,可以存储个数较多的缓存对象,也可以承接高流量的访问,读取缓存时远程连接,一般耗时也在零点几到几ms不等。 
4. redis,nosql,是内存的kv存储,可以做为缓存使用,也可以持久化,它的性能和memcached相近。而
redis最大的特点是一个data-structure store,这时redis官网首页介绍redis的第一句话,它可以保存list,hash,set,sorted set等数据结构,使用时和memcached区别是,它不用将数据取到客户端再做逻辑判断,而是可以直接在redis服务器上完成操作,比如查看某个元素是不是一个范围内,队列的长度有多长等。redis可以用来做分布式服务器的进程间的通信,比如我们经常有需要分布式锁的场景,控制同一个用户发券的并发等。
根据业务需要选择了合适类型的缓存后,还要合理去使用。 虽然说缓存是为了抵挡数据库的流量而生,本身性能非常强大,但仍然是受到缓存服务器性能甚至服务器网卡流量的限制的,不合理的使用比如单个key对应的缓存对象过大、一次读取中缓存key数量过多、短时间内频繁更新缓存等都是系统的隐患、并发越高时就越能体现。

秒杀业务的典型特点有:
1. 瞬时流量大
2. 参与用户多,可秒杀商品数量少
3. 请求读多写少
4. 秒杀状态转换实时性要求高
一次秒杀的流程可以分为三个阶段: 

加载活动页请求

主要是展示活动相关配置信息,活动背景图片,优惠力度,活动规则等相对静态的内容,通过web项目渲染成页面。
对于这样的请求,我们可以使用varnish反向代理,以页面相关的参数比如本次秒杀的活动ID和城市ID的hash为key把整个页面缓存在varnish机器上,而秒杀活动的状态等动态信息通过ajax来刷新。
varnish作用机制
达到的效果是活动期间,加载页面请求都会打到varnish机器直接返回,而不会给web和service带来任何压力。

查询活动状态

秒杀状态就三种,未开始,可抢,已抢完,由两个因素共同决定
1. 活动开始时间
2. 剩余库存
读取秒杀状态的请求数并发也是非常高的,对于这个接口也要加上合适的缓存来处理。 对于活动开始时间,是一个较固定且不会发生变化的属性,并且,同时在线的秒杀活动数目并不多,所以把它也作为discount相关的信息,选择用响应快的ehcache来缓存。
对于库存,剩余库存个数,一般来说是全局需要一致的,可以用memcached来缓存,在秒杀的过程中,库存变化的非常快,如果直接对库存个数进行缓存,那么秒杀期间就需要频繁的更新缓存,像之前说的,虽然缓存是用来扛并发的,但要调用缓存的时机也要合理,memcached处理的并发请求越少,相对成功率就会越高。 其实对于秒杀活动来说,当时的剩余库存数在秒杀期间变化非常快,某个时间点上的库存个数并没有太大的意义,而用户更关心的是 能不能抢,true or false。如果缓存true or false的话,这个值在秒杀期间是相对稳定的,只需要在库存耗尽的时候更新一次,而且为了防止这一次的更新失败,可以重复更新,利用memcached的cas操作,最后memcached也只会真正执行一次set写操作。 因为秒杀期间查询活动状态的请求都打在memcached上,减少写的频率可以明显减轻memcached的负担。
其实活动状态除了活动时间和库存之外,还有第三个因素来决定,下面说到秒杀请求的优化时会详细来说

秒杀请求分析

秒杀请求是一个秒杀系统能不能抗住高并发的关键 因为秒杀请求和之前两个请求不同,它是写请求,不能缓存,而且是活动峰值的主力。
一个用户从发出秒杀请求到成功秒杀简单地说需要两个步骤: 1. 扣库存 2. 发送秒杀商品 这是至少两条数据库操作,而且扣库存的这一步,在mysql的innodb引擎行锁机制下,update的sql到了数据库就开始排队,期间数据库连接是被占用的,当请求足够多时就会造成数据库的拥堵。 可以看出,秒杀请求接口是一个耗时相对长的接口,而且并发越高耗时越长,所以首先,一定要限制能够真正进行秒杀的人数。

上面说了,秒杀业务的一个特点是参与人数多,但是可供秒杀的商品少,也就是说只有极少部分的用户最终能够秒杀成功 比如有2500个名额,理论上来说先发送请求的2500个用户能够秒杀成功,这2500个用户扣库存的sql在数据库排队的时候,库存还没有消耗完,比如2500个请求,全部排队更新完是需要时间的,就比如说0.5s 在这个时间内,用户会看到当前仍然是可抢状态,所以这段时间内持续会有秒杀请求进入,秒杀的高峰期,0.5秒也有几万的请求,让几万条sql来竞争是没有意义的,所以要限制这些参与到扣库存这一步的人数。

秒杀队列校验

可抢状态需要第三个因素来决定,那就是当前秒杀的排队人数。 加在判断库存剩余之前,挡上一层排队人数的校验, 即有库存 并且 排队人数 < 限制请求数 = 可抢,有库存 并且 排队人数 >= 限制请求数 = 抢完
比如2500个名额秒杀名额,目标放过去3000个秒杀请求
那么排队人数记在哪里? 这个可以有所选择,如果只记请求个数,可以用memcached的计数,一个用户进入秒杀流程increase一次,判断库存之前先判断队列长度,这样就限制了可参与秒杀的用户数量。

发起秒杀先去问排队队列是不是已满,满了直接秒杀失败,同时可以去更新之前缓存了是否可抢 true or false的缓存,直接把前台可抢的状态变为不可抢。没满继续查询库存等后续流程,开始扣库存的时候,把当前用户id入队。 这样,就限制了真正进入秒杀的人数。
这种方法,可能会有一个问题,既然限制了请求数,那就必须要保证放过去的用户能够秒完商品,假设有重复提交的用户,如果重复提交的量大,比如放过去的请求中有一半都是重复提交,就会造成最后没秒完的情况,怎么屏蔽重复用户呢? 就要有个地方来记参与的用户id,可以使用redis的set结构来保存,这个时候set的size代表当前排队的用户数,扣库存之前add当前用户id到set,根据add是否成功的结果,来判断是否继续处理请求。
最终,把实际上几万个参与数据库操作的用户从减少到秒杀商品的级别,这是一个数据库可控制的范围,即使参与的用户再多,实际上也只处理了秒杀商品数量级的请求。

1.分库存 一般这样做就已经能够满足常规秒杀的需求了,但有一个问题依然没有解决,那就是加锁扣库存依然很慢 假设的活动秒杀的商品量能够再上一个量级,像小米卖个手机,一次有几W到几十万的时候,数据库也是扛不住这个量的,可以先把库存数放在redis上,然而单一库存加锁排队依然存在,库存这个热点数据会成为扣库存的瓶颈。
一个解决的办法是 分库存,比如总共有50000个秒杀名额,可以分50份,放在redis上的50个不同的key,那么每份上1000个库存,用户进入秒杀流程后随机到其中一个库存来修改,这样有50个库存数来竞争,缩短请求的排队时间。
这样专门为高并发设计的系统最大的敌人 是低流量,在大部分库存都好近,而有几个剩余库存时, 用户会看到明明还能抢却总是抢不到,而在高并发下,用户根本就觉察不到。
2.异步消息 如果有必要继续优化,就是扣库存和发货这两个费时的流程,可以改为异步,得到秒杀结果后通过短信/push异步通知用户。 主要是利用消息系统削峰填谷的特性 来增加系统的容量。

先用varnish挡掉了所有的读取状态请求 然后用ehcache缓存活动时间,挡掉活动未开始时查询活动状态的请求 memcached缓存是否可抢的状态,挡掉活动开始后到结束状态的活动查询请求 redis队列挡掉了活动进行中,过量的秒杀请求 到最后只留下了秒杀商品数量级的请求到数据库中。

http://www.infoq.com/cn/articles/yhd-11-11-queuing-system-design
  1. 限流:当秒杀活动开始后,只有少部分消费者能抢购到秒杀商品,意味着其实大部分用户的流量传达到后台服务后都是无效。如果能引导这大部分的流量,不让这大部分的流量传达到后台服务,其实对我们系统的压力就很小了。因此设计思路之一就是,仅让能成功抢购到商品的流量(可以有一定余量)进入我们的系统。
  2. 削峰:进入系统的有效流量虽然总量不一定是很大的,但却是在很短的时间内涌入的,因此会存在很高的瞬时流量峰值。总量相同的流量在1秒钟进入系统,和在10分钟均匀地进入系统,对系统的冲击是相差很大的。高峰值的流量往往能将系统压垮。因此另一个设计思路是,如何将进入系统的瞬时高流量拉平,使得系统可以在自己处理能力范围内,将所有抢购的请求处理完毕。
  3. 异步处理:传统的系统对于请求是同步处理的,即收到请求后立即处理并把结果返回给用户。我们的系统有了削峰的设计后,请求不是被立刻处理的,因此就要求我们能将同步的服务改造成异步的。
  4. 可用性:我们设计时始终把系统的可用性放在重要的位置,针对系统可能出现的各种状况,都尽最大程度地保证高可用。
  5. 用户体验:系统设计一定要充分考虑用户体验。消费者点击抢购按钮后,无论是否能抢到商品,期望是能得到及时的反馈。系统上发生任何故障也要尽可能的保证用户体验的损害减到最小。

  1. 排队模块:负责接收用户的抢购请求,将请求以先入先出的方式保存下来。每一个参加秒杀活动的商品保存一个队列,队列的大小可以根据参与秒杀的商品数量(或加点余量)自行定义。排队模块还负责提供一系列接口,如:给已进入队列的用户查询下单状态的接口,给调度模块拉取请求的接口,服务模块回写业务处理状态的接口等。
  2. 调度模块:负责排队模块到服务模块的动态调度,不断检查服务模块,一旦处理能力有空闲,就从排队队列头上把用户访问请求调入服务模块。并负责向服务模块分发请求。这里调度模块扮演一个中介的角色,但不只是传递请求而已。它还担负着调节系统处理能力的重任。我们可以根据服务模块的实际处理能力,动态调节向排队系统拉取请求的速度。作用有点类似水坝的闸门,当下游干旱时就打开闸门多放些水,当下游洪涝时,就放下闸门少放些水。

5、容错处理

任何系统都不可能一帆风顺,但我们要能在出现错误时仍旧保证系统的高用性和良好的客户体验。举几个简单的例子,比如服务模块调用后面服务时,出现调用服务超时怎么办?对此我们设计了对于超时的请求,可以重试的机制。再比如,如果后面真正的业务处理系统宕机怎么办?如果是传统系统的话,可能就面临系统无法使用的尴尬。而我们的系统已经是异步的了,因此加入排队队列的用户请求,在业务处理系统恢复后都可以得到处理。只要我们在前端给用户以友好的交互、提示,系统还是能提供一定质量服务的。

6、用户交互

  • 当等待人数大于500人,页面提示:排在您前面的人超过500位;
  • 当等待人数小于等于500人,页面提示:您已挤进第***位;
  • 当等待时间大于等于1分钟,页面提示:剩余时间约*分钟。每次以分钟倒计时。
  • 当等待时间小于1分钟,页面提示:预计剩余*秒。
  • 抢购成功,后续跳转到订单支付页面
http://my.oschina.net/xianggao/blog/524943

1 秒杀业务分析

  1. 正常电子商务流程
    (1)查询商品;(2)创建订单;(3)扣减库存;(4)更新订单;(5)付款;(6)卖家发货
  2. 秒杀业务的特性
    (1)低廉价格;(2)大幅推广;(3)瞬时售空;(4)一般是定时上架;(5)时间短、瞬时并发量高;

2 秒杀技术挑战

假设某网站秒杀活动只推出一件商品,预计会吸引1万人参加活动,也就说最大并发请求数是10000,秒杀系统需要面对的技术挑战有:
  1. 对现有网站业务造成冲击
    秒杀活动只是网站营销的一个附加活动,这个活动具有时间短,并发访问量大的特点,如果和网站原有应用部署在一起,必然会对现有业务造成冲击,稍有不慎可能导致整个网站瘫痪。
    解决方案:将秒杀系统独立部署,甚至使用独立域名,使其与网站完全隔离
  2. 高并发下的应用、数据库负载
    用户在秒杀开始前,通过不停刷新浏览器页面以保证不会错过秒杀,这些请求如果按照一般的网站应用架构,访问应用服务器、连接数据库,会对应用服务器和数据库服务器造成负载压力。
    解决方案:重新设计秒杀商品页面,不使用网站原来的商品详细页面,页面内容静态化,用户请求不需要经过应用服务
  3. 突然增加的网络及服务器带宽
    假设商品页面大小200K(主要是商品图片大小),那么需要的网络和服务器带宽是2G(200K×10000),这些网络带宽是因为秒杀活动新增的,超过网站平时使用的带宽。
    解决方案:因为秒杀新增的网络带宽,必须和运营商重新购买或者租借。为了减轻网站服务器的压力,需要将秒杀商品页面缓存在CDN,同样需要和CDN服务商临时租借新增的出口带宽
  4. 直接下单
    秒杀的游戏规则是到了秒杀才能开始对商品下单购买,在此时间点之前,只能浏览商品信息,不能下单。而下单页面也是一个普通的URL,如果得到这个URL,不用等到秒杀开始就可以下单了。
    解决方案:为了避免用户直接访问下单页面URL,需要将改URL动态化,即使秒杀系统的开发者也无法在秒杀开始前访问下单页面的URL。办法是在下单页面URL加入由服务器端生成的随机数作为参数,在秒杀开始的时候才能得到
  5. 如何控制秒杀商品页面购买按钮的点亮
    购买按钮只有在秒杀开始的时候才能点亮,在此之前是灰色的。如果该页面是动态生成的,当然可以在服务器端构造响应页面输出,控制该按钮是灰色还 是点亮,但是为了减轻服务器端负载压力,更好地利用CDN、反向代理等性能优化手段,该页面被设计为静态页面,缓存在CDN、反向代理服务器上,甚至用户浏览器上。秒杀开始时,用户刷新页面,请求根本不会到达应用服务器。
    解决方案:使用JavaScript脚本控制,在秒杀商品静态页面中加入一个JavaScript文件引用,该JavaScript文件中包含 秒杀开始标志为否;当秒杀开始的时候生成一个新的JavaScript文件(文件名保持不变,只是内容不一样),更新秒杀开始标志为是,加入下单页面的URL及随机数参数(这个随机数只会产生一个,即所有人看到的URL都是同一个,服务器端可以用redis这种分布式缓存服务器来保存随机数),并被用户浏览器加载,控制秒杀商品页面的展示。这个JavaScript文件的加载可以加上随机版本号(例如xx.js?v=32353823),这样就不会被浏览器、CDN和反向代理服务器缓存
    这个JavaScript文件非常小,即使每次浏览器刷新都访问JavaScript文件服务器也不会对服务器集群和网络带宽造成太大压力。
  6. 如何只允许第一个提交的订单被发送到订单子系统
    由于最终能够成功秒杀到商品的用户只有一个,因此需要在用户提交订单时,检查是否已经有订单提交。如果已经有订单提交成功,则需要更新 JavaScript文件,更新秒杀开始标志为否,购买按钮变灰。事实上,由于最终能够成功提交订单的用户只有一个,为了减轻下单页面服务器的负载压力, 可以控制进入下单页面的入口,只有少数用户能进入下单页面,其他用户直接进入秒杀结束页面。
    解决方案:假设下单服务器集群有10台服务器,每台服务器只接受最多10个下单请求。在还没有人提交订单成功之前,如果一台服务器已经有十单了,而有的一单都没处理,可能出现的用户体验不佳的场景是用户第一次点击购买按钮进入已结束页面,再刷新一下页面,有可能被一单都没有处理的服务器处理,进入了填写订单的页面,可以考虑通过cookie的方式来应对,符合一致性原则。当然可以采用最少连接的负载均衡算法,出现上述情况的概率大大降低。
  7. 如何进行下单前置检查
    • 下单服务器检查本机已处理的下单请求数目:
    如果超过10条,直接返回已结束页面给用户;
    如果未超过10条,则用户可进入填写订单及确认页面;
    • 检查全局已提交订单数目:
    已超过秒杀商品总数,返回已结束页面给用户;
    未超过秒杀商品总数,提交到子订单系统;
  8. 秒杀一般是定时上架
    该功能实现方式很多。不过目前比较好的方式是:提前设定好商品的上架时间,用户可以在前台看到该商品,但是无法点击“立即购买”的按钮。但是需要考虑的是,有人可以绕过前端的限制,直接通过URL的方式发起购买,这就需要在前台商品页面,以及bug页面到后端的数据库,都要进行时钟同步。越在后端控制,安全性越高。
    定时秒杀的话,就要避免卖家在秒杀前对商品做编辑带来的不可预期的影响。这种特殊的变更需要多方面评估。一般禁止编辑,如需变更,可以走数据订正多的流程。
  9. 减库存的操作
    有两种选择,一种是拍下减库存 另外一种是付款减库存;目前采用的“拍下减库存”的方式,拍下就是一瞬间的事,对用户体验会好些。
  10. 库存会带来“超卖”的问题:售出数量多于库存数量
    由于库存并发更新的问题,导致在实际库存已经不足的情况下,库存依然在减,导致卖家的商品卖得件数超过秒杀的预期。方案:采用乐观锁
    update auction_auctions set
    quantity = #inQuantity#
    where auction_id = #itemId# and quantity = #dbQuantity#
    
  11. 秒杀器的应对
    秒杀器一般下单个购买及其迅速,根据购买记录可以甄别出一部分。可以通过校验码达到一定的方法,这就要求校验码足够安全,不被破解,采用的方式有:秒杀专用验证码,电视公布验证码,秒杀答题

3 秒杀架构原则

  1. 尽量将请求拦截在系统上游
    传统秒杀系统之所以挂,请求都压倒了后端数据层,数据读写锁冲突严重,并发高响应慢,几乎所有请求都超时,流量虽大,下单成功的有效流量甚小【一趟火车其实只有2000张票,200w个人来买,基本没有人能买成功,请求有效率为0】。
  2. 读多写少的常用多使用缓存
    这是一个典型的读多写少的应用场景【一趟火车其实只有2000张票,200w个人来买,最多2000个人下单成功,其他人都是查询库存,写比例只有0.1%,读比例占99.9%】,非常适合使用缓存

4 秒杀架构设计

秒杀系统为秒杀而设计,不同于一般的网购行为,参与秒杀活动的用户更关心的是如何能快速刷新商品页面,在秒杀开始的时候抢先进入下单页面,而不是商品详情等用户体验细节,因此秒杀系统的页面设计应尽可能简单。
商品页面中的购买按钮只有在秒杀活动开始的时候才变亮,在此之前及秒杀商品卖出后,该按钮都是灰色的,不可以点击。
下单表单也尽可能简单,购买数量只能是一个且不可以修改,送货地址和付款方式都使用用户默认设置,没有默认也可以不填,允许等订单提交后修改;只有第一个提交的订单发送给网站的订单子系统,其余用户提交订单后只能看到秒杀结束页面。
要做一个这样的秒杀系统,业务会分为两个阶段,第一个阶段是秒杀开始前某个时间到秒杀开始, 这个阶段可以称之为准备阶段,用户在准备阶段等待秒杀; 第二个阶段就是秒杀开始到所有参与秒杀的用户获得秒杀结果, 这个就称为秒杀阶段吧。

4.1 前端层设计

首先要有一个展示秒杀商品的页面, 在这个页面上做一个秒杀活动开始的倒计时, 在准备阶段内用户会陆续打开这个秒杀的页面, 并且可能不停的刷新页面。这里需要考虑两个问题:
  1. 第一个是秒杀页面的展示
    我们知道一个html页面还是比较大的,即使做了压缩,http头和内容的大小也可能高达数十K,加上其他的css, js,图片等资源,如果同时有几千万人参与一个商品的抢购,一般机房带宽也就只有1G~10G,网络带宽就极有可能成为瓶颈,所以这个页面上各类静态资源首先应分开存放,然后放到cdn节点上分散压力,由于CDN节点遍布全国各地,能缓冲掉绝大部分的压力,而且还比机房带宽便宜~
  2. 第二个是倒计时
    出于性能原因这个一般由js调用客户端本地时间,就有可能出现客户端时钟与服务器时钟不一致,另外服务器之间也是有可能出现时钟不一致。客户端与服务器时钟不一致可以采用客户端定时和服务器同步时间,这里考虑一下性能问题,用于同步时间的接口由于不涉及到后端逻辑,只需要将当前web服务器的时间发送给客户端就可以了,因此速度很快,就我以前测试的结果来看,一台标准的web服务器2W+QPS不会有问题,如果100W人同时刷,100W QPS也只需要50台web,一台硬件LB就可以了~,并且web服务器群是可以很容易的横向扩展的(LB+DNS轮询),这个接口可以只返回一小段json格式的数据,而且可以优化一下减少不必要cookie和其他http头的信息,所以数据量不会很大,一般来说网络不会成为瓶颈,即使成为瓶颈也可以考虑多机房专线连通,加智能DNS的解决方案;web服务器之间时间不同步可以采用统一时间服务器的方式,比如每隔1分钟所有参与秒杀活动的web服务器就与时间服务器做一次时间同步
  3. 浏览器层请求拦截
    (1)产品层面,用户点击“查询”或者“购票”后,按钮置灰,禁止用户重复提交请求;
    (2)JS层面,限制用户在x秒之内只能提交一次请求;

4.2 站点层设计

前端层的请求拦截,只能拦住小白用户(不过这是99%的用户哟),高端的程序员根本不吃这一套,写个for循环,直接调用你后端的http请求,怎么整?
(1)同一个uid,限制访问频度,做页面缓存,x秒内到达站点层的请求,均返回同一页面
(2)同一个item的查询,例如手机车次,做页面缓存,x秒内到达站点层的请求,均返回同一页面
如此限流,又有99%的流量会被拦截在站点层。

4.3 服务层设计

站点层的请求拦截,只能拦住普通程序员,高级黑客,假设他控制了10w台肉鸡(并且假设买票不需要实名认证),这下uid的限制不行了吧?怎么整?
(1)大哥,我是服务层,我清楚的知道小米只有1万部手机,我清楚的知道一列火车只有2000张车票,我透10w个请求去数据库有什么意义呢?对于写请求,做请求队列,每次只透过有限的写请求去数据层,如果均成功再放下一批,如果库存不够则队列里的写请求全部返回“已售完”
(2)对于读请求,还用说么?cache来抗,不管是memcached还是redis,单机抗个每秒10w应该都是没什么问题的;
如此限流,只有非常少的写请求,和非常少的读缓存mis的请求会透到数据层去,又有99.9%的请求被拦住了。
  1. 用户请求分发模块:使用Nginx或Apache将用户的请求分发到不同的机器上。
  2. 用户请求预处理模块:判断商品是不是还有剩余来决定是不是要处理该请求。
  3. 用户请求处理模块:把通过预处理的请求封装成事务提交给数据库,并返回是否成功。
  4. 数据库接口模块:该模块是数据库的唯一接口,负责与数据库交互,提供RPC接口供查询是否秒杀结束、剩余数量等信息。
  • 用户请求预处理模块
    经过HTTP服务器的分发后,单个服务器的负载相对低了一些,但总量依然可能很大,如果后台商品已经被秒杀完毕,那么直接给后来的请求返回秒杀失败即可,不必再进一步发送事务了,示例代码可以如下所示:
    package seckill;
    import org.apache.http.HttpRequest;
    /**
    * 预处理阶段,把不必要的请求直接驳回,必要的请求添加到队列中进入下一阶段.
    */
    public class PreProcessor {
      // 商品是否还有剩余
      private static boolean reminds = true;
      private static void forbidden() {
          // Do something.
      }
      public static boolean checkReminds() {
          if (reminds) {
              // 远程检测是否还有剩余,该RPC接口应由数据库服务器提供,不必完全严格检查.
              if (!RPC.checkReminds()) {
                  reminds = false;
              }
          }
          return reminds;
      }
      /**
       * 每一个HTTP请求都要经过该预处理.
       */
      public static void preProcess(HttpRequest request) {
          if (checkReminds()) {
              // 一个并发的队列
              RequestQueue.queue.add(request);
          } else {
              // 如果已经没有商品了,则直接驳回请求即可.
              forbidden();
          }
      }
    }
    
    • 并发队列的选择
    Java的并发包提供了三个常用的并发队列实现,分别是:ConcurrentLinkedQueue 、 LinkedBlockingQueue 和 ArrayBlockingQueue。
    ArrayBlockingQueue是初始容量固定的阻塞队列,我们可以用来作为数据库模块成功竞拍的队列,比如有10个商品,那么我们就设定一个10大小的数组队列。
    ConcurrentLinkedQueue使用的是CAS原语无锁队列实现,是一个异步队列,入队的速度很快,出队进行了加锁,性能稍慢。
    LinkedBlockingQueue也是阻塞的队列,入队和出队都用了加锁,当队空的时候线程会暂时阻塞。
    由于我们的系统入队需求要远大于出队需求,一般不会出现队空的情况,所以我们可以选择ConcurrentLinkedQueue来作为我们的请求队列实现:
    package seckill;
    import java.util.concurrent.ArrayBlockingQueue;
    import java.util.concurrent.ConcurrentLinkedQueue;
    import org.apache.http.HttpRequest;
    public class RequestQueue {
      public static ConcurrentLinkedQueue<HttpRequest> queue = new ConcurrentLinkedQueue<HttpRequest>();
    }
    
  • 用户请求模块
    package seckill;
    import org.apache.http.HttpRequest;
    public class Processor {
      /**
       * 发送秒杀事务到数据库队列.
       */
      public static void kill(BidInfo info) {
          DB.bids.add(info);
      }
      public static void process() {
          BidInfo info = new BidInfo(RequestQueue.queue.poll());
          if (info != null) {
              kill(info);
          }
      }
    }
    class BidInfo {
      BidInfo(HttpRequest request) {
          // Do something.
      }
    }
    
  • 数据库模块
    数据库主要是使用一个ArrayBlockingQueue来暂存有可能成功的用户请求。
    package seckill;
    import java.util.concurrent.ArrayBlockingQueue;
    /**
    * DB应该是数据库的唯一接口.
    */
    public class DB {
      public static int count = 10;
      public static ArrayBlockingQueue<BidInfo> bids = new ArrayBlockingQueue<BidInfo>(10);
      public static boolean checkReminds() {
          // TODO
          return true;
      }
      // 单线程操作
      public static void bid() {
          BidInfo info = bids.poll();
          while (count-- > 0) {
              // insert into table Bids values(item_id, user_id, bid_date, other)
              // select count(id) from Bids where item_id = ?
              // 如果数据库商品数量大约总数,则标志秒杀已完成,设置标志位reminds = false.
              info = bids.poll();
          }
      }
    }
    

4.4 数据库设计

4.4.1 基本概念

概念一“单库”
输入图片说明
概念二“分片”
输入图片说明
分片解决的是“数据量太大”的问题,也就是通常说的“水平切分”。一旦引入分片,势必有“数据路由”的概念,哪个数据访问哪个库。路由规则通常有3种方法:
  1. 范围:range
    优点:简单,容易扩展
    缺点:各库压力不均(新号段更活跃)
  2. 哈希:hash 【大部分互联网公司采用的方案二:哈希分库,哈希路由】
    优点:简单,数据均衡,负载均匀
    缺点:迁移麻烦(2库扩3库数据要迁移)
  3. 路由服务:router-config-server
    优点:灵活性强,业务与路由算法解耦
    缺点:每次访问数据库前多一次查询
概念三“分组”
输入图片说明
分组解决“可用性”问题,分组通常通过主从复制的方式实现。
互联网公司数据库实际软件架构是:又分片,又分组(如下图)
输入图片说明

4.4.2 设计思路

数据库软件架构师平时设计些什么东西呢?至少要考虑以下四点:
  1. 如何保证数据可用性;
  2. 如何提高数据库读性能(大部分应用读多写少,读会先成为瓶颈);
  3. 如何保证一致性;
  4. 如何提高扩展性;
  • 1. 如何保证数据的可用性?
    解决可用性问题的思路是=>冗余
    如何保证站点的可用性?复制站点,冗余站点
    如何保证服务的可用性?复制服务,冗余服务
    如何保证数据的可用性?复制数据,冗余数据
    数据的冗余,会带来一个副作用=>引发一致性问题(先不说一致性问题,先说可用性)
  • 2. 如何保证数据库“读”高可用?
    冗余读库
    输入图片说明
    冗余读库带来的副作用?读写有延时,可能不一致
    上面这个图是很多互联网公司mysql的架构,写仍然是单点,不能保证写高可用。
  • 3. 如何保证数据库“写”高可用?
    冗余写库
    输入图片说明
    采用双主互备的方式,可以冗余写库带来的副作用?双写同步,数据可能冲突(例如“自增id”同步冲突),如何解决同步冲突,有两种常见解决方案:
    1. 两个写库使用不同的初始值,相同的步长来增加id:1写库的id为0,2,4,6...;2写库的id为1,3,5,7...;
    2. 不使用数据的id,业务层自己生成唯一的id,保证数据不冲突;
实际中没有使用上述两种架构来做读写的“高可用”,采用的是“双主当主从用”的方式
输入图片说明
仍是双主,但只有一个主提供服务(读+写),另一个主是“shadow-master”,只用来保证高可用,平时不提供服务。 master挂了,shadow-master顶上(vip漂移,对业务层透明,不需要人工介入)。这种方式的好处:
  1. 读写没有延时;
  2. 读写高可用;
不足:
  1. 不能通过加从库的方式扩展读性能;
  2. 资源利用率为50%,一台冗余主没有提供服务;
那如何提高读性能呢?进入第二个话题,如何提供读性能。
  • 4. 如何扩展读性能
    提高读性能的方式大致有三种,第一种是建立索引。这种方式不展开,要提到的一点是,不同的库可以建立不同的索引
    输入图片说明
    写库不建立索引;
    线上读库建立线上访问索引,例如uid;
    线下读库建立线下访问索引,例如time;
    第二种扩充读性能的方式是,增加从库,这种方法大家用的比较多,但是,存在两个缺点:
    1. 从库越多,同步越慢;
    2. 同步越慢,数据不一致窗口越大(不一致后面说,还是先说读性能的提高);
    实际中没有采用这种方法提高数据库读性能(没有从库),采用的是增加缓存。常见的缓存架构如下:
    输入图片说明
    上游是业务应用,下游是主库,从库(读写分离),缓存
    实际的玩法:服务+数据库+缓存一套
    输入图片说明
    业务层不直接面向db和cache,服务层屏蔽了底层db、cache的复杂性。为什么要引入服务层,今天不展开,采用了“服务+数据库+缓存一套”的方式提供数据访问,用cache提高读性能
    不管采用主从的方式扩展读性能,还是缓存的方式扩展读性能,数据都要复制多份(主+从,db+cache),一定会引发一致性问题
  • 5. 如何保证一致性?
    主从数据库的一致性,通常有两种解决方案:
    1. 中间件
    输入图片说明
    如果某一个key有写操作,在不一致时间窗口内,中间件会将这个key的读操作也路由到主库上。这个方案的缺点是,数据库中间件的门槛较高(百度,腾讯,阿里,360等一些公司有)。
    2. 强制读主
    输入图片说明
    上面实际用的“双主当主从用”的架构,不存在主从不一致的问题
    第二类不一致,是db与缓存间的不一致
    输入图片说明
    常见的缓存架构如上,此时写操作的顺序是:
    (1)淘汰cache;
    (2)写数据库;
    读操作的顺序是:
    (1)读cache,如果cache hit则返回;
    (2)如果cache miss,则读从库;
    (3)读从库后,将数据放回cache;
    在一些异常时序情况下,有可能从【从库读到旧数据(同步还没有完成),旧数据入cache后】,数据会长期不一致。解决办法是“缓存双淘汰”,写操作时序升级为:
    (1)淘汰cache;
    (2)写数据库;
    (3)在经验“主从同步延时窗口时间”后,再次发起一个异步淘汰cache的请求;
    这样,即使有脏数据如cache,一个小的时间窗口之后,脏数据还是会被淘汰。带来的代价是,多引入一次读miss(成本可以忽略)。
    除此之外,最佳实践之一是:建议为所有cache中的item设置一个超时时间
  • 6. 如何提高数据库的扩展性?
    原来用hash的方式路由,分为2个库,数据量还是太大,要分为3个库,势必需要进行数据迁移,有一个很帅气的“数据库秒级扩容”方案。
    如何秒级扩容?
    首先,我们不做2库变3库的扩容,我们做2库变4库(库加倍)的扩容(未来4->8->16)
    输入图片说明
    服务+数据库是一套(省去了缓存),数据库采用“双主”的模式
    扩容步骤:
    第一步,将一个主库提升;
    第二步,修改配置,2库变4库(原来MOD2,现在配置修改后MOD4),扩容完成;
    原MOD2为偶的部分,现在会MOD4余0或者2;原MOD2为奇的部分,现在会MOD4余1或者3;数据不需要迁移,同时,双主互相同步,一遍是余0,一边余2,两边数据同步也不会冲突,秒级完成扩容!
    最后,要做一些收尾工作:
    1. 将旧的双主同步解除;
    2. 增加新的双主(双主是保证可用性的,shadow-master平时不提供服务);
    3. 删除多余的数据(余0的主,可以将余2的数据删除掉);
    输入图片说明
    这样,秒级别内,我们就完成了2库变4库的扩展。

5 大并发带来的挑战

5.1 请求接口的合理设计

一个秒杀或者抢购页面,通常分为2个部分,一个是静态的HTML等内容,另一个就是参与秒杀的Web后台请求接口
通常静态HTML等内容,是通过CDN的部署,一般压力不大,核心瓶颈实际上在后台请求接口上。这个后端接口,必须能够支持高并发请求,同时,非常重要的一点,必须尽可能“快”,在最短的时间里返回用户的请求结果。为了实现尽可能快这一点,接口的后端存储使用内存级别的操作会更好一点。仍然直接面向MySQL之类的存储是不合适的,如果有这种复杂业务的需求,都建议采用异步写入
输入图片说明
当然,也有一些秒杀和抢购采用“滞后反馈”,就是说秒杀当下不知道结果,一段时间后才可以从页面中看到用户是否秒杀成功。但是,这种属于“偷懒”行为,同时给用户的体验也不好,容易被用户认为是“暗箱操作”。

5.2 高并发的挑战:一定要“快”

我们通常衡量一个Web系统的吞吐率的指标是QPS(Query Per Second,每秒处理请求数),解决每秒数万次的高并发场景,这个指标非常关键。举个例子,我们假设处理一个业务请求平均响应时间为100ms,同时,系统内有20台Apache的Web服务器,配置MaxClients为500个(表示Apache的最大连接数目)。
那么,我们的Web系统的理论峰值QPS为(理想化的计算方式):
20*500/0.1 = 100000 (10万QPS)
咦?我们的系统似乎很强大,1秒钟可以处理完10万的请求,5w/s的秒杀似乎是“纸老虎”哈。实际情况,当然没有这么理想。在高并发的实际场景下,机器都处于高负载的状态,在这个时候平均响应时间会被大大增加
就Web服务器而言,Apache打开了越多的连接进程,CPU需要处理的上下文切换也越多,额外增加了CPU的消耗,然后就直接导致平均响应时间增加。因此上述的MaxClient数目,要根据CPU、内存等硬件因素综合考虑,绝对不是越多越好。可以通过Apache自带的abench来测试一下,取一个合适的值。然后,我们选择内存操作级别的存储的Redis,在高并发的状态下,存储的响应时间至关重要。网络带宽虽然也是一个因素,不过,这种请求数据包一般比较小,一般很少成为请求的瓶颈。负载均衡成为系统瓶颈的情况比较少,在这里不做讨论哈。
那么问题来了,假设我们的系统,在5w/s的高并发状态下,平均响应时间从100ms变为250ms(实际情况,甚至更多):
20*500/0.25 = 40000 (4万QPS)
于是,我们的系统剩下了4w的QPS,面对5w每秒的请求,中间相差了1w。
然后,这才是真正的恶梦开始。举个例子,高速路口,1秒钟来5部车,每秒通过5部车,高速路口运作正常。突然,这个路口1秒钟只能通过4部车,车流量仍然依旧,结果必定出现大塞车。(5条车道忽然变成4条车道的感觉)。
同理,某一个秒内,20*500个可用连接进程都在满负荷工作中,却仍然有1万个新来请求,没有连接进程可用,系统陷入到异常状态也是预期之内。
输入图片说明
其实在正常的非高并发的业务场景中,也有类似的情况出现,某个业务请求接口出现问题,响应时间极慢,将整个Web请求响应时间拉得很长,逐渐将Web服务器的可用连接数占满,其他正常的业务请求,无连接进程可用。
更可怕的问题是,是用户的行为特点,系统越是不可用,用户的点击越频繁,恶性循环最终导致“雪崩”(其中一台Web机器挂了,导致流量分散到其他正常工作的机器上,再导致正常的机器也挂,然后恶性循环),将整个Web系统拖垮。

5.3 重启与过载保护

如果系统发生“雪崩”,贸然重启服务,是无法解决问题的。最常见的现象是,启动起来后,立刻挂掉。这个时候,最好在入口层将流量拒绝,然后再将重启如果是redis/memcache这种服务也挂了,重启的时候需要注意“预热”,并且很可能需要比较长的时间
秒杀和抢购的场景,流量往往是超乎我们系统的准备和想象的。这个时候,过载保护是必要的。如果检测到系统满负载状态,拒绝请求也是一种保护措施。在前端设置过滤是最简单的方式,但是,这种做法是被用户“千夫所指”的行为。更合适一点的是,将过载保护设置在CGI入口层,快速将客户的直接请求返回

6 作弊的手段:进攻与防守

秒杀和抢购收到了“海量”的请求,实际上里面的水分是很大的。不少用户,为了“抢“到商品,会使用“刷票工具”等类型的辅助工具,帮助他们发送尽可能多的请求到服务器。还有一部分高级用户,制作强大的自动请求脚本。这种做法的理由也很简单,就是在参与秒杀和抢购的请求中,自己的请求数目占比越多,成功的概率越高
这些都是属于“作弊的手段”,不过,有“进攻”就有“防守”,这是一场没有硝烟的战斗哈。

6.1 同一个账号,一次性发出多个请求

部分用户通过浏览器的插件或者其他工具,在秒杀开始的时间里,以自己的账号,一次发送上百甚至更多的请求。实际上,这样的用户破坏了秒杀和抢购的公平性。
这种请求在某些没有做数据安全处理的系统里,也可能造成另外一种破坏,导致某些判断条件被绕过。例如一个简单的领取逻辑,先判断用户是否有参与记录,如果没有则领取成功,最后写入到参与记录中。这是个非常简单的逻辑,但是,在高并发的场景下,存在深深的漏洞。多个并发请求通过负载均衡服务器,分配到内网的多台Web服务器,它们首先向存储发送查询请求,然后,在某个请求成功写入参与记录的时间差内,其他的请求获查询到的结果都是“没有参与记录”。这里,就存在逻辑判断被绕过的风险。
输入图片说明
应对方案:
在程序入口处,一个账号只允许接受1个请求,其他请求过滤。不仅解决了同一个账号,发送N个请求的问题,还保证了后续的逻辑流程的安全。实现方案,可以通过Redis这种内存缓存服务,写入一个标志位(只允许1个请求写成功,结合watch的乐观锁的特性),成功写入的则可以继续参加
输入图片说明
或者,自己实现一个服务,将同一个账号的请求放入一个队列中,处理完一个,再处理下一个。

6.2 多个账号,一次性发送多个请求

很多公司的账号注册功能,在发展早期几乎是没有限制的,很容易就可以注册很多个账号。因此,也导致了出现了一些特殊的工作室,通过编写自动注册脚本,积累了一大批“僵尸账号”,数量庞大,几万甚至几十万的账号不等,专门做各种刷的行为(这就是微博中的“僵尸粉“的来源)。举个例子,例如微博中有转发抽奖的活动,如果我们使用几万个“僵尸号”去混进去转发,这样就可以大大提升我们中奖的概率。
这种账号,使用在秒杀和抢购里,也是同一个道理。例如,iPhone官网的抢购,火车票黄牛党。
输入图片说明
应对方案:
这种场景,可以通过检测指定机器IP请求频率就可以解决,如果发现某个IP请求频率很高,可以给它弹出一个验证码或者直接禁止它的请求
  1. 弹出验证码,最核心的追求,就是分辨出真实用户。因此,大家可能经常发现,网站弹出的验证码,有些是“鬼神乱舞”的样子,有时让我们根本无法看清。他们这样做的原因,其实也是为了让验证码的图片不被轻易识别,因为强大的“自动脚本”可以通过图片识别里面的字符,然后让脚本自动填写验证码。实际上,有一些非常创新的验证码,效果会比较好,例如给你一个简单问题让你回答,或者让你完成某些简单操作(例如百度贴吧的验证码)。
  2. 直接禁止IP,实际上是有些粗暴的,因为有些真实用户的网络场景恰好是同一出口IP的,可能会有“误伤“。但是这一个做法简单高效,根据实际场景使用可以获得很好的效果。

6.3 多个账号,不同IP发送不同请求

所谓道高一尺,魔高一丈。有进攻,就会有防守,永不休止。这些“工作室”,发现你对单机IP请求频率有控制之后,他们也针对这种场景,想出了他们的“新进攻方案”,就是不断改变IP
输入图片说明
有同学会好奇,这些随机IP服务怎么来的。有一些是某些机构自己占据一批独立IP,然后做成一个随机代理IP的服务,有偿提供给这些“工作室”使用。还有一些更为黑暗一点的,就是通过木马黑掉普通用户的电脑,这个木马也不破坏用户电脑的正常运作,只做一件事情,就是转发IP包,普通用户的电脑被变成了IP代理出口。通过这种做法,黑客就拿到了大量的独立IP,然后搭建为随机IP服务,就是为了挣钱。
应对方案:
说实话,这种场景下的请求,和真实用户的行为,已经基本相同了,想做分辨很困难。再做进一步的限制很容易“误伤“真实用户,这个时候,通常只能通过设置业务门槛高来限制这种请求了,或者通过账号行为的”数据挖掘“来提前清理掉它们
僵尸账号也还是有一些共同特征的,例如账号很可能属于同一个号码段甚至是连号的,活跃度不高,等级低,资料不全等等。根据这些特点,适当设置参与门槛,例如限制参与秒杀的账号等级。通过这些业务手段,也是可以过滤掉一些僵尸号

7 高并发下的数据安全

我们知道在多线程写入同一个文件的时候,会存现“线程安全”的问题(多个线程同时运行同一段代码,如果每次运行结果和单线程运行的结果是一样的,结果和预期相同,就是线程安全的)。如果是MySQL数据库,可以使用它自带的锁机制很好的解决问题,但是,在大规模并发的场景中,是不推荐使用MySQL的。秒杀和抢购的场景中,还有另外一个问题,就是“超发”,如果在这方面控制不慎,会产生发送过多的情况。我们也曾经听说过,某些电商搞抢购活动,买家成功拍下后,商家却不承认订单有效,拒绝发货。这里的问题,也许并不一定是商家奸诈,而是系统技术层面存在超发风险导致的。

7.1 超发的原因

假设某个抢购场景中,我们一共只有100个商品,在最后一刻,我们已经消耗了99个商品,仅剩最后一个。这个时候,系统发来多个并发请求,这批请求读取到的商品余量都是99个,然后都通过了这一个余量判断,最终导致超发。
输入图片说明
在上面的这个图中,就导致了并发用户B也“抢购成功”,多让一个人获得了商品。这种场景,在高并发的情况下非常容易出现。

7.2 悲观锁思路

解决线程安全的思路很多,可以从“悲观锁”的方向开始讨论。
悲观锁,也就是在修改数据的时候,采用锁定状态,排斥外部请求的修改。遇到加锁的状态,就必须等待。
输入图片说明
虽然上述的方案的确解决了线程安全的问题,但是,别忘记,我们的场景是“高并发”。也就是说,会很多这样的修改请求,每个请求都需要等待“锁”,某些线程可能永远都没有机会抢到这个“锁”,这种请求就会死在那里。同时,这种请求会很多,瞬间增大系统的平均响应时间,结果是可用连接数被耗尽,系统陷入异常

7.3 FIFO队列思路

那好,那么我们稍微修改一下上面的场景,我们直接将请求放入队列中的,采用FIFO(First Input First Output,先进先出),这样的话,我们就不会导致某些请求永远获取不到锁。看到这里,是不是有点强行将多线程变成单线程的感觉哈。
输入图片说明
然后,我们现在解决了锁的问题,全部请求采用“先进先出”的队列方式来处理。那么新的问题来了,高并发的场景下,因为请求很多,很可能一瞬间将队列内存“撑爆”,然后系统又陷入到了异常状态。或者设计一个极大的内存队列,也是一种方案,但是,系统处理完一个队列内请求的速度根本无法和疯狂涌入队列中的数目相比。也就是说,队列内的请求会越积累越多,最终Web系统平均响应时候还是会大幅下降,系统还是陷入异常。

7.4 乐观锁思路

这个时候,我们就可以讨论一下“乐观锁”的思路了。乐观锁,是相对于“悲观锁”采用更为宽松的加锁机制,大都是采用带版本号(Version)更新。实现就是,这个数据所有请求都有资格去修改,但会获得一个该数据的版本号,只有版本号符合的才能更新成功,其他的返回抢购失败。这样的话,我们就不需要考虑队列的问题,不过,它会增大CPU的计算开销。但是,综合来说,这是一个比较好的解决方案。
输入图片说明

有很多软件和服务都“乐观锁”功能的支持,例如Redis中的watch就是其中之一。通过这个实现,我们保证了数据的安全。

http://mm.fancymore.com/reading/java-interview-project-killsgood.html
  • 性能问题 在性能参数上,需要支撑6000人/秒的压力,这块使用缓存、队列都是很好的解决方案。
  • 一致性问题 毕竟是和商品相关的秒杀,所以不能因为并发问题而带来超卖。这块就需要有统一的一个服务来处理事务,每次被抢或者未支付而回归的数量都必须通过这个服务来处理。这个服务业务要尽量简单,而且一定要有同步锁。
  1. 先建立集群,应对大量的访问链接。
    • 并发做负载均衡,前面一层web服务器,如Nginx,(它一秒可支持近2万的连接数)。分流请求到下层。
    • 应用服务器集群分流,后面一层多个tomcat集群。每个处理相应的请求。
  2. tomcat应用服务器接受秒杀请求。
    • 用户处理逻辑,过滤并存用户信息(用户ID,秒杀时间,秒杀商品),把符合的用户放到秒杀队列。秒杀队列可放在同一个后端的服务。
    • 队列处理秒杀
      a、 java concurrent 包中有几个Queue,如 LinkedBlockingQueue (线程安全的阻塞队列),
      ConcurrentLinkedQueue(采用CAS操作,来保证元素的一致性),ArrayBlockingQueue。
      
      b、MQ Server的消息队列(如ActiveMQ),当然用MQ有个问题,如果你从队列中取出来的消息,可是写
      失败的怎么办? 因为MQ是异步的,它当时并不返回成功或失败的结果,如果过了一会儿
      返回的结果是失败,该怎么办?
      
      这时就要用NoSQL 来做一个保证机制了,如NoSQL中也要有个字段来标识
      消息是否写成功,如果没有写成功,则再将失败的消息写回MQ。
      
      用户抢到商品后,如何保证该商品不再被其它用抢户到,这里面就涉及到一个对数据的原子 操作问题,或加锁问题。这个过程当中一般都是使用缓存memcached。
    • 异步支付处理
http://blog.csdn.net/qibobo/article/details/41721949
http://blog.csdn.net/MikeAfc/article/details/10426563
https://zh.wikipedia.org/wiki/%E6%82%B2%E8%A7%82%E5%B9%B6%E5%8F%91%E6%8E%A7%E5%88%B6
关系数据库管理系统里,悲观并发控制(又名“悲观锁”,Pessimistic Concurrency Control,缩写“PCC”)是一种并发控制的方法。它可以阻止一个事务以影响其他用户的方式来修改数据。如果一个事务执行的操作读某行数据应用了锁,那只有当这个事务把锁释放,其他事务才能够执行与该锁冲突的操作。

关系数据库管理系统里,乐观并发控制(又名“乐观锁”,Optimistic Concurrency Control,缩写“OCC”)是一种并发控制的方法。它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,正在提交的事务会进行回滚。乐观事务控制最早是由孔祥重(H.T.Kung)教授提出[1]
乐观并发控制多数用于数据争用不大、冲突较少的环境中,这种环境中,偶尔回滚事务的成本会低于读取数据时锁定数据的成本,因此可以获得比其他并发控制方法更高的吞吐量

http://www.importnew.com/24185.html

Labels

Review (572) System Design (334) System Design - Review (198) Java (189) Coding (75) Interview-System Design (65) Interview (63) Book Notes (59) Coding - Review (59) to-do (45) Linux (43) Knowledge (39) Interview-Java (35) Knowledge - Review (32) Database (31) Design Patterns (31) Big Data (29) Product Architecture (28) MultiThread (27) Soft Skills (27) Concurrency (26) Cracking Code Interview (26) Miscs (25) Distributed (24) OOD Design (24) Google (23) Career (22) Interview - Review (21) Java - Code (21) Operating System (21) Interview Q&A (20) System Design - Practice (20) Tips (19) Algorithm (17) Company - Facebook (17) Security (17) How to Ace Interview (16) Brain Teaser (14) Linux - Shell (14) Redis (14) Testing (14) Tools (14) Code Quality (13) Search (13) Spark (13) Spring (13) Company - LinkedIn (12) How to (12) Interview-Database (12) Interview-Operating System (12) Solr (12) Architecture Principles (11) Resource (10) Amazon (9) Cache (9) Git (9) Interview - MultiThread (9) Scalability (9) Trouble Shooting (9) Web Dev (9) Architecture Model (8) Better Programmer (8) Cassandra (8) Company - Uber (8) Java67 (8) Math (8) OO Design principles (8) SOLID (8) Design (7) Interview Corner (7) JVM (7) Java Basics (7) Kafka (7) Mac (7) Machine Learning (7) NoSQL (7) C++ (6) Chrome (6) File System (6) Highscalability (6) How to Better (6) Network (6) Restful (6) CareerCup (5) Code Review (5) Hash (5) How to Interview (5) JDK Source Code (5) JavaScript (5) Leetcode (5) Must Known (5) Python (5)

Popular Posts