当前位置: 首页 > 科技观察

高并发秒杀系统架构解密,不是所有的秒杀都是秒杀!

时间:2023-03-21 23:52:59 科技观察

作者个人研发在高并发场景下提供了一个简单、稳定、可扩展的延迟消息队列框架,具有精准的定时任务和延迟队列处理功能。开源半年多以来,已成功为十几家中小企业提供精准定时调度解决方案,经受住了生产环境的考验。为了造福更多的童鞋,这里给出开源框架的地址:https://github.com/sunshinelyz/mykit-delay前面写了很多小伙伴说学习高并发题目是为了so长,但是当他们真正在做项目的时候,还是不知道如何应对高并发的业务场景!甚至很多小伙伴还停留在简单提供接口(CRUD)的阶段,不知道如何把学到的并发知识应用到实际项目中,更不知道如何搭建高并发系统!什么样的系统是高并发系统?今天,我们将解密一个典型的秒杀系统在高并发业务场景下的架构,并结合高并发专题下的其他文章学以致用。电子商务系统架构在电子商务领域,有典型的闪购场景,那么什么是闪购场景呢?简单来说,一个产品的买家数量远远大于这个产品的库存,这个产品会在短时间内销售一空。例如每年的618、双11、小米新品促销等业务场景就是典型的闪购场景。我们可以简化电子商务系统的架构,如下图所示。如图所示,我们可以简单地将电商系统的核心层划分为:负载均衡层、应用层和持久层。接下来,我们估计每一层的并发度。如果负载均衡层使用高性能的Nginx,我们可以估算Nginx的最大并发为:10W+,这里的单位是10000。假设我们在应用层使用Tomcat,可以估计Tomcat的最大并发量在800左右,这里的单位是百。假设持久层的缓存使用Redis,数据库使用MySQL,那么MySQL的最大并发量可以估算在1000左右,以千为单位。Redis的最大并发量可以估计在5W左右,以万为单位。所以,负载均衡层、应用层、持久层的并发度是不一样的。那么,为了提高系统的整体并发和缓存,我们通常可以采用哪些方案呢?(1)系统扩展系统扩展包括纵向扩展和横向扩展,增加设备和机器配置,在大多数场景下都有效。(2)缓存本地缓存或集中缓存,减少网络IO,基于内存读取数据。适用于大多数场景。(3)读写分离采用读写分离,分而治之,增加机器的并行处理能力。秒杀系统的特点对于秒杀系统,我们可以从业务和技术的角度来说明其自身的一些特点。这里,我们可以以12306网站为例。每年春运期间,12306网站访问量非常大,但平时的网站访问量相对平缓。也就是说,在每年的春运期间,12306网站的访问量都会出现骤增。再比如小米的秒杀系统,上午10:00开始卖产品,上午10:00之前的访问量比较平淡,到了上午10:00也会出现并发流量的突然增加。因此,我们可以用下图来表示秒杀系统的流量和并发。从图中可以看出,秒杀系统的并发量具有瞬时峰值的特点,也称为流量尖峰现象。我们可以将秒杀系统的特点总结如下。(一)在规定时间内实行限时、限量、限价;秒杀活动商品数量有限;商品的价格会远低于原价,也就是说,在秒杀活动中,商品会远低于原价销售。例如,秒杀活动的时长仅限于当日上午10:00-10:30,产品数量只有10万件,售完即止,产品价格非常低廉,比如:1元购等业务场景。期限、限价、限价可以单独存在,也可以同时存在。(2)活动预热需要提前配置好活动;活动开始前,用户可以查看活动的相关信息;秒杀活动开始前,大力宣传活动。(3)短期购买人数多;货物将很快售罄。在系统流量呈现上,会出现秒杀现象。这个时候并发访问量很高。在大多数闪购场景中,产品会在很短的时间内售罄。秒杀系统的技术特点我们可以将秒杀系统的技术特点总结如下。(1)瞬时并发非常高。大量用户会同时抢购商品;瞬时并发峰值很高。(2)系统产品页面访问量巨大;可购买的产品数量很少;库存的查询访问次数远大于购买次数。产品页面经常会加入一些限流措施。例如,早期秒杀系统的产品页面会添加验证码,以平滑前端访问系统的流量。近期秒杀系统的商品详情页面,用户打开页面时会提示用户登录系统。.这些是限制对系统的访问的一些措施。(3)流程简单秒杀系统的业务流程一般比较简单;总的来说,秒杀系统的业务流程可以概括为:下订单,减少库存。秒杀的三个阶段通常,从开始秒杀到结束,通常有三个阶段:准备阶段:这个阶段也叫系统预热阶段。此时秒杀系统的业务数据会被提前预热。刷新秒杀页面,查看秒杀是否已经开始。在一定程度上,可以将一些数据存储在Redis中,供用户不断刷新页面进行预热。秒杀阶段:该阶段主要是秒杀活动的过程,会产生瞬时高并发流量,对系统资源造成巨大影响。所以在秒杀阶段一定要做好系统保护。结算阶段:秒杀完成后的数据处理工作,如数据一致性问题处理、异常情况处理、商品退货处理等。对于这种短时间内流量大的系统,不适合使用系统扩容,因为即使对系统进行扩容,扩容后的系统也会在短时间内使用。大多数情况下,系统无需扩容即可正常访问。那么,我们可以采取哪些方案来提高秒杀系统的性能呢?秒杀系统解决方案根据秒杀系统的特点,我们可以采取以下措施来提高系统的性能。(1)异步解耦,将整体流程拆解,通过队列控制核心流程。(2)限流防刷控制网站整体流量,提高请求门槛,避免系统资源耗尽。(3)资源控制在全过程中控制资源调度,扬长避短。因为应用层能承载的并发量远小于缓存的并发量。因此,在高并发系统中,我们可以直接使用OpenResty从负载均衡层访问缓存,避免调用应用层带来的性能损失。你可以去https://openresty.org/cn/了解更多关于OpenResty的信息。同时,由于秒杀系统的产品数量较少,我们还可以利用动态渲染技术和CDN技术来加快网站访问性能。如果秒杀活动一开始并发过高,我们可以将用户的请求放入队列中处理,弹出用户排队页面。秒杀系统时序图网上很多秒杀系统和秒杀系统的解决方案都不是真正的秒杀系统。他们使用的只是一种同步请求处理方案。一旦并发真的增加了,他们所谓的秒杀系统的性能就会急剧下降。我们先来看看秒杀系统在同步下单时的时序图。同步下单流程1.用户发起秒杀请求在同步下单流程中,首先,用户发起秒杀请求。商城服务需要执行以下流程才能处理秒杀请求。(1)识别验证码是否正确。商城服务判断用户发起秒杀请求时提交的验证码是否正确。(2)判断活动是否结束,验证当前秒杀活动是否结束。(3)验证访问请求是否在黑名单电子商务领域存在大量恶意竞争,即其他商家可能通过不正当手段恶意请求杀系统,占用大量系统的带宽和其他系统资源。这时候就需要借助风控系统来实现黑名单机制。为了简单起见,也可以使用拦截器统计访问频率来实现黑名单机制。(4)验证真实库存是否充足。系统需要验证商品的真实库存是否充足,能否支持闪购的库存。(5)扣除缓存中的库存在秒杀业务中,缓存中往往会存储商品库存等信息。这时候还需要验证秒杀活动使用的商品库存是否充足,秒杀活动的商品库存需要扣减数量。(6)计算闪购价格由于闪购活动中商品的闪购价格与商品实际价格存在差异,因此需要计算商品的闪购价格。注意:如果秒杀场景下系统涉及的业务越复杂,涉及的业务操作就越多。在这里,我只是列举一些常见的业务操作。2.提交订单(1)订单入口将用户提交的订单信息保存到数据库中。(2)扣除真实库存订单入库后,需要从商品真实库存中扣除成功下单商品的数量。如果我们用上面的流程开发一个秒杀系统,当用户发起秒杀请求时,由于系统的各个业务流程都是串行执行的,所以整个系统的性能不会太高。当并发量过高时,我们会弹出如下排队页面提示用户等待。此时的排队时间可能是15秒,也可能是30秒,甚至更长。这里有个问题:从用户发起秒杀请求到服务器返回结果这段时间,客户端和服务器的连接是不会被释放的,会占用大量的服务器资源。网上很多介绍如何实现秒杀系统的文章都是用的这种方法。那么,这个方法可以作为秒杀系统吗?答案是可以的,但是这种方式支持的并发度并不算高。这时候可能有网友会问了:我们公司是这样使用秒杀系统的!上线后我一直在用,没问题!我想说的是:使用同步下单方式确实可以作为秒杀系统,但是同步下单性能不会太高。之所以你们公司的秒杀系统同步下单没有出现大的问题,是因为你们的秒杀系统的并发度没有达到一定的水平,也就是说你们的秒杀系统的并发度其实并不高。高的。因此,很多所谓的秒杀系统都有秒杀服务,但不能称为真正的秒杀系统,因为他们使用的是同步下单流程,限制了系统的并发量。之所以上线后没有出现大的问题,是因为系统的并发度不够高,没有压垮整个系统。如果12306、淘宝、天猫、京东、小米等大型商城都这么操作秒杀系统,那他们的系统迟早会被秒杀,难怪他们的系统工程师不被开除!因此,在秒杀系统中,这种同步处理下单业务流程的方案不可取。以上就是同步下单的整个流程操作。如果订购过程更复杂,涉及的业务操作也会更多。异步下单流程既然同步下单流程的秒杀系统不能称为真正的秒杀系统,那么我们就需要采用异步下单流程。异步下单流程不会限制系统的高并发流量。1.用户发起秒杀请求用户发起秒杀请求后,商城服务会经过以下业务流程。(1)检查验证码是否正确当用户发起秒杀请求时,验证码会一起发送,系统会检查验证码是否有效和正确。(2)是否限流系统会判断是否对用户的请求进行限流。这里,我们可以通过判断消息队列的长度来判断。因为我们把用户的请求放在了消息队列中,而用户的请求在消息队列中是累积的,所以我们可以根据当前消息队列中待处理请求的数量来判断是否需要对用户的请求进行限流。比如在秒杀活动中,我们卖出了1000件商品,此时消息队列中有1000条请求。如果以后还有用户发起秒杀请求,我们可以停止处理后续的请求,直接按照售出完成提示将商品退还给用户。因此,使用限流后,我们可以更快的处理用户请求,释放连接资源。(3)发送MQ用户的秒杀请求通过前面的验证后,我们就可以将用户的请求参数等信息发送给MQ进行异步处理,同时将结果信息响应给用户。在商城服务中,会有专门的异步任务处理模块来消费消息队列中的请求,并处理后续的异步流程。当用户发起秒杀请求时,异步下单流程处理的业务操作比同步下单流程少。将后续操作通过MQ发送给异步处理模块进行处理,并快速将响应结果返回给用户,释放请求连接。2.异步处理我们可以异步处理订单流程的以下操作。(1)判断活动是否结束(2)判断请求是否在系统黑名单中。为了防止电子商务领域同行的恶意竞争,可以在系统中加入黑名单机制,将恶意请求放入系统黑名单。可以通过使用拦截器统计访问频率来实现。(3)扣除缓存中秒杀产品的库存数量。(4)生成秒杀Token,与当前用户和当前秒杀活动绑定。只有产生秒杀Token的请求才有资格进行秒杀活动。这里引入异步处理机制。在异步处理中,可以控制系统使用多少资源,分配多少线程来处理相应的任务。3、短轮询查询闪购结果这里可以通过客户端短轮询查询闪购是否合格。例如,客户端可以每3秒轮询一次请求服务器,检查是否符合秒杀条件。这里我们在服务端的处理就是判断当前用户是否有秒杀token。如果服务器为当前用户生成秒杀令牌,则当前用户有秒杀资格。否则继续轮询,直到超时或服务器返回商品已售罄或不符合闪购条件等信息。当使用短轮询查询闪购结果时,我们也可以在页面提示用户排队,但此时客户端会每隔几秒轮询一次服务端,查看闪购资格的状态。与同步下单流程相比,无需长时间挂起请求连接。这时候可能有网友会问:采用短轮询的查询方式,会不会出现超时查询不到秒杀资格的状态?答案是:有可能!这里我们试着想象一下秒杀的真实场景从本质上讲,商家参与秒杀活动并不是为了赚钱,而是为了增加产品的销量和商家的知名度,吸引更多的用户购买他们的产品。因此,我们不必保证用户可以100%查询自己是否拥有flashkill资格状态。4、秒杀结算(1)订单Token的验证客户端提交秒杀结算时,会一并提交SeckillToken给服务端,商城服务会验证当前SeckillToken是否有效。(2)增加秒杀购物车。商城服务在验证秒杀Token合法有效后,会将用户的秒杀商品加入秒杀购物车。5.提交订单(1)订单存储将用户提交的订单信息存入数据库。(2)删除Token秒杀产品订单存储成功后,删除秒杀Token。这里可以思考一个问题:为什么我们只在异步下单流程的粉红色部分采用异步处理,而在其他部分不采取异步削峰填谷措施?这是因为在异步点餐流程的设计中,无论是产品设计还是界面设计,我们都实现了在用户发起秒杀请求时,对用户的请求进行限流操作。可以说系统的限流操作非常先进。当用户发起秒杀请求时,进行了流量限制,顺利解决了系统的流量高峰。往后看,系统的并发量和系统流量都不是很高。所以网上很多文章和帖子在介绍秒杀系统的时候都说异步调峰是在下单的时候进行一些限流操作。为了后面的操作,必须对限流操作进行预处理。在秒杀业务后面的进程中进行限流操作是没有用的。高并发“黑科技”及制胜招数假设在秒杀系统中,我们使用Redis来实现缓存,假设Redis的读写并发量在5万左右。我们商城秒杀业务需要支持的并发量在100万左右。如果100万并发全部进入Redis,Redis很有可能挂掉,那我们怎么解决这个问题呢?接下来,我们将一起讨论这个问题。在高并发秒杀系统中,如果使用Redis来缓存数据,Redis缓存的并发处理能力是关键,因为很多前缀操作都需要访问Redis。异步调峰只是基础操作,关键是保证Redis的并发处理能力。解决这个问题的关键思想是:分而治之,将商品库存分开。我们在Redis中存储秒杀商品的库存数量时,可以对秒杀商品的库存进行“分段”,以增加Redis的读写并发。比如原来闪购商品id为10001,库存为1000件,在Redis中存储为(10001,1000)。我们把原库存分成5份,每份库存200件。此时我们在Redia中存储的信息为(10001_0,200),(10001_1,200),(10001_2,200),(10001_3,200),(10001_4,200)。至此,我们对库存进行划分后,每个划分后的库存使用商品id加上数字标识进行存储。这样,当对存储商品库存的各个Key进行Hash运算时,得到的Hash结果是不同的。是的,这意味着存放商品库存的Key大概率不在Redis的同一个槽中,这样可以提高Redis处理请求的性能和并发度。分库后,我们还需要在Redis中存储一个分库后的商品id和Key的映射关系。此时映射关系的Key为商品的id,为10001,Value为库存划分后存储库存信息的Key,即10001_0、10001_1、10001_2、10001_3、10001_4。在Redis中我们可以使用List来存储这些值。在实际处理库存信息时,我们可以先从Redis中查询秒杀产品对应的拆分库存对应的所有key,同时使用AtomicLong记录当前的请求数量,通过请求数量匹配对应的秒杀从Redia查询商品对库存划分后的所有key的长度取模,结果分别为0、1、2、3、4。然后拼接前面的商品id,得到真正的库存缓存key。此时可以根据这个Key直接去Redis中获取对应的库存信息。在高并发业务场景下,我们可以直接使用Lua脚本库(OpenResty)直接从负载均衡层访问缓存。这里,我们想一个场景:如果在秒杀业务场景中,秒杀的产品瞬间售罄。这时候,当用户再次发起秒杀请求时,如果系统向负载均衡层请求应用层的服务,然后应用层的服务去访问缓存和数据库,其实是没有意义的本质是因为产品卖完了,通过系统的应用层层层验证意义不大!!而且应用层的并发访问以百为单位,一定程度上会降低系统的并发量。为了解决这个问题,这时候我们可以在系统的负载均衡层取出用户发送请求时携带的用户id、产品id、秒杀activityid,直接访问库存信息通过Lua脚本等技术缓存。如果闪购商品的库存小于等于0,则直接返回用户商品已售罄的提示信息,无需经过应用层的层层验证。对于这个架构,我们可以参考本文电子商务系统的架构图(正文开头第一张图)。Redis辅助秒杀系统我们可以在Redis中设计一个Hash数据结构来支持商品库存的扣减操作,如下图。seckill:goodsStock:${goodsId}{totalCount:200,initStatus:0,seckillCount:0}在我们设计的Hash数据结构中,有3个非常主要的属性。totalCount:表示参与闪购的产品总数。在闪购开始之前,我们需要提前将这个值加载到Redis缓存中。initStatus:我们将这个值设计为布尔值。在秒杀开始之前,这个值为0,表示秒杀没有开始。可以通过定时任务或后台操作将此值修改为1,表示秒杀已经启动。seckillCount:秒杀项的个数。在秒杀过程中,这个值的上限为totalCount。当这个值达到totalCount时,说明秒杀已经完成。在闪购预热阶段,我们可以使用如下代码片段来缓存参与闪购的商品数据。/***@authorbinghe*@description秒杀前构建产品缓存示例*/publicclassSeckillCacheBuilder{privatestaticfinalStringGOODS_CACHE="seckill:goodsStock:";privateStringgetCacheKey(Stringid){returnGOODS_CACHE.concat(id);}publicvoidprepare(Stringid,inttotalCount){Stringkey=getCacheKey(id);Mapgoods=newHashMap<>();goods.put("totalCount",totalCount);goods.put("initStatus",0);goods.put("seckillCount",0);redisTemplate.opsForHash().putAll(key,goods);}}在秒杀启动的时候,首先需要判断缓存中的秒杀计数值是否小于代码中的totalCount值。如果seckillCount值确实小于totalCount值,我们将能够锁定库存。在我们的程序中,这两个步骤实际上并不是原子的。如果在分布式环境下,我们同时通过多台机器操作Redis缓存,就会出现同步问题,造成“超卖”的严重后果。在电子商务领域,有一个专业术语叫“超卖”。顾名思义:“超卖”就是售出的商品数量多于库存商品的数量,这是电子商务领域非常严重的问题。那么,我们如何解决“超卖”问题呢?Lua脚本完美解决超卖问题。我们如何解决多台机器同时操作Redis时出现的同步问题呢?更好的解决方案是使用Lua脚本。我们可以使用Lua脚本将Redis中的扣库存操作封装成一个原子操作,从而保证操作的原子性,从而解决高并发环境下的同步问题。例如,我们可以编写如下Lua脚本代码,在Redis中进行库存扣减操作。localresultFlag="0"localn=tonumber(ARGV[1])localkey=KEYS[1]localgoodsInfo=redis.call("HMGET",key,"totalCount","seckillCount")localtotal=tonumber(goodsInfo[1])localalloc=tonumber(goodsInfo[2])ifnottotalthenreturnresultFlagendiftotal>=alloc+nthenlocalret=redis.call("HINCRBY",key,"seckillCount",n)returntostring(ret)endreturnresultFlag我们可以使用下面的Java代码来调用上面的Lua脚本。publicintsecKill(Stringid,intnumber){Stringkey=getCacheKey(id);ObjectseckillCount=redisTemplate.execute(script,Arrays.asList(key),String.valueOf(number));returnInteger.valueOf(seckillCount.toString());},当我们执行秒杀活动时,可以保证操作的原子性,从而有效避免数据同步问题,有效解决“超卖”问题。本文转载自微信公众号“冰河科技”,可通过以下二维码关注。转载本文请联系冰川科技公众号。