一个大型网站应用一般都是从最初小规模网站甚至是单机应用发展而来的,为了让系统能够支持足够大的业务量,从前端到后端也采用了各种各样技术,前端静态资源压缩整合、使用CDN、分布式SOA架构、缓存、数据库加索引、读写分离等等。 这些技术是高并发系统所必须的,但是今天先不细说,而先谈谈在这些架构既定的情况下,一些高并发业务/接口实现时应该注意的原则,以及通过工作中一个6万QPS的秒杀活动,来介绍一下秒杀业务的特点以及如何优化。
高并发系统设计原则
高并发的接口/系统有一个共同的特性,那就是”快”。
在系统其它条件既定的情况下,系统处理请求越快,用户得到反馈的时间就越短,单位时间内服务器能够处理请求的数量就会越多。所以”快”几乎可以算是高并发系统的要满足的必要条件,要评估一个系统性能如何,某次优化是否提高系统的容量,”快”是一个很直观的衡量标准。
那么,如何才能做得快呢?有两个需要注意的原则 :
做得少,一方面是指在功能特性上有所为,有所不为,另一方面是指一次处理的信息量要少。
做得巧,根据业务自身的特点,选择合理的业务实现方式,选择合理的缓存类型和缓存调用时机。
做得少
世界上最快的程序,是什么都不做的程序。
一个接口负责的功能越少,读取信息量越少,速度越快。
功能特性有选择
对于一个需要承受高并发的接口,在功能上,尽量不涉及一些难以缓存和预热的数据。 一个典型的例子,用户维度个性化的数据,用户和用户的信息不同,userId数量又很多,即使加上缓存,缓存命中率依然很低,压力还是会打到数据库,不光接口快不了,高并发的sql也会给数据库带来风险。
举一个例子,在点评电影早期的秒杀活动页上,展示了一个用户当前秒杀资格的信息,由于不同用户抢到秒杀资格的时间、优惠不同,每次都需要读数据库的来取,也就是每个用户进入主页都会产生一条sql。
还有一个例子,一般电商搞大促的时候,比如同时有多个优惠活动可以降低商品的价格,而一般只展示最低价的优惠,同时用户一个优惠只能参与一次,这样不同用户参与了不同活动之后可以享受的最低价就会随之改变,如果要在商品页面上展示这个动态价格,就免不了取到各个用户参加这些在线优惠的信息。
如果遇到这样的数据,要怎么解决呢?
一个办法是尝试转移数据的维度:刚才说的秒杀活动资格信息,如果以用户userId为key,会出现缓存命中率低,仍要sql读的情况,但是能够秒到的用户数量其实很少,所以如果以这次秒杀活动id为key,存储一个成功秒到用户的userid的list,就能够解决缓存命中率低的问题。
还有一个办法是可以把这些需要个性化数据的功能在业务流程上后移,流量漏斗,越往后流量越少,创建订单级的sql查询是可接受的。 刚才说的第二个例子,商品最优惠的价格,可以排除用户相关信息,只在商品列表/详情上展示只和优惠相关的最低价,而在提交订单的时候才真正去取用户参加活动情况,如果用户已经参加过给出提示并选择次优的优惠。商品的列表/详情页都在用户路径上相对靠前的位置,排除了用户个性化信息可以让商品列表/详情更容易缓存,响应速度更快,系统可承受的高并发量更高。
处理信息量要少
我们写业务代码的时候都有对应的业务对象,它们都存在一定的业务范围之内,比如类目、地区、日期等自身相关的维度。 一个系统中的业务对象,在多个维度的细分下,对应的量并不多,但如果一次全部都展示在一个页面/接口下,即使覆盖上了缓存,也会由于缓存占用空间过大或者缓存key数目过多、网络传输耗时、对象序列化反序列耗时等拖慢接口/页面响应速度。一般只要看一下这个页面/接口给出的业务对象的数量级,就能大致知道这个接口的性能了。
大家在做设计的时候,一般会估算一个接口的量级,如果一看就有几千几万个业务对象,就不会这样设计了,但是需要警惕的是业务对象数量级可变的情况,比如随着业务发展数量会快速增长,或者某些特殊维度下业务对象特别多。设计的时候要按照预估的最大量级来,并且对接口/页面做出数量的限制,如果发现当前返回的业务对象过多,可以继续根据业务维度来拆分,分次分批来处理。
举一个例子,比如一个影院下所有的活动场次,开始的时候一家影院下的场次有限,几十一百场,很好展示,后来随着业务发展,一个影院下各个影院下场次数到了几百一千,一次全部拿完,在高并发时,memcached缓存的multi get会出现很多超时,请求会打到mysql数据库,给系统很大压力。之后我们做了改造项目,每次根据用户的交互按照影片、日期、影院的维度来分批取,一次只有十几个场次,接口响应变快了,服务的压力也小的多。
做得巧
根据业务特性选择实现方式
平时涉及到的业务,总有属于它的特性,比如实时性要求多高,数据一致性要求多高,涉及什么维度的数据,量有多大等等,我们要根据这些特性来选择实现的方案,比如一些统计数据,如某类目下所有商品的最低价,按照逻辑需要遍历商品来获取,但这样每次实时读取所有的对象,涉及读取缓存数据库操作,接口会很耗时,但如果选择作业离线计算,把计算结果写表,加上缓存,搜索直接读取,显然会快很多了。
涉及到业务各阶段特性的例子就是秒杀系统,在第二部分秒杀实践中我会详细介绍。
合适选择和调用缓存
除了业务特性方面,缓存是业务对抗高并发非常重要的一个环节,合理选择缓存的类型和调用缓存的时机非常重要。
我们知道内存运算速度快于远程连接,所以存储上来说效率如下 内存 <= ehcache < redis <= memcached < mysql 可以看出,尽量少的远程连接,常规覆盖数据库访问的缓存,都能提高程序的性能。
要根据不同缓存的特性和原理,才能根据业务选出最合适的,来看看几种常用的缓存 :
varnish,可以作为反向代理,缓存一些资源,例如可以把struts,freemarker动态生成的页面存储起来,达到直接挡掉到达web服务器的请求。
ehcache,主要存储在当前机器内存中,存取非常快,缺点是内存有限,各台机器内存中各存一份,失效时间不一致,数据就会出现不一致,一般用来缓存不常变化,且缓存个数较少的数据。
memcached缓存,kv分布式缓存集群,可扩展性好,可以存储个数较多的缓存对象,也可以承接高流量的访问,读取缓存时远程连接,一般耗时也在零点几到几ms不等。
redis,nosql,是内存的kv存储,可以做为缓存使用,也可以持久化,它的性能和memcached相近。而redis最大的特点是一个data-structure store,这时redis官网首页介绍redis的第一句话,它可以保存list,hash,set,sorted set等数据结构,使用时和memcached区别是,它不用将数据取到客户端再做逻辑判断,而是可以直接在redis服务器上完成操作,比如查看某个元素是不是一个范围内,队列的长度有多长等。redis可以用来做分布式服务器的进程间的通信,比如我们经常有需要分布式锁的场景,控制同一个用户发券的并发等。
根据业务需要选择了合适类型的缓存后,还要合理去使用。 虽然说缓存是为了抵挡数据库的流量而生,本身性能非常强大,但仍然是受到缓存服务器性能甚至服务器网卡流量的限制的,不合理的使用比如单个key对应的缓存对象过大、一次读取中缓存key数量过多、短时间内频繁更新缓存等都是系统的隐患、并发越高时就越能体现。
秒杀实践
秒杀业务分析
秒杀业务的典型特点有:
瞬时流量大
参与用户多,可秒杀商品数量少
请求读多写少
秒杀状态转换实时性要求高
一次秒杀的流程可以分为三个阶段:
活动未开始
活动开始前,用户进入活动页,这个阶段有两种请求,一种是加载活动页信息,一个是查询活动状态得到未开始的结果, 一个用户进入页面两个请求各发起一次,这两种请求占比各半。
活动进行中
这个阶段持续时间非常短,看到抢购按钮的用户大量发起秒杀请求,瞬时秒杀请求占比增高,能不能抗住秒杀请求就是秒杀系统是否能抗住高并发的关键。
活动结束
当商品被抢购完,进入结束状态,请求情况同活动开始前
各阶段流量图
其实贯穿整个活动的只有三种请求,加载活动页请求,读取活动状态请求,秒杀请求加载活动页请求
主要是展示活动相关配置信息,活动背景图片,优惠力度,活动规则等相对静态的内容,通过web项目渲染成页面。
对于这样的请求,我们可以使用varnish反向代理,以页面相关的参数比如本次秒杀的活动ID和城市ID的hash为key把整个页面缓存在varnish机器上,而秒杀活动的状态等动态信息通过ajax来刷新。
varnish作用机制
达到的效果是活动期间,加载页面请求都会打到varnish机器直接返回,而不会给web和service带来任何压力。
查询活动状态
秒杀状态就三种,未开始,可抢,已抢完,由两个因素共同决定
活动开始时间
剩余库存
读取秒杀状态的请求数并发也是非常高的,对于这个接口也要加上合适的缓存来处理。 对于活动开始时间,是一个较固定且不会发生变化的属性,并且,同时在线的秒杀活动数目并不多,所以把它也作为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队列挡掉了活动进行中,过量的秒杀请求 到最后只留下了秒杀商品数量级的请求到数据库中。
其实做为一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这里我推荐一个Java交流群730379855,不管你是小白还是大牛欢迎入驻,大家一起交流成长。
网友评论