现在的电商系统功能繁多,除了最基本的购买商品功能,还有物流跟踪,订单管理,社区交互等功能。不过面试中关注的主要是购买商品功能,我们将其他次要功能归类为其他业务功能,购买商品流程如下:
想象你自己从零搭建一个电商平台,一开始平台里的商品种类以及日订单量都较少,商品种类有 100 款,日订单量只有 1000 条。
根据以上信息,我们可以设计出简单架构 1,下单流程如下所示:
架构 1 简单直观,它忽略了系统可用性以及可扩展性,但在日订单量少,不会出现多位客户对同一件商品同时下单的情况下,它很好地完成了我们需要的功能。
但是,经过一段时间后,你的电商平台商品种类增加到 1 万款,日订单量飙升到 100 万条,而且在高峰期,例如晚饭后,睡觉前的订单量会特别多。
这是一个好的消息,这说明你要发财了,不过同时你发现了一个问题,某些商品的成功下单量要大于库存量,也就是说出现了商品超卖的情况。这可是个严重的问题,因为没办法及时交货给客户对电商平台的信誉有极大影响。
仔细分析架构 1 后,我们发现了问题的根源:当商品库存只剩下 1 件而有多位客户同时下单的时候,每个下单请求在查询的时候都发现库存大于零,并且将库存减 1 返回下单成功。下图中,在库存只有 1 件的时候,两个请求却都返回下单成功。
这就是我们常说的并发问题,同时我们也知道的是大部分并发问题都可以通过锁机制或者队列服务来解决,下面我们用锁机制来解决这种数据并发问题:
我们可以观察到,超卖问题的原因在于事务查询和更新库区期间,库存已经被其他事务修改了。在学习悲观锁之前,我们先了解下什么是两阶段加锁,两阶段加锁是一个典型的悲观锁策略:
两阶段加锁方法类似,但锁的强制性更高。多个事务可以同时读取同一对象,当只要出现任何写操作(包括修改或删除),则必须加锁以独占访问。—《数据密集型应用系统设计》
我们的电商系统中可以应用两阶段加锁,由于下单请求涉及到修改库存,可以先使用排他锁锁定记录,防止被其他事务所修改。大部分关系型数据库都提供这种功能(在 MySQL 里面的语法是 SELECT … FOR UPDATE
)。流程如下图:
我们可以看到悲观锁成功解决了商品超卖问题,不过它的缺点也比较明显:
处理性能不高,当一件商品有多位客户同时下单的时候,每个请求需要等待排他锁,也要较长才知道是否下单成功。
容易发生死锁:在实际工程中,下单操作不只涉及了库存修改,还可能涉及其他业务功能,由于悲观锁下每个请求都轮流持有锁,应用层的代码处理不好的话会更容易发生死锁。
和悲观锁不同,乐观锁策略下事务会记录下查询时的版本号,当事务准备更新库存的时候,如果此时的版本号与查询时的版本号不同,则代表库存被其他事务修改了,这时候就会回滚事务,流程如下图:
乐观锁因为并不需要等待锁,所以在事务竞争较少的情况下比悲观锁有更好的性能,缺点是事务竞争较多的情况下,由于经常需要回滚事务导致性能反而较差。
分布式锁在服务端以及数据库之间加上分布式组件来保证请求的并发安全,国内较常使用 Redis 或者 ZooKeeper。和悲观锁类似,每个请求需要先从组件中获取分布式锁之后才可以继续执行。流程如下图:
分布式锁的优点是将功能进行分离,分布式组件负责解决并发安全的问题,数据库负责数据存储。
不过缺点在于:
分布式锁的正确实现并不简单,错误的实现方式容易引起其他一致性的问题。
分布式锁在高并发下也会产生锁竞争的问题,性能不佳。
由于引入了新的组件,要考虑分布式组件的可靠性,以及崩溃之后的恢复机制。
另一个直观的解决方法就是使用消息队列,确保每个商品每个时刻只有一个请求,流程如下图:
消息队列的优点对业务进行了解耦,除了数据库之外,其他对下单请求感兴趣的业务系统,例如数据分析,日志记录等都可以订阅下单请求的消息。缺点在于 1)因为消息队列可能会崩溃,消息发送也可能失败,所以要考虑消息只消费一次,不会因为重复消费导致重复下单。2)由于引入了新的组件,要考虑消息队列的可靠性,以及崩溃之后的恢复机制。
对比两个方案的优缺点之后,队列服务更适合我们的电商系统,架构升级后,最终 架构 2 如下:
秒杀系统和电商系统有两个核心区别:
针对这两个区别,我们发现架构 2 有 3 个潜在问题:
针对这三个问题我们可以考虑两个方案:流量控制和资源隔离。
第三个问题相对简单,可以将秒杀页面使用 CDN 缓存起来,客户端就可以直接从 CDN 获取到秒杀页面(那个静态页面),不需要重复请求服务器。另外两个问题可以通过流量限制来解决,可以通过限流器,负载均衡以及安全验证组件实现:
限流器分为前端限流与后端限流:
负载均衡负责将下单请求通过负载均衡算法转发到最合适的服务器。
安全验证组件分为前端安全验证以及后端安全验证:
这时候系统的整体架构如下:
既然大部分流量集中在少量商品中,我们能不能针对这些商品进行特殊处理呢?这样既可以防止秒杀活动影响其他业务功能,也可以针对热门商品进行资源分配,答案是可以的,首先我们需要识别出热门商品,这里有两种常见的方法:
识别出热门商品之后,我们可以将热门商品的资源进行隔离,并且设置独立的策略,例如
根据以上两个方案,我们可以设计出最后的架构 3:
秒杀系统的特点是大流量以及流量倾斜,大量流量会集中在少量的几种商品中。
秒杀系统需要保证:
要保证上述三个性质,主要方案有三个:
“应该在什么时候扣除库存,是下单后扣除库存还是支付后扣除库存呢?为什么?”
应该在下单的时候扣除库存,如果在支付成功再扣除库存的话会出现下单请求成功数量大于库存的情况。
“对秒杀商品进行分库分表之后可能导致某个分表库存为零,但其他分表还有库存,如何解决这个问题?”
“有三种解决方案:
- 如果当前分表没有库存的话,到其他分表进行重试,缺点是会放大流量。
- 通过路由组件记录每个分表的库存情况,将下单请求转发到有库存的分表中。
- 使用分布式缓存记录每个分表的库存情况,并且每次下单请求只更新缓存,缓存后续再更新到数据库中,缺点是可能出现缓存和数据库不一致的问题。”
“客户下单后可能支付超时并释放库存,这时候有哪些要注意的?”
“服务器能够通知限流器以及前端库存发生变化,限流器能够重新接收请求,前端页面显示可下单的页面,确保后续的用户能继续购买商品。”
“消息队列方案有什么潜在问题吗?”
“秒杀系统下,可能 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,否则容易造成雪崩效应,系统陷入负反馈循环,无法正常恢复。
“怎么测试你的方案,使用最小的资源实现一个稳定的秒杀系统?”
需要分析系统可能出现的瓶颈,并提出优化手段。
“上面的方案有哪些是需要人工运营的,有没有办法将它自动化?”
可以从自己熟悉的领域回答,例如分库分表,自动扩容,自动化测试等方向。
“你的方案还有哪些可以优化的地方?”
首先需要了解不同方案的优缺点,例如乐观锁与悲观锁的优缺点,锁机制与消息队列的优缺点。然后根据不同的基础架构,流量分布以及业务读写比例调整方案。