查看原文
其他

冲进了小米,二面速通!

小林coding 小林coding 2024-04-19

图解学习网站:https://xiaolincoding.com

大家好,我是小林。

新能源汽车今年是真的卷呀,小米汽车还没开卖,其他新能源汽车厂商已经主动降价了,有的甚至 50% 降幅。

小米一入局,妥妥影响了市场,确实让新能源车更适合年轻人了。

昨天看到消息,小米将会在 3 月底开小米汽车的发布会,期待一波小米汽车的价格,是否能感动人心。

聊到小米,那今天分享一位同学小米后端的面经,总共两轮技术面,面试内容除了后端八股,还有一些场景题,我把后端的内容帮大家解析了一下。

考察的知识点范围,我帮大家罗列了一下:

  • Redis:持久化、线程模型、大 key
  • MySQL:表结构设计、char 和 varchar、索引数据结构、事务隔离级别、分表设计
  • 网络:一台机器最大 tcp 连接数
  • 操作系统:进程间通信、进程、线程、携程
  • Java:sleep、wait

Redis

Redis持久化方式有什么方式?

Redis 的读写操作都是在内存中,所以 Redis 性能才会高,但是当 Redis 重启后,内存中的数据就会丢失,那为了保证内存中的数据不会丢失,Redis 实现了数据持久化的机制,这个机制会把数据存储到磁盘,这样在 Redis 重启就能够从磁盘中恢复原有的数据。

Redis 持久化的方式有两种:

  • AOF 日志:每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里;
  • RDB 快照:将某一时刻的内存数据,以二进制的方式写入磁盘;

AOF 日志是如何实现的?

Redis 在执行完一条写操作命令后,就会把该命令以追加的方式写入到一个文件里,然后 Redis 重启时,会读取该文件记录的命令,然后逐一执行命令的方式来进行数据恢复。

我这里以「set name xiaolin」命令作为例子,Redis 执行了这条命令后,记录在 AOF 日志里的内容如下图:


Redis 提供了 3 种写回硬盘的策略, 在 Redis.conf 配置文件中的 appendfsync 配置项可以有以下 3 种参数可填:

  • Always,这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;
  • Everysec,这个单词的意思是「每秒」,所以它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;
  • No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。

我也把这 3 个写回策略的优缺点总结成了一张表格:

RDB 快照是如何实现的呢?

因为 AOF 日志记录的是操作命令,不是实际的数据,所以用 AOF 方法做故障恢复时,需要全量把日志都执行一遍,一旦 AOF 日志非常多,势必会造成 Redis 的恢复操作缓慢。

为了解决这个问题,Redis 增加了 RDB 快照。所谓的快照,就是记录某一个瞬间东西,比如当我们给风景拍照时,那一个瞬间的画面和信息就记录到了一张照片。

所以,RDB 快照就是记录某一个瞬间的内存数据,记录的是实际数据,而 AOF 文件记录的是命令操作的日志,而不是实际的数据。

因此在 Redis 恢复数据时, RDB 恢复数据的效率会比 AOF 高些,因为直接将 RDB 文件读入内存就可以,不需要像 AOF 那样还需要额外执行操作命令的步骤才能恢复数据。

Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave,他们的区别就在于是否在「主线程」里执行:

  • 执行了 save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程
  • 执行了 bgsave 命令,会创建一个子进程来生成 RDB 文件,这样可以避免主线程的阻塞

Redis 是单线程还是多线程?

Redis 单线程指的是「接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端」这个过程是由一个线程(主线程)来完成的,这也是我们常说 Redis 是单线程的原因。

