打破「公平」,让秒杀系统飞起来

1,487 阅读12分钟

坑爹的技术竞赛

几年前,公司组织了一次技术竞赛。自由组队,每组4人,36小时之内按要求完成设计、代码实现和文档。第一名奖金3万元。

一开始不是很想参加,因为要通宵熬夜(现在想想,不参加竞赛是个明智之举)!不过后来还是接收了其他部门的邀请,参赛了。但是,在比赛前一天,其他三个人放我鸽子!!!放我鸽子!!!放我鸽子!!!没办法,只能重新组队,最终两个后端、两个前端!但是,比赛当天,另一个后端去参加歌唱比赛了!!!参加歌唱比赛了!!!参加歌唱比赛了!!!所以变成了一个后端+两个前端。我们成了当时参赛小组里唯一的三人小组,唯一的有前端的小组,还是两个前端!!!

然后比赛的题目是:实现一个秒杀系统!!!

  • 不能使用Java和Spring技术体系外的技术(因为除了我们组,其他组都是Java后端,所以只对Java技术做了限制),如果使用要扣分
  • 吞吐量越高越好
  • 不能出现超卖或少卖的情况
  • 要有完善的文档
  • ......

当时我们三个就想直接弃权了!但是转念一想,都走到这一步了,还是试试吧!

最终的结果是,我们没完成(意不意外?惊不惊喜?)!!!前端在最后时间点才开发完成,测试的时间都没有!后来测试的时候,修复了几个bug,为了公平起见,就没有参与最后的比赛!

不过,在所有的参赛队伍中,我们的吞吐量是最高的,其他队伍的TPS基本在三四千左右!我们的TPS基本在一万七左右!如果一开始就完成了,基本秒杀其他组!

我们的吞吐量是其它组的四倍,究其原因是因为我们的人员组成与其他组不同,导致我们的设计思路与其他组的设计思路也不同

在之前的什么是软件架构一文中,我对软件架构做了一个定义:「架构是特定约束下决策的结果」!

打破「公平」,让秒杀系统飞起来

这次技术竞赛的经历,正好可以验证这一观点:即使是在技术不对口、人数不足等劣势条件下,只要做出合适的决策,依然能做出超出预期的架构设计!

有时候,对于合适的架构设计,劣势有时候会成为优势

我们先来看下,对于秒杀系统来说,一般的设计思路。

秒杀系统一般设计思路

秒杀系统的特点是:

  • 瞬时请求量很高
  • 持续时间较短

所以秒杀系统需要解决的是「在高并发情况下,用户请求及数据更新的问题」!

一般的设计思路:

  • (变相)扩容
  • 提高性能

具体方式有:

动静分离

对于一般的应用来说,请求流程大致如下:

  • 服务端接收到请求,从数据库中查询相应数据
  • 选择对应的展示模板
  • 通过模板和数据渲染出最终页面
  • 将页面返回给客户端

当访问量很大的时候,服务器压力会非常的大!解决方案就是动静分离

做软件开发的都知道要「将变化的内容和不变的内容隔离开」,以便于独立进化。这里其实也是一样的思路。

模板是个静态的内容,部署后一般是不会变化的;而数据是个相对动态的内容,根据请求参数的不同,数据可能不同。所以我们需要将模板与数据分离。

以前的做法是后端事先生成渲染后的页面,缓存起来或直接部署到静态服务器或CDN,请求时直接从缓存(静态服务器/CDN)中获取页面,而动态数据通过AJAX请求的方式获取。服务器不再需要渲染页面,只需要返回少量的数据即可。既降低了服务器压力,又减少了服务端数据的传输。

而现在很流行的前后端分离就能很容易的解决这个问题。页面独立部署,数据异步获取,页面渲染由浏览器负责。这里和普通的前后端分离还有些差异,需要将相对静态的数据都静态化,以减少动态数据量。

