秒杀系统总结-转

前言

秒杀业务为什么难做?例如,小米手机每周二的秒杀,可能手机只有1万部,但瞬时进入的流量可能是几百几千万;12306抢票,票是有限的,库存一份,瞬时流量非常多,都读相同的库存。读写冲突,锁非常严重,这是秒杀业务难的地方。

转载地址 https://fengberlin.github.io/post/seckill/

秒杀业务场景具有典型的“事务”特性秒杀业务的核心在于对库存的处理,也就是说减库存和记录购买明细。两者要形成一个完整的事务,然后这个事务要准确地数据落地(也就是反应到数据库具体的修改和插入等操作)。为什么需要事务?因为在这个秒杀操作中,如果没有事务,则很容易出现减了库存而没有记录购买明细或者记录了购买明细而没有减库存。即使存在事务,也会出现超卖/少卖问题。

超卖:多个进程进入事务,产生共享锁,在update库存的时候,可能会出现查询库存都是大于0的,结果一减库存,出现库存小于0,产生超卖现象。

在我做的一个秒杀的小项目中,我主要解决的是超卖现象(利用主键的唯一性以及事务、sql语句的优化)和整个系统的响应性(主要思路是加缓存降低数据库的压力)。下面我们来讨论一下优化的具体做法。

优化方向

我们先来看一下没优化前,大量的请求是如何把系统搞瘫痪的。在前端,用户不断地去刷新页面去看一下秒杀时间到了没,除此之外,他们还会不断点击商品去查看秒杀商品的详细信息,并且他们还会尝试去秒杀同一件产品多次和秒杀不同的产品,更有甚者会利用爬虫等工具去不断地请求我们的各种页面。极小的时间段里面,大量的并发请求被发起,如果仅仅是由数据库去承受那么多的压力,势必会系统瘫痪。所以,在这里我们优化的思路会逐渐清晰,那就是尽量拦截用户的请求到数据层(后端数据库),降低后端数据库的压力,除此之外,需要利用缓存来降低数据库的压力。也就是说,我们有两个优化方向:

(1)将请求尽量拦截在系统上游(不要让锁冲突落到数据库上去,因为做写操作的时候会锁住行级锁,并发性能大大降低)。传统秒杀系统之所以挂,请求都压倒了后端数据层,数据读写锁冲突严重,并发高响应慢,几乎所有请求都超时,流量虽大,下单成功的有效流量甚小。以12306为例,一趟火车其实只有2000张票,200w个人来买,基本没有人能买成功,请求有效率为0。

(2)充分利用缓存,秒杀买票,这是一个典型的读多写少的应用场景,大部分请求是车次查询,车票查询,下单和支付才是写请求。一趟火车其实只有2000张票,200w个人来买,最多2000个人下单成功,其他人都是查询库存,写比例只有0.1%,读比例占99.9%,非常适合使用缓存来优化。

秒杀架构

秒杀的整体架构分为3层(加上缓存也可以是4层),那我们既然用到了缓存,那么就按4层来说。4层分别是客户端层(网页、app)、站点层(控制器,映射客户访问路径,访问后端数据)、服务层(向上游屏蔽底层数据细节,提供数据访问)、数据层(这里就是数据库所在的层,当然也会有我们的缓存如redis)。我们要做的是尽量降低数据库层面的请求压力。

各层次优化

(1)客户端层

我们可以想一下秒杀时间到了,我们会点击秒杀按钮,但结果通常比较慢,这时我们会不自觉地多点几次,如果这没有限制,就平白无故地增加来系统负载,一个用户点击5次,但成功只会有一次(假设成功),那么在这种情况下就多出来了80%的无用请求,所以这是一个优化的点。

所以,在秒杀结束后和用户成功秒杀后将按钮置灰(将button变成disable),使用户不能重复点击。然后查询的时候,可以限制用户几秒之后可以再执行查询操作。

(2)站点层

对于普通用户的搞频度访问,我们可以在客户端层面就可以拦截,但对于一些用工具或爬虫的高级用户,在站点层面我们还可以对访问频率做一个限制。具体操作就是我们将用户的访问频率记录在redis中,key为用户id(唯一),然后值为我们设定的在有效期内能最多访问的次数,所以这个记录需要一个有效期。

(3)服务层

层层拦截,就是不要让无实际意义的请求落到数据库层面上去。当参加秒杀的用户还是很多的时候,即使一人自由一个请求,那么到服务层的请求量还是很大的。比如我们有100W用户同时抢100台小米手机,服务层并发请求压力至少为100W。那么我们明明知道秒杀的商品就只有那么多,那么这么多的请求其实没有必要传递到数据库层,所以这里还需要一层东西,那就是消息队列。有多少秒杀的商品,就透多少个请求到数据库。如果库存不够了,那么队列里的其他请求就返回失败信息。

