“秒杀”系统的技术分析

“秒杀”的业务和行业背景

2019年阿里巴巴双11,订单峰值达到54.4 万笔/秒,是2009年第一次双十一的1360倍
2019年京东618,18日0点-1点接待量峰值达到46.9万次
2020.4.20我司在台州发政府消费券,抢券峰值14179/s

业务特点:

  1. 瞬时流量突增:业务促销活动在特定时间开启,大量用户请求等待活动开启后瞬间涌入
  2. 抢购脚本带来压力:灰产通过抢购脚本薅羊毛,一方面带来额外的系统压力,另一方面影响抢购活动公平性
  3. DDOS趁虚而入:可能存在竞对在活动期间使用DDOS攻击网站
  4. 热点集中:少量优惠力度大的商品成为抢购热点,比如小米华为手机,10万台手机在1分钟内售罄
  5. 未知热点:部门商家和商品可能并不在预计的热点范围内,但是可能突然成为爆款
  6. 数据一致性要求高:一方面优惠商品库存有限,超卖会给商家带来损失;另一方面用户抢到商品后如果不能支付,则会引起客诉

“秒杀”系统的技术挑战与架构优化

接入层加速与限流

  1. 前端动静分离,把90%的静态数据缓存在用户端或者CDN上,当真正秒杀时用户只需要点击特殊的按钮“刷新抢宝”即可,而不需要刷新整个页面,这样只向服务端请求很少的有效数据,而不需要重复请求大量静态数据。
  2. 网站负载均衡层或业务网关层需要能够对访问请求按用户粒度进行流量限制,以降低抢购脚本对系统带来的压力。
  3. 在安全方面,通过高防CDN或高防IP,降低DDOS攻击的影响。
  4. 在业务方面,通过引入答题环节,将突然涌入的压力平滑到3s左右的时间段内。

业务层隔离、限流与弹性伸缩

  1. 通过对后台系统的微服务化改造和数据库层面的拆分(SOA),实现微服务之间的隔离,避免相互影响,实现不同核心服务相互独立的容量评估和紧急情况下的限流熔断。
  2. 在活动进行过程中,如果业务流量过大,业务需要紧急扩容,底层容器服务需要能够支持分钟内的快速弹性扩容,因此容器调度、镜像分发、服务发现的效率都需要相应的进行提升和优化。
  3. 在处理业务弹性扩容的过程中,还有一点也需要考虑到,即数据库的连接数风险,在没有类似dbproxy这样的服务进行连接池收敛的情况下,业务的弹性扩容能力需要考虑数据库的对连接数的承载力。

数据读取加速

在抢购业务中,对商品库存数量的更改主要通过数据库进行,但是由于读取流量过大,一般需要通过两级缓存的机制进行优化,即:Java服务进程内本地缓存-->分布式缓存服务-->数据库服务。

由于库存数据更新非常频繁,再加上后面要提到的库存拆分设计,缓存一致性在系统设计时是需要折中考虑的,库存数据的缓存往往被设计为延后定时刷新,而不是在每次成功扣减库存后去刷新,用户可能会看到商品仍由剩余库存,但是实际下单时返回售罄;更进一步甚至可以像12306那样只缓存“有余票”或“没有余票”两个状态。

数据库并发扣减库存

