再见,秒杀

艾小仙

共 11774字,需浏览 24分钟

 · 2022-06-30

前言

最近心血来潮,想起前段时间公司举办的线下秒杀活动不理想,想研究一下秒杀系统的优化。当时活动现场有 200+ 会员,由于我们先前没有经验,各种原因导致用户在秒杀的时候 APP 页面白屏、卡死。业务部门想把手机甩我们开发脸上......当时我刚毕业也刚入职不久,不敢发表意见。现在逐渐膨胀,是时候重新设计一套秒杀系统了......

问题分析

有经验的同学看到 200+ 会员都出现白屏、卡死,可能会觉得公司技术太 low 。其实不然,公司系统架构还是很好的,大佬搭建了一套 SpringCloud 组件,都是比较新的版本。这次秒杀活动失利的确是之前没有这样的经验,很多代码考虑没到位,访问数据库的次数太多。虽然会员数是 200 ,但是会员从进入秒杀页面,点击秒杀商品,再到秒杀下单,这中间夸了几个微服务,对于数据库的访问远远不止 200 。

对代码分析之后,我们公司秒杀其实是和普通订单是同一个流程,只是加了一个秒杀 ID 字段。下单流程在一个事务里面各种校验、跨服务调用、加锁扣减库存、插入订单商品信息、物流配送信息......等。这样跨服务和多次访问数据库很明显无法满足秒杀业务瞬间流量巨大的特性。就像下面的图

上面就是我们下单的粗略流程,其实具体比这个要复杂,在每个微服务里面又调用了其他服务访问数据库。。。因为一个用户至少就是一个线程,当用户量过大线程数就很多,服务资源是有限的。当资源不够用的时候,后来的用户请求就会等待服务器释放资源处理,用户就会觉得卡。用户一旦觉得卡,就很可能会回退页面刷新,或者再次点击提交订单,然后请求又过来,又访问数据库,就会更卡。一次请求每多访问一次数据库,就需要更多的时间来处理,所以请求太多最终服务器处理不过来,前端得不到响应,用户屏幕会卡在那白屏。

还有一个关键原因是秒杀商品的查询,也是走的数据库,而且由于公司业务特殊性,不同地区的会员看到的商品不同等其他业务,导致秒杀商品的查询也比较慢,系统吞吐量低,最后可能导致用户白屏,流程和上面下单的差不多。说到底就是要提高系统吞吐量,让服务器尽快释放资源。

针对上述问题:在应对秒杀系统这样一瞬间的巨大流量,现在系统架构存在的核心问题:

  • 没有遵循服务单一原则,把秒杀功能做在订单服务里面,万一秒杀系统压力过大还会影响到正常的订单业务

  • 和普通订单一样巨大流量蜂拥而至加锁扣减库存,导致很多无效请求占用资源

  • 秒杀订单链接没有加密,给专业团队可趁之机

  • 大量操作直接操作数据库,频繁磁盘 IO,系统吞吐量很低,甚至有可能数据库挂掉

以上是几大核心问题,解决了这几个问题,基本上就可以实现较好的秒杀系统了。下面全面分析、解决秒杀系统问题:

服务单一职责

我们都知道秒杀的特性,瞬间流量巨大。如果将秒杀功能做在订单服务里面,万一秒杀占用的资源过多,或者秒杀功能直接把服务搞挂,正常订单业务也会受影响。所以秒杀要单独部署微服务

巨大流量处理

秒杀一瞬间的巨大流量不仅仅有广大用户正常请求,还有用户不必要的频繁点击、恶意用户、恶意攻击等。如果不做好处理很有可能请求还没到库存扣减那里,微服务集群就顶不住了。对于巨大的流量,采取适当的限流措施是很有必要的。常用限流方式有下面几种:

前端限流

我们要在巨大流量的基础上去尽可能减少一些不必要的流量,活动未开始的时候前端按钮就置灰,不让点击,活动开始之后限制用户点击按钮的频率。这样可以去除正常用户的大量不必要请求。

