如何设计一个秒杀系统

Posted by jintang on 2018-11-15

秒杀系统本质上就是一个满足大并发、高性能和高可用的分布式系统…

– 摘自 “如何设计一个秒杀系统” by 许令波 极客时间

架构原则 - 4要1不要

数据量要尽量少

请求的数据量要少: 数据经过服务器处理,压缩和字符编码,需要消耗CPU
数据对系统的依赖要少: 减少服务器后台服务和数据库打交道,这些都要耗CPU

请求数要尽量少

比如js,css资源文件,http请求要经过三次握手,连接请求,还有有些资源需要串行加载

  • 可以把多个文件合并压缩成一个文件
  • 可以放到cdn中,减少对服务器的请求
  • 请求不同域名下的资源,还涉及域名的DNS解析耗时

路径要尽量短

用户发出请求到返回数据这个过程中,需求经过的中间的节点数要少

依赖要尽量少

所谓依赖,指的是要完成一次用户请求必须依赖的系统或者服务,这里的依赖指的是强依赖

  • 给系统分级,比如0级系统,1及系统…如此类推,0级是最重要的系统
  • 为防止系统被拖垮,适当时候需要把一些系统降级,然后去掉

不要有单点

  • 消除单点,是服务无状态化,让服务可以在服务器中随意移动
  • 配置动态化,通过配置中心来推送
  • 存储服务的无状态化,需要通过冗余多个备份方式,主备模式

架构是一种平衡的艺术,而最好的架构一旦脱离了它所适应的场景,一切都是空谈

不同场景下的不同架构案例

淘宝秒杀价购演进主线

架构升级都是要根据业务场景来具体问题具体分析的

第一版本,支持 QPS < 10w/s 的量级

如果你想快速搭建一个简单的秒杀系统,只需要把你的商品购买页面增加一个“定时上架”功能,仅在秒杀开始时才让用户看到购买按钮,当商品的库存卖完了也就结束了。
这就是当时第一个版本的秒杀系统实现方式

第二版本,支持 10w/s < QPS < 100w/s 的量级

10w级别,通常的瓶颈在数据读取上,增加缓存一般可以解决

相对于第一版本的的优化:

  1. 秒杀系统独立打造,可以有针对性做优化
    • 减少页面复杂度,减少装饰功能的
  2. 单独部署一个机器集群,不影响正常商品的业务
  3. 独立缓存系统存放热点数据,提高“读性能”
  4. 增加“秒杀答题”,防止刷单

第三版本,支持 QPS > 100w/s 的量级

100w级别,通常瓶颈在于服务端网络带宽,需要把动静分离,把大部分静态资源放到cdn上,还有放到本地缓存中,减少对主服务器的请求量

相对第二版本的优化:

  1. 页面彻底动静分离,不刷新整个页面,降低数据量
  2. 本地缓存商品信息,减少服务器公共缓存集群的查询
  3. 增加限流保护

动静分离

秒杀的场景中,对于系统的要求其实就三个字:快、准、稳

理解动静数据

简单来说:“动态数据”和“静态数据”的主要区别就是看页面中输出的数据是否和 URL、浏览者、时间、地域相关,以及是否含有 Cookie等私密数据

比如:

  1. 媒体新闻中,某一篇文章,不管谁访问都是一样的,他就是一个典型的静态数据
  2. 淘宝首页,会根据个人特征进行推荐的,可能每个人访问,不同时间访问都可能不一样,这些个性化的数据就是动态数据

动静分离后,分离出来的静态数据就可以放到缓存中,提高静态数据的访问效率

怎么对静态资源做缓存呢

  1. 静态资源应该缓存在里用户最近的地方
    • 浏览器里,CDN,服务端的Cache中
  2. 静态化改造就是要直接缓存HTTP连接
    • 静态化改造是直接缓存 HTTP 连接而不是仅仅缓存数据
    • Web 代理服务器根据请求 URL,直接取出对应的 HTTP响应头和响应体然后直接返回
  3. 决定更好的方式来缓存静态数据
    • 不同语言写的Cache软件处理缓存数据的效率也不相同,都有自身的弱点
    • 选择在web服务器层做,如(Nginx、Apache、Varnish)也更擅长处理大并发的静态文件请求。