先简单介绍扣减库存在数据库上操作的例子,SQL可以抽象为这种形式:“update stock_table set inventory=inventory-1 where item_id=xxxx and inventory>0;” 即指定商品ID(item_id),并判断库存充足情况下,扣减库存,隔离级别大于等于ReadCommitted的关系数据库可以保证这条语句执行的原子性。在处理对少量热点商品高并发扣减库存的业务时,关系数据库都会面临如下几个难题:

  1. 并发冲突代价:当前主流的关系数据库,无论是老牌商业产品Oracle、流行开源项目MySQL、还是国产开源新秀TiDB,它们都使用经典的WAL(write ahead log)方式来实现数据的持久化,即在事务提交时保证被更新的数据(WAL)写到硬盘后,才能给客户端返回成功。而硬盘写入的latency比内存操作大几个数量级,为了优化性能,大家都引入了组提交机制(group commit),即将同时提交的多个事务的数据,合并为一条WAL写入硬盘,对于每个事务来说,latency还是一次硬盘写入IO的耗时,但是对于整个系统来说,可以将TPS从原来与硬盘IOPS相近的水平,提升几倍甚至几十倍。

    但是并不是所有的并发事务都能够合并成组提交,如果两个事务之间存在冲突(比如并发修改同一行),那么无论是基于悲观锁进行并发控制的Oracle/MySQL,还是基于乐观锁进行并发控制的TiDB,对于相互冲突的事务,他们本质上的处理方式,都只能是排队执行,即后一个事务要等前一个事务提交完成后才能执行。使用扣减库存的SQL举例如下:

    找到并对商品记录加锁 --> 判断库存余额 --> 修改库存余额 --> 提交WAL写盘 --> 释放锁

    针对同一个热点商品的多个并发事务,在上面加锁和释放锁之间的这段操作是无法做到并发执行的,因此在不引入任何优化的情况下,在同一个数据表中针对一个热点商品扣减库存TPS的天花板就是硬盘的IOPS,而在大量并发事务都在争抢行锁的情况下,情况会进一步恶化,较高的系统负载,叠加上锁冲突检测等额外代价,可能造成系统的整体吞吐降低至个位数。

  2. 可能存在超卖风险:考虑到上述并发事务提交WAL的问题,在实际系统上,为了降低写WAL的latency,保证系统吞吐,一般会将写硬盘和同步备机调整为异步方式,而这个调整又会带来新的问题,即主库宕机情况下的数据不一致,主库重启或者备库切换为主库后,可能存在宕机前部分WAL没有被持久化的风险,反映到扣减库存的逻辑上就是已经被扣减的库存又被恢复了回来,最终在业务上形成超卖。2012年阿里双11由于商品超卖给商家的赔付,产生了较大的经济损失。

  3. 复杂事务恶化冲突:上面所举的例子是单行事务的update,行锁的临界区(“找到并对商品记录加锁 --> 判断库存余额 --> 修改库存余额 --> 提交WAL写盘 --> 释放锁”)都在数据库处理的边界之内,但是在某些复杂场景下,在库存扣减的事务中可能存在多条语句的情况,比如扣减库存(update)+生成订单(insert)在一个事务内完成,这种情况下行锁的临界区扩大到受业务网络交互的影响,整体冲突加剧、吞吐进一步降低。

数据库层面对于并发扣减库存的优化思路:

  1. 库存拆分:在业务层将同一个商品的库存记录拆分为多行甚至多个表里面去,降低在同一行或同一个数据表上的并发冲突,比如针对业务请求中的userid计算hash取模后确定要扣减哪个库存记录。这个方案能够很大程度的降低并发冲突,不需要数据库内核配合做修改,是行业内的主流方案,它的问题是:同一个商品不同库存记录的扣减速度不均衡(热点商品往往在几十秒内被强光,这个不均衡问题并不严重),给总库余额计数带来的复杂度,业务需要预先感知热点商品并且针对性的进行库存拆分。

  2. 批处理:通过修改数据库内核代码,将相互冲突的事务,合并为一个事务或者一次WAL组提交,达到批处理的效果,AliSQL的做法是在MySQL server层识别这类update语句,将它们解析后合并成为一条SQL再执行,比如10个扣减库存语句,合并为一个扣减库存的语句一次性扣减数量为10,这个做法的优势是对数据库内核代码修改不多、复杂度可控,局限是只能在特定语句的基础上进行优化,没有比较好的普适性;OceanBase则选择了另外一个优化思路,即提前释放锁,在事务确定要提交(比如单行事务执行成功或者用户在事务最后一条语句上标记“Commit on success”)的情况下,不需要等WAL同步,而先把事务涉及的行锁先释放掉,这样可以使得其他并发事务能够进入临界区,最终效果可以达到对同一行修改的多个并发事务的WAL,可能在一次组提交内完成。

  3. 请求排队:即使我们在数据库内核层面引入了上述“批处理”的优化,对热点行的并发扣减库存业务仍然会面临多个事务并发争抢进入临界区的情况,并发等锁的事务会占据宝贵的连接和线程资源,系统负载可能持续恶化;这里的一个优化思路是,在数据库内核层面将并发扣减同一个商品库存的事务排到一个队列处理(比如让用户在SQL注释上标记这个事务划分队列的依据,一般来说可以用商品ID取模),降低并发冲突,减少对连接和线程资源的占用,降低系统负载。这个优化目前已经在AliSQL上开源,效果还是比较明显的。

  4. 存储过程或类似命令:对于一个事务里要执行多条语句的情况,会造成临界区的扩大,严重影响并发度,一个最有效的方案是数据库层面支持存储过程,多个语句放在存储过程里一次性提交给数据库;但是MySQL并不支持存储过程,因此可以针对具体场景引入一些类似存储过程的优化,当然核心仍然是将一个事务中的多条语句合并,实现与数据库在一次交互中完成。比如AliSQL的Commit on success,可以用在扣减库存+生成订单的场景中,即开启事务后先执行几乎没有并发冲突的insert语句生成订单,然后带上Commit on success标记执行扣减库存命令,库存扣减成功后就立即提交事务,不需要等待客户端再发commit,这样一来热点行冲突的临界区仍然与单行事务一样了。再比如OceanBase引入的... when row_affected()语法,允许在一个语句内先执行update,然后根据受影响的函数来决定事务执行其他修改,这已经很像存储过程了。

