业务场景题-设计一个秒杀系统
设计一个秒杀系统
这是前几天面试的时候有个面试官问我的一个问题
他问的是如果让你设计一个支付订单系统,你会考虑什么,其实这件就是一个秒杀系统,因为主要考虑的就是在高并发的场景下的问题。
当时我好像没说几句话,主要是场景题确实是没咋准备。
以下我的文章完全作为一个后端的视角去思考
瞬时高并发
一般在秒杀点的前几分钟,用户并发量才真正突增,达到秒杀时间点时,并发量会达到顶峰。
但是由于这类活动是大量用户抢少量商品的场景,必定会出现狼多肉少的情况,所以绝大部分的用户会秒杀失败,只有极少部分用户能成功。
正常情况下,大部分用户会受到商品已经抢完的提醒,受到该提醒后,他们大概率不会在那个活动页面停留了,如此一来,用户并发量又会急剧下降,所以这个峰值持续的时间其实是非常短的,这样就会出现瞬时高并发的情况。
那么可以从以下几点思考
- 缓存
- mq异步处理
- 限流
- 分布式锁
读多写少
在秒杀过程中,系统一般会先检查一下库存是否足够,如果足够才允许下单,写数据库。如果不够,则直接返回该商品已经抢完。
由于大量用户抢少量商品,只有极少部分用户能抢成功,所以绝大部分用户在秒杀时,库存其实是不足的,系统会直接返回该商品已经抢完。
这是非常典型的读多写少的场景。
如果有数十万的请求过来,同时通过数据库查缓存是否足够,此时数据库可能会挂掉,因为数据库的连接资源非常有限,比如mysql,无法同时支持这么多的连接,而应该改用缓存,比如redis。(可能会问redis为什么快,接下来有空更新)
缓存问题
通常情况下,我们需要在redis中保存商品信息,同时数据库中也要有相关信息,毕竟缓存并不完全可靠。
用户在垫底秒杀按钮,请求秒杀接口的过程中,需要传入商品参数,然后服务端需要校验该商品是否合法。
根据商品id,先从缓存中查询商品,如果商品存在,则参与秒杀,如果不在,则需要从数据库中查询商品,如果存在,则将商品信息放入缓存,然后参与秒杀,如果商品不存在,则直接提示失败,
但这里需要注意几个问题:
- 缓存击穿
- 缓存穿透
- 缓存雪崩
- 以及redis与mysql的双写一致性(这个我不会,想看的可以看美团二面:Redis与MySQL双写一致性如何保证?Redis与MySQL双写一致性如何保证? 这道题其实就是在问缓存和 - 掘金)
库存问题
对于库存问题看似简单,实则还是有点可圈可点的
真正的秒杀商品的场景,不是说扣完库存就完事 了,如果用户在一段时间内未支付,导致订单超时过期了,库存还是要加回去的
此外还需要注意库存不足和库存超卖的问题。
数据库扣减库存
最简单的做法就是直接扣,但这不能保证在并发的场景下库存小于零。
那么可能会有人说,在更新前检查下不就好了吗,但这又不符合原子性。还是会超卖。
有人说不是加把锁就好了吗,这样确实可以解决,但是性能不好。
还有更优雅的解决方案,就是基于数据库的乐观锁,这样会少一次数据查询,而且能够天然的保证数据操作的原子性。
乐观锁:数据库乐观锁是一种并发控制策略,核心思想是 假设并发冲突概率低,操作时不加锁,仅在提交(更新)数据时检查是否有冲突。适用于读多写少,最终一致性可接受(允许数据在短时间内存在不一致,但经过一段时间后自动同步为一致的业务场景)
redis扣减库存
redis的incr方法是原子性的,可以用该方法扣减库存
代码流程如下:
-
先判断该用户有没有秒杀过该商品,如果已经秒杀过,则直接返回-1。
-
查询库存,如果库存小于等于0,则直接返回0,表示库存不足。
-
如果库存充足,则扣减库存,然后将本次秒杀记录保存起来。然后返回1,表示成功。
但如果在高并发下,有多个请求同时查询库存,当时都大于0。由于查询库存和更新库存非原则操作,则会出现库存为负数的情况,即库存超卖。
这里可以使用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;");
那么现在的流程变为
- 先判断商品id是否存在,如果不存在则直接返回。
- 获取该商品id的库存,判断库存如果是-1,则直接返回,表示不限制库存。
- 如果库存大于0,则扣减库存。
- 如果库存等于0,是直接返回,表示库存不足。
分布式锁
在秒杀的时候,需要先从缓存中查是否存在,如果不存在,则会从数据库中查商品。如果在数据库放入缓存中,然后返回。如果数据库中没有,则直接返回失败。
如果在高并发下,有大量请求都去查一个缓存中不存在的商品,这鞋 请求都会直接打到数据库,就会造成缓存穿透,然后全打到数据库上,数据库由于承受不住这样的压力会直接挂掉。
那么这时候就需要使用redis分布式锁了
setNx
if (jedis.setnx(lockKey, val) == 1) {
jedis.expire(lockKey, timeout);
}
用该命令其实可以加锁,但和后面的设置超时时间是分开的,并非原子操作。
假如加锁成功了,但是设置超时时间失败了,该lockKey就变成永不失效的了。在高并发场景中,该问题会导致非常严重的后果。
那么,有没有保证原子性的加锁命令呢?
set
可以指定多个参数
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
return true;
}
return false;
其中:
-
lockKey:锁的标识
-
requestId:请求id
-
NX:只在键不存在时,才对键进行设置操作。
-
PX:设置键的过期时间为 millisecond 毫秒。
-
expireTime:过期时间
由于该命令只有一步,所以它是原子操作。
释放锁
在加锁时,既然已经有了lockKey锁标识,为什么要需要记录requestId呢?
requestId是在释放锁的时候用的
if (jedis.get(lockKey).equals(requestId)) { jedis.del(lockKey); return true; } return false;在释放锁的时候,只能释放自己加的锁,不允许释放别人加的锁。
这里为什么要用requestId,用userId不行吗?
答:如果用userId的话,假设本次请求流程走完了,准备删除锁。此时,巧合锁到了过期时间失效了。而另外一个请求,巧合使用的相同userId加锁,会成功。而本次请求删除锁的时候,删除的其实是别人的锁了。
lua脚本
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
它能保证查询锁是否存在和删除锁是原子操作。
自旋锁
上面的加锁方法看起来好像都没有问题,但是如果仔细想想,如果并发量特别高,有百万级别的去竞争那把锁,那么就会只有一个请求成功,其余的请求都失败
在秒杀场景下会有什么问题?
那这变成一种线性的秒杀了,就不是高并发的秒杀了,这和我们预期的不一样。
那么如何解决
使用自旋锁
try {
Long start = System.currentTimeMillis();
while(true) {
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
return true;
}
long time = System.currentTimeMillis() - start;
if (time>=timeout) {
return false;
}
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} finally{
unlock(lockKey,requestId);
}
return false;
在规定的时间,比如500ms内,自旋不断尝试加锁,如果成功则直接返回,如果失败则休眠一段时间,再发起新一轮的尝试,如果到了超过时间,还未加锁成功,则直接返回失败,
mq异步处理
在真实的秒杀场景中,有三个核心流程:
秒杀->下单->支付
而这三个核心流程中,真正并发量大的是秒杀功能,下单和支付功能实际并发量都很小,因为商品的量有限,所以我们在设计秒杀系统时,有必要把下单和支付功能从秒杀的住流程拆分出来,特别是下单功能能要做出mq异步处理的。而支付功能,是义务场景本身保证的异步。
于是,秒杀后下单的流程变成了
消息丢失问题
秒杀成功了,往mq发送下单消息的时候,有时候可能会失败。原因有很多,比如网络问题,broker挂了,mq服务端磁盘问题等,这些情况都可能会造成消息丢失。
那么有哪些防止消息丢失的手段呢。
加一张表记录状态,秒杀发送前先将消息记录在表里,等下单成功了再回来更改表里订单的状态。
但这又不符合了原子性,如果生产者把消息写入消息发送表之后,再发送mq消息到mq服务端的过程中失败了,造成了消息丢失。
这时候,要如何处理呢?
那就得使用重试机制,
每隔一段时间就去查询表里待处理的数据并发送给mq。
重复消费问题
本来消费者消费消息时,在ack应答的时候,如果网络超时,本身就可能会消费重复的消息。但由于消息发送者增加了重试机制,会导致消费者重复消息的概率增大。
还是加一张表解决
垃圾回收问题
这套方案还有个问题,就是如果因为某些原因,消费者一直下单失败,一直不能回调状态变更接口,这样就会不停地重试发消息,最会就会产生大量的垃圾消息。
就给消息发送次数设置一个阈值,如果达到了阈值则直接返回并且报错,如果没有就次数加1,然后发送消息
这样能保证就算出现了异常也只会产生少量的垃圾消息,不会影响到正常的业务。
延迟消费问题
通常情况下,如果用户秒杀成功了,下单之后如果15分钟之内未完成支付的话,该订单会被自动取消,回退库存。
那么,在15分钟内未完成支付,订单被自动取消的功能,要如何实现呢?
使用rocketmq的延迟队列功能,这简直就是天生为超时订单设计的
下单的时候生产者往mq的延迟队列里发送一个消息通常是订单id,此时订单状态为待支付,达到了延迟时间,消息消费者会更新订单状态为取消,如果不是待支付状态而是已支付状态,就会直接返回,因为当支付成功后会直接修改订单状态为已支付。
如果限流
针对单一用户(防脚本)
给单一用户设置阈值,限制一个用户在规定时间内请求接口的次数
针对同一ip
有的人有多台设备,也有黄牛帮忙抢票什么的,所以还得针对同一ip去设置阈值
对接口限流
有的专业抢票黄牛可能会更换ip,然后把网站搞炸让正常用户使用不了。这时候可以限制请求接口的总次数,在高并发的场景下,这种限制对系统的稳定性非常必要,至少可以保证一部分用户的正常使用。
面对脚本最好的方法就是加上人机验证和验证码
同样能限制用户的访问次数,但这种情况不会出现误杀,也能有效防止黄牛。

浙公网安备 33010602011771号