Nginx 限流

前端限流只能防止正常用户,但是有些恶意用户有点开发知识,通过网页获取 URL 来模拟请求,这里可以通过 Nginx 对同一 IP 做出每秒访问次数限制。

limit_req_zone $binary_remote_addr zone=one:10m rate=20r/s; #限制同一IP 允许访问20次/S

网关限流

根据现有的网关集群、秒杀服务集群,估算集群能够支撑的最大请求,然后限制流量。网关限流以 GateWay 为例,可以对 IP、用户、接口限流,这里可以选择对秒杀接口限流。常用的网关限流算法有,漏桶算法 和 令牌桶算法,我们选择使用令牌桶算法限流,因为这个算法可允许突发的瞬间流量处理,GateWay 内置了一个过滤器工厂配置 RequestRateLimiterGatewayFilterFactory 使用它即可,它需要依赖 Redis
<dependency>    <groupId>org.springframework.boot</groupId>    <artifatId>spring-boot-starter-data-redis-reactive</artifactId></dependency>

配置:

server:  port: 40000spring:  cloud:    gateway:      routes:        - id: sec_kill_route          uri: lb://hosjoy-b2b-seckill          predicates:          - Path=/seckill/**          filters:          - name: RequestRateLimiter            args:              key-resolver: '#{@apiKeyResolver}'  #从Spring容器中获取限流的Bean              redis-rate-limiter.replenishRate: 100 #令牌填充速率(每秒处理的请求)              redis-rate-limiter.burstCapacity: 3000 #令牌总容量(1秒内能允许的最大请求数)  application:    name: hosjoy-b2b-gateway  redis:    host: localhost    port: 6379    database: 0

代码:

@Beanpublic KeyResolver apiKeyResolver() {    return exchange -> Mono.just(exchange.getRequest().getPath().value());}

如果公司有使用阿里的 Sentinel 组件,这里也可以在网关层使用 Sentinel 做限流,功能强大,方便监控、熔断、降级,使用非常方便!

集群实例扩充

没有什么流量是服务实例个数解决不了的,如果有,那么就继续加服务实例......这样通过增加服务实例来对应大流量也可以变相的达到限流效果 。

应对恶意请求

相信大家都有所耳闻,有些 “专业团队” ,专门通过代别人抢茅台等商品牟利。对于这种团队,他们不仅有多 IP ,甚至还有可能有多账户!就是通过各种渠道低价购买正常用户的账号来逃避风控系统。对于这样的 “专业团队”,单纯的限流不能完全解决这个问题,你想一下,有可能发生这种情况,这些恶意的请求被处理了,抢到了商品,但是广大用户没有抢到,这个问题就很严重。对于这种情况,我们可以采取两种方案:

秒杀链接加密

