秒杀系统设计分析

业务场景分析

业务场景:商品秒杀、商品抢购、群红包、抢优惠券、抽奖、买火车票

业务特点:瞬时售空、瞬时并发高、持续时间短、一般是商品定时上架

技术特点:读多写少、高并发、资源冲突

技术特点分析

读多写少:秒杀系统请求数量巨大,但是真正抢到资源的请求很少,所以是读多写少。使用缓存减缓对后端的压力,比如利用CDN将图片等页面静态资源缓存掉。

高并发:秒杀系统最关键的就是要利用限流,把大多数请求挡在外面;负载均衡将请求分摊给各个后端;利用消息队列中间件做异步削峰;缓存缓解后端压力。

资源冲突:保证关键资源(如商品库存)的原子操作。数据库锁(乐观锁、悲观锁),分布式锁(redis、zk),利用redis decr的原子操作。

请求链路分析

一次秒杀请求,首先是由客户端发起的,经过网络链路到达负载层,再由负载层分发到后端服务层,服务层处理业务逻辑之后,最终结果反映在数据库上。那么我们分析一下在整个请求链路上,针对秒杀系统,我们都有哪些解决方案。

秒杀请求链路

客户端层:客户端呈现的秒杀页面中包含的图片、音乐、商品详情等静态资源,可以在客户端缓存起来,避免每次都要请求服务端而增加后端压力。客户端需要控制好确认秒杀的按钮,如果秒杀过了要即时置灰,另外可以使用图形验证码防止恶意的接口秒杀。

网络层:对超大并发的秒杀,有必要购买CDN产品做静态资源的加速和缓存。

负载层:Nginx做负载均衡,也可以做动静分离,把对静态资源的请求重定向到指定服务;也可以在Nginx做反向代理缓存和限流(ngx_http_limit_req模块)。

服务层:服务端层可以使动态页面静态化,利用本地或分布式缓存,利用消息队列异步削峰,并做好对关键资源的原子操作。

数据库:数据库层可利用乐观锁或悲观锁实现原子操作。

最后需要注意的是,在做秒杀系统之前,要对并发量做预估,估计的标准主要是宣传和广告的力度、往年经验等。然而,有时候我们并不能准确预估并发量,为保险起见,秒杀系统要与主营业务分开部署,防止出现秒杀并发过大而影响主业务的情况。

乐观锁 vs. 悲观锁

假设有商品表goods,schema如下

1
2
3
4
5
6
7
CREATE TABLE `goods` (
`id` bigint(20) NOT NULL DEFAULT 0 AUTO_INCREMENT COMMENT '商品id',
`name` varchar(32) NOT NULL DEFAULT '' COMMENT '商品名称',
`count` int(11) NOT NULL DEFAULT 0 COMMENT '剩余库存数量',
`version` int(11) NOT NULL DEFAULT 0 COMMENT '版本号',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

goods表中id是1的数据为:

1
2
3
4
5
+-------+-----------+-----------+
| id | name | count | version |
+-------+-----------+-----------+
| 1 | toy | 100 | 0 |
+-------+-----------+-----------+

下面我们分析一下两个并发请求同时要买id是1的商品,使用以下3种锁时发生的情况。

使用乐观锁

2个请求都需要做以下2步db操作。

(1) select version from goods where id = 1

(2) update goods set count = count-1, version = version+1 where id = 1 and version = {version}

首先获取版本号,然后把库存减一、版本号加一,当然,更新成功的条件是当前版本号必须要等于之前获取到的版本号,否则更新失败。

请求1和请求2同时到来,此时二者获取到的版本号都是0,只有一个请求能成功执行步骤(2),假设是请求1,所以结果就是请求1生成订单、秒杀成功了,请求2秒杀失败。

使用带重试的乐观锁

带重试的乐观锁是指更新失败就立即重试,直到更新成功为止。

请求2失败后,如果立即进行重试,那么下次可能就会秒杀成功

使用悲观锁

如果使用悲观锁,首先会锁住商品,select * from goods where id = 1 for update,然后更新库存、生成订单。

乐观锁和悲观锁的选择

通常考虑以下几点

  • 响应速度
  • 冲突频率
  • 重试代价

如果要求响应速度快,尽量使用乐观锁,因为不用锁住这一行

如果高并发下秒杀数量很少的商品,比如2000并发抢10个商品,如果使用乐观锁冲突频率会很高,会有大量请求更新失败

如果重试代价很高,尽量不要使用带重试的乐观锁

一个小例子

假设有一场秒杀活动,并发预估为200,商品是儿童玩具,库存数量为100个。最终生成订单数量、卖出商品数量与并发控制方式的对应关系如下表所示。

并发控制方式 生成订单数量 卖出商品数量
不使用锁 200 <100
使用乐观锁 n (n<=100) n (n<=100)
使用带重试的乐观锁 100 100
使用悲观锁 100 100

正常情况下,生成订单的数量与卖出的商品数量要相同,否则就说明出现了数据不一致的情况,可以看到,使用乐观锁、重试乐观锁和悲观锁都能保证这一点。重试乐观锁和悲观锁都能保证库存中的所有商品都能被卖掉;而不带重试的乐观锁,由于失败后没有重试,因此很有可能出现商品没卖完的情况,通常来说机器的性能越高,并发处理能力就越强,冲突失败的请求就越多,卖出的商品就会越少。