秒杀项目面试-项目经验(58.3万高QPS,高并发)

在学习传智项目库中的秒杀项目后,我总结出了以下的面试内容
项目库地址传智研究院项目库(秒杀系统)

-----------以下所有代码和图只是为了方便理解,最主要的还是可以用自己的话表达出来---------------------

1.简单描述秒杀的核心流程(技术)

在这里插入图片描述

我们把架构分为了三个部分

第一部分:对商品的请求页进行了处理,我们使用,静态化+Nginx+CDN来优化前端页面的高QPS,具体就是我们使用canal监听数据库的变化,当商品变为秒杀商品的时候,我们会生成静态详情页。用户请求要获取详情页的时候,我们通过nginx 找到对应的静态页,并且这里我们考虑到页面中的所有静态资源(js、css、图片等),放在自己的服务器上,会占用自己服务器的网络资源,增加Nginx的压力。所以我们采用用CDN对静态资源进行加速。

第二部分:对热点数据实时分析:这里包括热点数据的发现和热点数据的隔离,热点数据的发现就是用户获取详情页之前 我们先用lua脚本采集用户的访问日志发送给kafka,然后通过apache-druid 订阅kafka的数据 ;通过我们自己写的实时热点分析系统定时查询apache-druid ,查到数据后对热点商品进行隔离和锁定,将商品信息缓存在redis中。

第三部分 : 首先访问商品详情页,在进入页面之前我们进行了限流和令牌识别,通过之后 才能进行秒杀下单,我们读取redis缓存里的数据 ,能查到就是热点商品 否则就是非热点数据;非热点商品我们就直接下单;热点商品的话我们排队下单,将消息发给kafka消息队列,订单系统订阅kafka的消息,生成订单,并通过WebSocket将订单状态推送给用户。

2.可以介绍下你的秒杀业务的流程么

http://admin-seckill-java.itheima.net/#/users/index

在这里插入图片描述

3.如果你进入我们公司,打算如何快速熟悉项目呢?(适当答出几个)

1.通读需求文档,了解项目用途

2.熟悉开发工具

3.准备环境,把项目跑起来

4.整体浏览代码,了解代码结构

5.抽取部分功能代码进行细读

6.尝试修改一些程序bug

4.如何优化mysql批量导入(适当答出几个)

1.一次插入多条数据

2.不使用主键,索引,外键

3.合理进行事务提交

4.多线程批量插入

5.mysql服务器参数优化

5.现在有一个数据迁移的需求,你应该怎么做呢?或者说迁移之前,你会考虑哪现点呢?

考虑的点:1.原有和现有的业务差异 2.原有和现有的数据差异

数据迁移的方式:简单业务:使用数据迁移工具 复杂业务:实践数据迁移代码

在这里插入图片描述

6.前端页面访问如何进行性能优化

在这里插入图片描述

秒杀活动中,热卖商品的详情页访问频率非常高,详情页的数据加载,我们可以采用直接从数据库查询加载,但这种方式会给数据库带来极大的压力,甚至崩溃,这种方式我们并不推荐。

商品详情页主要有商品介绍、商品标题、商品图片、商品价格、商品数量等,大部分数据几乎不变,可能只有数量会变,因此我们可以考虑把商品详情页做成静态页,每次访问只需要加载库存数量,这样就可以大大降低数据库的压力。

用户访问详情页,nginx中得到静态html页面,从商品微服务中得到动态数据,页面的静态资源从cdn(cdn 中存储的是修改频率低的静态资源 如 js,css;用户端根据这些数据最终形成详情页)中获得

扩展:前端页面高QPS优化方案:

在这里插入图片描述

1)方案1:直接查询数据库
在这里插入图片描述

查询优化:1.使用索引(主要手段) 2.使用慢查询定位较慢的sql,再使用explain进行分析

适用场景:1.用户数量少 2.交互的数据量小

2)方案2:使用缓存

在这里插入图片描述

流程:1.如果数据在redis存在,应用就可以直接从redis里拿数据,不用访问数据库

​ 2.如果redis里面没有,先到数据库查询,然后写入到redis中,再返回给应用

适用场景:1)查询频率高 2)并发比较高

缺点:数据双写不一致的问题

  1. 方案3:页面静态化+nginx
    在这里插入图片描述