如何做动静分离的改造

  1. URL唯一化
    • URL唯一化为了缓存整个HTTP连接
    • 比如商品详情,商品ID做唯一表示
  2. 分离浏览者相关的因素
    • 浏览者相关因素:是否已登录,登录身份等,单独拆出来,通过动态请求来获取
  3. 分离时间的因素
    • 服务器输出的时间也通过动态请求获取
  4. 异地化地域因素
    • 异地化相关的内容也通过动态请求获取
  5. 去掉cookies
    • 缓存的静态数据中不还有cookies

动静分离后,如何组织这些页面

  1. ESI(SSI): Server Side Includes
    • 就是在服务器端做动态数据请求,然后插入到静态页面中,再把整个完整的页面返回给用户
  2. CSI: Client Side Includes
    • 就是用户得到页面后,页面单独发起异步的javascript请求,向服务器拿到动态数据后,在插入到页面中,服务器性能好了,但是用户端页面可能延时,体验稍差

动静分离的几种架构方案

  1. 实体机单机部署
  2. 统一Cache层
  3. 上CDN

实体单机部署

就是把服务应用机子改成实体机,cache和应用共存,应用直接访问本机cache

优点:

  1. 没有网络瓶颈,而且能使用大内存
  2. 技能提升命中率,又能减少Gzip压缩
  3. 减少Cache失效压力

缺点:

  1. 既有应用服务,又有cache,造成运维复杂度,增加维护成本

统一的Cache层

就是将单机的Cache统一分离出来,做成一个独立的Cache集群

优点:

  1. 独立Cache,可以减少多个应用接入使用Cache的成本
  2. 统一的Cache方案更易于维护,方便做监控,自动化,升级扩展等
  3. 可以共享内存

缺点:

  1. Cache层内部交换成为瓶颈
  2. 缓存服务器的网卡也会成为瓶颈
  3. 机器较少时,风险较大,挂掉一台就影响很大一部分缓存数据

解决方案:对Cache机子做hash分组,做一致性哈希

上 CDN

就是将Cache移动到CDN上,因为CDN离用户最近,效果更好

问题:

  1. 失效问题
    • 当数据有更新时,需要保证CDN可以在秒级内,让全国各地的Cache同时失效,对失效要求很高。
  2. 命中率问题
    • 因为将数据放到全国各地的CDN上,必然导致Cache分散,而Cache分散又会降低命中率
  3. 发布更新问题

由于以上问题,想要放到全国的所有CDN节点不太现实,可以尝试使用几个节点来尝试实施,但需要满足几个条件:

  1. 靠近访问量比较集中的地区
  2. 离主站相对比较远
  3. 节点到主站的网络比较好,且稳定
  4. 节点容量比较大,不会占用其他CDN太多的资源
  5. 节点不要太多

CDN的二级Cache

二级cache是指cdn设置了多级回源机制,就是如果缓存没有命中再到二级缓存中去取,而不是直接回服务端来请求,本质是减少回原的请求量

选择CDN的耳机Cache比较适合,因为二级Cache数量偏少,容量大,让用户请求先回源的CDN的二级Cache中,如果没有命中再回源站获取数据

使用CDN的二级Cache作为缓存,可以达到和当前服务端静态化Cache类似的命中率,因为节点不多,Cache不是很分散,访问量比较集中,这样解决了命中率的问题,比较理想的一种CDN方案

特点:

  1. 把整个页面缓存在用户浏览器中
  2. 如果强制刷新整个页面,也会请求CDN
  3. 实际有效请求,只有用户对“刷新抢宝”按钮的点击

这样90%的静态数据缓存在了用户端或CDN中,性能自然提升不少

二八原则

有针对性地处理好系统的“热点数据”

关注热点数据

热点数据可能只占到所有数据的1%,却由于访问量大,很可能抢占99%的服务器资源。如果这些热点数据请求还没有价值,那么会对系统资源来说完全是浪费

