秒杀问题及解决方案

秒杀业务特性

秒杀具有瞬时高并发的特点,秒杀请求在时间上高度集中于某一特定的时间点(秒杀开始那一秒),就会导致一个特别高的流量峰值,它对资源的消耗是瞬时的。

但对秒杀场景来说,最终能够抢到商品的人数是固定的,也就是说100人和10000人发起请求的结果都是一样的,并发度越高,无效请求也越多

但是从业务上来说,秒杀活动是希望更多的人来参与,开始之前希望有更多的人来刷页面,但是真正开始下单时,秒杀请求并不是越多越好。

流量削峰

服务器处理资源是恒定的,用或者不用它的处理能力都是一样的,出现峰值很容易导致忙到处理不过来,闲的时候却又没有什么要处理。

流量削峰,一是可以让服务端处理变得更加平稳,二是可以节省服务器的资源成本。针对秒杀这一场景,削峰从本质上来说就是更多地延缓用户请求的发出,以便减少和过滤掉一些无效请求,它遵从请求数要尽量少的原则。流量削峰的比较常见的思路:排队答题分层过滤

秒杀业务设计

营销工具:系统整理的促销工具,可以对某些特定的工具详细解释

营销活动:从营销工具中提出创建一个活动

营销活动订单:针对营销活动产生的订单

商品级优惠:限时促销、限时抢购、秒杀、商品包邮

订单级优惠:满就赠、满立减、送优惠券、折扣、Vip折扣、订单包邮

全站级促销优惠:优惠券、优化券补发、银行促销、支付红包、团购预售、微信砍价

秒杀技术特性

单一职责、流量错峰、限流、熔断、降级、队列削峰、预热快速扣减、动静分离


一般下单流程分为下单确认下单提交,核心点为价格计算库存处理,在下单确认时首先做一些检查、然后获取会员、商品等信息计算金额生成商品信息。

  • 信息检查:检查本地缓存售罄状态、校验token是否有权限购买、判断redis库存是否充足、检查是否正在排队中
  • 调用会员服务获取会员信息
  • 调用产品服务获取产品信息
  • 验证秒杀时间是否超时
  • 获取用户收获列表
  • 构建商品信息
  • 根据各种优惠计算订单金额

下单提交的核心流程为:

  • 信息检查:检查本地缓存售罄状态、校验token是否有权限购买、判断redis库存是否充足、检查是否正在排队中
  • 调用会员服务获取会员信息
  • 调用产品服务获取产品信息
  • 验证秒杀时间是否超时
  • 预减库存(异步流程)
  • 生成下单商品信息
  • 库存处理

库存问题

高并发下会出现超卖问题何时扣减库存

超卖问题

可通过数据库锁redis特性异步下单等解决方案来解决

数据库锁

悲观锁,通过MySQL提供的select...for update实现的悲观锁方式,但select...for update语句执行中所有扫描过的行都会被锁上,因此在MySQL中用悲观锁务必须确定走索引,而不是全表扫描,否则将会将整个数据表锁住。

1
2
3
4
5
begin;
select flash_promotion_count from sms_flash_promotion_product_relation where id = 43 for UPDATE;
update sms_flash_promotion_product_relation set flash_promotion_count = flash_promotion_count - 1 where id = 43;
--ROLLBACK;
commit

悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性,若加锁时间过长,其他用户长时间无法访问,影响了程序的并发访问性,同时这样对数据库性能开销影响很大,特别是对长事务而言,这样的开销往往无法承受,这时就需要乐观锁。

乐观锁,在数据进行提交更新时,才会正式对数据的冲突与否进行检测,若发现冲突则返回错误信息,让用户决定如何去做。版本号的实现数据版本机制时间催机制两种。

1
2
3
4
5
begin;
select flash_promotion_count from sms_flash_promotion_product_relation where id = 43 ;
update sms_flash_promotion_product_relation set flash_promotion_count = flash_promotion_count, version = version + 1 where id = 43 and version = #version#;
-- ROLLBACK;
Commit

除了查询库存还需要更新库存,还有订单、订单日志、订单详情等需要插入数据库。库存更新没问题,但插入订单时失败是否回滚,若不在一个事务就会出错。若在一个事务又涉及到事务过长甚至可能是跨库然后无法用本地事务来解决。