分离后,静态内容和动态内容就可以独立进化。例如静态内容可以部署到CDN上,用户可以从最近的服务器获取到数据。相对热点的动态数据可以做缓存,降低数据库压力,进一步提高服务端响应。

独立部署

「独立部署」其实也可以看成是一种「动静分离」。将秒杀系统这个相对动态的系统,和相对静态的业务系统分开部署

原因很好理解,秒杀系统的请求量很大,可能会由于预估不足或系统问题,导致了秒杀系统的负载过高、响应变慢。如果秒杀系统是业务系统的一部分,则会导致业务系统响应变慢,甚至导致系统没有响应。且秒杀是个短期活动也不是核心业务,而业务系统是需要长期稳定运行的。不能因为一个短期非核心的活动,而影响了核心的业务系统。

所以秒杀系统最好和业务系统分开独立部署。即使秒杀系统挂了,也不会影响业务系统的正常对外服务。

同样的道理,秒杀系统的数据库也需要和业务系统的数据库独立开。

限流削峰

动静分离独立部署能提高系统的响应能力和容量。但是可提供的访问量是一定的,当超过了系统所能承受的容量,该怎么办呢?你可能会说,可以扩容啊。的确是可以,但是扩容也是有限度的。假设单机能承受10万的请求量,预计有1亿的请求量,你要扩容1000台服务器?!这会导致严重的浪费。

首先,上面提到了,秒杀是短期活动,为了秒杀多部署1000台服务器,秒杀结束后这些服务器再销毁?既浪费硬件资源、又浪费人力资源。

其次,秒杀的商品数量其实并不多,可能秒杀赚的那点钱还不够付服务器和带宽的费用。真·花钱赚吆喝!

我们该如何处理呢?

上面说了,秒杀的商品数量不多,也就是说,其实最后的真实成交量并不大。再进一步讲,很多的请求都是没用的。

其次,在秒杀前,买家会频繁的刷页面,这又额外增加了无用请求的数量。

我们只要把这些无用的请求提前都过滤掉,最终到达服务端的请求就会少很多,也就不需要这么多的服务器了。这就是限流削峰。具体做法有很多:

  • 秒杀时间未到时,秒杀按钮置灰:也就是说在秒杀未到时间时,不可发送下单请求。前面我们已经将页面静态化,分发到了CDN,所以用户的刷新操作只会到CDN。这就削除了刷新操作导致的请求。
  • 秒杀按钮点击后置灰:即避免double-click,一个用户只能点击一次。限制用户点击次数,避免秒杀工具带来过量无效请求。
  • 秒杀前先做题:即在秒杀前需要先做题目,类似验证码功能,其实是降低了用户的点击频率,也限制了秒杀工具的使用。不过体验不好,不推荐使用。
  • 限制请求次数:可以用js判定,限制用户多少时间间隔内,只能请求多少次。在代理层也可以基于ip做次数限制,限制单ip的请求数量。
  • 直接跳转:假设秒杀已结束或秒杀队列已满,对后续的请求,直接跳转到秒杀结束页面。请求不再到达服务端。
  • 请求排队:通过消息队列、内存排队等手段,对请求进行排队。类似EDA、Reactor。当队列满了以后,可拒绝后续请求。

服务端优化

上面的「请求排队」,可以做在web服务层,也可以在服务端处理,亦可以两处都处理。除了排队,服务端的优化的核心手段就是缓存,尽量减少到数据库的数据访问,将热点数据缓存起来。

更极致的优化可能还涉及到:

  • 减少序列化:大家都知道Java序列化和反序列化都是比较耗时的操作,即使使用第三方的序列化工具,也是需要消耗时间的,尽量减少序列化操作,能减少这部分的时间消耗
  • 不要使用框架:现在一般开发都会使用框架开发,例如SpringMVC。SpringMVC使用了前端控制器,还包括很多的Filter,拦截器等,额外的增加了请求时间。使用纯Servlet,能降低此部分的时间消耗。因为毕竟秒杀逻辑简单,用不用框架,开发效率影响不大。
  • 使用字节流:即使用InputStream、OutputStream,不要使用Writer,Reader。与「减少序列化」类似,编解码也会消耗时间。