什么是“热点”

  1. 热点操作

    • 大量刷新页面,刷新购物车,0点下单
    • 抽象为“读写请求”
  2. 热点数据

    • 静态热点数据
      • 可以提前预测的热点数据,如系统提前对热点商品打标、大数据分析发现
    • 动态热点数据
      • 不能提前预测到,是系统运行过程中临时产生的。如一个商品打广告,不知道哪天火了,短时间内转为热点商品

发现热点数据

热点数据都知道需要缓存起来,那么你怎么知道哪些数据才是热点数据呢?

  1. 发现静态热点数据
  2. 发现动态热点数据

发现静态热点数据

提前预热,商品打标,大数据分析,top N商品

发现动态热点数据

静态热点数据虽然可以预测和分析得来,但是实时性都较差

如果系统能够在秒级内自动发现热点商品就完美了

动态热点数据发现具体实现:

  1. 构建一个异步的系统,它可以收集交易链路上各个环节中的中间件产品的热点 Key,如 Nginx、缓存、RPC 服务框架等这些中间件(一些中间件产品本身已经有热点统计模块)。
  2. 建立一个热点上报和可以按照需求订阅的热点服务的下发规范,主要目的是通过交易链路上各个系统(包括详情、购物车、交易、优惠、库存、物流等)访问的时间差,把上游已经发现的热点透传给下游系统,提前做好保护。比如,对于大促高峰期,详情系统是最早知道的,在统一接入层上 Nginx 模块统计的热点 URL。
  3. 将上游系统收集的热点数据发送到热点服务台,然后下游系统(如交易系统)就会知道哪些商品会被频繁调用,然后做热点保护。

可以看到,上图通过对“站内导航”和“中间件”日志抓取后,分析平台调用,聚合分析,在推送到后台服务

异步采集日志,Zabbix了解一下

处理热点数据

  1. 优化
    • 缓存热点数据
    • LRU淘汰算法替换
  2. 限制
    • 根据商品ID做一致性Hash,根据Hash做分桶
    • 每个分桶设置一个队列处理,把请求限制在一个队列里,防止商品占用台多的服务器资源
    • 例如对每个请求的商品id取模,让后根据取模的结果分别设置多个linkedhashmap,每个map当做一个队列
  3. 隔离
    • 秒杀系统设计第一原则就是隔离热点数据,不要让1%的热点数据,影响99%的业务
    • 业务隔离
      • 把秒杀做成一个营销活动,提前预热,做好热点商品缓存
    • 系统隔离
      • 单独集群部署
      • 单独域名请求
    • 数据隔离
      • 单独的Cache集群
      • 单独的MySQL数据库存放

实现隔离有很多实现方式:

  1. 按照用户来区分,不同用户分配不同的Cookies,路由到不同的服务器接口中
  2. 在接入层针对URL中的不同Path来设置限流策略

流量削峰

秒杀系统中,秒杀开始前,流量几乎是一条直线,到了秒杀时刻,流量在时间上高度集中于一点,峰值对资源的消耗是瞬间的

为什么要削峰

峰值的出现,导致服务器忙不过来,但是闲时又没什么要处理

削峰的意义:

  1. 可以让服务端处理变得更加平稳
  2. 可以节省服务器资源的成本

削峰的本质就是用户请求迟缓发出,以便减少和过滤掉一些无效的额请求

削峰的思路

  1. 排队
  2. 答题
  3. 封层过滤

排队

使用消息队列来缓冲瞬时流量,把同步变成异步

除了消息队列,类似的排队还有:

  1. 利用线程池枷锁等待
  2. 先进先出,先进后出等常用的内存排队算法
  3. 把请求序列化到文件中,在顺序读文件(类似MySQL binlog的同步机制)

答题

就是点击秒杀按钮后,增加答题环节,答对了之后才进行下一步

目的:

  1. 防止秒杀机器作弊
  2. 延缓请求,把在一个时间点的请求(1s),转化成时间片的请求(2s~10s)

答题系统设计:

答题验证:

答题验证包括:

  1. 答案的正确性验证
  2. 用户身份验证,是否登录,资格验证等
  3. 提交答案时间验证,答题时间只花了1s,一般人为答题不不能达到,也能防止机器答题

分层过滤

