如何设计一个秒杀系统?

一、秒杀应该考虑哪些问题?
 1.1:超卖问题
假如备货只有100个,但是最终超卖了200,这就叫超卖。一般来讲秒杀系统的价格都比较低,如果超卖将严重影响公司的财产利益。
 1.2:高并发
秒杀具有时间短、并发量大得特点,而秒杀持续时间较短。所以短时间内会有大量请求涌进来,后端如何防止并发过高造成缓存击穿或者失效,击垮数据库都是需要考虑的问题。
 1.3:接口防刷
现在的秒杀大多都会出来针对秒杀对应的软件,这类软件会模拟不断向后台服务器发起请求,一秒几百次都是很常见的,如何 防止 这类软件的重复无效请求 ,防止不断发起的请求也是需要我们针对性考虑的。
 1.4:秒杀url
在未达到规定时间时,秒杀按钮是灰色的,如果此时用户通过F12查看浏览器得network看到秒杀得url,通过特定软件去请求也能实现秒杀。或者提前知道秒杀url的人,已请求就直接实现秒杀了。这个问题需要考虑解决。
1.5:数据库设计
每秒上万甚至十几万的QPS(每秒请求数)直接打到数据库,基本上都要把库打挂掉,而且你服务不单单是做秒杀的还涉及其他的业务,如果让它与我们的其他业务使用在同一个数据库中,耦合在一起,就很有可能牵连和影响其他的业务。
如何防止这类问题发生,就算秒杀发生了宕机、服务器卡死问题,也应该让他尽量不影响线上正常进行的业务。
 
二、设计与解决方案

2.1: 秒杀系统数据库设计

针对1.5提出的秒杀数据库的问题,因此应该单独设计一个秒杀数据库,防止因为秒杀活动的高并发访问拖垮整个网站。
这里只需要两张表,一张是秒杀订单表,一张是秒杀货品表

详解:如何设计出健壮的秒杀系统?

详解:如何设计出健壮的秒杀系统?

其实应该还有几张表,商品表: 可以关联goods_id查到具体的商品信息,商品图像、名称、平时价格、秒杀价格等,还有用户表:
根据用户user_id可以查询到用户昵称、用户手机号,收货地址等其他额外信息,这个具体就不给出实例了。

2.2: 秒杀url的设计

为了避免有程序访问经验的人通过下单页面url直接访问后台接口来秒杀货品,我们需要将秒杀的url实现动态化,即使是开发整个系统的人都无法在秒杀开始前知道秒杀的url。
具体的做法就是通过md5加密一串随机字符作为秒杀的url,然后前端访问后台获取具体的url,后台校验通过之后才可以继续秒杀。

2.3: redis预减库存

很多请求进来,都需要后台查询库存,这是一个频繁读的场景。 可以使用redis来预减库存,在秒杀开始前可以在redis设值
比如 redis.set(goodsId,100) ,这里预放的库存为100可以设值为常量,每次下单成功之后, Integer stock =
(Integer)redis.get(goosId) ; 然后判断sock的值,如果小于常量值就减去1。
不过注意当取消的时候,需要增加库存,增加库存的时候也得注意不能大于之间设定的总库存数(查询库存和扣减库存需要原子操作,此时可以借助lua脚本)下次下单再获取库存的时候,直接从redis里面查就可以了。

2.4:单体redis升级为集群redis

秒杀是一个读多写少的场景,使用redis做缓存再合适不过。
不过考虑到缓存击穿问题,我们应该构建redis集群,采用哨兵模式,可以提升redis的性能和可用性。

2.5:使用nginx

nginx是一个高性能web服务器,它的并发能力可以达到几万,而tomcat只有几百。
通过nginx映射客户端请求,再分发到后台tomcat服务器集群中可以大大提升并发能力。

2.6:精简sql