但是,Redis 程序并不是单线程的,Redis 在启动的时候,是会启动后台线程(BIO)的:

  • Redis 在 2.6 版本,会启动 2 个后台线程,分别处理关闭文件、AOF 刷盘这两个任务;
  • Redis 在 4.0 版本之后,新增了一个新的后台线程,用来异步释放 Redis 内存,也就是 lazyfree 线程。例如执行 unlink key / flushdb async / flushall async 等命令,会把这些删除操作交给后台线程来执行,好处是不会导致 Redis 主线程卡顿。因此,当我们要删除一个大 key 的时候,不要使用 del 命令删除,因为 del 是在主线程处理的,这样会导致 Redis 主线程卡顿,因此我们应该使用 unlink 命令来异步删除大key。
  • Redis 在 6.0 版本之后,采用了多个 I/O 线程来处理网络请求这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上。但是对于命令的执行,Redis 仍然使用单线程来处理

Redis大key会有什么问题?怎么解决?

大 key 会带来以下四种影响:

  • 客户端超时阻塞。由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
  • 引发网络阻塞。每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
  • 阻塞工作线程。如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。
  • 内存分布不均。集群模型在 slot 分片均匀情况下,会出现数据和查询倾斜情况,部分有大 key 的 Redis 节点占用内存多,QPS 也会比较小。

解决方式:

  • 拆分成多个小key。这是最容易想到的办法,降低单key的大小,读取可以用mget批量读取。
  • 设置合理的过期时间。为每个key设置过期时间,并设置合理的过期时间,以便在数据失效后自动清理,避免长时间累积的大Key问题。
  • 启用内存淘汰策略。启用Redis的内存淘汰策略,例如LRU(Least Recently Used,最近最少使用),以便在内存不足时自动淘汰最近最少使用的数据,防止大Key长时间占用内存。
  • 数据分片。例如使用Redis Cluster将数据分散到多个Redis实例,以减轻单个实例的负担,降低大Key问题的风险。
  • 删除大key。使用UNLINK命令删除大key,UNLINK命令是DEL命令的异步版本,它可以在后台删除Key,避免阻塞Redis实例。

MySQL

设计MySQL的表结构要考虑什么问题?

  • 主键设计要合理:主键设计的话,最好不要与业务逻辑有所关联。有些业务上的字段,比如身份证,虽然是唯一的,一些开发者喜欢用它来做主键,但是不是很建议哈。主键最好是毫无意义的一串独立不重复的数字,比如UUID,又或者Auto_increment自增的主键,或者是雪花算法生成的主键等等;

  • 优先考虑逻辑删除,而不是物理删除:给数据添加一个字段,比如is_deleted,以标记该数据已经逻辑删除,最好不要进行物理删除,因为恢复数据很困难,而且物理删除会使自增主键不再连续。

  • 一张表的字段不宜过多:张表的字段不宜过多哈,一般尽量不要超过 20 个字段,果一张表的字段过多,表中保存的数据可能就会很大,查询效率就会很低。当表的字段数非常多时,可以将表分成两张表,一张作为条件查询表,一张作为详细内容表。

  • 尽可能使用 not null 定义字段:首先,NOT NULL 可以防止出现空指针问题。其次,NULL值存储也需要额外的空间的,它也会导致比较运算更为复杂,使优化器难以优化 SQL。NULL值有可能会导致索引失效

  • 设计表时,评估哪些字段需要加索引:区分度不高的字段,不能加索引,如性别等。索引也不要建得太多,一般单表索引个数不要超过5个。因为创建过多的索引,会降低写得速度。索引创建完后,还是要注意避免索引失效的情况,如使用 mysql 的内置函数,会导致索引失效的。索引过多的话,可以通过联合索引的话方式来优化。然后的话,索引还有一些规则,如覆盖索引,最左匹配原则等等。

MySQL的char和varchar 有什么区别?

  • CHAR是固定长度的字符串类型,定义时需要指定固定长度,存储时会在末尾补足空格。CHAR适合存储长度固定的数据,如固定长度的代码、状态等,存储空间固定,对于短字符串效率较高。
  • VARCHAR是可变长度的字符串类型,定义时需要指定最大长度,实际存储时根据实际长度占用存储空间。VARCHAR适合存储长度可变的数据,如用户输入的文本、备注等,节约存储空间。

索引的底层数据结构有哪些实现方式?了解hash索引吗?