假设请求分别经过:

  1. CDN
  2. 前台读系统(商品详情)
  3. 后台系统(交易系统)
  4. 数据库

过滤的过程:

  1. 大部分数据和流量在用户浏览器和CDN上获取,可以拦截大部分数据读请求
  2. 经过第二层(商品详情页)时,数据都走Cache,过滤掉无效请求
  3. 经过第三层(交易系统),做数据的二次校验,如登录,答题验证等,请求书进一步减少
  4. 最后一步,数据库完成强一致性校验,如库存不足

分层过滤的核心思想是:在不同的层次尽可能地过滤掉无效请求,让“漏斗”最末端的才是有效请求。

分成校验的基本原则:

  1. 将动态请求的读数据缓存(Cache)在web端,过滤掉无效的数据读
  2. 对读数据不做强一致性校验,减少因为一致性校验产生的瓶颈问题
  3. 对写数据进行基于时间片的合理分片,过滤掉失效的请求
  4. 对写数据做限流保护,过滤掉超出承载能力的请求
  5. 对写数据进行强一致性校验,保证数据的最终准确性(如库存不足)

提高系统的性能

影响性能的因素

服务端的性能指标:

QPS(Query Per Second,每秒请求数)

影响这个性能指标的两个因素:

  1. RT(Reponse Time),服务器响应时间
    • 正常情况下,服务器处理请求的响应时间越短,一秒钟处理的请求数就越多
    • 实际的测试得出:减少CPU的一半执行时间,可以增加一倍的QPS
  2. 处理请求的线程数
    • QPS = RT * 线程数量
    • 很多多线程的场景都有一个默认配置:线程数 = 2 x CPU核数 + 1
    • 最佳实践得来的公式:线程数 = [(线程等待时间+线程CPU时间)/线程CPU时间]xCPU数量
    • 当然,最好的办法是通过性能测试来发现最佳线程数

如何发现瓶颈

瓶颈可能是:CPU、内存(存储系统)、硬盘(I/O)和网络等

在秒杀场景中,瓶颈更多的发生在CPU中

使用CPU诊断工具:JProfiler和Yourkit,这些工具可以列出整个请求中每个函数的CPU执行时间

判断CPU是否是瓶颈

看当QPS达到极限时,你的服务器CPU使用率是不是超过95%,如果没有,则还有提升空间

如何优化系统

  1. 减少编码
    • 就是基于把静态的字符串提前编码成字节并缓存,然后直接输出字节内容到页面,从而大大减少编码的性能消耗的,网页输出的性能比没有提前进行字符到字节转换时提升了 30% 左右。
  2. 减少序列化
    • 序列化大部分发生在RPC调用中,可以减少RPC调用,通过把应用合并部署,避免远程RPC,在同一个台机子上,也不能走本机的socket
  3. 应用的极致优化
    • 使用Web服务器做静态化改造,让大部分请求和数据直接在Web服务器上直接返回
    • 直接处理请求,不使用MVC框架
    • 直接输出流数据,使用json而不是模板引擎
  4. 并发读优化
    • 采用应用层的LocalCache,秒杀系统单机上缓存商品的相关数据
    • 商品标题和描述这些数据本身不变的数据,可以秒杀前全量推到秒杀机器上,并且一直缓存到秒杀结束
    • 库存,动态数据会采用“被动失效“的方式,失效后再去拉取

读场景中,可以允许一定的脏数据,可以等到真正写数据库在保证最终一致性。

库存减扣

抛出问题:

商品到底用户下单了算卖出去,还是付了款才算卖出去?

减库存的几种方式

  1. 下单减库存
    • 下单后马上减库存,最简单,控制最精确的一种方式
    • 存在问题:会导致被人刷单不付款的问题
  2. 付款减库存
    • 用户真正付款后才减库存。
    • 存在问题:如果并发高,容易出现超卖
  3. 预扣库存
    • 下单后,库存保留一定的时间(如10分钟),超过这个时间后,库存自动释放
    • 当买家付款前,系统检测库存是否还有效,如果没有则尝试减库存,如果库存不足(尝试失败)则不允许继续付款,如果尝试减库存成功,则完成付款并且实际减库存
    • 存在问题:仍然有刷单不付款和超卖的情况

