高并发下如何设计秒杀系统

# 高并发下如何设计秒杀系统

本文总结自如果面试遇到秒杀系统,要这样回答。。。

image

秒杀是一种促销活动,在一个时间开放购买,很多用户抢购商品,但只有极少数用户能够购买成功

秒杀这种活动商家通常是不赚钱的,用来宣传自己,但这种活动对技术的要求不低,下面总结一下秒杀相关的技术细节

瞬时高并发

秒杀真正的高并发时间是比较短的,在一个促销的时间点(比如0点),并发量会达到高峰,而只有极少数用户能够购买成功,大部分用户会收到该商品已抢完的提醒。在收到这个提醒后,他们应该也不会在这个页面停留了,所以这个高并发是瞬时高并发。

面对瞬时高并发的场景,需要设计一套全新的秒杀系统来应对,需要注意以下几个方面:

  1. 页面静态化
  2. CDN加速
  3. 缓存
  4. MQ异步处理
  5. 限流
  6. 分布式锁

下面具体来讲解这几个方面

1. 页面静态化

image

这个问题很好理解,活动界面是用户流量的第一入口,并发量最大。如果这些流量都能直接访问服务端,那么我们就会面临服务端在高压下直接挂掉的风险。

不过,活动页面绝大多数内容是固定的,比如固定的商品名称,图片,描述,提前设置好的价格等等。我们可以静态化这些内容,用户浏览商品信息,查看活动信息不会请求到服务端,只有到时间了,所有的秒杀请求才会到达服务端,如下图所示:

image

2. CDN

CDN(Content Delivery Network,内容分发网络)使用户就近访问相关内容,降低网络拥塞

image

3. 秒杀按钮

image

很多用户在秒杀开始前的一段时间就进入活动页面,此时秒杀按钮是置灰,不可点击的,只有到了秒杀时间点的时刻,秒杀按钮才会变为可点击的。

用户通常会在秒杀开始前不停刷新页面,争取第一时间看到秒杀系统的点亮。

所以我们需要一个js文件来控制静态页面上的按钮在秒杀开始的时间点才点亮

所以如何实现这一点?

我们要知道的是,CDN也有自己的缓存,我们想要让CDN每次都从JS文件中读取数据,就不能让CDN走缓存。上图中的random参数就是做这件事的。

image

flag更改为true时,random的值也更新了

4. 读多写少

image

大量用户抢少量商品,只有极少用户能够抢购成功,大部分用户都是直接返回失败的响应。

这就是一个读多写少的场景,大量的查询库存的请求,少量的更改库存的请求

由于数据库的连接资源比较有限,无法同时支持这么多的连接,所以应该改用缓存,比如用redis

image

并且应该视请求量,部署多个节点

5. 缓存问题

一个不处理缓存问题的流程如下图所示:

image

5.1 缓存击穿

  1. 在缓存未命中时获取分布式锁,这样就不会在同一时刻有大量请求打到数据库上了

image

  1. 上面的加锁其实是一个最后的保险,实际上我们需要在请求到数据库之前就试图解决缓存击穿的问题,如果还是发生了,分布式锁就是一道保险
    在项目启动前,先进行预热,把该有的数据都放到缓存里,并设置好缓存的过期时间

5.2 缓存穿透

对于缓存穿透的问题,背过面试八股文的应该很熟悉,可以使用布隆过滤器来解决,不过布隆过滤器并不是任何情况下的最优解。它会引出一个问题:

布隆过滤器中的数据如何与缓存中的数据保持一致?

如果缓存中数据有更新,就需要及时同步到布隆过滤器当中。并且,为了防止同步失败,还需要增加重试机制。并且由于实际生产环境很可能是集群部署的,跨数据源如何保证实时一致性呢?很显然并不能保证。所以布隆过滤器大部分使用在缓存数据更新很少的场景中。

如果缓存数据更新非常频繁,怎么处理呢?

可以将不存在的商品id也放到缓存里,这样下次有查询请求过来,也可以从缓存中查询到“不存在”的标记值。当然这个缓存的超时时间应该设置的短一点。

6. 库存问题

image

库存并不是扣完就可以了,如果规定时间内还没完成支付,扣减的库存需要加回去,所以这里引入一个预扣库存的概念。

6.1 数据库扣减库存

我们当然可以直接在数据库上扣减库存,比如说

update product set stock = stock - 1 where id = 123;

我们如何在此基础上,在库存不足的情况下不让用户操作呢

在调用update之前,先查询一下库存,如果stock > 0,则update库存