1
2
3
4
5
6
7
8
9
10
-- 扣减库存,防止库存超卖,若可以买多个,上面的SQL就有问题
UPDATE sms_flash_promotion_product_relation
SET flash_promotion_count = CASE
WHEN flash_promotion_count >= #{stock} THEN
flash_promotion_count - #{stock}
ELSE
flash_promotion_count
END
WHERE
id = #{id}

数据库锁的问题:若数据库只有10个商品,1000个人来抢,意味着990个请求没有意义,但这种方案1000个请求都会到数据库尝试扣减库存,大量请求会导致数据库超载。

Redis版本

使用数据库锁方案数据库性能相对来说是有很大瓶颈,故可把库存放到redis中,秒杀下单时先从redis中获取库存数量,然后根据库存数量判断是否可进行下一步,若有库存就直接下单没有库存就不能下单。这样可拦截大部分流量进入到数据库中。需要将商品库存预先加载到Redis中。

1
2
3
4
5
// 从redis缓存当中取出当前要购买的商品库存
Integer stock = redisOpsUtil.get(RedisKeyPrefixConst.MIAOSHA_STOCK_CACHE_PREFIX + productId, Integer.class);
if (stock == null || stock <= 0) {
return CommonResult.failed("商品已经售罄,请购买其它商品!");
}

对于Redis来说还是有网络IO,当商品售罄时在本地缓存中设置该商品的售罄标志为true,从而减少Redis的网络IO。

1
2
3
4
5
6
7
8
9
10
11
Boolean localcache = cache.getCache(RedisKeyPrefixConst.MIAOSHA_STOCK_CACHE_PREFIX + productId);
if (localcache != null && localcache) {
return CommonResult.failed("商品已经售罄,请购买其它商品!");
}
// 从redis缓存当中取出当前要购买的商品库存
Integer stock = redisOpsUtil.get(RedisKeyPrefixConst.MIAOSHA_STOCK_CACHE_PREFIX + productId, Integer.class);
if (stock == null || stock <= 0) {
// 设置标记,如果售罄了在本地cache中设置为true
cache.setLocalCache(RedisKeyPrefixConst.MIAOSHA_STOCK_CACHE_PREFIX + productId, true);
return CommonResult.failed("商品已经售罄,请购买其它商品!");
}

虽然可通过增加本地缓存减少Redis网络IO,但会存在产品售罄标志同步问题,可通过Zookeeper的watcher机制来实现同步,给每个JVM都监听Zookeeper的某个节点,一旦数据有改变之后通知到其他节点上。还可以利用Redis的Channel机制实现的发布订阅模式来实现产品售罄标志同步

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class RedisOpsUtil {
public void publish(String channel,Object message){
redisTemplate.convertAndSend(channel,message);
}
}
public boolean shouldPublishCleanMsg(Long productId) {
Integer stock = redisOpsUtil.get(RedisKeyPrefixConst.MIAOSHA_STOCK_CACHE_PREFIX + productId, Integer.class);
return (stock == null || stock <= 0);
}
//通知服务群,清除本地售罄标记缓存
if (shouldPublishCleanMsg(productId)) {
redisOpsUtil.publish("cleanNoStockCache", productId);
}
public class RedisChannelListener implements MessageListener {
@Autowired
private LocalCache localCache;
@Override
public void onMessage(Message message, @Nullable byte[] pattern) {
log.info("sub message :) channel[cleanNoStockCache] !");
String productId = new String(message.getBody(), StandardCharsets.UTF_8);
localCache.remove(RedisKeyPrefixConst.MIAOSHA_STOCK_CACHE_PREFIX + productId);
}
}

ZookeeperRedis各有各的优缺点,Zookeeper是CP模式的可保证高可用,但吞吐量会比较低Redis这种发布订阅模式没有Ack,发出去后不管是否收到,因为减少了通讯吞吐量相对来说会比较高

异步下单

前面的方案,下单时会插入很多张表,

  • 异步下单可以分流、让服务器处理压力变小、数据库压力减少
  • 解耦,业务更清晰
  • 天然排队处理能力
  • 消息中间件有很多特性可以利用,如订单取消

