# 好未来 Java 面试
大家好,我是小林。
好未来主要是搞 K12 教育相关的教育软件产品和工具,来看看好未来 25 届开发岗的校招薪资情况如下:
- 普通 offer:22k x 14.5 = 31.9w,北京
- sp offer:26k x 14.5 = 37.7w,北京
训练营也有同学好未来的offer:
好未来面试难度不算简单,看到一篇同学的好未来的后端开发面经,同学反馈面试官水平挺高的,408基础比他狂背八股的要深多了,最后也有手撕算法代码。
这是一面的面经,面试时间长达 50 分钟, 面试官挺好的,问完会给出自己的答案以及自己想问的点,主要考察了计算机基础、网络协议、MySQL、Redis 的问题。
你们觉得难度如何?
# 计算机基础
# 单核CPU如何执行多个程序?
操作系统会为每个程序分配一个时间片,这个时间片是一个很短的时间间隔,例如几十毫秒。
单核 CPU 会轮流执行每个程序,在一个时间片内执行一个程序,当这个时间片用完后,就切换到下一个程序。这种方式给用户一种多个程序在同时运行的假象,因为切换速度非常快,用户难以察觉程序之间的切换。
# CPU的流水线设计有了解吗?
一条指令的执行需要经过取指令
,翻译指令
,执行指令
三个基本流程。CPU内部的电路也分为不同单元:取指单元
、译码单元
、执行单元
等,指令的执行也是按照流水线的工序一步一步执行的。
如果采用流水线技术,则每个时钟周期内只有一个单元在工作,其余两个单元在“观望”
流水线设计将这些操作分成多个独立的阶段,每个阶段由专门的硬件单元负责,使不同指令的不同阶段可以并行执行,流水线的本质就是拿空间换时间。将每条指令的步骤分解到不同的电路单元,从而使得多个指令并行执行。
这就好像我们的后端程序员不需要等待功能上线,就会从产品经理手中拿到下一个需求,开始开发 API。这样的协作模式,就是我们所说的 指令流水线 。这里面每一个独立的步骤,我们就称之为 流水线阶段 或者流水线级。
如果我们把一个指令拆分成“取指令 - 指令译码 - 执行指令”这样三个部分,那这就是一个三级的流水线。如果我们进一步把“执行指令”拆分成“ALU 计算(指令执行)- 内存访问 - 数据写回”,那么它就会变成一个五级的流水线。
例如,在一个简单的 5 级流水线中,当一条指令在执行阶段时,下一条指令可以在译码阶段,再下一条指令可以在取指阶段。
尽管流水线无法减少单条指令执行时的 “延时” 这一性能指标,不过,借助于同时对多条指令的不同阶段展开执行,我们能够有效地提升 CPU 的 “吞吐率”。从外部视角来看,CPU 仿佛具备了 “一心多用” 的能力,在同一时刻,可以同时对 5 条不同指令的不同阶段进行处理。在 CPU 内部,其运行机制就好似一条生产线,不同分工的组件持续处理着从上游传递过来的内容,而无需像传统模式那样,等到一件商品完全生产完毕之后,才开始下一件商品的生产流程。
# CPU绑定的操作有了解吗?
在单核 CPU,虽然只能执行一个线程,但是操作系统给每个线程分配了一个时间片,时间片用完了,就调度下一个线程,于是各个线程就按时间片交替地占用 CPU,从宏观上看起来各个线程同时在执行。
而现代 CPU 都是多核心的,线程可能在不同 CPU 核心来回切换执行,这对 CPU Cache 不是有利的,虽然 L3 Cache 是多核心之间共享的,但是 L1 和 L2 Cache 都是每个核心独有的,如果一个线程在不同核心来回切换,各个核心的缓存命中率就会受到影响,相反如果线程都在同一个核心上执行,那么其数据的 L1 和 L2 Cache 的缓存命中率可以得到有效提高,缓存命中率高就意味着 CPU 可以减少访问 内存的频率。
当有多个同时执行「计算密集型」的线程,为了防止因为切换到不同的核心,而导致缓存命中率下降的问题,我们可以把线程绑定在某一个 CPU 核心上,这样性能可以得到非常可观的提升。
在 Linux 上提供了 sched_setaffinity 方法,来实现将线程绑定到某个 CPU 核心这一功能。
# 网络
# URL从输入到响应的流程?
- 解析URL:分析 URL 所需要使用的传输协议和请求的资源路径。如果输入的 URL 中的协议或者主机名不合法,将会把地址栏中输入的内容传递给搜索引擎。如果没有问题,浏览器会检查 URL 中是否出现了非法字符,则对非法字符进行转义后在进行下一过程。
- 缓存判断:浏览器会判断所请求的资源是否在缓存里,如果请求的资源在缓存里且没有失效,那么就直接使用,否则向服务器发起新的请求。
- DNS解析:如果资源不在本地缓存,首先需要进行DNS解析。浏览器会向本地DNS服务器发送域名解析请求,本地DNS服务器会逐级查询,最终找到对应的IP地址。
- 获取MAC地址:当浏览器得到 IP 地址后,数据传输还需要知道目的主机 MAC 地址,因为应用层下发数据给传输层,TCP 协议会指定源端口号和目的端口号,然后下发给网络层。网络层会将本机地址作为源地址,获取的 IP 地址作为目的地址。然后将下发给数据链路层,数据链路层的发送需要加入通信双方的 MAC 地址,本机的 MAC 地址作为源 MAC 地址,目的 MAC 地址需要分情况处理。通过将 IP 地址与本机的子网掩码相结合,可以判断是否与请求主机在同一个子网里,如果在同一个子网里,可以使用 APR 协议获取到目的主机的 MAC 地址,如果不在一个子网里,那么请求应该转发给网关,由它代为转发,此时同样可以通过 ARP 协议来获取网关的 MAC 地址,此时目的主机的 MAC 地址应该为网关的地址。
- 建立TCP连接:主机将使用目标 IP地址和目标MAC地址发送一个TCP SYN包,请求建立一个TCP连接,然后交给路由器转发,等路由器转到目标服务器后,服务器回复一个SYN-ACK包,确认连接请求。然后,主机发送一个ACK包,确认已收到服务器的确认,然后 TCP 连接建立完成。
- HTTPS 的 TLS 四次握手:如果使用的是 HTTPS 协议,在通信前还存在 TLS 的四次握手。
- 发送HTTP请求:连接建立后,浏览器会向服务器发送HTTP请求。请求中包含了用户需要获取的资源的信息,例如网页的URL、请求方法(GET、POST等)等。
- 服务器处理请求并返回响应:服务器收到请求后,会根据请求的内容进行相应的处理。例如,如果是请求网页,服务器会读取相应的网页文件,并生成HTTP响应。
# 追问:TCP连接除了IP地址还需要什么?
还需要端口号,端口号用于标识一个主机上的特定服务或进程。
- 源端口号是发送端应用程序使用的端口,它是一个 16 位的数字,范围从 0 到 65535。一般来说,客户端通常使用一个大于 1023 的临时端口号。
- 目的端口号是接收端应用程序使用的端口,不同的服务通常使用固定的知名端口号,例如 HTTP 使用端口 80 或 8080,HTTPS 使用端口 443,FTP 使用端口 21 等。
# 追问:流程在传输层做了什么事情?
主要有以下这些事情:
- 建立连接:TCP 三次握手连接的建立,这个过程中会确定双方的初始序列号和滑动窗口大小
- 数据传输 :当应用层的数据传递给传输层时,TCP 会将较大的数据分割成适合在网络中传输的较小的段,并为每个段添加 TCP 头部,包括序列号、确认号、窗口大小等信息。当接收方收到 TCP 段后,会发送 ACK 数据包来确认收到的数据。客户端根据服务器的 ACK 和窗口大小调整数据发送速度,继续发送后续 TCP 段。如果发送方在一定时间内没有收到 ACK,会认为数据丢失,会重传相应的 TCP 段,这个时间间隔称为超时重传时间(RTO)。
- 关闭连接:关闭 TCP 连接时,会进行四次挥手过程。
# 追问:流程在网络层做了什么事情?
传输层的 TCP 段传递到网络层,网络层将其封装成 IP 数据包,设置源 IP 地址和目的 IP 地址,添加其他 IP 头部信息。
路由器根据 IP 数据包的目的 IP 地址和自己的路由表进行路由选择,如果数据超过 MTU 大小,就会对数据包进行分片。
数据包在网络中传输,经过多个路由器,每个路由器都会根据路由表转发数据包,并递减 TTL。
当数据包到达目的主机,网络层进行解封和重组操作,将 TCP 段传递给传输层。
# 追问:TCP有TCP的分段,IP层有IP分片。这两个有什么区别?现在用的是哪个?
两者的区别:
- TCP 分段:发生在传输层,由 TCP 协议执行。当 TCP 从应用层接收到较大的数据块,超过 MSS 大小之后,会将其分成多个较小的 TCP 段,每个 TCP 段都包含 TCP 头部,接收方根据 TCP 段的序列号将它们重新组装成完整的数据块,然后传递给应用层。
- IP 分片:发生在网络层,由 IP 协议执行。当 IP 数据包的大小超过链路层的最大传输单元(MTU)时,IP 会将数据包分成多个较小的分片。每个 IP 分片包含 IP 头部,由目的主机的网络层进行重组。目的主机根据 IP 分片的标识、标志和片偏移将它们重新组合成原始的 IP 数据包,然后传递给传输层。
TCP 通常会尽量避免 IP 分片,原因是 IP 分片之后,只有第一个分片才有TCP头部信息,那么一旦一个分片丢失,整个数据包都需要重传,会影响传输效率,并且由于 IP 分片是基于网络链路的 MTU,可能会在不同的网络链路中发生多次分片和重组,增加网络设备(如路由器)的处理开销。
在现代网络中,TCP 通常会尽量避免 IP 分片,主要作法是:TCP 在建立连接时,会协商 MSS,它是 TCP 段中数据部分的最大长度,通常是根据链路的 MTU 计算得到。例如,在以太网中,MTU 通常是 1500 字节,TCP 的 MSS 通常是 1460 字节(MTU 减去 IP 头部和 TCP 头部的长度)。这样 TCP 会将数据分成 MSS 大小的段,从而避免在网络层进行 IP 分片,提高传输效率。
# 拥塞控制介绍一下 ?
- 连接建立,cwnd = MSS,进入慢启动阶段,每收到一个 ACK,cwnd 翻倍,发送数据量不断增加。
- 当 cwnd 达到 ssthresh,进入拥塞避免阶段,cwnd 线性增长。
- 如果发生超时,ssthresh 减半,cwnd 重置为 MSS(1 个MSS),重新进入慢启动。
- 当发送方收到三个重复的 ACK 时,认为数据包丢失,但不是因为网络拥塞,而是可能某个数据包的乱序。在快速重传之后,发送方进入快速恢复阶段。通常将 ssthresh 减半,cwnd 设置为 ssthresh 加上 3 倍的 MSS(因为收到了三个重复的 ACK),并继续发送新数据。
# mysql
# MySQL是如何保障数据不丢失的?
主要是通过 redolog 来实现事务持久性的,事务执行过程,会把对 innodb 存储引擎中数据页修改操作记录到 redolog 里,事务提交的时候,就直接把 redolog 刷入磁盘,即使脏页中途没有刷盘成功, mysql 宕机了,也能通过 redolog 重放,恢复到之前事务修改数据页后的状态,从而保障了数据不丢失。
# RedoLog是在内存里吗?
事务执行过程中,生成的 redolog 会在 redolog buffer 中,也就是在内存中,等事务提交的时候,会把 redolog 写入磁盘。
# 为什么要写RedoLog,而不是直接写到B+树里面?
因为 redolog 写入磁盘是顺序写,而 b+树里数据页写入磁盘是随机写,顺序写的性能会比随机写好,这样可以提升事务提交的效率。
最重要的是redolog具备故障恢复的能力,Redo Log 记录的是物理级别的修改,包括页的修改,如插入、更新、删除操作在磁盘上的物理位置和修改内容。例如,当执行一个更新操作时,Redo Log 会记录修改的数据页的地址和更新后的数据,而不是 SQL 语句本身。
在数据页实际更新之前,先将修改操作写入 Redo Log。当数据库重启时,会进行恢复操作。首先,根据 Redo Log 检查哪些事务已经提交但数据页尚未完全写入磁盘。然后,使用 Redo Log 中的记录对这些事务进行重做(Redo)操作,将未完成的数据页修改完成,确保事务的修改生效。
# mysql 两次写(double write buffer)了解吗?
我们常见的服务器一般都是Linux操作系统,Linux文件系统页(OS Page)的大小默认是4KB。而MySQL的页(Page)大小默认是16KB。
MySQL程序是跑在Linux操作系统上的,需要跟操作系统交互,所以MySQL中一页数据刷到磁盘,要写4个文件系统里的页。
需要注意的是,这个操作并非原子操作,比如我操作系统写到第二个页的时候,Linux机器断电了,这时候就会出现问题了。造成”页数据损坏“。并且这种”页数据损坏“靠 redo日志是无法修复的。
Doublewrite Buffer的出现就是为了解决上面的这种情况,虽然名字带了Buffer,但实际上Doublewrite Buffer是内存+磁盘的结构。
Doublewrite Buffer 作用是,在把页写到数据文件之前,InnoDB先把它们写到一个叫doublewrite buffer(双写缓冲区)的共享表空间内,在写doublewrite buffer完成后,InnoDB才会把页写到数据文件的适当的位置。如果在写页的过程中发生意外崩溃,InnoDB在稍后的恢复过程中在doublewrite buffer中找到完好的page副本用于恢复,所以本质上是一个最近写回的页面的备份拷贝。
如上图所示,当有页数据要刷盘时:
- 页数据先通过memcpy函数拷贝至内存中的Doublewrite Buffer(大小为约 2MB)中,Doublewrite Buffer 分为两个区域,每次写入一个区域(最多 1MB 的数据)。
- Doublewrite Buffer的内存里的数据页,会fsync刷到Doublewrite Buffer的磁盘上,写两次到到共享表空间中(连续存储,顺序写,性能很高),每次写1MB;
- 写入完成后,再将脏页刷到数据磁盘存储.ibd文件上(随机写);
当MySQL出现异常崩溃时,有如下几种情况发生:
- 情况一:步骤1前宕机,刷盘未开始,数据在redo log,后期可以恢复
- 情况二:步骤1后,步骤2前宕机,因为是在内存中,宕机清空内存,和情况1一样
- 情况三:步骤2后,步骤3前宕机,因为DWB的磁盘有完整的数据,可以修复损坏的页数据
由此我们可以得出结论,double write buffer是针对实际的buffer数据页的原子性保证,就是避免MySQL异常崩溃时,写的那几个data page不会出错,要么都写了,要么什么都没有做。
为什么redolog无法代替double write buffer?
redolog的设计之初,是“账本的作用”,是一种操作日志,用于MySQL异常崩溃恢复使用,是InnoDB引擎特有的日志,本质上是物理日志,记录的是 “ 在某个数据页上做了什么修改 ” ,但如果数据页本身已经发生了损坏,redolog来恢复已经损坏的数据块是无效的,数据块的本身已经损坏,再次重做依然是一个坏块。 所以此时需要一个数据块的副本来还原该损坏的数据块,再利用重做日志进行其他数据块的重做操作,这就是double write buffer的原因作用。
# MySQL的加锁机制?
在 MySQL 里,根据加锁的范围,可以分为全局锁、表级锁和行锁三类。
全局锁:通过flush tables with read lock 语句会将整个数据库就处于只读状态了,这时其他线程执行以下操作,增删改或者表结构修改都会阻塞。全局锁主要应用于做全库逻辑备份,这样在备份数据库期间,不会因为数据或表结构的更新,而出现备份文件的数据与预期的不一样。
表级锁:MySQL 里面表级别的锁有这几种:
表锁:通过lock tables 语句可以对表加表锁,表锁除了会限制别的线程的读写外,也会限制本线程接下来的读写操作。
元数据锁:当我们对数据库表进行操作时,会自动给这个表加上 MDL,对一张表进行 CRUD 操作时,加的是 MDL 读锁;对一张表做结构变更操作的时候,加的是 MDL 写锁;MDL 是为了保证当用户对表执行 CRUD 操作时,防止其他线程对这个表结构做了变更。
意向锁:当执行插入、更新、删除操作,需要先对表加上「意向独占锁」,然后对该记录加独占锁。意向锁的目的是为了快速判断表里是否有记录被加锁。
行级锁:InnoDB 引擎是支持行级锁的,而 MyISAM 引擎并不支持行级锁,行级锁如下:
- 记录锁,锁住的是一条记录。而且记录锁是有 S 锁和 X 锁之分的,满足读写互斥,写写互斥
- 间隙锁,只存在于可重复读隔离级别,目的是为了解决可重复读隔离级别下幻读的现象。
- Next-Key Lock 称为临键锁,是 Record Lock + Gap Lock 的组合,锁定一个范围,并且锁定记录本身。
# 为什么要锁间隙锁而不是加行锁?
可重复读隔离级会有间隙锁,间隙锁主要是为了避免其他事务插入新记录,导致同一个事务前后两次查询的结果集数不一致的幻读问题。
假设,表中有一个范围 id 为(3,5)间隙锁,那么其他事务就无法插入 id = 4 这条记录了,这样就有效的防止幻读现象的发生。
# MVCC的实现机制?
MVCC允许多个事务同时读取同一行数据,而不会彼此阻塞,每个事务看到的数据版本是该事务开始时的数据版本。这意味着,如果其他事务在此期间修改了数据,正在运行的事务仍然看到的是它开始时的数据状态,从而实现了非阻塞读操作。
对于「读提交」和「可重复读」隔离级别的事务来说,它们是通过 Read View 来实现的,它们的区别在于创建 Read View 的时机不同,大家可以把 Read View 理解成一个数据快照,就像相机拍照那样,定格某一时刻的风景。
- 「读提交」隔离级别是在「每个select语句执行前」都会重新生成一个 Read View;
- 「可重复读」隔离级别是执行第一条select时,生成一个 Read View,然后整个事务期间都在用这个 Read View。
Read View 有四个重要的字段:
- m_ids :指的是在创建 Read View 时,当前数据库中「活跃事务」的事务 id 列表,注意是一个列表,“活跃事务”指的就是,启动了但还没提交的事务。
- min_trx_id :指的是在创建 Read View 时,当前数据库中「活跃事务」中事务 id 最小的事务,也就是 m_ids 的最小值。
- max_trx_id :这个并不是 m_ids 的最大值,而是创建 Read View 时当前数据库中应该给下一个事务的 id 值,也就是全局事务中最大的事务 id 值 + 1;
- creator_trx_id :指的是创建该 Read View 的事务的事务 id。
对于使用 InnoDB 存储引擎的数据库表,它的聚簇索引记录中都包含下面两个隐藏列:
- trx_id,当一个事务对某条聚簇索引记录进行改动时,就会把该事务的事务 id 记录在 trx_id 隐藏列里;
- roll_pointer,每次对某条聚簇索引记录进行改动时,都会把旧版本的记录写入到 undo 日志中,然后这个隐藏列是个指针,指向每一个旧版本记录,于是就可以通过它找到修改前的记录。
在创建 Read View 后,我们可以将记录中的 trx_id 划分这三种情况:
一个事务去访问记录的时候,除了自己的更新记录总是可见之外,还有这几种情况:
如果记录的 trx_id 值小于 Read View 中的 min_trx_id 值,表示这个版本的记录是在创建 Read View 前已经提交的事务生成的,所以该版本的记录对当前事务可见。
如果记录的 trx_id 值大于等于 Read View 中的 max_trx_id 值,表示这个版本的记录是在创建 Read View 后才启动的事务生成的,所以该版本的记录对当前事务不可见。
如果记录的 trx_id 值在 Read View 的 min_trx_id 和 max_trx_id 之间,需要判断 trx_id 是否在 m_ids 列表中:
如果记录的 trx_id 在 m_ids 列表中,表示生成该版本记录的活跃事务依然活跃着(还没提交事务),所以该版本的记录对当前事务不可见。
如果记录的 trx_id 不在 m_ids列表中,表示生成该版本记录的活跃事务已经被提交,所以该版本的记录对当前事务可见。
这种通过「版本链」来控制并发事务访问同一个记录时的行为就叫 MVCC(多版本并发控制)。
# Redis
# Redis和数据库如何保证数据一致性?
对于读数据,我会选择旁路缓存策略,如果 cache 不命中,会从 db 加载数据到 cache。对于写数据,我会选择更新 db 后,再删除缓存。
缓存是通过牺牲强一致性来提高性能的。这是由CAP理论决定的。缓存系统适用的场景就是非强一致性的场景,它属于CAP中的AP。所以,如果需要数据库和缓存数据保持强一致,就不适合使用缓存。
所以使用缓存提升性能,就是会有数据更新的延迟。这需要我们在设计时结合业务仔细思考是否适合用缓存。然后缓存一定要设置过期时间,这个时间太短、或者太长都不好:
- 太短的话请求可能会比较多的落到数据库上,这也意味着失去了缓存的优势。
- 太长的话缓存中的脏数据会使系统长时间处于一个延迟的状态,而且系统中长时间没有人访问的数据一直存在内存中不过期,浪费内存。
但是,通过一些方案优化处理,是可以最终一致性的。针对删除缓存异常的情况,可以使用 2 个方案避免:
- 删除缓存重试策略(消息队列)
- 订阅 binlog,再删除缓存(Canal+消息队列)
消息队列方案
我们可以引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。
- 如果应用删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存,这个就是重试机制。当然,如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。
- 如果删除缓存成功,就要把数据从消息队列中移除,避免重复操作,否则就继续重试。
举个例子,来说明重试机制的过程。
重试删除缓存机制还可以,就是会造成好多业务代码入侵。
订阅 MySQL binlog,再操作缓存
「先更新数据库,再删缓存」的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。
于是我们就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的。
Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。
下图是 Canal 的工作原理:
将binlog日志采集发送到MQ队列里面,然后编写一个简单的缓存删除消息者订阅binlog日志,根据更新log删除缓存,并且通过ACK机制确认处理这条更新log,保证数据缓存一致性
# 如果做一个大流量的网站,单Redis无法承压了如何解决?
- 读写分离:部署多个 Redis 从节点(Slave),主节点(Master)负责写操作,从节点负责读操作。主节点将数据同步到从节点,从节点可以处理大量的读请求,减轻主节点的压力。
- 构建集群:部署 Redis Cluster 集群,Redis Cluster 将数据自动划分为 16384 个槽(slots),每个槽都可以存储键值对。这些槽会被分配到多个 Redis 节点上,通过哈希函数将键映射到相应的槽,再由槽映射到具体的 Redis 节点。例如,使用
CRC16(key) % 16384
来确定键属于哪个槽,然后根据槽与节点的映射关系将键值对存储到相应节点。通过数据分片,将数据和请求分散到多个节点,避免单个节点的负载过高。不同节点负责不同的槽,各自处理一部分请求,实现负载均衡。
# 算法
- lc. 152 乘积最大子数组
对了,最新的互联网大厂后端面经都会在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。