文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

秒杀系统架构解析:应对高并发的艺术

2024-11-29 20:38

关注

秒杀活动是大家耳熟能详的购物方式,它伴随着一个极端的场景:极度的商品供不应求。这里面既有需求真实且迫切的用户,也有试图从中牟利的黄牛。反应到系统应对的挑战上,就是相较于以往千倍万倍的用户规模,可能是真人可能是机器人,在同一瞬间对系统发起冲击,需要海量的计算资源才能支撑。

对于各大电商平台而言,爆款运营和促销活动的日常化已成为常态,而支撑这些的秒杀系统自然是不可或缺的一环。同时,秒杀活动的巨大流量就像一头洪荒之兽,若控制不当,可能会冲击整个交易体系。因此,秒杀系统在交易体系中便扮演着至关重要的角色。

从个人角度来看,秒杀系统的设计套路往往适用于其他高并发场景,具有较高的借鉴价值。同时,其特殊的挑战和需求,需要架构师在设计中权衡考量,这也有助于培养个人在权衡取舍方面的能力。

无损的技术方案

应对高并发就好比应对水患:

因为后续方案会围绕请求经过的多个层级展开,所以在介绍方案之前,我们需要先了解一个基本情况:一个请求打到服务器的基本链路为:DNS->网关->前端/后端,其中流量峰值也应该逐层减少。如图:

1.系统隔离

分布式系统由一个个发布单元共同组成。独立的发布单元可以按需做容量伸缩,也可以在发生故障时及时做故障隔离,以保证不会出现个别服务故障导致整个系统不可用的情况。

秒杀活动因为其高峰值的特性,所以一般我们会把它隔离出来,成一个独立的秒杀系统(常规服务都是按领域特性做纵切,但这里我们按品类做横切,带有秒杀活动标的商品将会分流到独立的秒杀系统集群)。

考虑到交易系统体量是很大的,如果为秒杀品类把整个交易系统都复制一份,那成本就太大了。所以我们把隔离区分为物理隔离和逻辑隔离:

画成部署架构如下:

首先,我们采用独立的秒杀域名和nginx集群(物理隔离)。这样:

接着,我们将商详页和下单页独立部署(前端+BFF,物理隔离)。基于秒杀活动的玩法特征,海量用户在活动快开始时会反复刷新商详页,在活动开始时又会瞬时并发到访问下单页,所以这两个页面都是承受流量冲击的大头,需要隔离开(非功能性需求)。同时因为秒杀活动的特性,商品属于极端供不应求的场景,卖家占优势,所以可以做服务降级,以降低计算资源消耗、提高性能(定制化逻辑)。比如:

最后,商品购买成功还需要依赖,订单系统创建订单,库存系统扣减商品库存,结算系统确认支付等等步骤。到达这里流量相对已经比较平稳,并且逻辑上没有什么定制化诉求(压力小了,没必要围绕性能做定制化了),所以就采用逻辑隔离复用原交易系统的集群。逻辑隔离有两种实现思路:

为什么集群节点少了,出现故障发生过载的可能就提高了?就好比公里原本4条道能并行4辆车,现在给按车辆类型分成了机动车和公交车专用,机动车道2条。如果其中1条机动车道发生车祸,原本分散在2条道上的车流就要汇聚在1条道,原本顺畅的通行可能立马就开始堵车了。

2.多级缓存

多级缓存,无非就是在系统的多个层级进行数据缓存,以提高响应效率,这是高并发架构中最常用的方案之一。

(1) DNS层

一般我们会将静态资源挂到CDN上,借助CDN来分流和提高响应效率。以秒杀系统为例,就是将秒杀前端系统的商详页和下单页缓存到CDN网络上。一个借助CDN的用户请求链路如下:

如果用户终端有页面缓存就走终端本地缓存,没有就请求远端CDN的域名(静态资源走CDN域名),请求来到DNS调度的节点,调度一个最近的CDN节点,如果该CDN节点有页面缓存则返回,没有则向缘站发起溯源,请求就会走普通链路过秒杀系统ng到秒杀系统前端。

(2) 网关层

网关这个有多种组合情况,最简单的就是一个接入层网关加一个应用层网关,比如:ISV(四层)-> Nginx(七层)。以这个为例,这里的缓存优化主要看接入层的负载均衡算法和应用层的本地缓存和集中内存缓存。

