业务场景分析
业务场景:商品秒杀、商品抢购、群红包、抢优惠券、抽奖、买火车票
业务特点:瞬时售空、瞬时并发高、持续时间短、一般是商品定时上架
技术特点:读多写少、高并发、资源冲突
技术特点分析
读多写少:秒杀系统请求数量巨大,但是真正抢到资源的请求很少,所以是读多写少。使用缓存减缓对后端的压力,比如利用CDN将图片等页面静态资源缓存掉。
高并发:秒杀系统最关键的就是要利用限流,把大多数请求挡在外面;负载均衡将请求分摊给各个后端;利用消息队列中间件做异步削峰;缓存缓解后端压力。
资源冲突:保证关键资源(如商品库存)的原子操作。数据库锁(乐观锁、悲观锁),分布式锁(redis、zk),利用redis decr的原子操作。
请求链路分析
一次秒杀请求,首先是由客户端发起的,经过网络链路到达负载层,再由负载层分发到后端服务层,服务层处理业务逻辑之后,最终结果反映在数据库上。那么我们分析一下在整个请求链路上,针对秒杀系统,我们都有哪些解决方案。
客户端层:客户端呈现的秒杀页面中包含的图片、音乐、商品详情等静态资源,可以在客户端缓存起来,避免每次都要请求服务端而增加后端压力。客户端需要控制好确认秒杀的按钮,如果秒杀过了要即时置灰,另外可以使用图形验证码防止恶意的接口秒杀。
网络层:对超大并发的秒杀,有必要购买CDN产品做静态资源的加速和缓存。
负载层:Nginx做负载均衡,也可以做动静分离,把对静态资源的请求重定向到指定服务;也可以在Nginx做反向代理缓存和限流(ngx_http_limit_req模块)。
服务层:服务端层可以使动态页面静态化,利用本地或分布式缓存,利用消息队列异步削峰,并做好对关键资源的原子操作。
数据库:数据库层可利用乐观锁或悲观锁实现原子操作。
最后需要注意的是,在做秒杀系统之前,要对并发量做预估,估计的标准主要是宣传和广告的力度、往年经验等。然而,有时候我们并不能准确预估并发量,为保险起见,秒杀系统要与主营业务分开部署,防止出现秒杀并发过大而影响主业务的情况。
乐观锁 vs. 悲观锁
假设有商品表goods,schema如下
|
|
goods表中id是1的数据为:
|
|
下面我们分析一下两个并发请求同时要买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 |
正常情况下,生成订单的数量与卖出的商品数量要相同,否则就说明出现了数据不一致的情况,可以看到,使用乐观锁、重试乐观锁和悲观锁都能保证这一点。重试乐观锁和悲观锁都能保证库存中的所有商品都能被卖掉;而不带重试的乐观锁,由于失败后没有重试,因此很有可能出现商品没卖完的情况,通常来说机器的性能越高,并发处理能力就越强,冲突失败的请求就越多,卖出的商品就会越少。