解决方案

结合安全和反作弊措施

  1. 给经常下单不付款的买家进行打标(被打标的买家下单时不减库存)
  2. 给某些类目设置最大购买件数(一人3件等)
  3. 重复下单不付款的操作次数进行限制

业务应对超卖问题:

  1. 普通商品允许补货可以补货解决
  2. 完全不能超卖的(飞机票,酒店),只能在买家付款时提示库存不足

秒杀中的库存减扣-下单后减库存

经典的预扣库存的方案:买机票和电影票,下单后都有有效付款时间,超过时间,订单失效

秒杀中的库存减扣方式,采用”下单后减扣”比较合理

库存减扣的解决方案:

  1. 应用成许中通过事务判断来,即保证减扣后库存不能为负数,否则回滚
  2. 直接设置数据库中的字段数据为无符号,减扣时库存字段小于零,sql会报错
  3. 使用CASE WHEN来判断:
1
2
UPDATE item SET inventory = CASE WHEN inventory >= xxx 
THEN invenory - xxx ELSE inventory END

秒杀库存减扣的极致优化

库存的并发读问题:

  1. 库存的查询放到LocalCache中,解决并发读问题

库存的并发写问题:

如果秒杀商品的减库存逻辑单一,没有复杂的SKU库存和总库存的联动关系,可以使用缓存中减库存,但是如果有比较复杂的库存逻辑,或者需要使用事务,则还是需要在数据库中减库存

  1. 在缓存中减库存

  2. 在数据库中减库存

    • 应用层排队
      • 使用队列,减少同一台机器对数据库同一行记录进行操作的并发度
    • 数据库层排队
      • 对数据库做优化,让其支持并发写排队(阿里做了,艹,说个屁)

兜底方案

高可用建设的各个阶段:

  • 架构阶段
  • 编码阶段
  • 测试阶段
  • 发布阶段
  • 运行阶段
  • 故障发生

针对运行阶段处理方案:

  1. 降级
  2. 限流
  3. 拒绝服务

降级

降级,就是当系统容量达到一定程度时,限制或关闭某些非核心功能,把有限的资源留给合型业务

降级是需要提前做好预案和系统开关来实现,例如:

当秒杀流量达到5w/s时,通过一个开关,把成功交易从展示20条降级到展示5条

限流

  1. 客户端限流
    • 就是减少客户端发出请求,缺点就是不好把控限流阀值
  2. 服务端限流
    • 可以合理设置限流阀值,缺点是大量的请求还是到达了服务器,服务端处理不了的请求就是无用请求也会消耗服务器资源

方案:

测试最大的QPS,假设最大支持1W/s QPS,可以设置8000来进行限流保护

拒绝服务

系统负载达到一定的阀值:cpu超过90%等,系统直接拒绝所有请求

在最前端的 Nginx 上设置过载保护,当机器负载达到某个值时直接拒绝HTTP请求,并返回503错误码

缓存失效的应对策略

应用层用队列接受请求,然后结果怎么返回的问题

也就是把用户的所有秒杀请求都放到一个队列进行排队,然后在队列里按照进入队列的顺序进行选择,先到先得

这种方案会有两个问题:

  1. 体验差,因为异步的方式,在页面中搞个倒计时,处理时间长
  2. 秒杀判断是根据进入队列时间,没意思

答案:

这种方案完全没有必要:

因为服务器接受请求本身就是按照请求顺序处理的,而这个处理在web层是实时同步的,处理结果也会立马返回给用户

我的理解:

把请求入队列,就是把用户信息放入一个队列中,队列满了,后来请求入不了队列的请求就返回“抢光了“

并不是说,把每个请求进入队列,然后一直等待队列处理再返回

缓存失效问题

有Cache的地方必然存在失效问题,因为需要保证数据的一致性

失效有两种方式:

  1. 被动失效
    • 就是设置缓存的过期时间,针对某些时效性不是很高的数据,到期则自动失效
  2. 主动失效
    • 一般有Cache的失效中心,通过监控数据库表的变化来发送失效请求