适用场景:1)性能优先级高 2)需要减轻后台服务的压力

缺点:可能需要大量硬盘

4)方案4:静态化+Nginx+CDN

在这里插入图片描述

优点:提高企业站点的访问速度

缺点:成本较高

7.如何保证mysql与redis的一致性问题?

一致性问题的定义

因为这些数据是很少修改的,所以在绝大部分的情况下可以命中缓存。但是,一旦被缓存的数据发生变化的时候,我们既要操作数据库的数据,也要操作 Redis 的数据,

所以问题来了。现在我们有两种选择:

1、先操作 Redis 的数据再操作数据库的数据

2、先操作数据库的数据再操作 Redis 的数据

到底选哪一种

首先需要明确的是,不管选择哪一种方案, 我们肯定是希望两个操作要么都成功,要么一个都不成功。不然就会发生 Redis 跟数据库的数据不一致的问题。

但是,Redis 的数据和数据库的数据是不可能通过事务达到统一的,我们只能根据相应的场景和所需要付出的代价来采取一些措施降低数据不一致的问题出现的概率,在数据一致性和性能之间取得一个权衡。

对于数据库的实时性一致性要求不是特别高的场合,比如 T+1 的报表,可以采用定时任务查询数据库数据同步到 Redis 的方案。

由于我们是以数据库的数据为准的,所以给缓存设置一个过期时间,是保证最终一致性的解决方案。

方案选择:Redis删除还是更新?

这里我们先要补充一点,当存储的数据发生变化,Redis 的数据也要更新的时候,我们有两种方案,一种就是直接更新,调用 set;还有一种是直接删除缓存,让应用在下次 查询的时候重新写入。

这两种方案怎么选择呢?这里我们主要考虑更新缓存的代价。 更新缓存之前,是不是要经过其他表的查询、接口调用、计算才能得到最新的数据, 而不是直接从数据库拿到的值。如果是的话,建议直接删除缓存,这种方案更加简单, 而且避免了数据库的数据和缓存不一致的情况。在一般情况下,我们也推荐使用删除的方案。

这一点明确之后,现在我们就剩一个问题:

1、到底是先更新数据库,再删除缓存

2、还是先删除缓存,再更新数据库

1.先更新数据库,再删除缓存

正常情况

更新数据库,成功。

删除缓存,成功。

异常情况

1、更新数据库失败,程序捕获异常,不会走到下一步,所以数据不会出现不一致。

2、更新数据库成功,删除缓存失败。数据库是新数据,缓存是旧数据,发生了不一致的情况。

这种问题怎么解决呢?我们可以提供一个重试的机制。

比如:如果删除缓存失败,我们捕获这个异常,把需要删除的 key 发送到消息队列。 然后后自己创建一个消费者消费,尝试再次删除这个 key。

这种方式有个缺点,会对业务代码造成入侵

所以我们又有了第二种方案(异步更新缓存):

因为更新数据库时会往 binlog 写入日志,所以我们可以通过一个服务来监听 binlog 的变化(比如阿里的 canal),然后在客户端完成删除 key 的操作。如果删除失败的话,再发送到消息队列。

总之,对于后删除缓存失败的情况,我们的做法是不断地重试删除,直到成功。 无论是重试还是异步删除,都是最终一致性的思想。

2.先删除缓存,再更新数据库

正常情况:

删除缓存,成功。

更新数据库,成功。

异常情况:

1、删除缓存,程序捕获异常,不会走到下一步,所以数据不会出现不一致。

2、删除缓存成功,更新数据库失败。 因为以数据库的数据为准,所以不存在数据

不一致的情况。

看起来好像没问题,但是如果有程序并发操作的情况下:

1)线程 A 需要更新数据,首先删除了 Redis 缓存

2)线程 B 查询数据,发现缓存不存在,到数据库查询旧值,写入 Redis,返回

3)线程 A 更新了数据库

这个时候,Redis 是旧的值,数据库是新的值,发生了数据不一致的情况。

那问题就变成了:能不能让对同一条数据的访问串行化呢?代码肯定保证不了,因为有多个线程,即使做了任务队列也可能有多个服务实例。数据库也保证不了,因为会有多个数据库的连接。只有一个数据库只提供一个连接的情况下,才能保证读写的操作是串行的,或者我们把所有的读写请求放到同一个内存队列当中,但是这种情况吞吐量 太低了。

