# 高并发下如何设计秒杀系统
本文总结自如果面试遇到秒杀系统,要这样回答。。。
秒杀是一种促销活动,在一个时间开放购买,很多用户抢购商品,但只有极少数用户能够购买成功
秒杀这种活动商家通常是不赚钱的,用来宣传自己,但这种活动对技术的要求不低,下面总结一下秒杀相关的技术细节
瞬时高并发
秒杀真正的高并发时间是比较短的,在一个促销的时间点(比如0点),并发量会达到高峰,而只有极少数用户能够购买成功,大部分用户会收到该商品已抢完的提醒。在收到这个提醒后,他们应该也不会在这个页面停留了,所以这个高并发是瞬时高并发。
面对瞬时高并发的场景,需要设计一套全新的秒杀系统来应对,需要注意以下几个方面:
- 页面静态化
- CDN加速
- 缓存
- MQ异步处理
- 限流
- 分布式锁
下面具体来讲解这几个方面
1. 页面静态化
这个问题很好理解,活动界面是用户流量的第一入口,并发量最大。如果这些流量都能直接访问服务端,那么我们就会面临服务端在高压下直接挂掉的风险。
不过,活动页面绝大多数内容是固定的,比如固定的商品名称,图片,描述,提前设置好的价格等等。我们可以静态化这些内容,用户浏览商品信息,查看活动信息不会请求到服务端,只有到时间了,所有的秒杀请求才会到达服务端,如下图所示:
2. CDN
CDN(Content Delivery Network,内容分发网络)使用户就近访问相关内容,降低网络拥塞
3. 秒杀按钮
很多用户在秒杀开始前的一段时间就进入活动页面,此时秒杀按钮是置灰,不可点击的,只有到了秒杀时间点的时刻,秒杀按钮才会变为可点击的。
用户通常会在秒杀开始前不停刷新页面,争取第一时间看到秒杀系统的点亮。
所以我们需要一个js文件来控制静态页面上的按钮在秒杀开始的时间点才点亮
所以如何实现这一点?
我们要知道的是,CDN也有自己的缓存,我们想要让CDN每次都从JS文件中读取数据,就不能让CDN走缓存。上图中的random参数就是做这件事的。
flag更改为true时,random的值也更新了
4. 读多写少
大量用户抢少量商品,只有极少用户能够抢购成功,大部分用户都是直接返回失败的响应。
这就是一个读多写少的场景,大量的查询库存的请求,少量的更改库存的请求
由于数据库的连接资源比较有限,无法同时支持这么多的连接,所以应该改用缓存,比如用redis
并且应该视请求量,部署多个节点
5. 缓存问题
一个不处理缓存问题的流程如下图所示:
5.1 缓存击穿
- 在缓存未命中时获取分布式锁,这样就不会在同一时刻有大量请求打到数据库上了
- 上面的加锁其实是一个最后的保险,实际上我们需要在请求到数据库之前就试图解决缓存击穿的问题,如果还是发生了,分布式锁就是一道保险
在项目启动前,先进行预热,把该有的数据都放到缓存里,并设置好缓存的过期时间
5.2 缓存穿透
对于缓存穿透的问题,背过面试八股文的应该很熟悉,可以使用布隆过滤器来解决,不过布隆过滤器并不是任何情况下的最优解。它会引出一个问题:
布隆过滤器中的数据如何与缓存中的数据保持一致?
如果缓存中数据有更新,就需要及时同步到布隆过滤器当中。并且,为了防止同步失败,还需要增加重试机制。并且由于实际生产环境很可能是集群部署的,跨数据源如何保证实时一致性呢?很显然并不能保证。所以布隆过滤器大部分使用在缓存数据更新很少的场景中。
如果缓存数据更新非常频繁,怎么处理呢?
可以将不存在的商品id也放到缓存里,这样下次有查询请求过来,也可以从缓存中查询到“不存在”的标记值。当然这个缓存的超时时间应该设置的短一点。
6. 库存问题
库存并不是扣完就可以了,如果规定时间内还没完成支付,扣减的库存需要加回去,所以这里引入一个预扣库存的概念。
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命令,可以指定多个参数:
- lockKey:锁的标识
- Request Id:请求id
- NX:只在键不存在时,才对键进行设置操作
- PX:表示设置键的过期时间为毫秒
- 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异步处理
在秒杀场景中,有三个核心流程:
graph LR 秒杀 --> 下单 下单--> 支付在核心流程中,真正并发量大的是秒杀功能,下单和支付实际并发量很小
所以设计秒杀系统时,需要把下单和支付从秒杀的主流程拆分出来,特别是下单要做mq异步处理,而支付是业务场景本身保证的异步(比如支付宝支付)
经过异步处理后秒杀下单的流程如下:
8.1 消息丢失问题
秒杀成功以后往mq发送消息时有可能会失败。网络问题,broker挂了,服务端磁盘问题等等都会影响到mq的可用。我们可以通过加一张消息发送表来解决消息丢失问题。
那如果写入消息发送表之后,在发送到服务端的过程中失败了,应该怎么处理呢?
很直观的想法就是增加重试机制,每隔一段时间去查询消息发送表中状态为待处理的数据,然后重新发送mq消息。
8.2 重复消费问题
消费者消费消息时,在ack应答的时候,如果网络超时,本身就可能消费重复的消息。
并且我们上面给消息发送者增加了重试机制,消费重复消息的概率进一步增大。
解决这一问题,可以加一张消息处理表
这里有个比较关键的点,下单和写消息处理表要放在同一个事务中,保证原子操作
8.3 垃圾消息问题
上面的系统设计大体上没有问题,但是如果由于某些原因,下单一直失败,job不停重试发消息,就会产生大量的垃圾消息。
我们可以加一个最大发送限制,这样就算出现问题也只会产生少量的垃圾消息。
8.4 延迟消费问题
30分钟(或其他时长)未支付,订单自动取消这样的功能,应该如何实现呢?
可能有人会想到job,但是隔一段时间处理一次,实时性不好。
我们可以使用延迟队列来解决
RocketMQ自带了延迟队列功能
这里有一个状态流转的问题:只有待支付状态的订单状态可以变为取消,防止用户支付和取消订单巧合并发执行。
9. 如何限流
自从有了秒杀活动,就有人使用脚本抢购,正常用户通过点击秒杀按钮来抢购商品,而有人可能在自己的服务器上模拟正常用户登录系统,跳过秒杀页面直接登录秒杀接口。如果用户手动操作的话,可能一秒钟点一次秒杀按钮,而如果是非法用户请求,一秒钟直接请求上千次接口也是可以的。如果不做任何限制,绝大部分商品可能都是被机器抢到。
为了限制这些非法请求,目前有两种常用的限流方式:
- 基于nginx限流
- 基于redis限流
而在限流的具体实现上,我们可以:
- 对同一用户限流
可以对同一用户访问接口做限制,比如说每分钟最多5次 - 对同一ip限流
这样可以防止模拟多个用户登录的情况
但是如果是公司或者网吧这种环境,可能很多用户走的是同一个ip,这样就限制住了正常用户的使用 - 对同一接口限流
可以防止模拟不同ip,但是如果非法请求太多,占用了正常用户的次数,正常用户没法参加活动了,这就有些得不偿失 - 加验证码
加验证码的方式可以说是比较精准了,普通验证码生成图片可能被破解,现在各大公司首选的方式是移动滑块 - 提高用户门槛
这又是通过业务的角度考虑问题了。加验证码确实很影响用户体验,秒杀功能的流程应该越简单越好才对。
这里举个例子:12306在最开始的时候,全国都在同一时间抢火车票,并发量太大,业务经常挂。后来经过优化,放宽了购票周期,可以提前20天购买火车票,这样降低了用户并发量,使得要处理的并发量少了很多。
回到我们的秒杀活动,我们可以限制只有等级到达3级的普通用户或者是会员用户才能参与秒杀,这样就将黄牛拒之门外了。
10. 秒杀的退货怎么处理
秒杀的退货最关键的就是库存增加,这里有两个方案:
第一种方案,其实我们大可不处理,秒杀的订单退货一件真的不重要,用户收到退款就行,库存10个,实际发了9个,也没什么大问题。更何况等到有人退货,估计秒杀活动都已经结束了。
第二种方案,如果我们确实需要处理库存,那可以用mq:
mq开启消费确认模式,然后再判断这个订单是否已经是已退款或已取消状态了,再开启mysql事务,回滚库存,redis可以使用redission获取锁,然后增加库存,都执行完了就确认这个mq消息被消费(ack),总的来说,回滚库存的操作也是需要保证在一个事务下的。
标签:缓存,lua,用户,并发,库存,秒杀,设计 From: https://www.cnblogs.com/utage/p/17837565.html