在秒杀控制器的传参中,我们一般会接受场次 ID、商品 ID,可以再多加一个和商品匹配的密码参数,只有密码正确才能继续流程,否则记录密码错误次数自增。这个密码是在秒杀场次和商品上架的时候随机生成,就连开发这个功能的人都不知道!
@PostMapping("/sec-kill")    public void secKill(@RequestParam("secId") Long secId,@RequestParam("productId") Long productId,@RequestParam("password") String password){        SecProductResponse secProduct = (SecProductResponse) redisTemplate.opsForValue().get("secId:productId");//场次商品信息        if(secProduct.getPassword().equals(password)) { //校验秒杀商品密码            //...        } else {            stringRedisTemplate.opsForValue().increment("black:secId:productId:userId");        }}

黑名单过滤

在网关层校验这个用户是不是黑名单,如果是,直接结束。
String s = stringRedisTemplate.opsForValue().get("secId:black:userId");if(s != null && Integer.parseInt(s) >= maxCount){    return;}

防止用户重复购买

一般来说秒杀活动对于同一个用户的购买是有限制的,如果已经购买过,那么这个用户就不应该继续购买。虽然前端已经做了限制,但是为了防止专业人士,这里在后端也要进行限制。我们可以使用 Redis 来实现

Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent("secId:productId:userId", 1, time, TimeUnit.SECONDS);if(flag){    //...}

超卖控制、库存扣减

秒杀商品发生超卖是个很可怕的事情,因为秒杀商品本身就很优惠。为了吸引流量以极低的秒杀价格售卖,甚至亏本。如果一旦超卖,公司或者商家可能亏的血本无归。为了控制超卖,我们可以在扣除库存的时候加锁。但是这里必须要加分布式锁,使用本地锁不能控制住使用分布式锁避免并发造成库存扣减超卖。但是如此一来系统吞吐量会有所下降。我们原来就是跨服务扣减库存,加分布式锁,还访问的数据库,拿大腿都能想到这个对于秒杀的请求量肯定不合适。现在我们在秒杀接口里面虽然可以优化到不跨服务访问数据库了,所以使用分布式锁也能解决这个问题。但是既然是锁,就有资源消耗。有没有不使用分布式锁的方案呢?所以我们可以换个角度考虑这个问题,秒杀商品库存一般都是有一定数量限制的,并且秒杀库存远小于商品可售卖库存。我们可以把这个秒杀库存的数量提前保存在 Redis 里面,然后用 Redis 来预先扣减库存,库存一旦扣减完,就返回秒杀结束,已抢完。如此一来,我们在这里有多少库存就会放进来多少请求,剩余的无效请求全部返回。不但防止了超卖,还做了流量限制,相对于原来的蜂拥而至排队扣减库存模式,这样吞吐量极高。我们可以采用 Redis 的分布式信号量实现,这里可以使用 Redisson 来做具体代码实现

RSemaphore semaphore = redissonClient.getSemaphore("secId:productId");boolean success=false;try {    success = semaphore.tryAcquire(1,50,TimeUnit.MILLISECONDS);} catch (InterruptedException e) {    log.error(e);    return;}if(success){    //生成订单号    //发送消息到MQ}

其实分布式信号量也可以算是一种分布式锁,但是它的性能极高,获取一次信号量几乎是 0 - 1 ms,基本不会影响系统吞吐量。

流量削峰

经过上面重重关卡,最后调用订单服务的请求数和秒杀商品的库存数量一样。假设 100 万人抢 400 茅台,那么就有 400 请求要调用订单服务,400 并发下单的话,由于还有一系列业务处理,并发访问数据库,其实又回到了最初的模式。在秒杀接口里面访问数据库,这样吞吐量是很低的,还有可能打挂数据库。我们应该让秒杀接口的操作全部走 Redis 。这里我们可以使用消息队列来做 为什么使用消息队列?使用 MQ 来削峰,平缓消费创建订单,将峰值流量散开。由于消息队列在强大并发下可能会造成消息丢失等问题,具体可参考 RabbitMQ 可靠性、重复消费、顺序性、消息积压解决方案

数据库分表分库

一般来说以上就能实现较好的秒杀系统效果了,如果公司数据量很大,业务很复杂。甚至 MQ 异步消费访问数据库也不能解决的话,那么就用读写分离,读库和写库分开,有效降低数据库压力。还可以去对数据库分表、分库来提升单表并发能力和磁盘 IO 读写性能。

解决以上问题,秒杀流程基本就 OK 了,其实上面的伪代码都很简单,真实实现的话,代码也不复杂,只是要合理的设计方案,该屏蔽过滤的请求就屏蔽过滤,不该访问数据库的不访问即可。下面具体看下这几个环节的流程图

商品上架/库存回退

商品上架其实很简单,我们只需要把需要的信息存入 Redis 即可。不过不同公司有不同的业务,比如我公司的业务 B → b → c 的模式,秒杀商品、活动是有区域的,就是说一场活动可能会发生,经营区域在 A、B、C 三个市的会员店可以参与,其他区域的会员店不可以参与。所以针对这种情况,我们需要把秒杀场次信息在所有可允许的区域都要存储一份,就像下面这样

//场次信息redisTemplate.opsForValue().set("province:cityId:secId","data");//商品信息redisTemplate.opsForValue().set("secId:productId","data");//库存信息redisTemplate.opsForValue().set("stock:secId:productId:password","data");

那么你可能会说,这得存多少 Redis 的 Key 啊......的确,如果场次多一点,选择的区域多一点,是要存不少 key 。计算一下,据 2016 年统计,中国总共好像是 293 个市,按 300 算。假设最近三天有 30 场秒杀活动, 每场活动有 10 个商品 。那么总共需要的 key 数量的计算方法为 城市场次 + 场次商品 + 场次商品库存 = 300 * 30 + 30 * 10 + 30 * 10 = 9600

再按照三天内扫描前后三天再乘个三好了,也就 30000 不到的 key。你觉得这个数量多吗?我们来看看官方对于 Redis 存储 key 数量给的描述:

Redis can handle up to 2^32 keys, and was tested in practice to handle at least 250 million >keys per instance. Every hash, list, set, and sorted set, can hold 2^32 elements. In other words your limit is likely the available memory in your system.

来源于 Redis 官网

官方说 Redis 理论上能存储 2^32 个 key ,实际测试中一个实例至少存储 2.5 亿的 key 。最后一句:你的限制其实是你系统的可用内存而已......而且这还只是一个 Redis 实例的数据。所以说不要太小看 Redis ,人家官网声称性能极高,读的速度是110000次/s,写的速度是81000次/s 。而且,如果一个互联网公司在当今缓存界对于 Redis 这么牛逼的缓存中间件的使用量很少,那么一般来说,业务用户量是有限的。不过有一点需要注意,一旦业务大量使用 Redis 作为缓存中间件,必须至少要防止三件事 Redis 实战应用篇 — 缓存雪崩、缓存击穿、缓存穿透和数据一致性

因为秒杀活动有一种业务场景是没卖完,虽然这有些尴尬......但是不得不考虑,这里需要在场次结束之后,把没有卖完的库存从 Redis 回退到库存表里面。

如上图,配置定时任务定期扫描近三天要秒杀的场次,然后上架,注意不要重复上架。上架主要是将上图的信息保存到 Redis,然后对于每个场次结束的商品发送延迟消息,在消费者里面判断如果信号量不为 0,就说明秒杀活动没有卖完,需要把库存回退,然后删除 Redis 中的信号量。

秒杀商品查询

由于秒杀活动查询频繁、巨大流量,千万不能去数据库查询商品信息。所有查询操作走 Redis ,注意在活动开始之前不要返回商品密码字段。

这里有一点需要注意的地方,因为页面上活动开始之前购买按钮是置灰的,所以在秒杀开始的前一秒,需要去请求一次服务器获取商品密码。假设有十万人准备抢购,那就有十万次请求发到服务器。其实十万次请求到是没什么问题,因为你既然有十万人准备抢购,就得有十万请求要到服务器,如果你在这里觉得十万次请求到服务器不太好,那么你的秒杀接口不是一样要放十万请求到服务器吗?所以关键的问题不是请求数量,而是请求的错峰。就是说你前端不能让十万客户端在真正相同毫秒级别的时间把请求发过来,比如 2021-05-01 00:00:00 有一场秒杀活动,那么前端在 2021-04-30 23:59:58 或者 59 的时候就可以发请求了,但是这里要精确到毫秒去发,1 s = 1000 ms ,前端可以在这 1000-2000 毫秒内错开十万的请求量,这样十万的请求量不在同一个毫秒级别的时间,服务器压力会小一些,而且服务器是走 Redis 查询的,响应时间应该 10 - 20 ms 就可以。拿到商品密码之后判断当前时间是否到达秒杀开始时间,如果到了就恢复按钮状态,如果没到就等时间到了再恢复按钮就行了。

秒杀流程

下面就是具体的秒杀流程详细图,按顺序描述每一节点要考虑的问题以及解决方案


秒杀流程的伪代码:

@Autowired    private RedisTemplate<String,Object> redisTemplate;    /**     * 秒杀流程     * */    @PostMapping("/sec-kill")    public void secKill(@RequestParam("secId") Long secId,@RequestParam("productId") Long productId,@RequestParam("password") String password){        SecResponse sec = (SecResponse) redisTemplate.opsForValue().get("secId");//场次信息        LocalDateTime now = LocalDateTime.now();        if(now.isAfter(sec.getStartTime()) && now.isBefore(sec.getEndTime())){ //校验已开始            SecProductResponse secProduct = (SecProductResponse) redisTemplate.opsForValue().get("secId:productId");//场次商品信息            if(secProduct.getPassword().equals(password)){ //校验秒杀商品密码                Duration duration = Duration.between(sec.getStartTime(), sec.getEndTime());                int random = (int)(Math.random() * 100);                long period = duration.getSeconds() + random;                Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent("secId:productId:userId", "1", period, TimeUnit.SECONDS);                if(flag != null && flag){ //校验已购买                    RSemaphore semaphore = redissonClient.getSemaphore("secId:productId:password" );                    try {                        //尝试在50ms内获取信号量                        boolean acquire = semaphore.tryAcquire(num,50, TimeUnit.MILLISECONDS);                        if(acquire){ //抢到库存                            String orderNo = generateOrderNo();//生成订单号                            rabbitTemplate.convertAndSend("hosjoy-b2b-secKill","routingKey","data");//发送消息                        } else{                            //如果没抢到,删除已购买的标识(其实不删也没什么问题)                            stringRedisTemplate.delete("secId:productId:userId");                        }                    } catch (InterruptedException e) {}                }            } else {                stringRedisTemplate.opsForValue().increment("black:secId:productId:userId");            }        }    }


以上就是大致的秒杀流程代码,也是我觉得比较好的秒杀流程,设计完之后请同事大佬指点(咳咳,其实我是想装个 X,嘘!)了一下。我与他的想法或者说设计思路有主要两点不同


  • 实现 Redis 库存的数据结构

  • 什么时候算秒杀成功

他使用的是 Redis 的 List 数据结构来存放库存,比如有 100 个库存就 leftPush 100 个商品 id。然后通过 pop 的方式去扣减库存。我对比了一下分布式信号量 Semaphore 和 List 结构,两者都可以实现,用起来也都很方便,还有个 incr 和 decr 自增自减其实也可以,但是这都是默认针对秒杀商品只能秒杀一件的 。如果说业务允许秒杀可以购买多件商品,那么 List 和 decr 就必须要加分布式锁来控制了,如此一来会让系统的吞吐量就相对被降低了。因为 List 一次只能弹出一个元素,decr 虽然可以传参数扣减,但是可以减到负数的。假设 A 用户秒杀 5 件,库存现在只有 4 件,B 用户秒杀 2 件,理论上 A 是秒杀失败,但是 B 应该秒杀成功,如果不加分布式锁,A 把库存减到 -1 ,发现不对,要把库存加回去,此时 B 秒杀 2 件,发现库存已经是 -1 了,也秒杀失败,这就有问题了。所以......分布式信号量牛逼啊!

还有个区别是他有个用户购买之后排队的概念来校验重复购买,我这直接 setIfAbsent 来校验,这个区别其实无关紧要,重要的是什么时候算秒杀成功。

我的设计思路

以我的设计方案,只要用户尝试获取信号量成功,就算秒杀成功,但是这里其实可以不用立即返回告诉用户,最好让用户手机继续转圈 1-2 秒之后告诉他秒杀成功,因为 MQ 发送消息到消费成功有一定时间,如果立即告诉用户秒杀成功,而订单还在生成中,可能会给用户带来不好的用户体验。等 1-2 秒之后 MQ 消息消费完成订单也生成成功,此时正好用户收到秒杀成功,订单也生成成功就很 NICE!

那么你可能会有疑问,如果消费者生成订单报错了怎么办?不得不说,这是个必须考虑的问题,毕竟 MQ 的消费说不准。这里当然我也考虑到了这种情况,如果消费失败首先采取重试,如果重试 3 次仍然失败,那说明这里产生了代码问题导致订单生成失败,记录下来报错消息,然后人工查询错误,恢复用户订单即可。毕竟这是个小概率的事情,也不会有一堆订单消费失败吧?更何况人家本来就是在秒杀服务抢到了库存,既然抢到了我就算他秒杀成功了,订单由于其他原因生成失败,我给他手动生成订单,保证最终一致性即可,不然怎么跟用户交代?

同事的设计思路

而同事他说不应该这么设计,应该设计为用户抢到信号量只是有一个秒杀机会,具体秒杀成功与否要看订单服务消费的结果。如果订单服务消费失败,就回滚秒杀库存到 Redis ,让其他用户来抢,因为可能会存在业务校验不通过,用户没有购买资格。不得不说,他考虑问题一向很周到,我从跟他后面做项目开始到现在也成长很多,他真的是实力很强的大佬!

不过我的设计初衷是没有考虑到有业务校验用户没有资格购买商品,为什么会有用户没有资格购买商品……这特么什么业务场景,既然没资格买为什么要让他看到秒杀活动?。但是仔细一想,这样根据订单生成结果判定秒杀结果其实是有点问题的。

存在的问题

  • 假设 100 万人抢 400 茅台,本来全部抢完之后你提醒没抢到的用户秒杀商品已抢完了。但是订单服务那边消费到第 399 和 400 个消息的时候失败了,回滚了订单,回滚了库存到 Redis 。如果是因为业务校验未通过,那我认为是否应该不让用户看见这个活动,或者想办法在抢到秒杀机会之前就提示用户没有参加资格会比较好

  • 此时消费到第 399 和 400 消息大约过了 3-5 秒,你把它回滚了。正常用户刚开始看没抢到,可能都走了,这还有可能发生少卖。

  • 如果不是因为业务校验的问题,而是代码问题导致的报错,这时回滚了订单,感觉这个用户有点惨啊,明明是系统问题,却让用户背锅......

  • 如果该用户由于代码问题被回滚了订单,然后去秒杀商品页面又看到了库存剩余再次秒杀,然后再次失败,再次秒杀,再次失败......如此循环下去,我觉得他的内心是崩溃的......,不过这个概率很小

看到这里大伙可能会觉得,我靠这个博主太不要脸了,就挑别人的刺,不考虑自己的问题


emmm 我怎么会是这种人呢......

我的方案存在的问题

  • 需要有人去关注秒杀活动,虽然出错的概率比较小,但是一旦订单服务报错,你得有人去尽快生成/恢复订单,耗费人力。如果恢复了订单,用户最后不支付的话,那这个人力资源相当于白费了呀。。。

  • 未支付就在设计逻辑上算用户秒杀成功,这样可能领导听起来不太能接受,如果先让用户支付,支付完成才算秒杀成功,然后去生成订单,这样领导应该会很赞同......这个看起来没问题,实际实现细节上有没有问题还没有研究过,毕竟天猫、淘宝也是先生成订单才去支付的,等第二版更新。

个人觉得每个人的方案都可能存在一定的局限、问题,毕竟没有完美的方案,只能最后根据实际业务情况或者公司所有同事一起讨论去选用一种更为符合的设计方案,或者在此基础上再做优化。

浏览 36
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报