减库存与生单一致性

在上面的例子中,扣减库存与生成订单的事务是在同一个数据库实例完成的,但是随着业务的拆分、业务逻辑的变化,扣减库存与生成订单可能被拆到不同的服务中去,那么如何保证扣减库存与生成订单的一致性,也成为一个有挑战的问题。

需要注意的是这种场景下,产生的数据不一致,不会造成商品超卖,而是会造成用户下单成功,却看不到待支付订单。4.20我们在台州发放政府券的活动中,有约30%的券没有及时发放到用户钱包中,本质上就是这样问题,当时的直接原因是扣减库存的金融业务撑住了压力,但是魔方业务产生了超时,很多券没有成功发给用户,造成大量客诉,事后也花费了几个小时来使用hive上的日志数据进行补偿。

针对这类问题,一般通过DRC/DTS这类中间件来配合实现数据一致性,即扣减库存成功后,MySQL就会有相应的binlog,DRC/DTS订阅库存中心的binlog,订单中心再根据DRC/DTS订阅的数据来生成订单。因为MySQL binlog有多份副本不会丢失,所以即使订单中心出现超时抖动等问题,在恢复正常后,就能够继续生成订单。当然,引入这类优化后,也意味着系统要进行异步化改造,因为生成订单的逻辑本质上变成了异步操作。

业务全链路压测

全链路压测是阿里2013年在双11压力之下被逼出来的技能,由于线上线下环境多少都会有些不同,很多问题只有在实际生产环境才能暴露,对于秒杀类业务,线上压测也能够实际评估出系统的真实承载力,为容量预估给出重要参考。

同时阿里的全链路压测是真正的“全链路”,淘宝、天猫、支付宝的系统都会一同参与。

准实时监控

这里的技术挑战主要是在海量业务和数据库的场景下,如何做到全局有效而实时的监控数据采集和分析,一方面是为了实时监控系统健康度,另一方面则是pr需求。比如阿里张瑞说的:

“在零点前有一个倒计时环节,连线杭州光明顶作战指挥室,逍遥子会为大家揭幕2015双11启动,然后直接切换到我们的媒体大屏,所以对GMV数字的要求基本上是零延迟,这个挑战有多大不言而喻。”

实时热点发现

与准实时的监控类似,技术团队需要及时发现系统中的热点和瓶颈,并作出调整。实时热点的发现,需要业务层监控、数据库层监控一起配合改进优化,才能准确分析出热点。

容灾与高可用

业务容器宕机、数据库主库宕机、机房级宕机都可能出现,技术团队需要通过有效的容灾规划、set化、分库分表等,降低“爆炸半径”,并且要做到快速切换。

因此这里的技术挑战是容器的快速扩容,容器镜像快速分发,数据库分库分表尽量降低单个集群主备切换的影响,业务层面的set化和灵活的流量切换。

系统预热

大量流量会在大促开始的第0秒集中涌入,活动开始前需要完成 JVM预加载代码、缓存预热、数据库连接池预热等系统预热工作。

同时在各个系统的设计时也要做到避免对单点的依赖,原则仍然是降低“爆照半径”,防止大量流量进入后,把系统中的某个单点压垮,比如2016年美团Tair的故障,configserver被自己的客户端压垮后造成整个系统的风暴。

我们可以做些什么

阿里双11的目的在于:去库存、提升影响力和拉新,而对研发和基础架构来说则是保持技术领先的年度演习。

我司在美团APP的发展上也会面临扩大影响力和吸引新客户的诉求,因此类似的大促场景后续可能也会出现在我司的业务中。

因此建议我们如下基础技术上进行积累:

  1. MySQL数据库内核优化,适配秒杀业务
  2. 构建全公司系统化的全链路压测解决方案
  3. 与有秒杀类需求的业务共建,从中间件、缓存、数据库、业务逻辑等方面构建全套解决方案
  4. 提升容器弹性伸缩效率,提升系统准实时的监控能力

参考文档

  1. AliSQL秒杀场景测试报告样例
  2. AliSQL秒杀测试脚本
  3. 知乎回答:12306 能扛得住明星出轨这种流量冲击吗?
  4. 酷壳:由12306.CN谈谈网站性能技术
  5. 淘宝大秒杀系统设计详解
  6. 张瑞:一个阿里技术男经历的六年“双11”:技术改变阿里
  7. 知乎文章:透过热闹看本质——如何理解天猫双11

你可能感兴趣的:(“秒杀”系统的技术分析)