对于用户的读请求(因为很多请求都是这种读请求),完全可以利用缓存。例如商品的详情页,一般是不会变的,那么可以在redis里面以商品的id为key,对应的html页面为value来存储。还有可以把商品这个对象缓存到数据库中。以id为key,value为商品对象本身,通过序列化工具把商品对象序列化成字节数组。另外还可以缓存我们的秒杀地址(地址已进行md5加密)。

对于写请求,我们还可以把库存量存到redis中,每个商品以商品的id为key,对应的库存量为value来存储,然后利用redis的原子减操作去操作库存,然后记录下用户的id和商品的id,最后通过消息队列把秒杀请求消息,最后数据库消费消息并进行数据落地。

(4)数据(库)层

浏览器拦截了80%,站点层拦截了99.9%并做了页面缓存,服务层又做了写请求队列与数据缓存,每次透到数据库层的请求都是可控的。db基本就没什么压力了,闲庭信步,单机也能扛得住,还是那句话,库存是有限的,透这么多请求来数据库没有意义。全部透到数据库,100w个下单,0个成功,请求有效率0%。透3k个到数据,全部成功,请求有效率100%。

总结

优化思路:

(1)尽量将请求拦截在系统上游(越上游越好);

(2)读多写少的常用多使用缓存(缓存抗读压力);

浏览器和APP:做限速

站点层:按照uid做限速,做页面缓存

服务层:按照业务做写请求队列控制流量,做数据缓存

数据层:闲庭信步

并且:结合业务做优化

Q&A

  1. 如何解决超卖?

有如下几句减库存的逻辑:

1
2
3
4
5
6
7
8
@Tansactional
...
count = execute("select amount from stock where stockId = ...");
if (count <= 0) {
return "库存不够"
} else {
updateCount = execute("update stock set amount = amount -1 where stockId = ...");
}

在高并发的场景下,当两个线程进入这个事务,由于读操作产生共享锁,可以一起读,两个线程都读到库存大于0,假设现在它们都读到库存为1,那么在下一步中,它们都会去执行update操作,最终导致库存减为负数,这就导致了超卖。解决方案:

(1)优化sql语句:select语句去掉,在update语句中把判断库存的条件加上去。如:update stock set amount = amount -1 where stockId = ... and amount > 0。当两个进程进入到同一事务的这条update语句,行级锁发挥作用,将锁住这行数据交由一个线程操作。貌似这个效率会比较低。

(2)利用redis操作库存:前面提到,可以利用redis来进行减库存操作,这个是因为redis的自减操作是原子的,可以很好地保证线程安全。使用redis的事务,WATCH住存储库存量的key,在WATCH之后,EXEC执行之前,如果key的值发生变化,则EXEC会失败。redis的WATCH为何能够保证事务性,本质上,它使用的就是乐观锁CAS机制。

这里还有一个例子:减库存分为3各步骤,分别为查询、扣减和设置,如果此时扣减这个操作有重试机制,那么在重试时,可能会得到错误的数据,导致重复扣减。具体见《库存扣多了,到底怎么整 | 架构师之路》

  1. 秒杀接口地址如何设计?

用一个类来暴露秒杀接口,里面有md5字段用来加密秒杀地址,有是否暴露接口的标志,如果秒杀时间还没到,则不暴露,否则通过秒杀商品的id加上salt构造md5,使得用户不能猜到我们的秒杀地址从而提前进行秒杀。

  1. 先减库存后记录秒杀明细还是先记录秒杀明细后减库存?

实际上两者都可以。但是,在我的小项目上,使用的是后者。为什么?很重要的一点是可以减少网络传输等延迟。先减库存,当一个线程锁住了行级锁,那么其他线程都需要等待,返回update操作结果后,如果成功,这个线程开始insert操作。每个线程都需要这样的操作。当我先插入秒杀明细的时候,这个时候由于是insert操作,可以并发执行,所以这样减少来一部分的网络延迟,最后才是减库存。

  1. 哪个操作需要事务?

插入秒杀明细 + 减库存(由于这个项目里面只实现来这个核心功能,所以就只有一个事务)。由于明细表设置了商品id和用户的唯一标识作为联合主键,所以能有效避免重复秒杀,如果在插入秒杀明细不成功的时候,抛出异常并进行rollback。

由于这只是一个非常非常小的项目,但是对于理解秒杀的业务是有一定帮助的,上面我所说的并不一定是实际业务所一定采取的方案,所以到时候到公司里面接触到相关业务的时候会有更深的理解。

项目地址:https://github.com/fengberlin/seckill

参考

  1. 《秒杀系统架构优化思路》
  2. 《库存扣多了,到底怎么整 | 架构师之路》
  3. 《库存扣减还有这么多方案? | 架构师之路》
  4. 《秒杀系统架构分析与实战》
  5. 《“米粉节”背后的故事——小米网抢购系统开发实践》