MySQL 常见索引有 B+Tree 索引、HASH 索引、Full-Text 索引。

  • B+Tree 索引:是 MySQL 默认存储引擎 InnoDB 采用索引数据结构,所有数据都存储在叶子节点中,非叶子节点只存储索引,提高范围查询的性能和减少磁盘IO开销,千万级的数据量 b+树的树高只需要 3 层。
  • Full-Text 索引:全文索引用于对文本内容进行搜索,采用倒排索引等数据结构来实现全文搜索功能,支持关键字搜索和模糊查询。
  • 哈希索引:哈希索引通过哈希函数计算键的存储位置,适用于等值查找,速度快但不支持范围查找。

为什么用B+树呢?

  • B+Tree vs 二叉树:对于有 N 个叶子节点的 B+Tree,其搜索复杂度为O(logdN),其中 d 表示节点允许的最大子节点个数为 d 个。在实际的应用当中, d 值是大于100的,这样就保证了,即使数据达到千万级别时,B+Tree 的高度依然维持在 3~4 层左右,也就是说一次数据查询操作只需要做 3~4 次的磁盘 I/O 操作就能查询到目标数据。而二叉树的每个父节点的儿子节点个数只能是 2 个,意味着其搜索复杂度为 O(logN),这已经比 B+Tree 高出不少,因此二叉树检索到目标数据所经历的磁盘 I/O 次数要更多。
  • B+Tree vs Hash:Hash 在做等值查询的时候效率贼快,搜索复杂度为 O(1)。但是 Hash 表不适合做范围查询,它更适合做等值的查询,这也是 B+Tree 索引要比 Hash 表索引有着更广泛的适用场景的原因。
  • B+Tree vs B Tree:B+Tree 只在叶子节点存储数据,而 B 树 的非叶子节点也要存储数据,所以 B+Tree 的单个节点的数据量更小,在相同的磁盘 I/O 次数下,就能查询更多的节点。另外,B+Tree 叶子节点采用的是双链表连接,适合 MySQL 中常见的基于范围的顺序查找,而 B 树无法做到这一点。

事务的隔离级别有哪些?

四个隔离级别如下:

  • 读未提交,指一个事务还没提交时,它做的变更就能被其他事务看到;
  • 读提交,指一个事务提交之后,它做的变更才能被其他事务看到;
  • 可重复读),指一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,MySQL InnoDB 引擎的默认隔离级别
  • 串行化;会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行;

按隔离水平高低排序如下:

针对不同的隔离级别,并发事务时可能发生的现象也会不同。

当前读和快照读有什么区别?

  • 当前读可以读取其他事务最新已经提交的事务,执行的过程中会通过加行级锁的方式保证事务的隔离性,比如 select for update、update、 delete 都属于当前读。
  • 快照读是无锁的, 主要是基于mvcc机制实现的,可重复读和读已提交的 select 都属于快照读。
    • 「可重复读」隔离级别是启动事务时生成一个 Read View,然后整个事务期间都在用这个 Read View,这样就保证了在事务期间读到的数据都是事务启动前的记录。
    • 「读提交」隔离级别是在每个 select 都会生成一个新的 Read View,也意味着,事务期间的多次读取同一条数据,前后两次读的数据可能会出现不一致,因为可能这期间另外一个事务修改了该记录,并提交了事务。

MySQL分表怎么设计?

一般可以采用以下几种常见的分表策略:

  • 按时间分表:根据数据的时间特征,按照时间范围(如年、月、日)将数据分散存储到不同的表中,便于数据归档和查询。

  • 按业务分表:根据业务需求将数据按照业务逻辑进行分表,可以根据不同的业务属性将数据分散到不同的表中,实现逻辑上的分离。

  • 按哈希分表:通过对数据的哈希计算,将数据均匀分布到多个表中,避免单表数据量过大导致性能问题。

  • 按范围分表:根据数据的某个范围属性(如用户ID、地区ID等)将数据分散到不同的表中,便于查询和管理特定范围的数据。

  • 按数据量分表:当单表数据量过大时,可以按照一定的规则将数据拆分到多个表中,避免单表数据量过大导致性能下降。