订单超时取消

  • 定时任务时间不准确,定时扫数据库的话消耗性能也很大,效率也会很低,对数据库压力太大,集群还需要保证处理的幂等性分布式问题

  • 消息队列异步取消:通过延时消息实现

何时扣减库存

下单时扣减redis中的库存

支付时扣减数据库中的库存

扣减库存系统中的库存


秒杀总结

尽量将请求拦截在系统上游,后续占据99%的请求,直接Nginx层面拦截掉

都多写少的场景多使用缓存,多级缓存保护好数据库

用消息中间件解决流量削峰,订单请求写入RocketMQ进行削峰,让RocketMQ轻松抗下高并发压力,让订单系统慢慢消费和处理下单操作

秒杀商品详细页架构解决方案

将秒杀活动商品详情页做成静态化

提前从数据库中把该页面需要的数据都提取出来组装成一份静态数据放在别的地方,避免每次访问都要访问后端数据库,该方案不适用商品比较多的商城如京东,适合商品较少的如小米,因为一旦修改了模板需要全部进行改动。

CDN+Nginx+Redis多级缓存架构

  • 第一级缓存:请求秒杀商品详情页数据时,从就近CND上加载,不需要每次请求都到某个机房
  • Nginx基于Lua脚本实现本地缓存:提前把秒杀商品详情页的数据放到Nginx中缓存,不需要把请求转发到商品系统上
  • 第二级缓存:Nginx上存在缓存数据过期之类的问题,导致没有找到需要的数据,此时由Nginx中的Lua脚本发送请求到本地缓存
  • 第三级缓存:若还没找到,把请求转发到Redis集群中加载提前放入的秒杀商品数据

秒杀下单TPS压力过大的解决方案

  • 加数据库服务器方案
    • 会导致公司服务器成本急剧飙升
    • 库存超卖:乐观锁和悲观锁,都会影响性能
  • 答题、复杂验证码的方案避免作弊以及延时下单:在前端或客户端设置秒杀答题,错开大量人下单的时间,阻止作弊器刷单
  • 为秒杀独立出一套订单系统,专门负责秒杀请求:若秒杀下单请求和普通下单请求都由一套订单系统来承载,可能导致秒杀下单请求耗尽订单系统资源,或导致系统不稳定,从而导致其他普通下单请求也出现问题。
  • 基于Redis实现下单时精准扣减库存,一旦库存扣减完则秒杀结束:一般会将每个秒杀商品库存提前写入Redis,在下单请求来后直接对Redis中的库存进行扣减
  • 抢购完毕后提前过滤无效请求,大幅度消减转发到后端的流量
    • 在Redis中库存扣减完成后,说明后续其他请求没有必要发送到秒杀系统中了,因为商品已经被抢购完成了,此时可让Nginx接收到后续请求时直接把后续请求过滤掉
    • 一旦商品抢购完毕,可在Redis或Zookeeper中写入一个秒杀完毕的标志位,然后反向通知Nginx中自己写的Lua脚本,通过Lua脚本将后续请求直接过滤掉
    • 在网关层或Sentinel做流量控制
  • 瞬时高并发下单请求进入RocketMQ进行削峰,订单系统慢慢拉取消息完成下单操作:若判断发现通过Redis完成了库存扣减,此时直接发送消息到RocketMQ即可,让普通订单系统从RocketMQ中消费秒杀成功的消息进行常规的流程处理即可,后续订单系统以每秒几千的速率慢慢处理,延迟可能几十秒,这些订单就能被处理完毕

前端验证问题

针对前端验证问题,可通过提前发Token,在秒杀前设置一个预约活动,如一个秒杀活动有20W个商品,可预先准备200W个Token,用户进行预约时,只发放200W个Token,其他人也能预约成功,但是其实没有获得token,后面秒杀直接通过该Token就可过滤掉一大部分人,相当于没有Token的人都只预约了个寂寞。

针对超卖问题

针对超卖问题,可使用Redis分布式锁防超卖,针对同一个商品ID,使用一把分布式锁,若同时有成千上万个商品要进行秒杀,那就意味着同一时间Redis上锁解锁的操作会要执行成千上万次,这对Redis的性能消耗是相当巨大的,Redis就有可能升级成为新的性能瓶颈。