所以我们有一种延时双删的策略,在写入数据之后,再删除一次缓存。

A 线程:

1)删除缓存

3)休眠 500ms(这个时间,依据读取数据的耗时而定)

2)更新数据库

4)再次删除缓存

伪代码:

public void write(String key,Object data){ 

redis.delKey(key); 

db.updateData(data); 

Thread.sleep(500); 

redis.delKey(key); 

}

8.如何进行MySQL查询优化

使用索引(主要手段)

使用慢查询定位较慢的sql,在使用EXPLAIN进行分析

优化数据库结构(表拆分,使用中间表)

9.什么情况会造成索引失效

1.条件有or,部分条件没有索引;
2.复合索引未用左列字段;
3.like以%开头;
4.需要类型转换;
5.where中索引列有运算;
6.where中索引列使用了函数;
7.加索引的列,数据重复率较高;

10.秒杀活动到期时,秒杀商品何时更新

  1. 秒杀活动需要对秒杀商品下架,手动修改效率低,所以我们采用了定时任务

  2. 说明为什么动态添加定时任务呢?

    因为所有秒杀商品都只是参与一段时间活动,活动时间过了需要将秒杀商品从索引中移除,同时删除静态页。如果采取静态的定时任务不停的轮训,比较耗费cpu的资源,所以采用动态定时任务

  3. 介绍Elastic-Job:基于quartz 二次开发的弹性分布式任务调度系统,功能丰富强大,采用zookeeper实现分布式协调,实现任务高可用以及分片。

  4. 当秒杀活动结束的时候,elasticjob会把当前商品的状态修改,并且此次修改会记录到binlog日志中,然后canal会监听binlog的日志,从而同步ES和删除商品详情页。

11.项目中如何收集用户访问日志

  1. 说明当前业务背景:

    首先我们在做秒杀的时候有一个前提,就是我们需要区分热门商品和冷门商品,针对于热门商品,我们会把具体的商品信息放入到redis中,那怎么来定义热门商品呢?就是通过收集用户访问该商品的日志,我们通过nginx+lua来收集用户访问商品的日志,并且通过kafka消息队列的方式,把用户日志数据存储到apachedruid中

  2. 使用Nginx+Lua收集日志的原因

    参考12

12.为什么要使用Nginx+lua

  1. 说明Tomcat的性能

    Tomcat的默认配置作为生产环境,尤其是内存和线程的配置,默认都很低,容易成为性能瓶颈.

  2. 说明Nginx的性能

    nginx 能够支支撑 5 万并发链接, 并且 cpu、内存等资源消耗却非常低,运行非常稳定

  3. 说明秒杀的业务问题(大流量、高并发)

    大流量、高并发

  4. 总结

    因为tomcat的承载能力不够,很可能会被冲垮,就算没有被冲垮,我们也需要很多很多的tomcat服务器来接收和处理这个请求。为了追求高性能,我们需要使用nginx和lua来进行业务的处理 ,来保证我们的性能。又省成本,又能撑住大量的请求

13.为什么要用Redis集群?数据如何存储?

  1. 首先使用redis集群,可以保证我们系统的高可用,提升我们的程序性能
  2. 具体我们在存储商品数据的时候,如果我们使用String类型,即使用单个k-v方式存放多条数据,会导致我们商品信息存储到Redis集群多个节点中,这样就没有办法保证数据的原子性问题。
  3. 所以我们把多条数据合并为一条,通过hash的方式,一次性进行存储到集群的结点中。

14.说下热点数据的隔离流程

在这里插入图片描述

我们采用elastic-job每5秒钟查询一次被访问的商品信息,如果某一个商品最近一小时的访问量超过1000,我们就认为是热点数据。商品数据查出来之后,我们把商品先进行锁定,然后再使用hash存储的方式,把商品数据放入redis中。

15.隔离逻辑中同时对MySQL和Redis进行操作,如何保证减库存数据一致性?

在这里插入图片描述

为啥要锁定:防止我在把商品库存查询出来,在放入redis的之前,有用户调用下单接口,递减库存,导致我redis中缓存的库存数据不一致。