网络

一台机器理论上能创建多少条TCP连接?

如果在不考虑服务器的内存和文件句柄资源的情况下,理论上一个服务端进程最多能支持约为 248 次方(2^32 (ip数) * 2^16 (端口数),约等于两百多万亿!

但是在实际中是支持不了这个数值的,每个 TCP 连接都是一个文件,会占用文件句柄资源,也会占用一定的内存空间。

一台服务器是可以有多个服务端进程的,每个服务端进程监听不同的端口,当然所有65535个端口你都可以用来监听一遍,这样理论上线就到了2的32次方(ip数)×2的16次方(port数)×2的16次方(服务器port数)个,这个基本相当于无穷个了。

但是 Linux每维护一条TCP连接都要花费内存资源的,每一条静止状态(不发送数据和不接收数据)的 TCP 连接大约需要吃 3.44K 的内存,那么 8 GB 物理内存的服务器,最大能支持的 TCP 连接数=8GB/3.44KB=2,438,956(约240万)。

实际过程中的 TCP 连接,还会进行发送数据和接收数据了,那么这些过程还是会额外消耗更多的内存资源的,并发很难达到百万级别。

之前有分享过这个文章:腾讯三面:一台服务器,最大支持的TCP连接数是多少?

操作系统

进程间通信方式有哪些?

之前图解过进程间通信的文章:凉了!张三同学没答好「进程间通信」,被面试官挂了....

由于每个进程的用户空间都是独立的,不能相互访问,这时就需要借助内核空间来实现进程间通信,原因很简单,每个进程都是共享一个内核空间。

Linux 内核提供了不少进程间通信的方式,其中最简单的方式就是管道,管道分为「匿名管道」和「命名管道」。

匿名管道顾名思义,它没有名字标识,匿名管道是特殊文件只存在于内存,没有存在于文件系统中,shell 命令中的「|」竖线就是匿名管道,通信的数据是无格式的流并且大小受限,通信的方式是单向的,数据只能在一个方向上流动,如果要双向通信,需要创建两个管道,再来匿名管道是只能用于存在父子关系的进程间通信,匿名管道的生命周期随着进程创建而建立,随着进程终止而消失。

命名管道突破了匿名管道只能在亲缘关系进程间的通信限制,因为使用命名管道的前提,需要在文件系统创建一个类型为 p 的设备文件,那么毫无关系的进程就可以通过这个设备文件进行通信。另外,不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。

消息队列克服了管道通信的数据是无格式的字节流的问题,消息队列实际上是保存在内核的「消息链表」,消息队列的消息体是可以用户自定义的数据类型,发送数据时,会被分成一个一个独立的消息体,当然接收数据时,也要与发送方发送的消息体的数据类型保持一致,这样才能保证读取的数据是正确的。消息队列通信的速度不是最及时的,毕竟每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程。

共享内存可以解决消息队列通信中用户态与内核态之间数据拷贝过程带来的开销,它直接分配一个共享空间,每个进程都可以直接访问,就像访问进程自己的空间一样快捷方便,不需要陷入内核态或者系统调用,大大提高了通信的速度,享有最快的进程间通信方式之名。但是便捷高效的共享内存通信,带来新的问题,多进程竞争同个共享资源会造成数据的错乱。

那么,就需要信号量来保护共享资源,以确保任何时刻只能有一个进程访问共享资源,这种方式就是互斥访问。信号量不仅可以实现访问的互斥性,还可以实现进程间的同步,信号量其实是一个计数器,表示的是资源个数,其值可以通过两个原子操作来控制,分别是 P 操作和 V 操作

与信号量名字很相似的叫信号,它俩名字虽然相似,但功能一点儿都不一样。信号是异步通信机制,信号可以在应用进程和内核之间直接交互,内核也可以利用信号来通知用户空间的进程发生了哪些系统事件,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令),一旦有信号发生,进程有三种方式响应信号 1. 执行默认操作、2. 捕捉信号、3. 忽略信号。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILLSIGSTOP,这是为了方便我们能在任何时候结束或停止某个进程。

