秒杀系统的全貌
秒杀这一业务场景已经发展多年,有套路可循。另外,秒杀属于极端大流量场景,它的应对经验对Web大流量应对方案有很好的借鉴意义。
1. 秒杀系统本质
秒杀正常的业务流程:查询商品 -> 创建订单 -> 减库存 -> 更新订单 -> 付款 -> 卖家发货。
而业务特性是:
- 低廉价格;
- 大幅推广;
- 瞬时售空;
- 一般是定时上架;
- 时间短、瞬时并发量高。
从技术角度看秒杀系统本质上是一个满足大并发、高性能和高可用的分布式系统。秒杀其实主要解决两个问题,一个是并发读,一个是并发写。并发读的核心优化理念是尽量减少用户到服务端来“读”数据,或者让他们读更少的数据;并发写的处理原则也一样,它要求我们在数据库层面独立出来一个库,做特殊的处理。另外,我们还要针对秒杀系统做一些保护,针对意料之外的情况设计兜底方案,以防止最坏的情况发生。
总的来说,架构是一种平衡的艺术,而最好的架构一旦脱离了它所适应的场景,一切都将是空谈。具体设计应该参照秒杀预估流量:
- QPS 小于1W:只需要把商品购买页面增加一个定时上架功能,仅在秒杀开始时才让用户看到购买按钮,当商品库存卖完了也就结束了。
- 随着请求量加大(QPS 1W/s -> 10W/s),这个简单的架构就很快遇到了瓶颈,因此需要做架构改造来提升系统性能。
- QPS 100W/s 以上
2. 怎样设计降低服务压力
2.1 架构设计原则
2.1.1 数据要尽量少
- 用户请求的数据能少就少。具体包含上传给系统的数据和系统返回给用户的数据。原因是首先这些数据在网络传输需要时间,其数据传输都需要服务器做压缩和字符编码,都非常消耗CPU,所以减少传输的数据量可以显著减少CPU的使用。
- 系统依赖的数据能少就少。依赖的路径越多会增加CPU处理时间(序列化和反序列化),同样会增加延时。
常见设计手段为:动静分离。
具体为变刷新整个页面为只点击“秒杀”按钮就够了。动静分离后,客户端大幅度减少了请求的数据量。
分离改造核心:分离出动态数据。如url唯一化,分离浏览者相关因素,分离时间因素,异步化地域因素,去掉cookie等。
- 对静态数据缓存:
- 静态数据缓存到离用户最近的地方。浏览器、CDN、服务端Cache。
- 静态化改造直接缓存HTTP连接
- Web服务器流入Nginx缓存静态数据优于Tomcat。
- 对动态数据缓存:
- ESI(edge side includes)服务端拼接动静态内容,组装一起返回,服务端性能有影响,但是客户端体验好
- CSI(client side include)客户端发起异步js请求,服务端性能好,客户端可能会有延时,体验稍差.
部署架构:
需要解决(失效问题,命中率问题,发布更新问题),其他细节:浏览器缓存和cdn缓存差别很大;合并是否用gzip压缩。
2.2.2 请求数要尽量少
用户请求的页面返回后,浏览器渲染这个页面还包含其他的额外请求。例如页面依赖的CSS/JS, 图片, Ajax请求等都被定义为“额外请求”,这些额外请求应该尽量少。因为上述每个资源请求都能增加连接(需要做三次握手),可能造成资源串行加载,不同域名还有DNS解析。解决办法:合并CSS/JS文件。
常见设计手段为:流量削峰。
2.2.1 流量削峰
本质上:延缓用户请求的发出。让服务处理更加平稳,节省服务器成本。削峰基本思路如下:
- 排队:用MQ来缓冲瞬时流量,把同步的直接调用转换成异步的间接推送,中间通过一个队列在一段承接瞬时的流量洪峰,在另一端平滑的将消息推送出去。
除了利用MQ,还可以使用线程池加锁方式实现排队,FIFO内存排队。这样就会存在异步返回结果问题:解决方案有两种1. 客户端轮询,例如支付页面,每秒轮询一次;2.服务端push结果。需要C/S保持长连接。
- 答题:防作弊,延缓请求。
- 分层过滤:对请求进行分层过滤,讲请求尽量拦截在系统上游。传统秒杀系统之所以挂,请求都压到在后端数据库层,数据读写锁冲突严重,并发响应高,几乎所有请求都超时。流量虽大,下单成功的有效流量甚小。分层过滤其实就是采用“漏斗式”设计来处理请求。核心思想为:在不同层次尽量过滤掉无效请求[根据库存判断无法抢到商品的人],让“漏斗”最末端的才是有效请求。
- 读系统尽量减少一致性校验的瓶颈,但尽量将不影响性能的检查条件提前
- 写系统主要对写数据进行一致性检查
2.3.3 路径尽量短
路径:用户发出请求到返回数据这个过程中,需要经过的中间节点数。
这是因为每增加一个连接都会增加新的不确定性。从概率统计上说,假如一个请求经过5个节点,每个节点可用性是99.9%的话,那么整个请求的可用性是:99.9的5次方,约等于99.5%。缩短路径不仅可以增加可用性,同样可以有效提升性能(减少中间节点可以减少数据的序列化和反序列化),并减少延时。
有一种缩短访问路径办法: 多个相互强依赖的应用合并部署在一起,把远程调用RPC 变成JVM内部之间的方法调用。
2.4.4 依赖要尽量少,系统分级
展示秒杀页面,这个页面必须强依赖商品信息、用户信息,还有其他如优惠券、成交列表等这些对秒杀不是非要不可的信息(弱依赖),这些弱依赖在紧急情况下就可以去掉。要减少依赖就必须对系统进行分级。0级系统要尽量减少对1级系统的强依赖,防止重要的系统被不重要的系统拖垮。在极端情况下可以把1级系统例如优惠券系统降级。
2.5.5 不要有单点
无单点的重点是避免将服务的状态和机器绑定,即把服务无状态化。
应用无状态化是有效避免单点的一种方式,但是像存储服务本身很难无状态话,因为数据要存储在磁盘上,本身就要和机器绑定,那么这种场景只能通过冗余多个备份来解决单点问题。
3. 热点数据处理
为什么要处理热点数据?热点请求会大量占用服务器处理资源,虽然这个热点可能只占请求总量的亿分之一,然而却可能抢占90%的服务器资源。
什么是热点:热点分为热点操作和热点数据。
- 热点操作,例如大量的刷新页面、大量的添加购物车、双十一零点大量的下单。对系统来说,这些操作可以抽象为“读请求”和“写请求”。热点操作中的写操作将下面单独一节讲解。
- 热点数据:用户的热点请求对应的数据。热点数据分为“静态热点数据”和“动态热点数据”。
- 静态热点数据:可以提前预测的热点数据。业务场景,通过卖家报名来打标。还可以通过数据分析历史成交记录,用户购物车记录分析出热点商品。
- 动态热点数据:不能被提前预测的热点数据,系统在运行过程中临时产生的热点。例如上家临时做了广告导致的热点数据。解决方案:构建数据动态发现系统,分析热点Key,数据上报统计。
处理热点数据:
- 一、优化 :缓存。热点数据动静分离。
- 二、限制 :热点数据限制到一个请求队列里,防止热点数据占用太多服务器资源导致其他请求无法处理。
- 三、隔离
- 系统隔离:为避免对现有网站业务的冲击:分组部署,将热点描述请求分到单独的集群。秒杀系统只是一个短时的促销活动,具有时间短、访问量高的特点。如果模块与原业务系统部署在一起,将对现有的业务造成冲击。因此,应当把秒杀模块迁移出去,独立部署。
- 数据隔离:热点秒杀数据启用单独的Cache/MySQL集群。
- 业务隔离:卖家报名秒杀提前感知热点,做数据预热。
4. 性能优化
核心:降低CPU消耗。
4.1 衡量指标
总QPS = (1000ms / RT) * 线程数量
其中线程数量一般默认配置为 2*CPU核数 + 1。
4.2 优化方法
- 减少编码
- 减少序列化
- 服务优化(如nginx返回静态数据,框架定制优化)
- 并发读优化:应用层的LocalCache,在秒杀系统的单机上缓存商品相关的数据.
- 静态数据(秒杀前全机推静态cache数据)
- 动态数据(类似库存,一般缓存几秒,被动失效,允许一定的脏数据)
- 流程:发现数据,减少短板,数据分级,减少中间环节,做好应用基线(性能基线,成本基线,链路基线)不断调整
5. 并发写-减库存
秒杀系统设计除了上述的并发读的问题,还有一个难点是如何解决并发写 – 多个用户在同时抢一件商品,也就是并发很高,但集中在同一商品上,造成实质为串行操作。因为在数据库这层本质执行的是对同一件商品扣库存 – 需要合理的减库存。用户的购买过程一般分两步:下单和付款。
BEGIN UPDATE stock SET count = count - 1 WHERE skuId = ? COMMIT
减库存一般有三种方式:
- 下单减库存。下单减库存最简单也是控制最精确的一种,下单时直接通过数据库的事务机制控制商品库存,这样一定不会出现超卖情况。缺点是:恶意下单(有些人下单完不一定会付款,但是库存已经扣了,会影响商家。)
- 付款减库存。等到用户付款完后才真正减库存,否则库存一直保留给其他买家。缺点是出现买家下单后无法付款。缺点:可能超卖。
- 预扣库存。买家下单后库存为其保留一段时间,超过这个时间,库存会自动释放。释放后其他买家继续购买。在买家付款前,系统会校验该订单的库存是否还有保留,有保留则尝试预扣,如果预扣失败,则不允许付款;如果预扣成功,则完成付款减库存。缺点:也可能恶意下单(只能结合安全和反作弊,标示用户并限制操作) 。
一般情况下秒杀减库存逻辑复杂,存在SKU库存和总库存联动关系,需要使用MySQL事务. 由于同一数据在数据库里肯定是一行存储,因此会有大量线程来竞争InnoDB行锁,而并发度越高等待线程会越多,TPS会下降,响应事件RT会上升,数据库的吞吐量就会严重手影响。这就会发生单个热点商品影响整个数据库性能,导致0.01%商品影响99.99的商品的售卖。
解决并发锁的问题:
- 乐观锁/悲观锁
- 悲观锁:可能会造成大量线程抢锁等待,结果可能会瞬间增大响应时间,造成系统连接数耗尽。
- 乐观锁:根据版本号的思路,可能会操作操作失败次数增多,需要上层业务重试,或者交给用户重试。
select * from tab1 where id = ? udpate tab1 set col1 = ? where id = ? and version = ?
缺点:在高并发下可能更新失败,所以要通过重试来提高更新效率。
- FIFO队列
- 排队:并行强制改成串行,单机内存队列,如果生产远高于生产可能造成内存爆掉。即使内存没问题,如果消费过慢用户响应时间也会长。
- redis watch
- 如果可以把数据放到内存数据库中,可以考虑redis watch机制,采用乐观锁方式更新。
WATCH mykey val = GET mykey val = val + 1 MULTI SET mykey $val EXEC
6. 高可用建设
参考:
- 极客时间-如何设计一个秒杀系统 https://time.geekbang.org/column/127
- MySQL的并发更新 https://www.cnblogs.com/tao_/p/9537666.html