隔离的实现:通过在数据库中的秒杀商品表中设置一个islock字段,初始值为1,在redis缓存商品的数据之前,先锁定商品的数据,把islock改为2 ,那这样递减库存的sql即变为

update tb_sku set seckill_num=seckill_num-#{count} where id=#{id} and seckill_num>=#{count} and islock=1

从而保证数据的一致性

16.如何实现热点/非热点商品隔离下单?

在这里插入图片描述

首先用户在点击购买按钮的时候,请求经过nginx这一层的时候,我们会通过lua脚本,进行用户的登录校验以及判断是否有库存,然后判断商品是否为热点商品,如果是非热点商品,则直接调用订单系统进行下单操作,如果是热点商品,则向Kafka生产消息进行排队下单,订单系统会订阅排队下单信息,这样可以降低服务器所直接承受的抢单压力,这种操作也叫队列削峰。

17.什么是超卖?如何解决超卖问题?

参考:redis分布式锁的文档

超卖:一个商品被多个用户抢到

1.首先我们减库存分为非热点商品减库存和热点商品减库存,非热点商品减库存,我们在数据库层面就可以保证超卖,我们超卖问题主要出现在热点商品减库存中,我们热点商品减库存的步骤是先去redis中查询商品的库存,再去做库存递减操作,之后再把递减后的库存写入到redis中。

2.由于判断库存和递减库存是俩步操作,所以在多线程减库存的时候,库存的递减操作就会发生线程不安全的问题,进而产生超卖现象

3.我们可以考虑使用单机锁,比如使用Synchronized或ReentrantLock,但是这样的锁,在分布式部署的时候会失效。

4.综合,我们采用分布式锁来避免超卖的问题

18. 分布式锁有哪些实现方式,如何选择?

1.基于数据库实现分布式锁

2.基于缓存(Redis等)实现分布式锁;

3.基于Zookeeper实现分布式锁

三种方案的比较:
从理解的难易程度角度(从低到高)
数据库 > 缓存 > Zookeeper

从实现的复杂性角度(从低到高)
Zookeeper > 缓存 > 数据库

从性能角度(从高到低)
缓存 > Zookeeper >= 数据库

从可靠性角度(从高到低)
Zookeeper > 缓存 > 数据库

而我们秒杀项目,对性能要求比较高,对于偶尔出现的可靠性问题,我们是可以接受的,所以我们选择基于redis的分布式锁

扩展:基于Zookeeper实现分布式锁一般使用在金融场景比较多。

19.Redisson分布式锁原理?

参考redis分布式锁文档来看

在这里插入图片描述

1)加锁:将业务封装在lua中发给redis,保障业务执行的原子性,并且redisson还提供了不停重试功能,不停地去加锁

if (redis.call('exists', KEYS[1]) == 0) then 
        redis.call('hset', KEYS[1], ARGV[2], 1);
         redis.call('pexpire', KEYS[1], ARGV[1]); 
         return nil;
          end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
        redis.call('hincrby', KEYS[1], ARGV[2], 1);
        redis.call('pexpire', KEYS[1], ARGV[1]); 
        return nil;
        end;
return redis.call('pttl', KEYS[1]);

2)解锁:redisson和Synchronized,ReentrantLock一样,都是可重入锁, 执行lock.unlock(),每次都对myLock数据结构中的那个加锁次数减1。如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:“del myLock”命令,从redis里删除这个key,另外的客户端2就可以尝试完成加锁了。

if (redis.call('exists', KEYS[1]) == 0) then
       redis.call('publish', KEYS[2], ARGV[1]);
        return 1; 
        end;
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then 
     return nil;
     end;
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); 
if (counter > 0) then
     redis.call('pexpire', KEYS[1], ARGV[2]); 
     return 0; 
else redis.call('del', KEYS[1]); 
     redis.call('publish', KEYS[2], ARGV[1]); 
     return 1;
     end;
return nil;

3)锁的续期问题:redisson底层通过一个看门狗的策略,每隔一定时间扫瞄下,如果还持有锁,延长锁时间

4)缺点:Redisson存在一个问题,就是如果你对某个redis master实例,写入了myLock这种锁key的value,此时会异步复制给对应的master slave实例。但是这个过程中一旦发生redis master宕机,主备切换,redis slave变为了redis master。接着就会导致,客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁。此时就会导致多个客户端对一个分布式锁完成了加锁。这时系统在业务上一定会出现问题,导致脏数据的产生。