之所以说缓存还要提负载均衡算法,是因为节点的本地缓存的有效性和负载均衡算法是强绑定的。常用的负载均衡算法有轮询(也叫取模)和一致性哈希。轮询可以让请求分发更均衡,但同个缓存key的请求不一定会路由到同个应用层Nginx上,Nginx的本地缓存命中率低。一致性哈希可以让同个缓存key路由到同个应用层Nginx上,Nginx的本地缓存命中率高,但其请求分发不均衡容易出现单机热点问题。有一种做法是设置一个阈值,当单节点请求超过阈值时改为轮询,可以算是自适应性负载均衡的变种。但这种基于阈值判断的做法在应对真正的高并发时效果并不理想。

所以想要运用本地缓存强依赖业务运营,需要对每个热点商品key有较为准确的流量预估,并人为的组合这些商品key,进而控制流量均匀的落到每个应用层Nginx上(其实就是数据分片,然后每片数据流量一致)。这非常困难,所以笔者认为,还是采用轮询加集中内存缓存比较简单有效。

一个从接入层开始带有本地缓存和集中内存缓存的请求链路如下:

(3) 服务层

应用层ngnix->秒杀系统BFF->订单服务,其实两两组合和网关层是一样的场景。应用层ngnix基于ngnix的负载均衡转发请求到秒杀系统BFF,秒杀系统BFF基于RPC框架的负载均衡转发请求到订单服务。都面临着负载均衡策略选择和是否启用本地缓存的问题。不一样的点只是缓存的粒度和启用缓存的技术栈选择。

(4) 多级缓存失效

多级缓存因为缓存分散到多个层级,所以很难用单一的技术栈来应对缓存失效的问题,但都等到缓存过期,这种更新时延较长又不一定能被业务接受。所以这里就再提下这个话题。有一个做法是基于DB的binlog监听,各层监听自己相关的binlog信息,在发生缓存被变更的情况时,及时让集成内存的缓存失效。本地缓存在这里还有个缺陷,就是缓存失效时需要广播到所有节点,让每个节点都失效,对于频繁变更的热key就可能产生消息风暴。

3.无损消峰

秒杀活动的特点是瞬时高峰的流量,就像一座高耸的尖塔,短时间内涌入大量请求。为这个峰值准备对应的服务集群,首先成本太高,接着单纯的水平扩展也不一定能做到(分布式架构存在量变引起质变的问题,资源扩展到一定量级,原先的技术方案整个就不适用了。比如,当集群节点太多,服务注册发现可能会有消息风暴;出入口的带宽出现瓶颈,需要在部署上分流)。更别说这个峰值也不受控制,想要高枕无忧就会有很高的冗余浪费。

所以一般我们会采用消峰的方式:

这里我们先聊下无损消峰,有损放后边谈。

(1) MQ异步消费

MQ依赖三个特性可以做到平滑的最终一致,分别是消息堆积,匀速消费和至少成功一次:

以秒杀系统BFF下单操作向订单服务创建订单为例。如果没有消息队列(MQ),同时有100W个创建请求,订单系统就必须承担100W个并行连接的压力。但是,如果使用了MQ,那么100W个创建请求的压力将全部转移到MQ服务端,订单系统只需要维持64个并行连接,以稳定地消费MQ服务端的消息。

这样一来,订单系统的集群规模就可以大大减小,而且更重要的是,系统的稳定性得到了保障。由于并行连接数的减少,资源竞争也会降低,整体响应效率也会提高,就像在食堂排队打饭一样,有序排队比乱抢效率更高。但是,用户体验可能会受到影响,因为点击抢购后可能会收到排队提示(其实就是友好提示),需要延迟几十秒甚至几分钟才能收到抢购结果。

(2) 验证码问答题

引入验证码问答题其实有两层好处,一层是消峰,用户0.5秒内并发的下单事件,因为个人的手速差异,被平滑的分散到几秒甚至几十秒中;另外一层是防刷,提高机器作弊的成本。

① 验证码

基本实现步骤如下:

但这样其实是可以用暴力破解的,比如,用机器仿照一个用户发起10W个请求携带不同的6位随机字符。所以校验验证码时可以使用 GETDEL ,让验证码校验无论对错都让验证码失效。

② 问答题

基本实现思路和验证码几乎一样。差别在于,问答题的题库要提前生成,请求到来时从题库中拿到一组问题和答案。然后把答案存redis,问题塞到图片里返回给用户。

验证码和问答题具有很好的消峰效果。特别是问答题,想要提高消峰效果只要提高问题难度就行,例如,笔者曾经在12306上连续错了十几次问答题。但是这也是用户体验有损的,例如,虽然笔者当初未能成功抢到票而感到沮丧,但这魔性的题库依然把笔者成功逗笑。

无损消峰,无损了流量,但损失了用户体验。现如今技术水平在不断进步,解决方法在增多,这些有损用户体验的技术方案可能都会慢慢退出历史舞台,就像淘宝取消618预售。

