如何设计一个秒杀系统
秒杀活动是指网络商家为促销等目的组织或网上限时抢购活动,这种活动具有瞬时并发量大、库存量少和业务逻辑简单等特点。设计一个秒杀系统需要考虑的因素很多,比如对现有业务的影响、网络带宽消耗以及超卖等因素。
选择学习这部分内容主要是因为秒杀、抽奖这些实际开发中会遇到的问题是面试的高频问题,而且整个设计过程确实涉及了很多方面的内容。本文会讨论秒杀系统的各个环节可能存在的问题以及解决方案。
概述
秒杀系统设计是国内系统设计面试的高频题,在面试中,你需要分析架构的瓶颈,潜在问题以及不同方案的优缺点,在本文的最后我们会提到这些面试技巧。开始之前,你需要了解如何设计一个基础的电商系统,秒杀系统只是在电商系统上增加了一些特定条件。现在的电商系统功能繁多,除了最基本的购买商品功能,还有物流跟踪,订单管理,社区交互等功能。不过面试中关注的主要是购买商品功能,我们将其他次要功能归类为其他业务功能,购买商品流程如下:
- 客户通过客户端下单
- 如果下单成功则进入支付阶段,否则返回购买失败
- 进入支付阶段后,如果在一定时间内支付成功则返回购买成功,否则返回购买失败
从0到1000
想象你自己从零搭建一个电商平台,一开始平台里的商品种类以及日订单量都较少,商品种类有 100 款,日订单量只有 1000 条。根据以上信息,我们可以设计出架构 1:
- 客户端发送下单请求给服务端
- 服务端查询数据库
- 若该商品库存大于零,将库存减一并且返回下单成功
- 若该商品库存等于零的话,返回下单失败
架构 1 简单直观,它忽略了系统可用性以及可扩展性,但在日订单量少,不会出现多位客户对同一件商品同时下单的情况下,它很好地完成了我们需要的功能。
从1000到100万
经过一段时间后,你的电商平台商品种类增加到 1 万款,日订单量飙升到 100 万条,而且在高峰期,例如晚饭后,睡觉前的订单量会特别多。这是一个好的消息,不过同时你发现了一个问题,某些商品的成功下单量要大于库存量,也就是说出现了商品超卖的情况。这可是个严重的问题,因为没办法及时交货给客户对电商平台的信誉有极大影响。仔细分析架构 1 后,我们发现了问题的根源:当商品库存只剩下 1 件而有多位客户同时下单的时候,每个下单请求在查询的时候都发现库存大于零,并且将库存减 1 返回下单成功。下图中,在库存只有 1 件的时候,两个请求却都返回下单成功。
幸运的是,我们知道大部分并发问题都可以通过锁机制或者队列服务来解决:
锁机制
悲观锁
我们可以观察到,超卖问题的原因在于事务查询和更新库区期间,库存已经被其他事务修改了。在学习悲观锁之前,我们先了解下什么是两阶段加锁,两阶段加锁是一个典型的悲观锁策略:
两阶段加锁方法类似,但锁的强制性更高。多个事务可以同时读取同一对象,当只要出现任何写操作(包括修改或删除),则必须加锁以独占访问。—-《数据密集型应用系统设计》
我们的电商系统中可以应用两阶段加锁,由于下单请求涉及到修改库存,可以先使用排他锁锁定记录,防止被其他事务所修改。大部分关系型数据库都提供这种功能(在 MySQL 和 PostgresSQL 里面的语法是 SELECT … FOR 下UPDATE)。流程如下图:
- 蓝色请求先获取排他锁,查询和更新库存,在此期间黑色请求等待获取排他锁。
- 蓝色请求更新库存后释放排他锁,返回下单成功
- 黑色请求获取排他锁,发现库存为 0,释放排他锁,返回下单失败
我们可以看到悲观锁成功解决了商品超卖问题,不过它的缺点也比较明显:
- 处理性能不高,当一件商品有多位客户同时下单的时候,每个请求需要等待排他锁,也要较长才知道是否下单成功。
- 容易发生死锁:在实际工程中,下单操作不只涉及了库存修改,还可能涉及其他业务功能,由于悲观锁下每个请求都轮流持有锁,应用层的代码处理不好的话会更容易发生死锁。
乐观锁
和悲观锁不同,乐观锁策略下事务会记录下查询时的版本号,当事务准备更新库存的时候,如果此时的版本号与查询时的版本号不同,则代表库存被其他事务修改了,这时候就会回滚事务,流程如下图:
- 蓝色请求与黑色请求查询库存,并记录库存版本号
- 蓝色请求先更新库存为 0,返回下单成功
- 黑色请求更新前发现版本与之前版本号不同,回归事务,返回下单失败
乐观锁因为并不需要等待锁,所以在事务竞争较少的情况下比悲观锁有更好的性能,缺点是事务竞争较多的情况下,由于经常需要回滚事务导致性能反而较差。
分布式锁
分布式锁在服务端以及数据库之间加上分布式组件来保证请求的并发安全,国内较常使用 Redis 或者 ZooKeeper。和悲观锁类似,每个请求需要先从组件中获取分布式锁之后才可以继续执行。流程如下图:
- 蓝色请求先获取分布式锁,查询和更新库存,在此期间黑色请求等待获取分布式锁
- 蓝色请求更新库存后释放分布式锁,返回下单成功
- 黑色请求获取分布式锁,查询库存,发现库存为 0,释放分布式锁,返回下单失败
分布式锁的优点是将功能进行分离,分布式组件负责解决并发安全的问题,数据库负责数据存储。不过缺点在于:
- 分布式锁的正确实现并不简单,错误的实现方式容易引起其他一致性的问题。
- 分布式锁在高并发下也会产生锁竞争的问题,性能不佳。
- 由于引入了新的组件,要考虑分布式组件的可靠性,以及崩溃之后的恢复机制。
消息队列
另一个直观的解决方法就是使用消息队列,确保每个商品每个时刻只有一个请求,流程如下图:
- 客户端发送下单请求给服务端
- 服务端将请求发送到消息队列
- 数据库每次从消息队列取出请求
- 若该商品库存大于零,将库存减一
- 若该商品库存等于零的话,不做操作
- 服务端根据消息队列里的消息状态返回下单结果
从电商系统到秒杀系统
秒杀系统和电商系统有两个核心区别:
- 双十一也有极大的流量,但是双十一的商品种类很多,所以流量会分布到不同的商品中。而秒杀系统中,商品的种类和库存都比较少,导致大部分流量集中在少量商品中。
- 秒杀系统由于商品稀缺,价值高。同一位客户可能会对同一商品多次提交下单请求,而且恶意刷单的请求比较多,所以系统接收到的无效请求及非法请求较多。
针对这两个区别,我们发现架构 2 有 3 个潜在问题:
- 当一款商品库存只有 10 件却有 1 万名用户下单的时候,只有前 10 名客户可以下单成功,其他用户都浪费时间在队列等待以及无意义地查询库存,既牺牲了用户体验也增加了消息队列以及数据库的压力。
- 由于库存过少,有大量的请求(例如非法用户的请求,超过秒杀活动开始一定时间的请求)其实是没有机会抢到商品的,所以没有必要到达服务器,更不用说数据库了。
- 大量的客户端在下单前同时请求同一个商品的秒杀页面,导致服务器压力骤升。
针对这三个问题我们可以考虑两个方案:流量控制和资源隔离。
流量控制
第三个问题相对简单,可以将秒杀页面使用 CDN 缓存起来,客户端就可以直接从 CDN 获取到秒杀页面,不需要重复请求服务器。另外两个问题可以通过流量限制来解决,可以通过限流器,负载均衡以及安全验证组件实现:
- 限流器分为前端限流与后端限流:
- 前端限流包括验证答题,防止重复点击按钮等常见机制。
- 后端限流使用限流算法进行流量限制,简单情况下可以使用固定限流算法,例如秒杀商品的库存是 10 件,只要限流器接收到 10 k(k 可以根据业务进行调整)个请求之后,就停止接受该商品的所有请求。这样无论有多少个下单请求,最终到达服务器的单个商品请求数量都不超过 10 k。实际工程中,因为有客户可能会出现支付超时导致释放库存的情况,系统需要通知限流器接受新的请求。
- 负载均衡负责将下单请求通过负载均衡算法转发到最合适的服务器。
- 安全验证组件分为前端安全验证以及后端安全验证:
- 后端安全验证包括黑名单校验,IP 地址校验等机制。
- 前端安全验证包括:客户端账户验证(确保客户有资格参考秒杀活动),客户端版本安全验证(防止反编译以及修改客户端代码),秒杀接口动态生成(防止使用刷单脚本)等机制。
这时候系统的整体架构如下:
热门资源隔离
既然大部分流量集中在少量商品中,我们能不能针对这些商品进行特殊处理呢?这样既可以防止秒杀活动影响其他业务功能,也可以针对热门商品进行资源分配,答案是可以的,首先我们需要识别出热门商品,这里有两种常见的方法:
- 静态识别:包括京东在内的一些电商平台,客户在参加秒杀活动之前需要先进行预约,只有预约过的客户才能参考秒杀活动。这样系统可以提早识别热门商品以及进行流量预估。
- 动态识别:通过实时数据分析系统在秒杀活动前统计出现在较多客户浏览的热门商品,针对预估结果进行资源分配。
识别出热门商品之后,我们可以将热门商品的资源进行隔离,并且设置独立的策略,例如
- 使用特殊的限流器,由于秒杀系统的库存很少,在下单请求开始阶段就可以随机丢弃大部分请求。
- 使用单独的数据库,在架构 2 中,下单请求的处理速度受限于消费者的处理速度,也就是数据库的处理速度。我们可以对热门商品进行分库分表,这样可以将请求处理的压力分摊到多个数据库中。下图中,我们将秒杀系统的一些组件独立开来:
根据以上两个方案,我们可以设计出最后的架构 3:
- 客户端从 CDN 获取到秒杀页面
- 客户端发送下单请求给网关
- 在网关或者服务器前进行流量控制以及负载均衡等策略
- 服务端将请求发送到消息队列
- 数据库每次从消息队列取出请求
- 若该商品库存大于零,将库存减一
- 若该商品库存等于零的话,不做操作
- 服务端根据消息队列里的消息状态返回下单结果
关于秒杀系统的大概设计流程已经讲完了,但这也就只能回答面试官一个问题——设计流程,如果细问,依旧还有很多细节要学习,下面我们来看更多的细节问题。
一般在秒杀时间点前几分钟,用户并发量才真正突增,达到秒杀时间点时,并发量才会到达顶峰。
但由于这类活动是大量用户抢少量商品的场景,必定会出现狼多肉少
的情况,所以其实绝大部分用户秒杀会失败,只有极少部分用户能够成功。
正常情况下,大部分用户会收到商品已经抢完的提醒,收到该提醒后,他们大概率不会在那个活动页面停留了,如此一来,用户并发量又会急剧下降。所以这个峰值持续的时间其实是非常短的,这样就会出现瞬时高并发的情况,下面用一张图直观的感受一下流量的变化:
像这种瞬时高并发的场景,传统的系统很难应对,我们需要设计一套全新的系统。可以从以下几个方面入手:
- 页面静态化
- CDN加速
- 缓存
- mq异步处理
- 限流
页面静态化
活动页面是用户流量的第一入口,所以是并发量最大的地方。
如果这些流量都能直接访问服务端,恐怕服务端会因为承受不住这么大的压力,而直接挂掉。
不知道你是否还记得,最早的秒杀系统其实是要刷新整体页面的,但后来秒杀的时候,你只要点击“刷新抢宝”按钮就够了,这种变化的本质就是动静分离,分离之后,客户端大幅度减少了请求的数据量。这不自然就“快”了吗?
活动页面绝大多数内容都是固定的,比如:商品名称、商品描述、图片等。为了减少不必要的服务端请求,通常情况下,会对活动页面做静态化
处理。用户浏览商品等常规操作,并不会请求到服务端。只有到了秒杀时间点,并且用户主动点了秒杀按钮才允许访问服务端。
这样能过滤大部分无效请求。
但只做页面静态化还不够,因为用户分布在全国各地,有些人在北京,有些人在成都,有些人在深圳,地域相差很远,网速各不相同。
如何才能让用户最快访问到活动页面呢?
这就需要使用CDN,它的全称是Content Delivery Network,即内容分发网络。
使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。
何为动静数据
那到底什么才是动静分离呢?所谓“动静分离”,其实就是把用户请求的数据(如HTML页面)划分为“动态数据”和“静态数据”。
简单来说,“动态数据”和“静态数据”的主要区别就是看页面中输出的数据是否和URL、浏览者、时间、地域相关,以及是否含有Cookie等私密数据。比如说:
- 很多媒体类的网站,某一篇文章的内容不管是你访问还是我访问,它都是一样的。所以它就是一个典型的静态数据,但是它是个动态页面。
- 我们如果现在访问淘宝的首页,每个人看到的页面可能都是不一样的,淘宝首页中包含了很多根据访问者特征推荐的信息,而这些个性化的数据就可以理解为动态数据了。
这里再强调一下,我们所说的静态数据,不能仅仅理解为传统意义上完全存在磁盘上的HTML页面,它也可能是经过Java系统产生的页面,但是它输出的页面本身不包含上面所说的那些因素。也就是所谓“动态”还是“静态”,并不是说数据本身是否动静,而是数据中是否含有和访问者相关的个性化数据。
还有一点要注意,就是页面中“不包含”,指的是“页面的HTML源码中不含有”,这一点务必要清楚。
理解了静态数据和动态数据,我估计你很容易就能想明白“动静分离”这个方案的来龙去脉了。分离了动静数据,我们就可以对分离出来的静态数据做缓存,有了缓存之后,静态数据的“访问效率”自然就提高了。
那么,怎样对静态数据做缓存呢?我在这里总结了几个重点。
第一,你应该把静态数据缓存到离用户最近的地方。静态数据就是那些相对不会变化的数据,因此我们可以把它们缓存起来。缓存到哪里呢?常见的就三种,用户浏览器里、CDN上或者在服务端的Cache中。你应该根据情况,把它们尽量缓存到离用户最近的地方。
第二,静态化改造就是要直接缓存HTTP连接。相较于普通的数据缓存而言,你肯定还听过系统的静态化改造。静态化改造是直接缓存HTTP连接而不是仅仅缓存数据,如下图所示,Web代理服务器根据请求URL,直接取出对应的HTTP响应头和响应体然后直接返回,这个响应过程简单得连HTTP协议都不用重新组装,甚至连HTTP请求头也不需要解析。
第三,让谁来缓存静态数据也很重要。不同语言写的Cache软件处理缓存数据的效率也各不相同。以Java为例,因为Java系统本身也有其弱点(比如不擅长处理大量连接请求,每个连接消耗的内存较多,Servlet容器解析HTTP协议较慢),所以你可以不在Java层做缓存,而是直接在Web服务器层上做,这样你就可以屏蔽Java语言层面的一些弱点;而相比起来,Web服务器(如Nginx、Apache、Varnish)也更擅长处理大并发的静态文件请求。
如何做动静分离的改造
理解了动静态数据的“why”和“what”,接下来我们就要看“how”了。我们如何把动态页面改造成适合缓存的静态页面呢?其实也很简单,就是去除前面所说的那几个影响因素,把它们单独分离出来,做动静分离。
下面,我以典型的商品详情系统为例来详细介绍。这里,你可以先打开京东或者淘宝的商品详情页,看看这个页面里都有哪些动静数据。我们从以下5个方面来分离出动态内容。
- URL唯一化。商品详情系统天然地就可以做到URL唯一化,比如每个商品都由ID来标识,那么 http://item.xxx.com/item.htm?id=xxxx 就可以作为唯一的URL标识。为啥要URL唯一呢?前面说了我们是要缓存整个HTTP连接,那么以什么作为Key呢?就以URL作为缓存的Key,例如以id=xxx这个格式进行区分。
- 分离浏览者相关的因素。浏览者相关的因素包括是否已登录,以及登录身份等,这些相关因素我们可以单独拆分出来,通过动态请求来获取。
- 分离时间因素。服务端输出的时间也通过动态请求获取。
- 异步化地域因素。详情页面上与地域相关的因素做成异步方式获取,当然你也可以通过动态请求方式获取,只是这里通过异步获取更合适。
- 去掉Cookie。服务端输出的页面包含的Cookie可以通过代码软件来删除,如Web服务器Varnish可以通过unset req.http.cookie 命令去掉Cookie。注意,这里说的去掉Cookie并不是用户端收到的页面就不含Cookie了,而是说,在缓存的静态数据中不含有Cookie。
分离出动态内容之后,如何组织这些内容页就变得非常关键了。这里我要提醒你一点,因为这其中很多动态内容都会被页面中的其他模块用到,如判断该用户是否已登录、用户ID是否匹配等,所以这个时候我们应该将这些信息JSON化(用JSON格式组织这些数据),以方便前端获取。
前面我们介绍里用缓存的方式来处理静态数据。而动态内容的处理通常有两种方案:ESI(Edge Side Includes)方案和CSI(Client Side Include)方案。
- ESI方案(或者SSI):即在Web代理服务器上做动态内容请求,并将请求插入到静态页面中,当用户拿到页面时已经是一个完整的页面了。这种方式对服务端性能有些影响,但是用户体验较好。
- CSI方案。即单独发起一个异步JavaScript 请求,以向服务端获取动态内容。这种方式服务端性能更佳,但是用户端页面可能会延时,体验稍差。
架构方案
根据架构上的复杂度,有3种方案可选:
- 实体机单机部署;
- 统一Cache层;
- 上CDN。
上面我们也谈到要使用 CDN 来实现动静分离,所以在这里着重讨论一下,其余方案就不在细讲了。
使用 CDN 我们将面临以下几个问题:
- 失效问题。前面我们也有提到过缓存时效的问题,不知道你有没有理解,我再来解释一下。谈到静态数据时,我说过一个关键词叫“相对不变”,它的言外之意是“可能会变化”。比如一篇文章,现在不变,但如果你发现个错别字,是不是就会变化了?如果你的缓存时效很长,那用户端在很长一段时间内看到的都是错的。所以,这个方案中也是,我们需要保证CDN可以在秒级时间内,让分布在全国各地的Cache同时失效,这对CDN的失效系统要求很高。
- 命中率问题。Cache最重要的一个衡量指标就是“高命中率”,不然Cache的存在就失去了意义。同样,如果将数据全部放到全国的CDN上,必然导致Cache分散,而Cache分散又会导致访问请求命中同一个Cache的可能性降低,那么命中率就成为一个问题。
- 发布更新问题。如果一个业务系统每周都有日常业务需要发布,那么发布系统必须足够简洁高效,而且你还要考虑有问题时快速回滚和排查问题的简便性。
从前面的分析来看,将商品详情系统放到全国的所有CDN节点上是不太现实的,因为存在失效问题、命中率问题以及系统的发布更新问题。那么是否可以选择若干个节点来尝试实施呢?答案是“可以”,但是这样的节点需要满足几个条件:
- 靠近访问量比较集中的地区;
- 离主站相对较远;
- 节点到主站间的网络比较好,而且稳定;
- 节点容量比较大,不会占用其他CDN太多的资源。
最后,还有一点也很重要,那就是:节点不要太多。
基于上面几个因素,选择CDN的二级Cache比较合适,因为二级Cache数量偏少,容量也更大,让用户的请求先回源的CDN的二级Cache中,如果没命中再回源站获取数据,部署方式如下图所示:
使用CDN的二级Cache作为缓存,可以达到和当前服务端静态化Cache类似的命中率,因为节点数不多,Cache不是很分散,访问量也比较集中,这样也就解决了命中率问题,同时能够给用户最好的访问体验,是当前比较理想的一种CDN化方案。
除此之外,CDN化部署方案还有以下几个特点:
- 把整个页面缓存在用户浏览器中;
- 如果强制刷新整个页面,也会请求CDN;
- 实际有效请求,只是用户对“刷新抢宝”按钮的点击。
这样就把90%的静态数据缓存在了用户端或者CDN上,当真正秒杀时,用户只需要点击特殊的“刷新抢宝”按钮,而不需要刷新整个页面。这样一来,系统只是向服务端请求很少的有效数据,而不需要重复请求大量的静态数据。
秒杀的动态数据和普通详情页面的动态数据相比更少,性能也提升了3倍以上。所以“抢宝”这种设计思路,让我们不用刷新页面就能够很好地请求到服务端最新的动态数据。
秒杀按钮
大部分用户怕错过秒杀时间点
,一般会提前进入活动页面。此时看到的秒杀按钮
是置灰,不可点击的。只有到了秒杀时间点那一时刻,秒杀按钮才会自动点亮,变成可点击的。
但此时很多用户已经迫不及待了,通过不停刷新页面,争取在第一时间看到秒杀按钮的点亮。
从前面得知,该活动页面是静态的。那么我们在静态页面中如何控制秒杀按钮,只在秒杀时间点时才点亮呢?
没错,使用js文件控制。
为了性能考虑,一般会将css、js和图片等静态资源文件提前缓存到CDN上,让用户能够就近访问秒杀页面。
看到这里,有些聪明的小伙伴,可能会问:CDN上的js文件是如何更新的?
秒杀开始之前,js标志为false,还有另外一个随机参数。
当秒杀开始的时候系统会生成一个新的js文件,此时标志为true,并且随机参数生成一个新值,然后同步给CDN。由于有了这个随机参数,CDN不会缓存数据,每次都能从CDN中获取最新的js代码。
此外,前端还可以加一个定时器,控制比如:10秒之内,只允许发起一次请求。如果用户点击了一次秒杀按钮,则在10秒之内置灰,不允许再次点击,等到过了时间限制,又允许重新点击该按钮。
读多写少
在秒杀的过程中,系统一般会先查一下库存是否足够,如果足够才允许下单,写数据库。如果不够,则直接返回该商品已经抢完。
由于大量用户抢少量商品,只有极少部分用户能够抢成功,所以绝大部分用户在秒杀时,库存其实是不足的,系统会直接返回该商品已经抢完。
这是非常典型的:读多写少
的场景。
如果有数十万的请求过来,同时通过数据库查缓存是否足够,此时数据库可能会挂掉。因为数据库的连接资源非常有限,比如:MySQL,无法同时支持这么多的连接。
而应该改用缓存,比如:redis。单个 Redis 可以处理几万的 QPS,如果预估请求的 QPS 大于几万,我们还可以使用 Redis 集群模式来增加 Redis 的处理能力。
在 Redis 存放和售卖商品数目大小相同的数字,秒杀服务每次访问数据库之前,都需要先去 Redis 中扣减库存,扣减成功才能继续更新数据库。这样,最终到的数据库的请求数目和需要售卖商品的数目基本一致,数据库的压力可以大大减少。
缓存问题
通常情况下,我们需要在redis中保存商品信息,里面包含:商品id、商品名称、规格属性、库存等信息,同时数据库中也要有相关信息,毕竟缓存并不完全可靠。
用户在点击秒杀按钮,请求秒杀接口的过程中,需要传入的商品id参数,然后服务端需要校验该商品是否合法。
大致流程如下图所示:
根据商品id,先从缓存中查询商品,如果商品存在,则参与秒杀。如果不存在,则需要从数据库中查询商品,如果存在,则将商品信息放入缓存,然后参与秒杀。如果商品不存在,则直接提示失败。
这个过程表面上看起来是OK的,但是如果深入分析一下会发现一些问题。
缓存击穿
比如商品A第一次秒杀时,缓存中是没有数据的,但数据库中有。虽说上面有如果从数据库中查到数据,则放入缓存的逻辑。
然而,在高并发下,同一时刻会有大量的请求,都在秒杀同一件商品,这些请求同时去查缓存中没有数据,然后又同时访问数据库。结果悲剧了,数据库可能扛不住压力,直接挂掉。
如何解决这个问题呢?
这就需要加锁,最好使用分布式锁。
当然,针对这种情况,最好在项目启动之前,先把缓存进行预热
。即事先把所有的商品,同步到缓存中,这样商品基本都能直接从缓存中获取到,就不会出现缓存击穿的问题了。
是不是上面加锁这一步可以不需要了?
表面上看起来,确实可以不需要。但如果缓存中设置的过期时间不对,缓存提前过期了,或者缓存被不小心删除了,如果不加速同样可能出现缓存击穿。
其实这里加锁,相当于买了一份保险。
缓存穿透
如果有大量的请求传入的商品id,在缓存中和数据库中都不存在,这些请求不就每次都会穿透过缓存,而直接访问数据库了。
由于前面已经加了锁,所以即使这里的并发量很大,也不会导致数据库直接挂掉。
但很显然这些请求的处理性能并不好,有没有更好的解决方案?
这时可以想到布隆过滤器
。
系统根据商品id,先从布隆过滤器中查询该id是否存在,如果存在则允许从缓存中查询数据,如果不存在,则直接返回失败。
虽说该方案可以解决缓存穿透问题,但是又会引出另外一个问题:布隆过滤器中的数据如何更缓存中的数据保持一致?
这就要求,如果缓存中数据有更新,则要及时同步到布隆过滤器中。如果数据同步失败了,还需要增加重试机制,而且跨数据源,能保证数据的实时一致性吗?
显然是不行的。
所以布隆过滤器绝大部分使用在缓存数据更新很少的场景中。
如果缓存数据更新非常频繁,又该如何处理呢?
这时,就需要把不存在的商品id也缓存起来。
下次,再有该商品id的请求过来,则也能从缓存中查到数据,只不过该数据比较特殊,表示商品不存在。需要特别注意的是,这种特殊缓存设置的超时时间应该尽量短一点。
库存问题
对于库存问题看似简单,实则里面还是有些东西。
真正的秒杀商品的场景,不是说扣完库存,就完事了,如果用户在一段时间内,还没完成支付,扣减的库存是要加回去的。
所以,在这里引出了一个预扣库存
的概念,预扣库存的主要流程如下:
扣减库存中除了上面说到的预扣库存
和回退库存
之外,还需要特别注意的是库存不足和库存超卖问题。
检测库存不足问题,无论是直接使用 MySQL 的操作还是 Redis 的操作都会出现同一个问题,查询操作和更新操作不是原子的,在高并发的情况下,这种问题是绝对不能出现的。如何解决?
最简单的方法就是加锁。
虽然加锁可以解决商品超卖的问题,但是效率太低了。还有没有更好的方法,那就是 lua 脚本。
我们都知道lua脚本,是能够保证原子性的,它跟redis一起配合使用,能够完美解决上面的问题。
lua脚本有段非常经典的代码:
1 | StringBuilder lua = new StringBuilder(); |
该代码的主要流程如下:
- 先判断商品id是否存在,如果不存在则直接返回。
- 获取该商品id的库存,判断库存如果是-1,则直接返回,表示不限制库存。
- 如果库存大于0,则扣减库存。
- 如果库存等于0,是直接返回,表示库存不足。
mq 异步处理
我们都知道在真实的秒杀场景中,有三个核心流程:
而这三个核心流程中,真正并发量大的是秒杀功能,下单和支付功能实际并发量很小。所以,我们在设计秒杀系统时,有必要把下单和支付功能从秒杀的主流程中拆分出来,特别是下单功能要做成mq异步处理的。而支付功能,比如支付宝支付,是业务场景本身保证的异步。
于是,秒杀后下单的流程变成如下:
如果使用 mq,需要关注以下几个问题:
消息丢失问题
秒杀成功了,往mq发送下单消息的时候,有可能会失败。原因有很多,比如:网络问题、broker挂了、mq服务端磁盘问题等。这些情况,都可能会造成消息丢失。
那么,如何防止消息丢失呢?
答:加一张消息发送表。
在生产者发送mq消息之前,先把该条消息写入消息发送表,初始状态是待处理,然后再发送mq消息。消费者消费消息时,处理完业务逻辑之后,再回调生产者的一个接口,修改消息状态为已处理。
如果生产者把消息写入消息发送表之后,再发送mq消息到mq服务端的过程中失败了,造成了消息丢失。
这时候,要如何处理呢?
答:使用job,增加重试机制。
用job每隔一段时间去查询消息发送表中状态为待处理的数据,然后重新发送mq消息。
重复消费问题
本来消费者消费消息时,在ack应答的时候,如果网络超时,本身就可能会消费重复的消息。但由于消息发送者增加了重试机制,会导致消费者重复消息的概率增大。
那么,如何解决重复消息问题呢?
答:加一张消息处理表。
消费者读到消息之后,先判断一下消息处理表,是否存在该消息,如果存在,表示是重复消费,则直接返回。如果不存在,则进行下单操作,接着将该消息写入消息处理表中,再返回。
有个比较关键的点是:下单和写消息处理表,要放在同一个事务中,保证原子操作。
垃圾消息问题
这套方案表面上看起来没有问题,但如果出现了消息消费失败的情况。比如:由于某些原因,消息消费者下单一直失败,一直不能回调状态变更接口,这样job会不停的重试发消息。最后,会产生大量的垃圾消息。
那么,如何解决这个问题呢?
每次在job重试时,需要先判断一下消息发送表中该消息的发送次数是否达到最大限制,如果达到了,则直接返回。如果没有达到,则将次数加1,然后发送消息。
这样如果出现异常,只会产生少量的垃圾消息,不会影响到正常的业务。
延迟消费问题
通常情况下,如果用户秒杀成功了,下单之后,在15分钟之内还未完成支付的话,该订单会被自动取消,回退库存。
那么,在15分钟内未完成支付,订单被自动取消的功能,要如何实现呢?
我们首先想到的可能是job,因为它比较简单。
但job有个问题,需要每隔一段时间处理一次,实时性不太好。还有更好的方案吗?
答:使用延迟队列。
我们都知道 RocketMQ,自带了延迟队列的功能。
下单时消息生产者会先生成订单,此时状态为待支付,然后会向延迟队列中发一条消息。达到了延迟时间,消息消费者读取消息之后,会查询该订单的状态是否为待支付。如果是待支付状态,则会更新订单状态为取消状态。如果不是待支付状态,说明该订单已经支付过了,则直接返回。
还有个关键点,用户完成支付之后,会修改订单状态为已支付。
如何限流?
通过秒杀活动,如果我们运气爆棚,可能会用非常低的价格买到不错的商品(这种概率堪比买福利彩票中大奖)。
但有些高手,并不会像我们一样老老实实,通过秒杀页面点击秒杀按钮,抢购商品。他们可能在自己的服务器上,模拟正常用户登录系统,跳过秒杀页面,直接调用秒杀接口。
如果是我们手动操作,一般情况下,一秒钟只能点击一次秒杀按钮。
但是如果是服务器,一秒钟可以请求成上千接口。
这种差距实在太明显了,如果不做任何限制,绝大部分商品可能是被机器抢到,而非正常的用户,有点不太公平。
所以,我们有必要识别这些非法请求,做一些限制。那么,我们该如何现在这些非法请求呢?
目前有两种常用的限流方式:
- 基于nginx限流
- 基于redis限流
对同一用户限流
为了防止某个用户,请求接口次数过于频繁,可以只针对该用户做限制。
限制同一个用户id,比如每分钟只能请求5次接口。
对同一ip限流
有时候只对某个用户限流是不够的,有些高手可以模拟多个用户请求,这种nginx就没法识别了。
这时需要加同一ip限流功能。
限制同一个ip,比如每分钟只能请求5次接口。
但这种限流方式可能会有误杀的情况,比如同一个公司或网吧的出口ip是相同的,如果里面有多个正常用户同时发起请求,有些用户可能会被限制住。
对接口限流
别以为限制了用户和ip就万事大吉,有些高手甚至可以使用代理,每次都请求都换一个ip。
这时可以限制请求的接口总次数。
在高并发场景下,这种限制对于系统的稳定性是非常有必要的。但可能由于有些非法请求次数太多,达到了该接口的请求上限,而影响其他的正常用户访问该接口。看起来有点得不偿失。
加入验证码
相对于上面三种方式,加验证码的方式可能更精准一些,同样能限制用户的访问频次,但好处是不会存在误杀的情况。
通常情况下,用户在请求之前,需要先输入验证码。用户发起请求之后,服务端会去校验该验证码是否正确。只有正确才允许进行下一步操作,否则直接返回,并且提示验证码错误。
此外,验证码一般是一次性的,同一个验证码只允许使用一次,不允许重复使用。
普通验证码,由于生成的数字或者图案比较简单,可能会被破解。优点是生成速度比较快,缺点是有安全隐患。
还有一个验证码叫做:移动滑块
,它生成速度比较慢,但比较安全,是目前各大互联网公司的首选。
提高业务门槛
上面说的加验证码虽然可以限制非法用户请求,但是有些影响用户体验。用户点击秒杀按钮前,还要先输入验证码,流程显得有点繁琐,秒杀功能的流程不是应该越简单越好吗?
其实,有时候达到某个目的,不一定非要通过技术手段,通过业务手段也一样。
12306刚开始的时候,全国人民都在同一时刻抢火车票,由于并发量太大,系统经常挂。后来,重构优化之后,将购买周期放长了,可以提前20天购买火车票,并且可以在9点、10、11点、12点等整点购买火车票。调整业务之后(当然技术也有很多调整),将之前集中的请求,分散开了,一下子降低了用户并发量。
回到这里,我们通过提高业务门槛,比如只有会员才能参与秒杀活动,普通注册用户没有权限。或者,只有等级到达3级以上的普通用户,才有资格参加该活动。
这样简单的提高一点门槛,即使是黄牛党也束手无策,他们总不可能为了参加一次秒杀活动,还另外花钱充值会员吧?
面试中的常见问题
- “应该在什么时候扣除库存,是下单后扣除库存还是支付后扣除库存呢?为什么?”
应该在下单的时候扣除库存,如果在支付成功再扣除库存的话会出现下单请求成功数量大于库存的情况。
- “对秒杀商品进行分库分表之后可能导致某个分表库存为零,但其他分表还有库存,如何解决这个问题?”
“有三种解决方案:
- 如果当前分表没有库存的话,到其他分表进行重试,缺点是会放大流量。
- 通过路由组件记录每个分表的库存情况,将下单请求转发到有库存的分表中。
- 使用分布式缓存记录每个分表的库存情况,并且每次下单请求只更新缓存,缓存后续再更新到数据库中,缺点是可能出现缓存和数据库不一致的问题。”
- “客户下单后可能支付超时并释放库存,这时候有哪些要注意的?”
“服务器能够通知限流器以及前端库存发生变化,限流器能够重新接收请求,前端页面显示可下单的页面,确保后续的用户能继续购买商品。”
- “消息队列方案有什么潜在问题吗?”
“秒杀系统下,可能 80% 的流量都指向同一个热门商品,那么消息队列中的分区会特别大,影响了两个方面:
消息队列本身的稳定性,吞吐量会受单个分区限制,也可能影响其他业务。
下单请求受到消费者消费能力的限制,即使消息队列每秒可以处理大量消息,但是数据库每秒处理的数量有限。可以使用以下几种方案:
压力测试:在前期压力测试的时候,模拟流量极端分布的情况,确保现有架构能够支持服务。
资源隔离:对秒杀商品使用独立的消息队列,使用特殊的流量限流策略,配置更好的资源。
合并下单请求:将多个下单请求合并成一个请求,再交给数据库处理。不过在实际工程中,下单业务可能比较复杂,不只包含扣减库存。所以合并逻辑会影响后续业务的可扩展性。
合并事务:将多个事务合并成一个事务执行,这样能有效减少数据库压力,缺点是逻辑会比较复杂,而且一个事务执行失败会影响多个订单。
“消息队列怎么保证消息有且仅生效一次(Exactly Once)?”
- 为了保证最少一次生效, 消费者需要下单成功后才能返回确认 ACK,否则有可能会丢失消息。
- 为了防止消息重复消费的问题,需要使下单逻辑变为幂等操作,常见的解决方案是保证下单请求有全局唯一的 ID,并在消息队列中对 ID 进行持久化,在发送给消费者之前先检查 ID 是否已经消费过。要注意中间层的重试机制不要修改这个全局唯一的 ID,不然会导致消息队列误以为该消息没有消费过。
“分布式锁和数据库悲观锁相比有什么优势?有什么共同的缺点?”
- 优点:加锁的操作不依赖数据库,降低数据库资源冲突的概率和压力。
- 共同缺点:可扩展性差,对于单个商品都是串行操作,假如每个订单执行要 100ms,每秒只能执行 10 个对应的订单,可能会出现大量请求阻塞的情况。
“如果电商系统流量过大,如何进行降级服务?”
- 暂停非核心业务:例如淘宝在双十一会暂时关闭退款功能。
- 拒绝服务:当系统压力到达一个阈值的的时候,随机丢弃部分秒杀请求。
- 减少重试:将重试次数降低甚至设置为0,否则容易造成雪崩效应,系统陷入负反馈循环,无法正常恢复。
总结
关于秒杀系统的设计,方法有很多种,本文中也就只介绍了很少的一部分。在架构方面还有更都需要去学习的,需要考虑的因素很多,二八原则、流量削峰等等都是很有趣的内容,当然在本文中也有提到过一些相关的设计。
参考
https://github.com/resumejob/How-to-design-a-spike-system?tab=readme-ov-file
https://juejin.cn/post/6990307911117307934