典型的一个场景是在进行扣减库存的时候,传统的做法是先查询库存,再去update。 这样的话需要两个sql,而实际上一个sql我们就可以完成的。
可以用这样的做法: update miaosha_goods set stock =stock-1 where goos_id ={#goods_id}
and version = #{version} and sock>0 ;
这样的话,就可以保证库存不会超卖并且一次更新库存,还有注意一点这里使用了版本号的乐观锁,相比较悲观锁,它的性能较好。

2.7:接口限流

秒杀最终的本质是数据库的更新,但是有很多大量无效的请求,我们最终要做的就是如何把这些无效的请求过滤掉,防止渗透到数据库。
限流的话,需要入手的方面很多:

2.7.1:前端限流

首先第一步就是通过前端限流,用户在秒杀按钮点击以后发起请求,那么在接下来的5秒是无法点击(通过设置按钮为disable)。
这一小举措开发起来成本很小,但是很有效。

2.7.2:同一个用户xx秒内重复请求直接拒绝

具体多少秒需要根据实际业务和秒杀的人数而定,一般限定为10秒。
具体的做法就是通过redis的键过期策略,首先对每个请求都从 String value = redis.get(userId) ;
如果获取到这个 value为空或者为null,表示它是有效的请求,然后放行这个请求。如果不为空表示它是重复性请求,直接丢掉这个请求。
如果有效,采用 redis.setexpire(userId,value,10).value
可以是任意值,一般放业务属性比较好,这个是设置以userId为key,10秒的过期时间(10秒后,key对应的值自动为null)

2.7.3:令牌桶算法限流

接口限流的策略有很多,我们这里采用令牌桶算法。
令牌桶算法的基本思路是每个请求尝试获取一个令牌,后端只处理持有令牌的请求,生产令牌的速度和效率我们都可以自己限定,guava提供了RateLimter的api供我们使用。

以下做一个简单的例子,注意需要引入guava


    public class TestRateLimiter {  

        public static void main(String[] args) {  
            //1秒产生1个令牌  
            final RateLimiter rateLimiter = RateLimiter.create(1);  
            for (int i = 0; i < 10; i++) {  
                //该方法会阻塞线程,直到令牌桶中能取到令牌为止才继续向下执行。  
                double waitTime= rateLimiter.acquire();  
                System.out.println("任务执行" + i + "等待时间" + waitTime);  
            }  
            System.out.println("执行结束");  
        }  
    }  

上面代码的思路就是通过RateLimiter来限定我们的令牌桶每秒产生1个令牌(生产的效率比较低),循环10次去执行任务。
acquire会阻塞当前线程直到获取到令牌,也就是如果任务没有获取到令牌,会一直等待。
那么请求就会卡在我们限定的时间内才可以继续往下走,这个方法返回的是线程具体等待的时间。

执行如下:

详解:如何设计出健壮的秒杀系统?
可以看到任务执行的过程中,第1个是无需等待的,因为已经在开始的第1秒生产出了令牌。
接下来的任务请求就必须等到令牌桶产生了令牌才可以继续往下执行。 如果没有获取到就会阻塞(有一个停顿的过程)。
不过这个方式不太好,因为用户如果在客户端请求,如果较多的话,直接后台在生产token就会卡顿(用户体验较差),它是不会抛弃任务的,我们需要一个更优秀的策略:
如果超过某个时间没有获取到,直接拒绝该任务 。

接下来再来个案例:


    public class TestRateLimiter2 {  

        public static void main(String[] args) {  
            final RateLimiter rateLimiter = RateLimiter.create(1);  

            for (int i = 0; i < 10; i++) {  
                long timeOut = (long) 0.5;  
                boolean isValid = rateLimiter.tryAcquire(timeOut, TimeUnit.SECONDS);  
                System.out.println("任务" + i + "执行是否有效:" + isValid);  
                if (!isValid) {  
                    continue;  
                }  
                System.out.println("任务" + i + "在执行");  
            }  
            System.out.println("结束");  
        }  
    }  

其中用到了tryAcquire方法,这个方法的主要作用是设定一个超时的时间,如果在指定的时间内 预估(注意是预估并不会真实的等待)
,如果能拿到令牌就返回true,如果拿不到就返回false。
然后我们让无效的直接跳过,这里设定每秒生产1个令牌,让每个任务尝试在
0.5秒获取令牌,如果获取不到,就直接跳过这个任务(放在秒杀环境里就是直接抛弃这个请求);

程序实际运行如下:

详解:如何设计出健壮的秒杀系统?

只有第1个获取到了令牌,顺利执行了,下面的基本都直接抛弃了,因为0.5秒内,令牌桶(1秒1个)来不及生产就肯定获取不到返回false了。
这个限流策略的效率有多高呢?
假如我们的并发请求是400万瞬间的请求,将令牌产生的效率设为每秒20个,每次尝试获取令牌的时间是0.05秒,那么最终测试下来的结果是,每次只会放行4个左右的请求,大量的请求会被拒绝,这就是令牌桶算法的优秀之处。

2.8:异步下单

为了提升下单的效率,并且防止下单服务的失败。 需要将下单这一操作进行异步处理。
最常采用的办法是使用队列,队列最显著的三个优点: 异步、削峰、解耦 。

sequenceDiagram
A->>B: How are you?
B->>A: Great!

这里可以采用rabbitmq,在后台经过了限流、库存校验之后,流入到这一步骤的就是有效请求。 然后发送到队列里,队列接受消息,异步下单。
下完单,入库没有问题可以用短信通知用户秒杀成功。 假如失败的话,可以采用补偿机制,重试。

2.9:服务降级

假如在秒杀过程中出现了某个服务器宕机,或者服务不可用,应该做好后备工作。 之前的博客里有介绍通过Hystrix进行服务熔断和降级,可以开发一个备用服务。
假如服务器真的宕机了,直接给用户一个友好的提示返回,而不是直接卡死,服务器错误等生硬的反馈。


三:总结

秒杀流程图: 详解:如何设计出健壮的秒杀系统?

这就是秒杀流程图,当然不同的秒杀体量针对的技术选型都不一样,这个流程可以支撑起几十万的流量,如果是成千万破亿那就得重新设计了。
比如数据库的分库分表、队列改成用kafka、redis增加集群数量等手段。


简单来说: 

前端:在秒杀之前,按钮置灰,并且不给前端真正的请求地址。前端定时请求后端接口,如果到了秒杀时间,则返回给前端真正的地址,前端放开按钮,每次点击后都要等X秒才能点击。
服务器:服务器用nginx做集群、redis也做集群。
限流:在秒杀之前,将秒杀数量的令牌存入到redis中,可以用list,每次来请求都去redis中取出令牌,如果获取到说明秒杀成功,然后去访问数据库下单,如果没有获取到,则说明商品卖完了。
消息中间件:如果秒杀数量比较多,例如上万十万,则秒杀成功之后,将成功的请求放入到mq或者kafka中间件中,再从消息队列中获取请求。
服务降级:为了以防万一,还是要做服务熔断降级。
 
posted @ 2021-12-27 15:13  ThisTFF  阅读(601)  评论(0)    收藏  举报