4.库存扣减

我们知道,用户购买商品需要扣减库存,扣减库存需要查询库存是否足够,足够就占用库存,不够则返回库存不足(这里不区分库存可用、占用、已消耗等状态,统一成扣减库存数量,简化场景)。

在并发场景,如果查询库存和扣减库存不具备原子性,就有可能出现超卖,而高并发场景超卖的出现概率会增高,超卖的数额也会增高。处理超卖问题是件麻烦事,一方面,系统全链路刷数会很麻烦(多团队协作),客服外呼也会有额外成本。另一方面,也是最主要的原因,客户抢到了订单又被取消,会严重影响客户体验,甚至引发客诉产生公关危机。

(1) 实现逻辑

业内常用的方案就是使用redis+lua,借助redis单线程执行+lua脚本中的逻辑可以在一次执行中顺序完成的特性达到原子性(原子性其实不大准确,叫排它性可能更准确些,因为这里不具备回滚动作,异常情况需要自己回滚)。

lua脚本基本实现大致如下:

-- 获取库存缓存key KYES[1] = hot_{itemCode-skuCode}_stock
local hot_item_stock = KYES[1]
-- 获取剩余库存数量
local stock = tonumber(redis.call('get', hot_item_stock)) 
-- 购买数量
local buy_qty = tonumber(ARGV[1])
-- 如果库存小于购买数量 则返回 1, 表达库存不足
if stock < buy_qty thenreturn1end
-- 库存足够 
-- 更新库存数量 
stock = stock - buy_qty
redis.call('set', hot_item_stock, tostring(stock))
-- 扣减成功 则返回 2, 表达库存扣减成功
return2end

但这个脚本具备一些问题:

结合以上问题,我们对方案做些增强。

增强后的lua脚本如下:

-- 获取库存扣减记录缓存key KYES[2] = hot_{itemCode-skuCode}_deduction_history
-- 使用 Redis Cluster hash tag 保证 stock 和 history 在同个槽
local hot_deduction_history = KYES[2]
-- 请求幂等判断,存在返回0, 表达已扣减过库存
local exist = redis.call('hexists', hot_deduction_history, ARGV[2])
if exist = 1thenreturn0end

-- 获取库存缓存key KYES[1] = hot_{itemCode-skuCode}_stock
local hot_item_stock = KYES[1]
-- 获取剩余库存数量
local stock = tonumber(redis.call('get', hot_item_stock)) 
-- 购买数量
local buy_qty = tonumber(ARGV[1])
-- 如果库存小于购买数量 则返回 1, 表达库存不足
if stock < buy_qty thenreturn1end
-- 库存足够 
-- 1.更新库存数量 
-- 2.插入扣减记录 ARGV[2] = ${扣减请求唯一key}-${扣减类型} 值为 buy_qty
stock = stock - buy_qty
redis.call('set', hot_item_stock, tostring(stock))
redis.call('hset', hot_deduction_history, ARGV[2], buy_qty)
-- 如果剩余库存等于0 则返回 2, 表达库存已为0
if stock = 0thenreturn2end
-- 剩余库存不为0 返回 3 表达还有剩余库存
return3end

但是以上逻辑依旧有漏洞,比如(消息乱序消费),订单扣减库存超时成功触发了重新扣减库存,但同时订单取消触发了库存扣减回滚,回滚逻辑先成功,超时成功的重新扣减库存就会成为脏数据留在redis里。

处理方案有两种,一种是追加对账,定期校验hot_deduction_history中数据对应单据的状态,对于已经取消的单据追加一次回滚请求,存在时延(业务不一定接受)以及额外计算资源开销。另一种,是使用有序消息,让扣减库存和回滚库存都走同一个MQ topic的有序队列,借助MQ消息的有序性保证回滚动作一定在扣减动作后面执行,但有序串行必然带来性能下降。

(2) 高可用

存在redis终究是内存,一旦服务中断,数据就消失的干干净净。所以需要追加保护数据不丢失的方案。

运用redis部署的高可用方案来实现,方案如下:

定期归档冷数据。定期 + 库存为0触发redis数据往DB同步,流程如下:

CDC分发数据时,秒杀商品,hot_deduction_history的数据量不高,可以一次全量同步。但如果是普通大促商品,就需要再追加一个map动作分批处理,以保证每次执行CDC的数据量恒定,不至于一次性数据量太大出现OOM。具体代码如下:


public void distribute(String stockKey){
    final String historyKey = StrUtil.format("hot_{}_deduction_history", stockKey);
    // 获取指定库存key 所有扣减记录的key(一般会采用分页获取,防止数据量太多,内存波动,这里偷懒下)
    final List keys  = RedisUtil.hkeys(historyKey, stockKey);
    // 以100为大小,分片所有记录 key
    final List> splitKeys = CollUtil.split(keys, 100);
    // 将集合分发给各个节点执行
    map(historyKey, splitKeys);
}

public void mapExec(String historyKey, List stockKeys){
    // 获取指定库存key 指定扣减记录的map
    final Map keys = RedisUtil.HmgetToMap(historyKey, stockKeys);
    keys.entrySet()
        .stream()
        .map(stockRecordFactory::of)
        .forEach(stockRecord ->{
            //(幂等+去重)扣减+保存记录
            stockConsumer.exec(stockRecord);
            //删除redis中的 key 释放空间
            RedisUtil.hdel(historyKey, stockRecord.getRecordRedisKey());
        });
}

(3) 为什么不走DB

商品库存数据在DB最终会落到单库单表的一行数据上。无法通过分库分表提高请求的并行度。而在单节点的场景,数据库的吞吐远不如redis。最基础的原因:IO效率不是一个量级,DB是磁盘操作,而且还可能要多次读盘,redis是一步到位的内存操作。

同时,一般DB都是提交读隔离级别,为了保证原子性,执行库存扣减,得加锁,无论悲观还是乐观。这不仅性能差(抢不到锁要等待),而且因为非公平竞争,容易出现线程饥饿的问题。而redis是单线程操作,不存在共享变量竞争的问题。

有一些优化思路,比如,合并扣减,走批降低请求的并行连接数。但伴随而来的是集单的时延,以及按库分批的诉求;还有拆库存行,商品A100个库存拆成2行商品A50库存,然后扣减时分发请求,以此提高并行连接数(多行可落在不同库来提高并行连接数)。

但伴随而来的是复杂的库存行拆分管理(把什么库存行在什么时候拆分到哪些库),以及部分库存行超卖的问题(加锁优化就又串行了,不加总量还有库存,个别库存行不足是允许一定系数超卖还是返回库存不足就是一个要决策的问题)。

部分头部电商还是采用弱缓存抗读(非库存不足,不实时更新),DB抗写的方案。这个的前提在于,通过一系列技术方案,流量落到库存已经相对低且平滑了(扛得住,不用再自己实现操作原子性)。

有损的技术方案

秒杀活动有极高的瞬时流量,但仅有极少数流量可以请求成功。这为我们绕开海量计算资源采用一些特定方案达到同样的活动效果提供了空间。因为绝大部分流量都是要请求失败的,是真实抢购库存失败还是被规则过滤掉失败,都一样是失败,对于参与者来说是一样的活动体验。所以我们不用耿直地去承接所有流量,变成用一系列过滤手段,公平公正地过滤掉绝大部分流量,仅保留有限的优质流量可以请求到服务群即可。

基本思路就是,通过业务干预阻止无效流量,通过有损消峰丢弃超荷流量,通过防刷风控拦截非法流量,最终留给下游优质且少量的流量。如图:

1.业务干预

(1) 提报

借助提报系统,商户开展高压力的活动时都提早报备接受审批和调控。这样,可以提早知道商品、价格、活动开始时间、面向什么地域、预计参与人数、会员要求等等信息。帮助预估出大致流量,支撑编排活动调整活动组合,错位压力(也能不断保持热点),平滑流量,调整计算机资源应对高并发。设置参与门槛,阻挡非目标人群参与。

(2) 预约

借助预约系统,对活动做预热、帮助预估大致参与活动的人数帮助评估计算资源容量。引入风控规则,提早过滤刷子人群。采用发放参与资格(类似游戏预约测试资格和发放测试资格),控制参与人数大小。结合提报系统的参数,过滤非目标人群,并尽可能提高参与人员离散度(比如参与证书1W,华南华北华东华西各2500)(假设中奖的人影响范围是一个圆,人群集中这个圆就有交集,影响范围就会减少,所以会希望离散些。但也不排除有故意集中发放创造热点的营销手段)。

(3) 会员

借助会员系统,筛选出优质用户。愿意购买会员的用户相对粘性就比较高(可以借助会员体系做一些提高用户粘性的举措,比如信用分,积分,会员等级,优惠卷等等)。同时会员用户的规模也能帮助预估活动参与流量。

(4) 限购

借助限购系统,比如加强特定区域市场覆盖,从地区限制,仅华东可以参与购买;舆情公关防控,从用户限制,自家员工禁止购买(不能既做裁判也下场踢球);提高离散度,从商品限制,一次只能购买一件,一人一个月只能购买一次。

