对于苏宁易购主站而言,正常的用户购物流程囊括选品、下单、库存扣减、付款、订单状态更新、物流履约等。但是在电商业务中往往会涉及到对某些热点商品的秒杀场景。相比于正常购物流程,秒杀场景具有时效性高、并发量大、瞬时业务量极高的业务特性,往往会出现显著的分布式一致性问题。正常的业务系统不能很好地应对瞬时高并发的业务需求,因此就需要针对于秒杀场景进行相应的架构优化,抑或是设计专门用于秒杀的中台业务系统。
就秒杀业务而言,系统在瞬时会达到极高的并发量,如果与其它业务耦合,那么势必会对其它业务造成风险,因此基于安全性考虑和业务隔离原则,秒杀系统在设计上应该与其它系统充分解耦,单独部署。本文将讨论在苏宁现有的技术架构和中台组件的基础上,如何去实现一个通用型秒杀业务中台。
1-系统前端与负载层设计
图一:前端与负载层设计
鉴于秒杀业务本身的高并发特性,对用户请求进行前端分流是必不可少的一步。在系统上游就对部分用户请求进行处理,可以避免海量请求对后端服务器产生过大压力。因为用户往往在秒杀前几分钟就开始点击下单按钮,因此在秒杀开始前可以使用静态资源页面,用户请求由 CDN 直接响应,不必到达后端服务器。
此外,由于秒杀业务的高时效性特征,下单窗口基本集中在秒杀开始后的几秒钟之内。因此我们可以在秒杀前某个时间点再将下单 URL 发送给前端。为了防止有人提前拿到下单 URL 进入支付流程,可以在 URL 中加入服务端生成的随机字符串,或者对下单请求进行时间校验,单就性能而言,前一种方案校验逻辑更少,性能更优。
在秒杀开始前,需要在商品秒杀页显示活动开始倒计时,其一般情况下直接调用用户本地时钟,因此就可能存在客户端与服务端时钟不一致的情况。因此在服务端需要提供定期授时接口将服务端时钟同步给客户端,为了节省带宽可以将时间戳信息优化压缩为尽可能短的 JSON 格式,去除掉不必要的信息,减轻网络带宽压力。
参与秒杀活动的商品一般数量稀少,注定只有少数用户能够进入下单支付流程,因此可以在负载层进行相应控制。下单接口可以在苏宁应用防火墙配置流量控制,当下单请求超过阀值后熔断下单接口。而对于那些被应用防火墙放行的下单请求,由 Ngnix 集群将流量均匀负载到后端应用服务器。在服务器内存中可以定义一个请求计数器,当某台服务器受理的下单请求超过阀值后,则该服务器不再受理用户的下单信息,直接返回给用户“活动已结束”页面。
2-系统服务端设计
(1)系统服务端纵向拆分
图二:系统服务端纵向拆分
秒杀系统在纵向架构层面将主要分为三大模块:web 模块、admin 模块和 task 模块。其中 web 和 admin 模块为了兼容独占型业务将会包含一个接口路由子模块用于接口级路由策略控制,三大模块将分别部署在不同的 JBoss 集群上,通过分布式远程调用框架协同工作。
web 层:前台业务模块,该模块主要用于处理用户请求,这一模块承担最关键也是载荷最重的业务,因此必须对这一模块进行单独优化,除了服务器横向扩容外,前台模块在系统部署层面将会分为两个实例,用于展示链路和交易链路的业务分流。
admin 层:后台业务模块,本模块主要用于运维管理人员的日常数据维护,新增和管理秒杀活动的报名信息、商品信息、活动信息等。
task 层:中台定时任务模块,本模块主要负责处理来自统一调度平台的定时任务调度请求,如定时向前端授时,处理过期的活动,商品数据等。为便于集中管理,授时任务每分钟执行一次,对于需要向前端授时的活动,将单独存表,每分钟扫描需要执行授时任务的活动信息并下发时间戳。
(2)系统服务端横向拆分
图三:系统服务端横向拆分
网络层:鉴于秒杀系统本身的高并发特性,在架构设计上要尽可能践行前端处理的原则,能在前端响应的请求,就绝不放在后端。在秒杀开始前,CDN 直接响应静态页面给用户,为服务器分流大部分流量。在静态资源缓存时间设计上要精准灵活,当秒杀开始前几秒向服务器放行用户请求。如果 CDN 本身存在性能瓶颈或者后端服务器业务处理能力有限的话还可以在负载层加一套 Varish 集群作为二级缓存,进一步为后端分流。
负载层:到达苏宁内网的请求,首先经过苏宁应用防火墙。防火墙将会作为到应用服务器的第二道防线,承担过滤恶意请求,黄牛用户,黑客攻击的任务。对于下单接口,应用防火墙应当设置合理的流控策略。对于同一 IP 的用户,最多执行 10 次下单操作,超过 10 次的请求将直接拦截不再转发到后端。同时防火墙还应当在宏观层面对流量阀值进行控制,TPS 超过阀值后进行接口级熔断,防止流量过高引发应用服务器宕机。
应用层:在 CDN 和防火墙两层防线的加持下,最终到达应用服务器的请求应当只剩占比较小的一部分。有使于庞大的用户基数,这部分流量仍然不容小觑。除了校验,下单,支付外,还会有一部分商品信息相关的状态查询请求。因为前端页面已经尽可能实现了静态化,所以只需要对返回前端的商品状态数据格式进行合理的压缩,并在前端予以更新即可。为了进一步解除业务耦合,可以对展示链路和交易链路采用分集群部署,按域名分流的方案,进一步按业务分导流量,提高系统安全性和可用性。
数据层:为了有效减轻数据库压力,在数据层设计上将会采用双机房数据互补的独占型数据库设计,这一部分将在后文中详述。
(3)系统缓存设计与库存扣减方案
就秒杀业务场景而言,因为存在下单校验等前置流程,这就注定大部分用户都走不到支付这一步。该部分用户对数据库都只是发送读请求,而只有少部分下单成功的用户才会对数据库产生写请求。因此将大部分读请求放在缓存中处理将使系统性能显著提升。
图四:系统缓存设计
以库存为例,秒杀场景中库存校验和库存扣减必然在短时间内产生极高的并发量,库存缓存的设计将对系统性能产生即为重要的影响。秒杀活动开始前,运营会在后台维护商品的总库存,剩余库存和可锁库存信息,同时将信息提前预热到 Redis 缓存中。当用户通过支付校验并进入下单流程后,系统会首先操作 Redis 中的可锁库存数,同时在 DB 中写入一条锁库存记录。当用户支付成功后,扣减 Redis 中的剩余库存,同时删除 DB 中的锁库存记录。因为在这一过程中主要数据更新发生在 Redis 中,因此需要将 Redis 中的数据定时同步给 DB。系统管理员可以根据业务需求和实际的系统性能对数据同步周期进行配置。对 DB 和 Redis 的操作将放置在 TCC 分布式一致性框架中,当某一步骤失败时同时回滚 DB 和 Redis,避免数据库和缓存出现数据不一致的情形。
即便将热点数据操作都放置在 Redis 中,仍然有可能产生活动超卖的情形。比如某商品只剩一件时,同时有多个用户提交下单请求。因为库存剩余一件,因此每个用户都通过库存校验并进入下单流程,进而引发商品超售,对此我们可以采用以下几种解决方案:
图五:Redis 悲观锁机制下的库存扣减方案
方案一,采用 Redis 的悲观锁机制,当一个线程访问库存数据时拒绝其它线程的访问,这样显然可以解决多个用户同时通过下单引发超售的问题。但是这一方案会显著拖累系统性能,尤其是秒杀场景下并发量极高,如果每个用户都只能等到其他用户锁释放之后才能访问库存数据,那么有一部分用户可能永远都没有机会进入下单流程。
图六:FIFO 队列机制下的库存扣减方案
方案二,采用 FIFO 队列进行多线程转单线程处理,用一个先进先出的队列使用户请求实现序列化,这就保证每个用户请求都将基于先后顺序到达 Redis,进而有效避免了某些用户永远访问不到库存数据的情况。不过这一方案也存在弊端,因为这一中间队列显然会是一个入多出少的队列,那么如果队列本身内存冗余不够,那么海量用户请求有可能瞬间将队列挤爆,而中间队列所需要的资源也将进一步提升系统开销。
图七:Redis 乐观锁机制下的库存扣减方案
方案三,采用 Redis 的乐观锁机制。乐观锁与悲观锁的区别在于,当多个线程同时访问某个资源时,乐观锁并不会阻滞未得到锁的线程对资源的访问。但是更新数据是,只有版本号符合的请求才能够成功更新缓存数据。一言以蔽之,乐观锁是一种只限制更新,不限制查询的加锁机制。我们此处以双线程并发场景为例:
当库存为 1 时,两个用户同时进入库存校验流程。此时用户 A 先访问库存数据,并拿到库存为 1,当前版本号为 10,通过校验后,用户 A 进入下单流程。此时用户 B 访问库存数据,库存为 1,当前版本号为 10,并进入下单流程。之后用户 A 下单成功,库存信息更新为 0,版本号置为 11。此时用户 B 尝试修改库存信息,但拿到版本号信息为 11,版本不符合,放弃更新库存,回滚相关操作,并向用户返回秒杀结束页面。这一机制将能够很好实现库存数据在高并发场景下的线程安全问题,有效规避商品超售的情况。虽然这一方案会增加 CPU 开销,但是相较于前两种方案,在整体设计上更为均衡,没有明显的短板,是最为适合的一种库存缓存设计方案。
(4)系统数据库设计
鉴于秒杀系统对安全性和可用性的要求,在数据层设计上要尽可能细化和深化,分割业务数据,尽量避免一刀切的情况。因此在秒杀系统数据层将采用 8+1 型独占数据库设计。
图八:系统数据库设计
首先我们按照作用域将业务数据切分为全局数据和用户数据,全局作用的数据,比如活动信息,商品信息,价格信息,所有用户看到的都是一样的。而用户数据则是和用户相关的差异性数据,比如用户个人的订单记录等,更具体的说就是带有 memberId 字段信息的数据。在这一数据分类的基础上,进一步采用 8+1 型数据库设计。所谓 8+1 指的是 8 个分库组 +1 个单库的设计,8 个分库组只保存带有 memberId 的用户数据,并通过 MyCat 中间件按照 memberId 取模分片,而一个单库中只保存活动信息,商品信息等全局数据,8 个分库组采用独占型多活部署,而 1 个单库采用竞争型多活部署,这一部分将在后文中详细解释。
1-系统多活部署方案
就秒杀业务而言,系统的安全性和可用性无疑是第一考量,因此多活部署几乎是一个必然的选择。苏宁秒杀中台系统采用同城双机房部署方案,一方面可以对用户请求持续分流,同时也可以规避单点部署策略在意外因素下整机房宕机的风险,保证业务的持续可用性。
图九:系统多活部署方案
在网络层,CDN 首先对公网流量进行初次划拨,正常情况下每个机房负载 1/2 流量。
在负载层,对于 CDN 调拨过来的公网流量,经过高可用 VIP 到达防火墙后,防火墙按分片策略二次切分。当 CDN 与防火墙的流量切分策略一致时,防火墙不会进行额外的流量划拨(不带分片路由信息的请求除外)。当 CDN 和防火墙切分策略不一致时,防火墙会进行补偿性拨分,最终实际的流量调拨情况将遵循防火墙层面的拨分策略。
防火墙流量调度以系统为基本单元,不同系统可根据实际情况配置分拨策略。除了对 CDN 初次调拨进行补偿外,防火墙还可以承担在 CDN 失效后的替代方案,保证不会因为 CDN 失效而导致单机房负载压力过大。
在应用层,所有 HTTP 请求,经由接口路由子模块封装后,进一步转发到服务层和数据层。根据接口作用域的不同,采用主机房路由和分片路由的复合型策略来精准调度请求。对于涉及到全局数据的请求,比如活动新增,商品报名,库存查询,库存更新等,采用主机房路由策略路。而对于用户数据相关请求,则采取分片路由策略调拨到对应的独占库。苏宁分布式调用中台支持根据接口参数切分路由,这一规则可根据独占库设置自行定制。
在数据层面,采用与应用层一致的独占型加竞争型复合部署策略,所有全局数据的读写请求均路由到机房 A 的单库,并拓扑复制给机房 B。而用户数据则采用交叉互备的分库设计,机房 A 编号 1,2,3,4 的分库为主库,编号 5,6,7,8 的库为从库,机房 B 反之,数据通过数据库服务中台进行拓扑复制。
2-单机房宕机场景下的降级方案
当发生单机房故障时(以机房 A 宕机为例):
图十:单机房宕机场景下的降级方案
在网络层,由 CDN 统一控制将所有回源请求分拨到机房 B。
在负载层,此时机房 A 已经没有来自公网的请求,但是可能仍然会有部分内网请求,因此需要修改内网 DNS 解析值,完成内网流量的调拨。同时需要在应用防火墙层面切换流量分拨策略,防止仍有流量被防火墙分拨到机房 A。
在应用层面,需要将分布式调用中台的主机房策略修改为机房 B,同时将分片路由策略修改为“机房 A 流量:机房 B 流量”为“0:1”,将所有请求调度到机房 B。
在数据层面,需要将机房 B 单库和编号 1,2,3,4 的分库置为主库,修改拓扑复制关系。
至此,完成了机房 A 宕机情况下的降级,机房 B 将负载所有业务请求。
就秒杀系统的设计而言,关键是要紧抓几条设计原则,一是前端过滤,将大部分请求截流在上游缓存,减轻服务端压力。二是高并发安全,在瞬时极高并发的情况下既要保证系统可用性,又要避免出现超售场景等业务异常。三是多活容灾,冗余部署,在系统风险较大的情况下要尽可能异地分流,均摊风险,提高系统抗灾容灾能力。只要抓住这几点,那么就掌握了秒杀系统设计的核心奥义。