但这样做的问题就在于,查询操作和更新操作不是原子性的,会导致并发的场景下,出现库存超卖

那我们为什么不直接加把锁,比如synchronized,因为性能不好,悲观锁实际是串行执行

我们可以用基于数据库的乐观锁来做,这样可以删掉查询这一步,而且天然保证数据操作的原子性

update product set stock = stock - 1 where id = 123 and stock > 0;

这种方式是基于sql的,需要频繁访问数据库,数据库连接是非常昂贵的资源,容易造成数据库宕机。而且,如果多个请求并发,竞争行锁,可能会造成相互等待,出现死锁。

6.2 Redis扣减库存

redis的incr方法是原子性的,可以用该方法扣减库存,先简单写一段单机代码

boolean exist = redisClient.query(productId, userId);
if (exist) {
	return -1;
}
int stock = redisClient.queryStock(productId);
if (stock <= 0) {
	return 0;
}
redisClient.incrby(productId, -1);
redisClient.add(productId, userId);
return 1;

这段代码的问题同样是并发问题。查询库存和更新库存不是原子操作。如果加synchronized,同上,接口性能会急剧下降。于是我们可以有如下的优化思路:

boolean exist = redisClient.query(productId, userId);
if (exist) {
	return -1;
}
if (redisClient.incrby(productId, -1) < 0) {
	return 0;
}
redisClient.add(productId, userId);
return 1;

这个代码乍一看没什么问题,但是如果并发量很大,预减库存太多,库存负数负的太多,回退库存时很难保证库存准确。

6.3 Lua脚本扣减库存

Lua脚本可以保证原子性,跟redis配合使用可以完美解决这一问题。

给出一段经典代码:

StringBuilder lua = new StringBuilder();
lua.append("if (redis.call('exists', KEYS[1]) == 1) then");
lua.append("    local stock = tonumber(redis.call('get', KEYS[1]));");
lua.append("    if (stock == -1) then");
lua.append("        return 1;");
lua.append("    end;");
lua.append("    if (stock > 0) then");
lua.append("        redis.call("incrby", KEYS[1], -1);");
lua.append("        return stock;");
lua.append("    end;");
lua.append("    return 0;");
lua.append("end;");
lua.append("return -1;");

7. 分布式锁

7.1 setNx加锁

setNx命令可以加锁,但和后面的设置超时时间是分开的

if (jedis.setnx(lockKey, val) == 1) {
	jedis.expire(lockKey, timeout);
}

假如加锁成功了,但设置超时时间失败了,该lockKey就变成永不失效了

7.2 set加锁

使用redis的set命令,可以指定多个参数:

  1. lockKey:锁的标识
  2. Request Id:请求id
  3. NX:只在键不存在时,才对键进行设置操作
  4. PX:表示设置键的过期时间为毫秒
  5. expireTime:表示过期时间
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
	return true;
}
return false;

7.3 释放锁

加锁时我们设置了lockKey锁标识和requestId,为什么要有requestId呢,就是因为释放锁需要使用

在释放锁的时候,只能释放自己加的锁

为什么不用userId而是用requestId?

为了避免巧合。如果准备删除锁的时候,巧合锁的过期时间到了,锁失效了,而另一个请求巧合使用相同userId加锁,那删除的其实是别人的锁了。当然这其实还是个原子性问题,我们可以用lua脚本解决。保证查询锁是否存在和删除锁是原子操作。

7.4 自旋锁

这里是非常重要的一点,上面的加锁方式好像没有并发问题,可以用,但是仔细想想,它真的是我们需要的吗?

这就是业务层面的问题了,也就是说我们要理解业务,才能写出正确的代码。

按照上面的加锁方式,10000个请求同时到达,可能只有一个请求是成功的,再10000个请求,又有一个成功。秒杀的结果变成了均匀分布了。这显然不是我们想要的。我们需要的是,如果有5个库存,那分别是1,2,3,4,5的请求会成功,而不是1,10001,20001......的请求会成功。

使用自旋锁,我们可以一定程度缓解这个问题。

比如说,在500ms的时间内,如果加锁成功,则直接执行并返回,如果失败,则休眠50ms再重试。

7.5 Redisson

分布式锁有很多问题需要解决,比如说:锁竞争问题,续期问题,锁重入问题,多个redis实例加锁问题等等

既然有比较完善的轮子,那我们就直接拿来用,这些问题使用redisson可以解决

8. MQ异步处理

在秒杀场景中,有三个核心流程:

秒杀
下单
支付