20.分布式事务

1.为啥会存在分布式事务呢?简单的说,就是一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同数据库的数据一致性。

2.什么时候会产生分布式事务呢?1.多个Service(服务) 2.多个Resource(数据源)

3.CAP定理

C (一致性):对于数据分布在不同节点上的数据来说,如果在某个节点更新了数据,那么在其他节点如果都能读取到这个的数据,那么就称为强一致,如果有某个节点没有读取到,那就是分布式不一致。

A (可用性):非故障的节点在合理的时间内返回合理的响应(不是错误和超时的响应)。

P (分区容错性):当出现网络分区后,系统能够继续工作。打个比方,这里集群有多台机器,有台机器网络出现了问题,但是这个集群仍然可以正常工作。

在分布式系统中,网络无法 100% 可靠,分区其实是一个必然现象,并且所以我们只能选择CP或者AP

对于 CP 来说,放弃可用性,追求一致性和分区容错性,我们的 ZooKeeper 其实就是追求的强一致。

对于 AP 来说,放弃一致性(这里说的一致性是强一致性),追求分区容错性和可用性,这是很多分布式系统设计时的选择,后面的 BASE 也是根据 AP 来扩展。

21.异步下单如何通知用户订单创建成功?

  1. 用户抢单操作完成后,无论是非热点商品还是热点商品抢单,抢单完成后,我们应该要通知用户抢单状态,非热点商品可以直接响应抢单结果,而热点商品也应该有通知用户这样的机制。

    一般常见的有俩种方式

    1. 页面定时向后台发请求查询订单状态。

    2. 使用基于长连接的WebSocket 。

  2. 第一种方式效率低,会和服务器进行多次通信,比如当前有一万个用户下单,可能每秒光查询订单状态就有1万次请求。所以这块我们可以使用长连接websocket实现。

  3. WebSocket 是一种基于长连接的通信方式,使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据

  4. 具体的实现

在这里插入图片描述

22. 为什么要使用Netty+WebSocket?

  1. 介绍WebSocket连接过多的问题

    传统的BIO编程:每个客户端连接过来后,服务端都会启动一个线程去处理该客户端的请求。阻塞I/O的通信模型示意图如下:

    在这里插入图片描述

  2. 介绍NIO的多路复用效果:一个线程可以处理多个连接,并且NIO是非阻塞的,不需要一直等待操作完成才能干其他事情,而是在等待的过程中可以同时去做别的事情,所以能最大限度地使用服务器的资源。

在这里插入图片描述

  1. 介绍Netty

​ Netty封装了JDK的NIO,使用netty之后,你不用再写一大堆复杂的代码了。

23.服务器的配置

58.3万

性能测试环境
Jdk版本 Jdk1.8
测试工具 Jmeter5.4.1
Jmeter负载服务器主机 2台32核64G
Jmeter负载服务器从机 10台16核32G
监控机 1台8核16G

23264/32 ==

服务器部署环境
Nginx服务器 4台16核32G
Redis服务器 1台8核16G
Kafka服务器 1台8核16G
热点订单服务器 9台8核16G
Mysql存储服务器 与热点订单服务器共用
公共微服务部署服务器 1台4核8G

1万(压测是1万,实际结果3000左右)

性能测试环境
Jdk版本 Jdk1.8
测试工具 Jmeter5.4.1
Jmeter负载服务器主机 1台8核16G
监控机 1台2核4G
服务器部署环境
Nginx服务器 2台 2核4G(k8s容器化部署)
Redis服务器 3台2核4G (一主二从)(k8s容器化部署)
Kafka服务器 1台2核4G (看实际情况,由于防止数据丢失,可以不用容器部署,由于不用容器化部署,2核4G的服务器不好购买,可以使用阿里的kafka)
热点订单服务器 2台2核4G(k8s容器化部署)
Mysql存储服务器 3台2核4G(3主3从)AB BC AC
公共微服务部署服务器 2台4核8G (网关,用户服务,商品服务等等)(考虑订单用容器化部署,这里为了部署方便 ,也使用容器化的方式进行部署,每台机器都部署一份,保证高可用)
本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>