可把秒杀超卖的问题从分布式降级到本地JVM中,来获取极限性能。将秒杀服务接入配置中心,然后在秒杀服务开始前,由配置中心给每个应用服务实例下发一个库存数量。然后每次下单,每个服务器只管自己的库存数量,与其他应用服务器完全不进行库存同步,在各自的内存里扣减库存,这样就不会有超卖的情况发生。减少了网络消耗,性能也能够进一步提升。

可能给某服务器上的库存很快消耗完了,而其他的服务器上仍有库存,整个服务就会表现为你抢不到商品,但是在你后面抢商品的人却能抢到商品,但是这在秒杀这种场景下,完全是可以接受的。

某一个应用服务器挂了,给他分配的库存就会丢失,这时只需要统计好订单的数量,可通过MQ来统计,也可通过Redis统计,等秒杀活动30分钟等待支付期过去后,再将没卖出去的库存重新丢回库存池,与没有付款而被取消的订单商品一起返场售卖即可。


兜底方案之限流&降级

对于很多秒杀系统而言,在诸如双十一这样的大流量的迅猛冲击下,都曾经或多或少发生过宕机的情况。当一个系统面临持续的大流量时,它其实很难单靠自身调整来恢复状态,必须等待流量自然下降人为地把流量切走才行,这无疑会严重影响用户的购物体验。

在系统达到不可用状态之前就做好流量限制,防止最坏情况的发生。针对秒杀系统,在遇到大流量时,更多考虑的是运行阶段如何保障系统的稳定运行,常用的手段:限流降级拒绝服务

限流相对降级是一种更极端的保存措施,限流就是当系统容量达到瓶颈时,需要通过限制一部分流量来保护系统,并做到既可人工执行开关,也支持自动化保护的措施。

限流既可在客户端限流,也可在服务端限流。限流的实现方式既要支持URL以及方法级别的限流,也要支持基于 QPS线程的限流。限流必然会导致一部分用户请求失败,因此在系统处理这种异常时一定要设置超时时间,防止因被限流的请求不能fast fail(快速失败)而拖垮系统。

Nginx限流

可使用ngx_http_limit_conn_module对于一些服务器流量异常负载过大,甚至是大流量的恶意攻击访问等,进行并发数的限制;该模块可根据定义的键来限制每个键值的连接数,只有那些正在被处理的请求,这些请求的头信息已被完全读入,所在的连接才会被计数。

1
2
3
4
5
6
7
8
9
10
11
12
# 限制连接数,客户端的IP地址作为键,
# binary_remote_addr变量长度是固定4字节,在32位平台中占用32字节或64字节,在64位平台中占用64字节
# 1M共享空间可以保存3.2万个32位的状态,1.6万个64位的状态
# 若共享内存空间被耗尽,服务器将会对后续所有的请求返回503即Service Temporarily Unavailable错误
limit_conn_zone $binary_remote_addr zone=addr:10m;
server {
location /download/ {
# 指定每个给定键值的最大同时连接数,同一IP同一时间只允许有1个连接
limit_conn addr 1;
}
}
# 缺点:前端做LVS或反向代理,会出现大量的503错误,需要设置白名单对某些ip不做限制

通过ngx_http_limit_req_module模块可通过定义的键值来限制请求处理的频率。特别的可限制来自单个IP地址的请求处理频率。限制的方法如同漏斗,每秒固定处理请求数,推迟过多请求。

1
2
3
4
5
6
7
8
9
10
11
12
http {
# 区域名称为one,大小为10m,平均处理的请求频率不能超过每秒一次。键值是客户端IP
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
...
server {
...
location /search/ {
# 允许超出频率限制的请求数为5,默认会被延迟处理,如果不希望延迟处理,可以使用nodelay参数
limit_req zone=one burst=5 nodelay;
}
}
}

OpenResty利用Lua限流

网关接入Sentinel控制台

Route维度限流
API维度限流
应用层限流

系统第一次上线启动,或系统在Redis故障情况下重新启动,这时在高并发的场景下就会出现所有的流量都打到数据库上,导致数据库崩溃。因此需要通过缓存预热的方案,提前给Redis灌入部分数据后再提供服务。

可在流控规则中配置关联模式,将数据库资源加入限流资源中,当对数据库访问达到阈值,可对商品详情请求限流

服务降级总结