秒杀系统的设计与实现及相关面试题
1. 整体介绍
使用SpringBoot开发的秒杀系统,项目的重点就是在高并发情况下的秒杀优化,我们知道当并发数达到一定量的时候,会对数据库服务器带来很大的压力,那么如何缓解这些压力以及提高并发的QPS就是整个项目的重点。
亮点:
1.利用缓存减少数据库的压力,读取缓存的速度远远快于数据库
2.页面静态化技术加快用户访问速度,提高QPS,异步下单增强用户体验,以及内存标记减少对Redis的访问。
3.安全性优化:双重md5密码校验,接口限流防刷,数学公式验证码。
2. 秒杀系统的架构,如何设计一个秒杀系统?
一个常规的秒杀系统从前到后,依次有:
前端浏览器秒杀页面 =》中间代理服务 =》后端服务层 =》数据库层
根据这个流程,一般优化设计思路:将请求拦截在系统上游,降低下游压力。在一个并发量大,实际需求小的系统中,应当尽量在前端拦截无效流量,降低下游服务器和数据库的压力,不然很可能造成数据库读写锁冲突,甚至导致死锁,最终请求超时。
整体优化点:
主要需要解决的问题有两个:
- 并发对数据库产生的压力
- 竞争状态下如何解决库存的正确减少(超卖问题)
优化的思路:
- 尽量将请求拦截在系统上游
- 读多写少经量多使用缓存
- redis缓存 +RabbitMQ+ mysql 批量入库
限流防刷:1. 限制同一个用户在限定时间内,只能访问固定次数。2. 使用验证码防止机器人刷接口并且可以分散用户请求。
削峰:使用消息队列异步处理。拦截大量并发请求,后台业务根据自己的处理能力,从消息队列中主动的拉取请求消息进行业务处理。
缓存:秒杀系统最大的瓶颈一般都是数据库读写,由于数据库读写属于磁盘IO,性能很低,把部分数据或业务逻辑转移到 Redis 内存缓存,效率会有极大地提升。
3. 为什么要用 Redis?是怎么用的?如何缓解 Redis 的压力?
介绍
Redis 是一款高性能的 key-value 缓存数据库。整个数据库都在内存中运行操作,每秒可以支持10W左右的读写操作,支持数据持久化和多种数据结构。
为什么使用
在项目中使用 Redis,主要考虑两个角度:性能和并发。
性能:
在秒杀系统中,在同一时间,几乎所有人都在点,都在下单。执行的是同一操作——向数据库查数据。我们在碰到需要执行耗时特别久,且结果不频繁变动的 SQL,就特别适合将运行结果放入缓存。这样,后面的请求就去缓存中读取,使得请求能够迅速响应。
并发:
大并发的情况下,所有的请求直接访问数据库,数据库会出现连接异常。这个时候,就需要使用 Redis 做一个缓冲操作,让请求先访问到 Redis,而不是直接访问数据库。
具体使用
利用 Redis 我们可以对用户信息、商品信息、订单信息和token等数据进行缓存,利用 Redis 预减缓存来减少对数据库的访问,大大加快查询速度。
3.0 三级缓冲保护
- 在秒杀阶段使用本地标记对用户秒杀过的商品做标记,若被标记过直接返回重复秒杀,未被标记才查询redis,通过本地标记来减少对redis的访问
- 抢购开始前,将商品和库存数据同步到redis中,所有的抢购操作都在redis中进行处理,通过Redis预减少库存减少数据库访问
- 为了保护系统不受高流量的冲击而导致系统崩溃的问题,使用RabbitMQ用异步队列处理下单,实际做了一层缓冲保护,做了一个窗口模型,窗口模型会实时的刷新用户秒杀的状态。
- client端用js轮询一个接口,用来获取处理状态
3.1 大量的使用缓存,如何减少对 Redis 的访问(Redis预减库存和本地标记)
思路:
1.系统初始化的时候,将商品库存加载到Redis 缓存中保存
2.收到请求的时候,现在Redis中拿到该商品的库存值,进行库存预减,如果减完之后库存不足,直接返回逻辑Exception, 就不需要访问数据库再去减库存了,如果库存值正确,进行下一步
3.将请求入队,立即给前端返回一个值,表示正在排队中,然后进行秒杀逻辑,后端队列进行秒杀逻辑,前端轮询后端发来的请求,如果秒杀成功,返回秒杀,成功,不成功就返回失败。
(后端请求 单线程 出队,生成订单,减少库存,走逻辑)前端同时轮询
预减库存:
1.先将所有数据读出来,初始化到缓存中,并以 stock + goodid 的形成存入Redis,
2.在秒杀的时候,先进行预减库存检测,从redis中,利用decr 减去对应商品的库存,如果库存小于0,说明此时 库存不足,则不需要访问数据库。直接抛出异常即可
内存标记:
由于接口优化很多基于Redis的缓存操作,当并发很高的时候,也会给Redis服务器带来很大的负担,如果可以减少对Redis服务器的访问,也可以达到的优化的效果。
在Redis预减库存的时候,内存中维护一个map作为标记,标记对应商品的库存量是否还有,在访问Redis之前,在map中拿到对应商品的库存量标记,就可以不需要访问Redis 就可以判断没有库存了。
1.生成一个map,并在初始化的时候,将所有商品的id为键,标记false 存入map中。
2.在预减库存之前,从map中取标记,若标记为false,说明库存,还有,
3.预减库存,当遇到库存不足的时候,将该商品的标记置为true,表示该商品的库存不足。这样,下面的所有请求,将被拦截,无需访问redis进行预减库存。
3.2 Redis 的数据结构和底层实现
String
List:由双向链表实现。即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销
Set:值不重复的列表,内部实现是一个 value永远为null的HashMap,实际就是通过计算hash的方式来快速排重的,这也是set能提供判断一个成员是否在集合内的原因。
Hash:内部实际就是一个HashMap,实际这里会有2种不同实现:Hash的成员比较少时为了节省内存会采用类似一维数组的方式来紧凑存储,而不会采用真正的HashMap结构;当成员数量增大时会自动转成真正的HashMap。
ZSet:内部使用HashMap和跳跃表(SkipList)来保证数据的存储和有序,HashMap里放的是成员到score的映射,而跳跃表里存放的是所有的成员,排序依据是HashMap里存的score,使用跳跃表的结构可以获得比较高的查找效率,并且在实现上比较简单。
3.3 大量的使用了缓存,那么就存在缓存的过期时间控制以及缓存击穿以及缓存雪崩等问题?
缓存穿透:
请求去查询一条压根儿数据库中根本就不存在的数据,也就是缓存和数据库都查询不到这条数据,但是请求每次都会打到数据库上面去。
解决办法:秒杀业务中较少,可以通过缓存空值来解决。
缓存雪崩:
缓存中设置了大批量相同过期时间的数据同时过期失效,而在这一刻访问量剧增,缓存近乎失效。
解决办法:针对不同的缓存设置不同的过期时间,比如session缓存,在userKey这个前缀中,设置是30分钟过期,并且加入一层再登陆增加缓存时间的机制。这样每次取session,都会延长30分钟,相对来说,就减少了缓存过期的几率。
针对热点数据,比如演唱会票这种票详情信息,热点商品由于考虑到是一般抢票10分钟内几乎抢完,于是就设置为10分钟的缓存。
缓存击穿:
大量的请求同时查询一个key时,此时这个key正好失效了,就会导致大量的请求都打到数据库上面去。
解决办法:一波一波的抢票,这种,某个时间点万一大量并发,刚好我的这个票缓存时间过了,去访问数据库。对于这种热点数据,我将过期时间一起存入缓存中,取出来的时候,比对一下过期时间和当前时间,少于1分钟,就更新一下缓存,防止过期。
3.4 缓存更新策略以及缓存击穿、雪崩、穿透
参考.
秒杀过程中怎么保证redis缓存和数据库的一致性?
在其他一般读大于写的场景,一般处理的原则是:缓存只做失效,不做更新。
采用Cache-Aside pattern:
失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
更新:先把数据存到数据库中,成功后,再让缓存失效。
3.5 Redis 的持久化实现方式
快照方式
默认的持久化方式,Redis默认将数据快照的二进制文件写入磁盘中。
可以设置N秒操作几次后快照持久化一次,或者调用save命令。
原理:1.redis fork一个子进程 2.子进程将数据写入到临时rdb 文件中 3 写完后用新文件代替老文件 4 copy and write
缺点:当在两次快照执行期间丢失数据,将不会被记录.
AOF 方式(append only)
将==“操作 + 数据”==以格式化指令的方式追加到操作日志文件的尾部。保存在硬盘上
优势:1.灵活设置持久化时间,若将持久化时间间隔为1s,那么最多丢失1s的数据。2.支持日志重写和修复。
缺点:文件要比RDB大许多,恢复速度慢。
4. 为什么要用 RabbitMQ?是怎么用的?
1.Rabbit mq 是一个高级消息队列,在分布式的场景下,拥有高性能。对负载均衡也有很好的支持。
2.拥有持久化的机制,进程消息,队列中的信息也可以保存下来。
3.实现消费者和生产者之间的解耦。
4.对于高并发场景下,利用消息队列可以使得同步访问变为串行访问达到一定量的限流,利于数据库的操作。
5.可以使用消息队列达到异步下单的效果,排队中,后台进行逻辑下单。
高并发的时候,业务来不及同步处理,Redis压力,数据库有时候会有大量的insert 和update 操作,甚至请求堆积过多的时候,to many connections?
想到了消息队列,用来异步处理请求。每次请求过来,先不去处理请求,而是放入消息队列,然后在后台布置一个监听器,分别监听不同业务的消息队列,有消息来的时候,在进行秒杀抢票操作。这样防止多个请求同时操作的时候,数据库连接过多的异常。
四种交换机模式:
1.Direct 模式 2.Topic 模式 3.Fanout模式(广播模式) 4.Header 模式 (根据header中的键值 进行消息匹配)
本项目中使用的是Direct模式。
直连型交换机(direct exchange)是根据消息携带的路由键(routing key)将消息投递给对应队列的,步骤如下:
将一个队列绑定到某个交换机上,同时赋予该绑定一个路由键(routing key)
当一个携带着路由值为R的消息被发送给直连交换机时,交换机会把它路由给绑定值同样为R的队列。
思路:
流量削峰 开始抢购的瞬间 大量并发进入,先将请求入队,若队列满了,那么舍弃再入队的请求返回一个异常
先给前端一个数据返回表示排队中,再进行后续的业务处理,前端轮询最后成功或者失败在显示业务结果
1.当确认秒杀开始,(库存充足,且无重复秒杀)将秒杀请求需要的消息入队(封装),同时给前端返回一个code (0),
前端接收到数据后,显示排队中。
2.后端Rabbit MQ 监听 秒杀 queue 的这名字的通道,如果有消息过来,获取到传入的信息,执行真正的秒杀之前,要判断 数据库的库存,之前判断的是Redis 中的库存,判断是否重复秒杀,然后执行秒杀Service
3.此时,前端根据商品id 轮询请求 getResult ,如果请求到的商品生成了商品订单(userid,goodsId到数据库里查),说明秒杀成功,
前端会根据后端返回的值来判断 是秒杀成功还是继续轮询还是秒杀失败。
三种:-1 秒杀失败 0 排队中,继续轮询, >0 返回的是商品id ,说明秒杀成功
4.1 如何保证消息的稳定性
• 生产者发出后保证到达了MQ。
为了解决这个问题,RabbitMQ引入了事务机制和发送方确认机制(publisher confirm),由于事务机制过于耗费性能所以一般不用。另一个就是消息发送到MQ那端之后,MQ会回一个确认收到的消息给我们。
• MQ收到消息保证分发到了消息对应的Exchange。
消息找不到对应的Exchange。找不到对应的Queue。这两种情况都可以用RabbitMQ提供的mandatory参数来解决,它会设置消息投递失败的策略,有两种策略:自动删除或返回到客户端。
• Exchange分发消息入队之后保证消息的持久性。
对消息做持久化,以便MQ重新启动之后消息还能重新恢复过来。消息的持久化要做,还要做队列的持久化和Exchange的持久化。创建Exchange和队列时只要设置好持久化,发送的消息默认就是持久化消息。如果出现服务器宕机或者磁盘损坏则上面的手段统统无效,必须引入镜像队列,做异地多活来抵御这种不可抗因素。
• 消费者收到消息之后保证消息的正确消费。
消费者的消息确认。
案例
消息是先入库,然后生产者将数据包装成消息发给MQ。经过消费者消费之后对DB数据的状态进行更改。这中间有任何步骤失败,数据的状态都是没有更新的。这时通过一个定时任务不停的去刷库,找到有问题的数据将它重新扔到生产者那里进行重新投递。
参考https://blog.csdn.net/qq_45619439/article/details/105128242
5. 超卖问题具体怎么解决的?怎么优化的 SQL 语句?
卖超原因:
(1)一个用户同时发出了多个请求,如果库存足够,没加限制,用户就可以下多个订单。(2)减库存的sql上没有加库存数量的判断,并发的时候也会导致把库存减成负数。
解决办法:
(1):在后端的秒杀表中,对user_id和goods_id加唯一索引,确保一个用户对一个商品绝对不会生成两个订单。
(2):我们的减库存的sql上应该加上库存数量的判断
数据库自身是有行级锁的,每次减库存的时候判断count>0,它实际上是串行的执行update的,因此绝对不会卖超!。
UPDATE seckill
SET number = number-1
WHERE seckill_id=#{seckillId}
AND start_time <#{killTime}
AND end_time >= #{killTime}
AND number > 0;
另一种思路:
可以对读操作加上显式锁(即在select …语句最后加上for update)这样一来用户1在进行读操作时用户2就需要排队等待了
但是问题来了,如果该商品很热门并发量很高那么效率就会大大的下降,怎么解决?
解决方案:
我们可以有条件有选择的在读操作上加锁,比如可以对库存做一个判断,当库存小于一个量时开始加锁,让购买者排队,这样一来就解决了超卖现象。
或者
- 对库存更新时,先对库存判断,只有当库存大于0才能更新库存
- 对用户id和商品id建立一个唯一索引,通过这种约束避免同一用户发同时两个请求秒杀到两件相同商品
- 实现乐观锁,给商品信息表增加一个version字段,为每一条数据加上版本。每次更新的时候version+1,并且更新时候带上版本号,当提交前版本号等于更新前版本号,说明此时没有被其他线程影响到,正常更新,如果冲突了则不会进行提交更新。当库存是足够的情况下发生乐观锁冲突就进行一定次数的重试。
5.0 SQL 优化总结
- 建议使用预编译语句进行数据库操作
- 避免数据类型的隐式转换
- 充分利用表上已经存在的索引
- 数据库设计时,应该要对以后扩展进行考虑
- 程序连接不同的数据库使用不同的账号,禁止跨库查询
- 禁止使用 SELECT * 必须使用 SELECT <字段列表> 查询
- 禁止使用不含字段列表的 INSERT 语句
- 避免使用子查询,可以把子查询优化为 join 操作
- 避免使用 JOIN 关联太多的表
- 减少同数据库的交互次数
- 对应同一列进行 or 判断时,使用 in 代替 or
- 禁止使用 order by rand() 进行随机排序
- WHERE 从句中禁止对列进行函数转换和计算
- 在明显不会有重复值时使用 UNION ALL 而不是 UNION
- 拆分复杂的大 SQL 为多个小 SQL
5.1 如何解决少卖问题—Redis预减成功而DB扣库存失败?
前面的方案中会出现一个少卖的问题。Redis在预减库存的时候,在初始化的时候就放置库存的大小,redis的原子减操作保证了多少库存就会减多少,也就会在消息队列中放多少。
现在考虑两种情况:
1)数据库那边出现非库存原因比如网络等造成减库存失败,而这时redis已经减了。
2)万一一个用户发出多个请求,而且这些请求恰巧比别的请求更早到达服务器,如果库存足够,redis就会减多次,redis提前进入卖空状态,并拒绝。不过这两种情况出现的概率都是非常低的。
两种情况都会出现少卖的问题,实际上也是缓存和数据库出现不一致的问题!
但是我们不是非得解决不一致的问题,本身使用缓存就难以保证强一致性:
在redis中设置库存比真实库存多一些就行。
6. 其它一些问题
6.1 整体流程
6.2 两次MD5加密
第一次 (在前端加密,客户端):密码加密是(明文密码+固定盐值)生成md5用于传输,目的:由于http是明文传输,当输入密码若直接发送服务端验证,此时被截取将直接获取到明文密码,获取用户信息。
加盐值是为了混淆密码,原则就是明文密码不能在网络上传输。
第二次:在服务端再次加密,当获取到前端发送来的密码后。通过MD5(密码+随机盐值)再次生成密码后存入数据库。目的:防止数据库被盗的情况下,通过md5反查,查获用户密码。方法是盐值会在用户登陆的时候随机生成,并存在数据库中,这个时候就会获取到。
6.3 分布式 session 的实现
我们知道当服务器集群的时候,若用户第一个请求在第一台服务器上,第二个请求在其他服务器上,会出现session的丢失的情况,丢失用户信息。而且在这种高并发场景下,一定是很多服务器同步工作,所以如何解决session分布式的问题是一个重点。
本项目采用:利用redis缓存的方法,验证用户账号密码都正确情况下(登陆成功后),通过UUID生成唯一id作为token来标识用户,再将token作为key、用户信息作为value,模拟session,存储到redis 缓存,同时将token存储到cookie,保存登录状态(客户端在随后的访问中携带这个cookie,我们的服务端就根据cookie中的token,就能够找到token对应的用户。这样就不会出现用户session丢失的情况。(每次需要session,从缓存中取即可)
6.4 限流和降级怎么做的
限流防刷:
- 数学公式验证码
- 限制同一个用户在限定时间内,只能访问固定次数。
思路:每次点击之后,在缓存中生成一个计数器,第一次将这个计数器置1后存入缓存,并给其设定有效期。
每次点击后,取出这个值,计数器加一,如果超过限定次数,就抛出业务异常。
令牌桶算法原理:
令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。
降级:
当达到限流阀值,后续请求会被降级;降级后的处理方案可以是:返回排队页面(高峰期访问太频繁,等一会重试)、错误页等。
6.5 页面静态化及前后端分离
页面静态化的主要目的是为了加快页面的加载速度。
做法:将订票的详情页面做成静态HTML,放在CDN(减少了服务端的压力)上做为静态数据发送给用户端。而数据信息通过前端ajax 异步发送请求来获取,只获取动态数据信息部分,加载速度可以达到全部渲染的2倍。
6.6 登录流程的实现
1.首先输入登录页面的url,controller根据map映射返回给html页,到达登录页面
2.整个页面是一个login表单,包含用户名和密码两个输入框部分,还有一个登陆按钮和重置按钮。
3.在前端,给登录按钮绑定一个login()方法,login()方法中会获取表单中的用户名和密码,然后将密码利用封装好的md5()函数以及设置的固定盐值进行拼接,盐值设置为“1a2b3c”,然后进行MD5算法生成4个32位拼接的散列值作为输入密码(用于 网络传输),作为参数传给后端。(这里的目的主要是第一道加密,防止http明文传输,泄漏密码)。
4.然后ajax异步访问do_login 接口,参数为用户名和md5之后的密码,后端接收到前端传输来的参数后,会对用户名和密码进行参数校验,验证是否为空,是否有格式问题(密码长度6位以上,用户名格式11位等等),如果验证不通过,返回CodeMsg(),封装好的对应的错误信息给前端。
5.如果验证成功,进入下一步,用户的登录,首先通过用户名取用户对象信息(先从缓存中取,取不到取数据库取,取到了将用户信息存入缓存中,下一次登录我们可以先从缓存中取用户,降低数据库压力),然后返回一个user对象,再判断这个user对象是否为空,若是空就抛出异常,不是空的情况说明数据库中有该用户,然后根据传入的密码和数据中保存的随机盐值,进行md5再次拼接,获得的值若是和数据库中的密码一致,那么说明登录成功。
关键点6.登录成功的时候,随机生成uuid作为sessionId,将其写入cookie中返回给客户端,并且将模块前缀+该用户id作为key和sessionId 作为值,存入缓存(这里为分布式缓存提供的基础)。这时候跳转到 秒杀列表页面,如果密码不匹配,抛出异常,返回。