2.有损消峰

前边讲了分流的无损消峰,这里我们讲直接去头的有损消峰。常规方案就是采用限流降级手段,这也是应对高并发必用的手段。

限流是系统自我保护的最底层手段。再厉害的系统,总有其流量承载的上限,一旦流量突破这个上限,就会引起实例宕机,进而发生系统雪崩,带来灾难性后果。所以达到这个流量上限后,横竖都无法再响应请求,于是直接抛弃这部分请求,保证有限的流量能够正常交互便成了最优解。

(1) 分层限流

我们知道一个请求会走过多个层级,最终才能到达响应请求的服务节点。假设一个请求会走过网关->单服务集群->单服务节点->单接口这几个层级,每个层级考虑承载上限的维度和容量都不一样,所以一般都会有独立的限流规则。

网关一般是以一个路由配置或者一组api的吞吐指标进行限流,具体配置大致如下:

单服务集群一般是以整个集群所有API和所有服务节点为吞吐指标进行限流(不常用),具体配置大致如下:

(2) 热点参数限流

除开分层的限流,还有参数维度的限流。

比如,基于IP地址的吞吐量指标做限流。这个维度,对公司用户很不友好。因为一般公司就几个IP出口,大家都连着wifi,很容易就触发限流。所以,一般参与秒杀活动时还是切换回自己的4G网,wifi再快也架不住被限流。

比如,基于热点商品的吞吐量指标做限流。在没有商品维度限流的情况下,假设秒杀下单接口的集群并发限流为100,同一时间参与秒杀活动的商品有10个,商品A在一瞬间就抢占了80并发连接数,剩下的9商品就只能分摊20并发连接数,这会严重影响其活动体验。

限流的口径有很多,幸运的是它们可以组合使用。这样就能够确保服务在各种场景下都有一个可靠的底层防护。

3.防刷风控

秒杀活动中的供需失衡,也会吸引黑产用户借助非常规手段抢购。比如,通过物理或软件的按键精灵,用比正常用户更快的速度抢购;通过分析接口模仿下单请求,同时发起千万个请求,用比正常用户更高的频次抢购。这些行为不仅破坏了活动公平性,威胁到普惠和离散诉求,还对系统的高并发峰值带来了新的量级的挑战,严重影响活动的健康发展。

(1) 防刷

从更快的速度抢购的角度很难区分是正常用户还是黑产用户,但更高频次是很好被捕捉的,毕竟正常人总不能1秒钟千万次的点击吧。所以我们可以针对高频次这个场景构建一些防刷手段。

(2) 基于userID限流

我们可以采用热点参数限流的方式,基于用户ID的吞吐量指标做限流。例如,规定每个用户ID每秒仅能发起两次请求。并且,我们应将此限流措施尽可能地置于请求链路的上游,如应用网关上,以便在最外层就隔离掉主要流量,从而减少计算资源的浪费。这样的限流目的与常规的有损消峰略有所不同,它不仅旨在保护服务的稳定性,也在防止黑产用户的攻击,以此维护活动的公平性。

(3) 基于黑名单限流

依旧是采用热点参数限流的方式。但不再是看吞吐量指标,而是看是否命中黑名单来实现限流。黑名单里面的名单,一方面靠一些内部行为分析,比如发现某个用户每秒可以请求千万次来识别(就像游戏里面发现外挂封号)。另一方面就是靠外部风控数据的导入了。

(4) 风控

风控在系统防护中占据重要地位,然而其建立却颇为艰难。健全的风控体系需要依赖大量数据,并通过实际业务场景的严苛考验。简单来说,风控就像绘制用户画像,需要收集用户的静态信息,如身份证、IP、设备号(如同一设备或同一IP的多账户并行抢购)、信贷记录、社保信息、工作信息等多维度信息。同时,还要关注用户的动态信息,如是否存在每秒发起千万次请求的情况,或者用户是否只在特定活动中才呈现活跃等。

小结

高并发的主要挑战在于瞬时激增的大量用户请求需要同时使用大量的计算资源。为了解决这一挑战,互联网应用选用了水平伸缩的发展路线,即分布式架构,通过不断横向扩展集群节点来增加计算能力。而我们列举的方案大部分都直接或间接依赖于分布式架构设计,所以掌握分布式架构其实就等同于掌握高并发系统设计的核心。

优秀的架构更注重权衡,而不是追求极端。应该从业务场景和企业实际情况出发,寻找合适且投资回报率高的方案,而非过度设计或追求最极致的解决方案。更不应出于恐惧落后或投机取巧的心态,盲目追求所谓的"最佳实践"。

来源:Thoughtworks洞见内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