设计秒杀系统是国内系统设计面试的高频题,开始之前,你需要了解如何设计一个基础的电商系统,因为秒杀系统只是在电商系统上增加了一些特定条件。
作者:Windson & Alex
本文版权归©海外兔所有,请勿转载。
目录
- 概述
- 从 0 到 1000
- 从 1000 到 100万
- 锁机制
- 消息队列
- 从电商系统到秒杀系统
- 面试中的回答
- 面试中可能出现的问题
概述
现在的电商系统功能繁多,除了最基本的购买商品功能,还有物流跟踪,订单管理,社区交互等功能。不过面试中关注的主要是购买商品功能,我们将其他次要功能归类为其他业务功能,购买商品流程如下:
- 客户通过客户端下单
- 如果下单成功则进入支付阶段,否则返回购买失败
- 进入支付阶段后,如果在一定时间内支付成功则返回购买成功,否则返回购买失败
从 0 到 1000
想象你自己从零搭建一个电商平台,一开始平台里的商品种类以及日订单量都较少,商品种类有 100 款,日订单量只有 1000 条。根据以上信息,我们可以设计出架构 1:
- 客户端发送下单请求给服务端
- 服务端查询数据库
- 若该商品库存大于零,将库存减一并且返回下单成功
- 若该商品库存等于零的话,返回下单失败
架构 1 简单直观,它忽略了系统可用性以及可扩展性,但在日订单量少,不会出现多位客户对同一件商品同时下单的情况下,它很好地完成了我们需要的功能。
从 1000 到 100万
经过一段时间后,你的电商平台商品种类增加到 1 万款,日订单量飙升到 100 万条,而且在高峰期,例如晚饭后,睡觉前的订单量会特别多。这是一个好的消息,不过同时你发现了一个问题,某些商品的成功下单量要大于库存量,也就是说出现了商品超卖的情况。这可是个严重的问题,因为没办法及时交货给客户对电商平台的信誉有极大影响。仔细分析架构 1 后,我们发现了问题的根源:当商品库存只剩下 1 件而有多位客户同时下单的时候,每个下单请求在查询的时候都发现库存大于零,并且将库存减 1 返回下单成功。下图中,在库存只有 1 件的时候,两个请求却都返回下单成功。
幸运的是,我们知道大部分并发问题都可以通过锁机制或者队列服务来解决:
锁机制
悲观锁
我们可以观察到,超卖问题的原因在于事务查询和更新库区期间,库存已经被其他事务修改了。在学习悲观锁之前,我们先了解下什么是两阶段加锁,两阶段加锁是一个典型的悲观锁策略:
两阶段加锁方法类似,但锁的强制性更高。多个事务可以同时读取同一对象,当只要出现任何写操作(包括修改或删除),则必须加锁以独占访问。—《数据密集型应用系统设计》
我们的电商系统中可以应用两阶段加锁,由于下单请求涉及到修改库存,可以先使用排他锁锁定记录,防止被其他事务所修改。大部分关系型数据库都提供这种功能(在 MySQL 和 PostgresSQL 里面的语法是 SELECT … FOR 下UPDATE)。流程如下图:
- 蓝色请求先获取排他锁,查询和更新库存,在此期间黑色请求等待获取排他锁。
- 蓝色请求更新库存后释放排他锁,返回下单成功
- 黑色请求获取排他锁,发现库存为 0,释放排他锁,返回下单失败
我们可以看到悲观锁成功解决了商品超卖问题,不过它的缺点也比较明显:1)处理性能不高,当一件商品有多位客户同时下单的时候,每个请求需要等待排他锁,也要较长才知道是否下单成功。2)容易发生死锁:在实际工程中,下单操作不只涉及了库存修改,还可能涉及其他业务功能,由于悲观锁下每个请求都轮流持有锁,应用层的代码处理不好的话会更容易发生死锁。
乐观锁
和悲观锁不同,乐观锁策略下事务会记录下查询时的版本号,当事务准备更新库存的时候,如果此时的版本号与查询时的版本号不同,则代表库存被其他事务修改了,这时候就会回滚事务,流程如下图:
- 蓝色请求与黑色请求查询库存,并记录库存版本号
- 蓝色请求先更新库存为 0,返回下单成功
- 黑色请求更新前发现版本与之前版本号不同,回归事务,返回下单失败
乐观锁因为并不需要等待锁,所以在事务竞争较少的情况下比悲观锁有更好的性能,缺点是事务竞争较多的情况下,由于经常需要回滚事务导致性能反而较差。
分布式锁
分布式锁在服务端以及数据库之间加上分布式组件来保证请求的并发安全,国内较常使用 Redis 或者 ZooKeeper。和悲观锁类似,每个请求需要先从组件中获取分布式锁之后才可以继续执行。流程如下图:
- 蓝色请求先获取分布式锁,查询和更新库存,在此期间黑色请求等待获取分布式锁
- 蓝色请求更新库存后释放分布式锁,返回下单成功
- 黑色请求获取分布式锁,查询库存,发现库存为 0,释放分布式锁,返回下单失败
分布式锁的优点是将功能进行分离,分布式组件负责解决并发安全的问题,数据库负责数据存储。不过缺点在于 1)分布式锁的正确实现并不简单,错误的实现方式容易引起其他一致性的问题。2)分布式锁在高并发下也会产生锁竞争的问题,性能不佳。3)由于引入了新的组件,要考虑分布式组件的可靠性,以及崩溃之后的恢复机制。
消息队列
另一个直观的解决方法就是使用消息队列,确保每个商品每个时刻只有一个请求,流程如下图:
- 蓝色请求进入队列,黑色请求进入队列,数据库订阅下单请求
- 数据库处理蓝色请求,蓝色请求查询和更新库存,返回下单成功
- 数据库处理黑色请求,查询库存,发现库存为 0,返回下单失败
消息队列的优点对业务进行了解耦,除了数据库之外,其他对下单请求感兴趣的业务系统,例如数据分析,日志记录等都可以订阅下单请求的消息。缺点在于 1)因为消息队列可能会崩溃,消息发送也可能失败,所以要考虑消息只消费一次,不会因为重复消费导致重复下单。2)由于引入了新的组件,要考虑消息队列的可靠性,以及崩溃之后的恢复机制。
对比两个方案的优缺点之后,队列服务更适合我们的电商系统,架构升级后,架构 2 如下:
- 客户端发送下单请求给服务端
- 服务端将请求发送到消息队列
- 数据库每次从消息队列取出请求
- 若该商品库存大于零,将库存减一
- 若该商品库存等于零的话,不做操作
- 服务端根据消息队列里的消息状态返回下单结果
从电商系统到秒杀系统
秒杀系统和电商系统有两个核心区别:
- 双十一也有极大的流量,但是双十一的商品种类很多,所以流量会分布到不同的商品中。而秒杀系统中,商品的种类和库存都比较少,导致大部分流量集中在少量商品中。
- 秒杀系统由于商品稀缺,价值高。同一位客户可能会对同一商品多次提交下单请求,而且恶意刷单的请求比较多,所以系统接收到的无效请求及非法请求较多。
针对这两个区别,我们发现架构 2 有 3 个潜在问题:
- 当一款商品库存只有 10 件却有 1 万名用户下单的时候,只有前 10 名客户可以下单成功,其他用户都浪费时间在队列等待以及无意义地查询库存,既牺牲了用户体验也增加了消息队列以及数据库的压力。
- 由于库存过少,有大量的请求(例如非法用户的请求,超过秒杀活动开始一定时间的请求)其实是没有机会抢到商品的,所以没有必要到达服务器,更不用说数据库了。
- 大量的客户端在下单前同时请求同一个商品的秒杀页面,导致服务器压力骤升。
针对这三个问题我们可以考虑两个方案:流量控制和资源隔离。
流量限制
第三个问题相对简单,可以将秒杀页面使用 CDN 缓存起来,客户端就可以直接从 CDN 获取到秒杀页面,不需要重复请求服务器。另外两个问题可以通过流量限制来解决,可以通过限流器,负载均衡以及安全验证组件实现:
- 限流器分为前端限流与后端限流:
- 前端限流包括验证答题,防止重复点击按钮等常见机制。
- 后端限流使用限流算法进行流量限制,简单情况下可以使用固定限流算法,例如秒杀商品的库存是 10 件,只要限流器接收到 10 * k(k 可以根据业务进行调整)个请求之后,就停止接受该商品的所有请求。这样无论有多少个下单请求,最终到达服务器的单个商品请求数量都不超过 10 * k。实际工程中,因为有客户可能会出现支付超时导致释放库存的情况,系统需要通知限流器接受新的请求。
- 负载均衡负责将下单请求通过负载均衡算法转发到最合适的服务器。
- 安全验证组件分为前端安全验证以及后端安全验证:
- 后端安全验证包括黑名单校验,IP 地址校验等机制。
- 前端安全验证包括:客户端账户验证(确保客户有资格参考秒杀活动),客户端版本安全验证(防止反编译以及修改客户端代码),秒杀接口动态生成(防止使用刷单脚本)等机制。
这时候系统的整体架构如下:
热门资源隔离
既然大部分流量集中在少量商品中,我们能不能针对这些商品进行特殊处理呢?这样既可以防止秒杀活动影响其他业务功能,也可以针对热门商品进行资源分配,答案是可以的,首先我们需要识别出热门商品,这里有两种常见的方法:
- 静态识别:包括京东在内的一些电商平台,客户在参加秒杀活动之前需要先进行预约,只有预约过的客户才能参考秒杀活动。这样系统可以提早识别热门商品以及进行流量预估。
- 动态识别:通过实时数据分析系统在秒杀活动前统计出现在较多客户浏览的热门商品,针对预估结果进行资源分配。
识别出热门商品之后,我们可以将热门商品的资源进行隔离,并且设置独立的策略,例如
- 使用特殊的限流器,由于秒杀系统的库存很少,在下单请求开始阶段就可以随机丢弃大部分请求。
- 使用单独的数据库,在架构 2 中,下单请求的处理速度受限于消费者的处理速度,也就是数据库的处理速度。我们可以对热门商品进行分库分表,这样可以将请求处理的压力分摊到多个数据库中。下图中,我们将秒杀系统的一些组件独立开来:
根据以上两个方案,我们可以设计出最后的架构 3:
- 客户端从 CDN 获取到秒杀页面
- 客户端发送下单请求给网关
- 在网关或者服务器前进行流量控制以及负载均衡等策略
- 服务端将请求发送到消息队列
- 数据库每次从消息队列取出请求
- 若该商品库存大于零,将库存减一
- 若该商品库存等于零的话,不做操作
- 服务端根据消息队列里的消息状态返回下单结果
面试中的回答方式
国内的系统设计面试更多只需要讲述思路,这整个系统从头到尾说一遍的话肯定不够时间,面试官主要想考核的有三个方向:
- 理解到系统设计问题的核心,使用合适的组件来解决问题
- 分析现有架构的瓶颈,从不同的方案中找到合适的解决方案
- 对于一些关键组件,了解里面的具体原理以及最佳实践
面试回答来说,可以分成四个部分:
1. 描述系统特点
清晰描述该系统的特点:
“秒杀系统的特点是大流量以及流量倾斜,大量流量会集中在少量的几种商品中。”
2. 理解核心问题
理解该系统的核心问题,基本所有系统都需要保证高可用,高扩展以及一致性,可以将其对应到具体问题:
“秒杀系统需要保证:
- 高可用:服务器不因为大流量而崩溃,同时秒杀业务不影响其他业务。
- 高扩展,架构适合水平扩展,在特殊活动前能够迅速扩容。
- 一致性:商品不出现超卖和少卖的问题。”
3. 提出主要方案
从整个架构中,能够总结出主要方案:
“要保证上述三个性质,主要方案有三个:
- 合理使用消息队列,既可以解决并发安全问题,也可以进行业务解耦,方便水平扩展。
- 前后端的流量限制,将大部分的无效流量拦在服务器之前。
- 热门资源隔离,针对热门商品进行独立处理以及资源分配。”
4. 根据面试官反馈回答
面试官会根据你的方案提出问题,可能是不同方案优缺点,也可能是具体组件的相关问题,你需要根据对整个系统的了解来进行回答,我们这里列出了一些面试中可能出现的问题以及解答:
“应该在什么时候扣除库存,是下单后扣除库存还是支付后扣除库存呢?为什么?”
应该在下单的时候扣除库存,如果在支付成功再扣除库存的话会出现下单请求成功数量大于库存的情况。
“对秒杀商品进行分库分表之后可能导致某个分表库存为零,但其他分表还有库存,如何解决这个问题?”
“有三种解决方案:
- 如果当前分表没有库存的话,到其他分表进行重试,缺点是会放大流量。
- 通过路由组件记录每个分表的库存情况,将下单请求转发到有库存的分表中。
- 使用分布式缓存记录每个分表的库存情况,并且每次下单请求只更新缓存,缓存后续再更新到数据库中,缺点是可能出现缓存和数据库不一致的问题。”
“客户下单后可能支付超时并释放库存,这时候有哪些要注意的?”
“服务器能够通知限流器以及前端库存发生变化,限流器能够重新接收请求,前端页面显示可下单的页面,确保后续的用户能继续购买商品。”
“消息队列方案有什么潜在问题吗?”
“秒杀系统下,可能 80% 的流量都指向同一个热门商品,那么消息队列中的分区会特别大,影响了两个方面 1)消息队列本身的稳定性,吞吐量会受单个分区限制,也可能影响其他业务。2)下单请求受到消费者消费能力的限制,即使消息队列每秒可以处理大量消息,但是数据库每秒处理的数量有限。可以使用以下几种方案:
- 压力测试:在前期压力测试的时候,模拟流量极端分布的情况,确保现有架构能够支持服务。
- 资源隔离:对秒杀商品使用独立的消息队列,使用特殊的流量限流策略,配置更好的资源。
- 合并下单请求:将多个下单请求合并成一个请求,再交给数据库处理。不过在实际工程中,下单业务可能比较复杂,不只包含扣减库存。所以合并逻辑会影响后续业务的可扩展性。
- 合并事务:将多个事务合并成一个事务执行,这样能有效减少数据库压力,缺点是逻辑会比较复杂,而且一个事务执行失败会影响多个订单。
“消息队列怎么保证消息有且仅生效一次(Exactly Once)?”
- 为了保证最少一次生效, 消费者需要下单成功后才能返回确认 ACK,否则有可能会丢失消息。
- 为了防止消息重复消费的问题,需要使下单逻辑变为幂等操作,常见的解决方案是保证下单请求有全局唯一的 ID,并在消息队列中对 ID 进行持久化,在发送给消费者之前先检查 ID 是否已经消费过。要注意中间层的重试机制不要修改这个全局唯一的 ID,不然会导致消息队列误以为该消息没有消费过。
“消息队列如何保证消息有序/分布式事务一致性/高可用?”
请参考国内外云平台文档的使用场景以及最佳实践:https://cloud.tencent.com/product/tdmq
“如何正确地实现分布式锁?”
了解 SETNX 的局限性以及 RedLock 的基本原理,具体请参考 https://redis.io/topics/distlock
“分布式锁和数据库悲观锁相比有什么优势?有什么共同的缺点?”
- 优点:加锁的操作不依赖数据库,降低数据库资源冲突的概率和压力。
- 共同缺点:可扩展性差,对于单个商品都是串行操作,假如每个订单执行要 100ms,每秒只能执行 10 个对应的订单,可能会出现大量请求阻塞的情况。
“如何保证缓存和数据库的一致性?”
请参考:https://www.pixelstech.net/article/1562504974-Consistency-between-Redis-Cache-and-SQL-Database
“如果电商系统流量过大,如何进行降级服务?”
- 暂停非核心业务:例如淘宝在双十一会暂时关闭退款功能。
- 拒绝服务:当系统压力到达一个阈值的的时候,随机丢弃部分秒杀请求。
- 减少重试:将重试次数降低甚至设置为0,否则容易造成雪崩效应,系统陷入负反馈循环,无法正常恢复。
“怎么测试你的方案,使用最小的资源实现一个稳定的秒杀系统?”
需要分析系统可能出现的瓶颈,并提出优化手段。
“上面的方案有哪些是需要人工运营的,有没有办法将它自动化?”
可以从自己熟悉的领域回答,例如分库分表,自动扩容,自动化测试等方向。
“你的方案还有哪些可以优化的地方?”
首先需要了解不同方案的优缺点,例如乐观锁与悲观锁的优缺点,锁机制与消息队列的优缺点。然后根据不同的基础架构,流量分布以及业务读写比例调整方案。
总结
希望现在你对如何设计一个秒杀系统有一定的了解,如果有哪些不清楚的地方,欢迎联系我们进行讨论。