另外还有扣库存逻辑处理:

  • 拍下减库存:用户抢到后即扣除库存,但是如果用户抢到了不付款,最后秒杀的商品可能实际并没有卖出去。
  • 付款减库存:到用户付款后才去扣库存。这可能导致下单数量远超商品数量。导致的问题是,要么后付款的买家被提示付款失败。要么就是超卖。
  • 预扣库存:用户抢到即扣除库存。规定时间内没有付款则取消订单,恢复库存。这个是常用手段

上面说的秒杀系统的一般设计思路。下面来看看我们是怎么钻漏洞的!

竞赛漏洞

由于竞赛规定,只能使用Java和Spring来处理,对其他队来说,这就导致了一个比较棘手的问题,IO优化。

Java支持两种IO,BIO和NIO。对于秒杀这种场景来说,肯定不能使用BIO。但是,如果使用Java原生NIO的话,需要处理很多问题,例如半包问题。以最终结果来看,选用原生NIO自己手写异步IO框架的,全都以失败告终。

那就只有另外一条路,宁可扣分,也要使用第三方框架,例如Netty。这就是赌,赌使用JavaNIO的队伍无法完成系统了。

而对我们队来说,我们原先的劣势---前端、一下子就变成了优势。因为前端有node啊(如果没有node,我们妥妥的弃赛了)。当时node刚火起来,node天生就是个异步IO框架,竞赛规定里,可没有对前端技术做技术限制!这个漏洞,我们怎么能不钻?!

公平?公平!

最终竞赛评比时,我发现一个问题,其他队伍很看重公平!!!即先到先得原则,优先到达的请求,优先排队下单。这就导致,在秒杀结束前或请求被处理前,都需要等待,直到服务器处理后才有返回。

这明显增加了服务端的压力,这也是导致他们的吞吐量限制在4000左右的原因。但不是根本原因。

根本原因是这样做就真的公平吗?!这就要看每个人对公平的理解了!我认为这世上「没有绝对的公平,只有相对的公平」!

你在秒杀系统里排队,保证先到先得,这就是公平吗?

  • 如果一个买家是1M带宽,另一个买家是100M光纤,他们同时秒杀,你能保证公平吗?
  • 如果你的服务器在北京,北京的买家是不是比广州的买家更容易秒杀到?你能保证公平吗?
  • 如果一个买家是万年死宅,手速奇快;另一个买家手不太灵活。你能保证公平吗?

既然不能,我为什么要在服务端保证公平呢?!

我们的设计

秒杀就是拼个运气,只要不暗箱操作,那就是公平的。所以我们不保证先到达的请求就能先买到商品!客户哪知道他是不是先到的呢(虽然这样说,看起来不公平,但实际确实是这样)。所以我们放弃了所谓的公平

我们使用了两个队列:

  • 前端node队列
  • 后端下单队列

大致请求流程如下:

  • 假设商品数量为100,那可以设定node队列长度为1000,下单队列长度为100
  • 秒杀开始后,node队列接收前端请求,先到先进。当队列满了以后,直接响应后面的请求,秒杀失败/结束。
  • node队列中的数据批量传递给后端的下单队列,由消费线程从下单队列中获取请求进行处理
  • 如果100个商品全部处理完成(下单后,规定时间内没有付款,取消订单,恢复库存),则秒杀结束
  • 如果100个商品没有处理结束,继续从node队列获取下一批数据处理
  • 如果node队列有空余后,后续的请求继续进入队列
  • node队列中的请求设置超时,规定时间内没有得到处理,直接返回秒杀失败/结束

总结

人员、技术、考量点的不同都会影响架构设计。一个符合当前人员、技术以及适合考量点的架构,可能能得到意想不到的效果。