在核心流程中,真正并发量大的是秒杀功能,下单和支付实际并发量很小

所以设计秒杀系统时,需要把下单和支付从秒杀的主流程拆分出来,特别是下单要做mq异步处理,而支付是业务场景本身保证的异步(比如支付宝支付)

经过异步处理后秒杀下单的流程如下:

image

8.1 消息丢失问题

秒杀成功以后往mq发送消息时有可能会失败。网络问题,broker挂了,服务端磁盘问题等等都会影响到mq的可用。我们可以通过加一张消息发送表来解决消息丢失问题。

image

那如果写入消息发送表之后,在发送到服务端的过程中失败了,应该怎么处理呢?

很直观的想法就是增加重试机制,每隔一段时间去查询消息发送表中状态为待处理的数据,然后重新发送mq消息。

image

8.2 重复消费问题

消费者消费消息时,在ack应答的时候,如果网络超时,本身就可能消费重复的消息。

并且我们上面给消息发送者增加了重试机制,消费重复消息的概率进一步增大。

解决这一问题,可以加一张消息处理表

image

这里有个比较关键的点,下单和写消息处理表要放在同一个事务中,保证原子操作

8.3 垃圾消息问题

上面的系统设计大体上没有问题,但是如果由于某些原因,下单一直失败,job不停重试发消息,就会产生大量的垃圾消息。

image

我们可以加一个最大发送限制,这样就算出现问题也只会产生少量的垃圾消息。

8.4 延迟消费问题

30分钟(或其他时长)未支付,订单自动取消这样的功能,应该如何实现呢?

可能有人会想到job,但是隔一段时间处理一次,实时性不好。

我们可以使用延迟队列来解决

image

image

RocketMQ自带了延迟队列功能

这里有一个状态流转的问题:只有待支付状态的订单状态可以变为取消,防止用户支付和取消订单巧合并发执行。

9. 如何限流

自从有了秒杀活动,就有人使用脚本抢购,正常用户通过点击秒杀按钮来抢购商品,而有人可能在自己的服务器上模拟正常用户登录系统,跳过秒杀页面直接登录秒杀接口。如果用户手动操作的话,可能一秒钟点一次秒杀按钮,而如果是非法用户请求,一秒钟直接请求上千次接口也是可以的。如果不做任何限制,绝大部分商品可能都是被机器抢到。

为了限制这些非法请求,目前有两种常用的限流方式:

  1. 基于nginx限流
  2. 基于redis限流

而在限流的具体实现上,我们可以:

  1. 对同一用户限流
    可以对同一用户访问接口做限制,比如说每分钟最多5次
  2. 对同一ip限流
    这样可以防止模拟多个用户登录的情况
    但是如果是公司或者网吧这种环境,可能很多用户走的是同一个ip,这样就限制住了正常用户的使用
  3. 对同一接口限流
    可以防止模拟不同ip,但是如果非法请求太多,占用了正常用户的次数,正常用户没法参加活动了,这就有些得不偿失
  4. 加验证码
    加验证码的方式可以说是比较精准了,普通验证码生成图片可能被破解,现在各大公司首选的方式是移动滑块
  5. 提高用户门槛
    这又是通过业务的角度考虑问题了。加验证码确实很影响用户体验,秒杀功能的流程应该越简单越好才对。
    这里举个例子:12306在最开始的时候,全国都在同一时间抢火车票,并发量太大,业务经常挂。后来经过优化,放宽了购票周期,可以提前20天购买火车票,这样降低了用户并发量,使得要处理的并发量少了很多。
    回到我们的秒杀活动,我们可以限制只有等级到达3级的普通用户或者是会员用户才能参与秒杀,这样就将黄牛拒之门外了。

10. 秒杀的退货怎么处理

秒杀的退货最关键的就是库存增加,这里有两个方案:

第一种方案,其实我们大可不处理,秒杀的订单退货一件真的不重要,用户收到退款就行,库存10个,实际发了9个,也没什么大问题。更何况等到有人退货,估计秒杀活动都已经结束了。

第二种方案,如果我们确实需要处理库存,那可以用mq:
mq开启消费确认模式,然后再判断这个订单是否已经是已退款或已取消状态了,再开启mysql事务,回滚库存,redis可以使用redission获取锁,然后增加库存,都执行完了就确认这个mq消息被消费(ack),总的来说,回滚库存的操作也是需要保证在一个事务下的。

posted @   月社うたげ  阅读(24)  评论(0编辑  收藏  举报
努力加载评论中...
点击右上角即可分享
微信分享提示