首页 > 其他分享 >高并发下如何设计秒杀系统

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

时间:2023-11-16 23:55:20浏览次数:27  
标签:缓存 lua 用户 并发 库存 秒杀 设计

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

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

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异步处理

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

graph LR 秒杀 --> 下单 下单--> 支付

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

所以设计秒杀系统时,需要把下单和支付从秒杀的主流程拆分出来,特别是下单要做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),总的来说,回滚库存的操作也是需要保证在一个事务下的。

标签:缓存,lua,用户,并发,库存,秒杀,设计
From: https://www.cnblogs.com/utage/p/17837565.html

相关文章

  • MySQL数据库高并发优化配置
    在Apache,PHP,mysql的体系架构中,MySQL对于性能的影响最大,也是关键的核心部分。对于Discuz!论坛程序也是如此,MySQL的设置是否合理优化,直接影响到论坛的速度和承载量!同时,MySQL也是优化难度最大的一个部分,不但需要理解一些MySQL专业知识,同时还需要长时间的观察统计并且根据经验进......
  • 使用SquareLine Studio设计UI
    原文:http://www.bryh.cn/a/220739.htmlLVGL全程LittleVGL,是一个轻量化的,开源的,用于嵌入式GUI设计的图形库。并且配合LVGL模拟器,可以在电脑对界面进行编辑显示,测试通过后再移植进嵌入式设备中,实现高效的项目开发。LVGL中文教程手册:极客笔记之LVGL教程介绍:SquareLineStudio是LVG......
  • 11.16 基本完成个人任务管理系统项目后重新复习JavaScript高级程序设计——声明var与l
    我看的是js高级程序设计第四版,前两章快速了解了一下,第三章开始慢啃,虽然内容枯燥,很多东西自己也知道了,但还是有一些收获的。比如,声明变量的三个关键词:var、let、const;var以前经常用但是会出问题,相比let没有那么严谨(var声明范围函数作用域,而let声明范围块级作用域)。看个例子:这是v......
  • 学期2023-2024-1 20231401 《计算机基础与程序设计》第八周学习总结
    学期2023-2024-120231401《计算机基础与程序设计》第八周学习总结作业信息这个作业属于哪个课程2023-2024-1-计算机基础与程序设计这个作业要求在哪里2023-2024-1计算机基础与程序设计第八周作业这个作业的目标《计算机科学概论》第9章《C语言程序设计》第7章并......
  • 【开源】基于Vue.js的计算机机房作业管理系统的设计和实现
    一、摘要1.1项目介绍基于Vue+SpringBoot+MySQL的计算机机房作业管理系统包含课程档案模块、课时档案模块、学生作业模块,还包含系统自带的用户管理、部门管理、角色管理、菜单管理、日志管理、数据字典管理、文件管理、图表展示等基础模块,计算机机房作业管理系统基于角色的访问控制......
  • 【开源】基于Vue.js的车险自助理赔系统的设计和实现
    一、摘要1.1项目介绍基于Vue+SpringBoot+MySQL的车险自助理赔系统包含车辆管理模块、车险理赔模块、理赔审核模块,还包含系统自带的用户管理、部门管理、角色管理、菜单管理、日志管理、数据字典管理、文件管理、图表展示等基础模块,车险自助理赔系统基于角色的访问控制,给车险管理......
  • 设计模式—结构型模式之外观模式(门面模式)
    设计模式—结构型模式之外观模式(门面模式)外观(Facade)模式又叫作门面模式,是一种通过为多个复杂的子系统提供一个一致的接口,而使这些子系统更加容易被访问的模式。例子我们的电脑会有很多组件,比如CPU、硬盘、内存等等;如果我们电脑需要启动的话,需要挨个去调用每个组件的启动方法;停......
  • 面向对象笔记—设计模式
    设计模式一、概念设计模式是一系列在实践中总结出来的可复用的面向对象的软件设计方式设计模式就是描述一个反复出现的问题,以及解决这个问题的方案。可以重复使用这个解决方案而无须再做重复劳动。解决设计问题的固定套路重用,避免代码重复冗余优化体系结构提升系统的可维......
  • 软件设计-职责链模式
    软件设计                 石家庄铁道大学信息学院 实验15:职责链模式本次实验属于模仿型实验,通过本次实验学生将掌握以下内容:1、理解职责链模式的动机,掌握该模式的结构;2、能够利用职责链模式解决实际问题。     [实验任务一]:财务审批......
  • 1.单例设计模式
    单例模式的五种实现方式1、饿汉式(线程安全,调用效率高,但是不能延时加载publicclassImageLoader{privatestaticImageLoaderinstance=newImageLoader;privateImageLoader(){}publicstaticImageLoadergetInstance(){returninstanc......