前面说到的通信机制,都是工作于同一台主机,如果要与不同主机的进程间通信,那么就需要 Socket 通信了。Socket 实际上不仅用于不同的主机进程间通信,还可以用于本地主机进程间通信,可根据创建 Socket 的类型不同,分为三种常见的通信方式,一个是基于 TCP 协议的通信方式,一个是基于 UDP 协议的通信方式,一个是本地进程间通信方式。

进程和线程的区别是什么?

图片
  • 本质区别:进程是操作系统资源分配的基本单位,而线程是任务调度和执行的基本单位
  • 在开销方面:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小
  • 稳定性方面:进程中某个线程如果崩溃了,可能会导致整个进程都崩溃。而进程中的子进程崩溃,并不会影响其他进程。
  • 内存分配方面:系统在运行的时候会为每个进程分配不同的内存空间;而对线程而言,除了CPU外,系统不会为线程分配内存(线程所使用的资源来自其所属进程的资源),线程组之间只能共享资源
  • 包含关系:没有线程的进程可以看做是单线程的,如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程

协程为什么比线程快?

协程比线程快的主要原因有以下几点:

  • 用户态切换:协程是在用户态下进行切换,不涉及内核态的上下文切换和系统调用,切换成本低,执行效率高。

  • 轻量级:协程是由用户自己管理的,不需要操作系统进行调度和管理,占用的资源较少,创建和销毁的开销小。

  • 高并发:协程可以在同一个线程内并发执行,避免了线程切换和同步的开销,提高了并发处理能力。

Java

sleep和wait的区别是什么?

相同点:

  • sleep()wait() 调用都会暂停当前线程并让出 CPU

不同点:

  • 定义位置不同:sleep() 是线程类(Thread)的方法;wait() 是顶级类 Object 的方法;
  • 调用地方不同:sleep 方法可以在任何地方使用;wait 方法则只能在同步方法或同步块中使用;
  • 锁资源释放方式不同:sleep 方法只让出了CPU,没有释放同步资源锁!wait方法则是指当前线程让自己暂时退让出同步资源锁,以便其他正在等待该资源的线程得到该资源进而运行,只有调用了notify方法,之前调用wait()的线程才会解除wait状态,可以去参与竞争同步资源锁,进而得到执行。
  • 恢复方式不同:sleep调用后停止运行期间仍持有同步锁,所以到时间会继续执行;wait调用会放弃对象锁,进入等待队列,待调用notify()/notifyAll()唤醒指定的线程或者所有线程,才会进入锁池,再次获得对象锁后才会进入运行状态,在没有获取对象锁之前不会继续执行;
  • 异常捕获:sleep需要捕获或者抛出异常,而wait/notify/notifyAll则不需要。

为什么wait要包在同步块?

Java中的wait()方法需要在同步块(synchronized block)中调用的原因是因为wait()方法会释放对象的锁,而在同步块中可以确保线程在调用wait()方法前持有对象的锁,从而避免多线程执行时的竞争和冲突。

具体原因如下:

  • 线程安全:在同步块中调用wait()方法可以确保线程在调用wait()前已经获取了对象的锁,避免多线程之间的竞争和数据不一致性问题。

  • 对象监视器wait()方法会释放对象的监视器(monitor),其他线程可以获取该对象的监视器并执行同步操作,确保线程之间的协作和同步。

  • 唤醒机制:当调用wait()方法后,线程会进入等待状态,只有在其他线程调用notify()notifyAll()方法唤醒该线程时,线程才会继续执行。在同步块中调用wait()可以保证线程被正确唤醒。

推荐阅读:后端突击训练营,又开始卷了!
字节跳动,差点跪在一面!
腾讯也开始了,要冲就早点冲!
米哈游,顺利进入二面!
继续滑动看下一个
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存