OS
⼩红书 Java ⾯试
上海有三巨头互联⽹公司薪资都开的很⾼,分别 是得物、⼩红书、拼多多 这三家,主要都是在⾼
速发展的阶段,待遇开的确实很⾼。
但是⼯作强度也很⾼,⽐阿⾥、腾讯、美团等⼤⼚的⼯作强度都⾼,之前⽹上看到⼀张互联⽹公
司的每周⼯作时⻓的排⾏榜,得物和拼多多 的平均⼯作时⻓最⻓,分别 为 63.8 ⼩时 与 62.4 ⼩
时,第五是⼩红书。
上周刚跟⼤家聊了得物 ,得物 ssp 薪资最⾼能到 60w 年薪,普通 offer 年薪也能⾼达 40w+ 。
这次来看看 ⼩红书 25 届后端开发岗的校招薪资
36k x 16 + 3.6w 签字费 + 21w (期权 ,分 4 年发) + 12 x 1k 房补 + 全年加班费约 7w = 70w
总包
30k x 16 + 3w 签字费 + 12 x 1k 房补 + 全年加班费约 6w = 58w 总包
28k x 16 + 12 x 1k 房补 + 全年加班费约 5w = 50w 总包
⼩红书⼯作强度⽐较⼤⼀些,有⼤⼩周加班的情况,所以有额外的全年加班费。房补每⼀⽉1k ,
但限制要在上海市中⼼租房。
⽬前⼩红书开奖的数据不多(数据来源offershow+ 读者分享),所以就找到了上 ⾯这三个 数据,这⾥也贴⼀个去年 24 届⼩红书开发岗校招的薪资情况,供⼤家参考参考。

各位⼩伙伴们 看完去年⼩红书的薪资后,想必⼤家挤破脑袋都想进⼊⼩红书,不过⼩红书的⾯试
难度也特别⾼,⼀般⼈根本招架不住。
今天这位⼩红书的⾯经就特别有难度,⼩红书校招⼀⾯,竟然从OS 、Redis 、Java 、MySQL 等多个
⽅⾯进⾏考量,⼀般⼈根本招架不住,接下来让我们⼀起来看看 吧
考察的知识点,我给⼤家罗列了⼀下:

Java :ConcurrentHashMap
MySQL :存储引擎、锁、主从 复制、B+ 树
Redis: IO 多路复⽤、线程模型、数据结构、跳表、压缩列表、缓存⼀致性问题
OS: IO 多路复⽤
算法:最⼩覆盖⼦串
OS
讲⼀下io 多路复⽤
⼀个进程虽然任⼀时刻只能处理⼀个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以
内,这样 1 秒内就可以处理上千个请求,把时间拉⻓来看,多个请求复⽤了⼀个进程,这就是多
路复⽤,这种思想很类似⼀个 CPU 并发多个进程,所以也叫做时分多路复⽤。
我们熟悉的 select/poll/epoll 内核提供给⽤⼾态的多路复⽤系统调⽤,进程可以通过⼀个系统调⽤
函数从内核中获取多个事 件。
select/poll/epoll 是如何获取⽹络事件的呢?在获取事件时,先把所 有连接(⽂件描述符)传给内
核,再由内核返回产⽣了事 件的连接,然后在⽤⼾态中再处理这些连接对应的请求即可。
select/poll/epoll 这是三个 多路复⽤接⼝,都能实现 C10K 吗?接下来,我们分别 说说 它们。
select/poll
select 实现多路复⽤的⽅式是,将已连接的 Socket 都放到⼀个⽂件描述符集合,然后调⽤ select
函数将⽂件描述符集合拷⻉到内核⾥,让内核来检查是否有⽹络事件产⽣,检查的⽅式很 粗暴,
就是通过遍历⽂件描述符集合的⽅式,当检查到有事件产⽣后,将此 Socket 标记为可读或可写,
接着再把整个⽂件描述符集合拷⻉回⽤⼾态⾥,然后⽤⼾态还需要再通过遍历的⽅法找到可读或
可写的 Socket ,然后再对其处理。
所以,对于 select 这种⽅式,需要进⾏ 2 次「遍历」⽂件描述符集合,⼀次是在内核态⾥,⼀个
次是在⽤⼾态⾥ ,⽽且还会发⽣ 2 次「拷⻉」⽂件描述符集合,先从⽤⼾空间传⼊内核空间,由
内核修改后,再传出到 ⽤⼾空间中。
select 使⽤固定⻓度的 BitsMap ,表⽰⽂件描述符集合,⽽且所⽀持的⽂件描述符的个数是有限制
的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最⼤值为 1024 ,只能监听 0~1023 的
⽂件描述符。poll 不再⽤ BitsMap 来存储所关注的⽂件描述符,取⽽代之⽤动态数组,以链表形式来组织 ,突破了 select 的⽂件描述符个数限制,当然还会受到系统⽂件描述符限制。
但是 poll 和 select 并没有太⼤的本质区别,都是使⽤「线性结构」存储进程关注的 Socket 集合,
因此都需要遍历⽂件描述符集合来找到可读或可写的 Socket ,时间复杂度为 O(n) ,⽽且也 需要在⽤⼾态与内核态之间拷⻉⽂件描述符集合,这种⽅式随着并发数上来,性能的损耗会呈指数级增
⻓。
epoll
先复习下 epoll 的⽤法。如下的代码中,先⽤epoll_create 创建⼀个 epol l 对象 epfd ,再通过
epoll_ctl 将需要监视的 socket 添加到 epfd 中,最后调⽤ epoll_wait 等待数据。
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...);
listen(s, ...)
int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中
while(1) {
int n = epoll_wait(...);
for(接收到数据的socket){
//处理
}
}epoll 通过两个 ⽅⾯,很好解决了 select/poll 的问题。
第⼀点,epoll 在内核⾥使⽤红⿊树来跟踪进程所有待检测的⽂件描述字,把需要监控的 socket 通
过 epoll_ctl() 函数加⼊内核中的红⿊树⾥,红⿊树是个⾼效的数据结构,增删改⼀般时间复杂度是
O(logn) 。⽽ select/poll 内核⾥没有类似 epoll 红⿊树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次 操作时都传⼊整个 socket 集合给内核,⽽ epoll 因为在内核维护了红⿊树,
可以保 存所有待检测的 socket ,所以只需要传⼊⼀个待检测的 socket ,减少了内核和⽤⼾空间⼤
量的数据拷 ⻉和内存分配。
第⼆点, epoll 使⽤事件驱动的机制,内核⾥维护了⼀个链表来记录就绪事件,当某个 socket 有
事件发⽣时,通过回调函数内核会将其加⼊到这个就绪事件列表中,当⽤⼾调⽤ epoll_wait() 函数时,只会返回有事件发⽣的⽂件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集
合,⼤⼤提⾼了检测的效率。从下 图你可以看到 epoll 相关的接⼝作⽤:

epoll 的⽅式即使监听的 Socket 数量越多的时候,效率不会⼤幅度 降低,能够同时监听的 Socket
的数⽬也⾮常的多了,上限就为系统定义的进程打开的最⼤⽂件描述符个数。因⽽,epoll 被称为
解决 C10K 问题的利器。
边缘触发和⽔平触发
epoll ⽀持两种事件触发模式,分别 是边缘触发(edge-triggered ,ET )和⽔平触发(level-triggered ,LT )。这两个 术语还挺抽象的,其实它 们的区别还是很好理解的。
使⽤边缘触发模式时,当被监控的 Socket 描述符上有可读事件发⽣时,服务器端只会从
epoll_wait 中苏醒⼀次,即使进程没有调⽤ read 函数从内核读取数据,也依然只苏醒⼀次,因
此我们程序要保证⼀次性将内核缓冲区的数据读取完;
使⽤⽔平触发模式时,当被监控的 Socket 上有可读事件发⽣时,服务器端不断地从 epoll_wait
中苏醒,直到内核缓冲区数据被 read 函数读完才结束,⽬的是告诉我们有数据需要读取;
举个 例⼦,你的快递被放到了⼀个快递箱⾥,如果快递箱只会通过短信通知你⼀次,即使你 ⼀直
没有去取 ,它也不 会再发送第⼆条短信提醒你,这个⽅式就是边缘触发;如果快递箱发现你的快
递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是⽔平触发
的⽅式。
这就是两者的区别,⽔平触发的意思是只要满⾜事件的条件,⽐如内核中有数据需要读,就⼀直
不断地把这个事 件传 递给⽤⼾;⽽边缘触发的意思是只有第⼀次满⾜条件的时候才触发,之后就
不会再传递同样的事件了。
如果使⽤⽔平触发模式,当内核通知⽂件描述符可读写时,接下来还可以继续去检测它的状态,
看它是否依然可读或可写。所以在收到通知后,没必要⼀次执⾏尽可能多的读写操作。
如果使⽤边缘触发模式,I/O 事件发⽣时只会通知⼀次,⽽且我们不知道到底能读写多少数据,所
以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会 循环从⽂件描述符读
写数据,那么如果⽂件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那⾥,程序
就没办法继续往下执⾏。所以,边缘触发模式⼀般和⾮阻塞 I/O 搭配使⽤,程序会⼀直执⾏ I/O
操作,直到系统调⽤(如 read 和 write )返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK 。
⼀般来说,边缘触发的效率⽐⽔平触发的效率要⾼,因为边缘触发可以减少 epoll_wait 的系统调
⽤次数,系统调⽤也是有⼀定的开销的的 ,毕竟也存在上下 ⽂的切换。
Redis
Redis 怎么实现的io 多路复⽤?
为什么 Redis 中要使⽤ I/O 多路复⽤这种技术呢?
因为 Redis 是跑在「单线程」中的,所有的操作都是按照顺序线性执⾏的,但是由于读写操作等待
⽤⼾输⼊ 或 输出都是阻塞的,所以 I/O 操作在⼀般情况下往往 不能直接返回,这会导致某⼀⽂件
的 I/O 阻塞导,致整个进程⽆法对其它客 ⼾提供服务。⽽ I/O 多路复⽤就是为了 解决这个问题⽽出
现的。为了 让单线程(进程)的服务端应⽤同时处理多个客⼾端的事件,Redis 采⽤了 IO 多路复⽤机制。
这⾥“多路”指的是多个⽹络连接客⼾端,“复⽤”指的是复⽤同⼀个线程(单进程)。I/O 多路复⽤其实是使⽤⼀个线程来检查多个 Socket 的就绪状态,在单个线程中通过记录跟踪每⼀个 socket (I/O
流)的状态来管理处理多个 I/O 流。如下图是 Redis 的 I/O 多路复⽤模型:

如上图对 Redis 的 I/O 多路复⽤模型进⾏⼀下描述说明:
⼀个 socket 客⼾端与服务端连接时,会⽣成对应⼀个套接字描述符(套接字描述符是⽂件描述符
的⼀种),每⼀个 socket ⽹络连接其实都对应⼀个⽂件描述符。多个客⼾端与服务端连接时,Redis 使⽤ I/O 多路复⽤程序 将客⼾端 socket 对应的 FD 注册到
监听列表(⼀个队列)中。当客服端执⾏ read 、write 等操作命令时,I/O 多路复⽤程序会将命令封装成⼀个事 件,并绑定到对应的 FD 上。
⽂件事件处理器使⽤ I/O 多路复⽤模块同时监控多个⽂件描述符(fd )的读写情况,当
accept 、read 、write 和 close ⽂件事件产⽣时,⽂件事件处理器就会回调 FD 绑定的事件处理
器进⾏处理相关命令操作。
例如:以 Redis 的 I/O 多路复⽤程序 epoll 函数为例。多个客⼾端连接服务端时,Redis 会将客⼾
端 socket 对应的 fd 注册进 epoll ,然后 epoll 同时监听多个⽂件描述符(FD) 是否有数据到来,如果有数据来了就通知事件处理器赶紧处理,这样就不会存在服务端⼀直等待某个客⼾端给数据的情形。
整个⽂件事件处理器是在单线程上运⾏的,但是通过 I/O 多路复⽤模块的引⼊,实现了同时对
多个 FD 读写的监 控,当其中⼀个 client 端达到写或读的状态,⽂件事件处理器就⻢上执⾏,从
⽽就不会出现 I/O 堵塞的问题,提⾼了⽹络通信的性能。
Redis 的 I/O 多路复⽤模式使⽤的是 Reactor 设置模式的⽅式来实现。
Redis 的⽹络模型是怎样的?
Redis 6.0 版本之前,是⽤的是单Reactor 单线程的模式

单 Reactor 单进程的⽅案因为全部⼯作都在同⼀个进程内完成,所以实现起来⽐较简单,不需要考
虑进程间通信,也不 ⽤担⼼多进程竞 争。但是,这种⽅案存在 2 个缺点:
第⼀个缺点,因为只有⼀个进程,⽆法充分利 ⽤ 多核 CPU 的性能;
第⼆个缺点,Handler 对象在业务处理时,整个进程是⽆法处理其他连接的事件的,如果业务
处理耗时⽐较⻓,那么就造成响应的延迟;
所以,单 Reactor 单进程的⽅案不适⽤计算机密集型的场景,只适⽤于业 务处理⾮常快速的场景。
Redis 是由 C 语⾔实现的,在 Redis 6.0 版本之前采⽤的正是「单 Reactor 单进程」的⽅案,因为
Redis 业务处理主要是在内存中完成,操作的速度是很快 的,性能瓶颈不在 CPU 上,所以 Redis 对
于命令的处理是单进程的⽅案。
到 Redis 6.0 之后,就将 ⽹络IO 的处理改成多线程的⽅式了,⽬的是为了 这是因为随着⽹络硬件的
性能提升,Redis 的性能瓶颈有时会出现在⽹络 I/O 的处理上。
所以为了 提⾼⽹络 I/O 的并⾏度,Redis 6.0 对于⽹络 I/O 采⽤多线程来处理。但是对于命令的执
⾏,Redis 仍然使⽤单线程来处理,所以⼤家不要误解 Redis 有多线程同时执⾏命令。
Redis 为什么 快
官⽅使⽤基准测试的结果是,单线程的 Redis 吞吐 量可以达到 10W/ 每秒,如下图所⽰:

之所以 Redis 采⽤单线程(⽹络 I/O 和执⾏命令)那么快,有如下⼏个原因:
Redis 的⼤部分操作都在内存中完成,并且采⽤了⾼效的数据结构,因此 Redis 瓶颈可能是机器
的内存或者⽹络带宽,⽽并⾮ CPU ,既然 CPU 不是瓶颈,那么⾃然就采⽤单线程的解决⽅案
了;
Redis 采⽤单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的
开销,⽽且也不 会导致死锁问题。
Redis 采⽤了 I/O 多路复⽤机制处理⼤量的客⼾端 Socket 请求,IO 多路复⽤机制是指⼀个线程
处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运⾏单线程的
情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket 。内核会⼀直监听这些
Socket 上的连接请求或数据请求。⼀旦有请求到达,就会交给 Redis 线程处理,这就实现了⼀
个 Redis 线程处理多个 IO 流的效果。
Redis 哪些地⽅使⽤了多线程
edis 单线程指的是「接收客⼾端请求-> 解析请求 -> 进⾏数据读写等操作-> 发送数据给客⼾端」这
个过程是由⼀个线程(主线程)来完成的,这也是我们常说 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 为「关闭⽂件、AOF 刷盘、释放内存」这些任务创 建单独的线程来处理,是因为这
些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发
⽣阻塞,这样就⽆法处理后续的请求了。后台 线程相当于⼀个消费者,⽣产者把耗时任务丢到任
务队列中,消费者(BIO )不停轮询这个队列,拿出任务就去执⾏对应的⽅法即可。

image.png
虽然 Redis 的主要⼯作(⽹络 I/O 和执⾏命令)⼀直是单线程模型,但是在 Redis 6.0 版本之后,
也采⽤了多个 I/O 线程来处理⽹络请求,这是因为随着⽹络硬件的性能提升,Redis 的性能瓶颈有
时会出现在⽹络 I/O 的处理上。
所以为了 提⾼⽹络 I/O 的并⾏度,Redis 6.0 对于⽹络 I/O 采⽤多线程来处理。但是对于命令的执
⾏,Redis 仍然使⽤单线程来处理,所以⼤家不要误解 Redis 有多线程同时执⾏命令。
Redis 官⽅表⽰,Redis 6.0 版本引⼊的多线程 I/O 特性对性能提升⾄少是⼀倍以上。
Redis 6.0 版本⽀持的 I/O 多线程特性,默认情况下 I/O 多线程只针对发送响应数据(write client socket ),并不会以 多线程的⽅式处理读请 求(read client socket )。要想开启多线程处理客⼾端读请求,就需要把 Redis.conf 配置⽂件中的 io-threads-do-reads 配置项设为 yes 。
同时, Redis.conf 配置⽂件中提供了 IO 多线程个数的配置项。
// io-threads N,表示启用 N-1 个 I/O 多线程(主线程也算一个 I/O 线程)io-threads 4
关于线程数的设置,官⽅的建议是如果为 4 核的 CPU ,建议线程数设置为 2 或 3,如果为 8 核
CPU 建议线程数设置为 6,线程数⼀定要⼩于机器核数,线程数并不是越⼤越好。因此, Redis 6.0 版本之后,Redis 在启动的时候,默认情况下会额外创建 6 个线程(这⾥的线程数不包括主线程):
Redis-server :Redis 的主线程,主要负责 执⾏命令;
bio_close_file 、bio_aof_fsync 、bio_lazy_free :三个 后台 线程,分别 异步处理关闭⽂件任 务、
AOF 刷盘任务、释放内存任务;
io_thd_1 、io_thd_2 、io_thd_3 :三个 I/O 线程,io-threads 默认是 4 ,所以会 启动 3(4-1 )个
I/O 多线程,⽤来分担 Redis ⽹络 I/O 的压⼒。
讲⼀下Redis 底层的数据结构
Redis 提供了丰 富的数据类型,常⻅的有五种数据类型:String (字符串),Hash (哈希),List
(列表),Set (集合)、Zset (有序集合)。


随着 Redis 版本的更新,后⾯⼜⽀持了四种数据类型:BitMap (2.2 版新增)、HyperLogLog (2.8 版新增)、GEO (3.2 版新增)、Stream (5.0 版新增)。Redis 五种数据类型的应⽤场景:
String 类型的应⽤场景:缓存对象、常规计数、分布式锁、共享 session 信息等。
List 类型的应⽤场景:消息队列(但是有两个 问题:1. ⽣产者需要⾃⾏实现全局唯⼀ ID ;2. 不
能以消费组形式 消费数据)等。
Hash 类型:缓存对象、购物⻋等。
Set 类型:聚合计算(并集、交集、差集)场景,⽐如点赞、共同关注、抽奖活动等。
Zset 类型:排序场景,⽐如排⾏榜、电话和姓名排序等。
Redis 后续版本⼜⽀持四种数据类型,它们的应⽤场景如下:
BitMap (2.2 版新增):⼆值状态统计的场景,⽐如签到、判断⽤⼾登陆状态、连续签到⽤⼾总
数等;
HyperLogLog (2.8 版新增):海量数据基数统计的场景,⽐如百万级⽹⻚ UV 计数等;
GEO (3.2 版新增):存储地理位置信息的场景,⽐如滴滴 叫⻋;
Stream (5.0 版新增):消息队列,相⽐于基于 List 类型实现的消息队列,有这两个 特有的特
性:⾃动⽣成全局唯⼀消息ID ,⽀持以消费组形式 消费数据。
跳表和压缩列表是怎么实现的?
跳表
Redis 只有 Zset 对象的底层实现⽤到了跳表,跳表的优势是能⽀持平均 O(logN) 复杂度的节点查找。
链表在查找元素的时候,因为需要逐⼀查找,所以查询效率⾮常低,时间复杂度是O(N) ,于是就出现了跳表。跳表是在链表基础上改进过 来的,实现了⼀种「多层」的有序链表,这样的好处 是
能快读定位数据。
那跳表⻓什么 样呢?我这⾥举个 例⼦,下图展⽰了⼀个层级为 3 的跳表。

图中头节点有 L0~L2 三个 头指针,分别 指向了不 同层级的节点,然后每个层级的节点都通过指针
连接起来:
L0 层级共有 5 个节点,分别 是节点1、2、3、4、5;
L1 层级共有 3 个节点,分别 是节点 2、3、5;
L2 层级只有 1 个节点,也就是节点 3 。
如果我们要在链表中查找节点 4 这个元素,只能从头开始遍历链表,需要查找 4 次,⽽使⽤了跳
表后,只需要查找 2 次就能定位到节点 4,因为可以在头节点直接从 L2 层级跳到节点 3,然后再
往前遍历找到节点 4。
可以看到,这个查找过程就是在多个层级上跳来跳去,最后定位到元素。当数据量很⼤时,跳表
的查找复杂度就是 O(logN) 。那跳表节点是怎么实现多层级的呢?这就需要看「跳表节点」的数据结构了,如下:
typedef struct zskiplistNode {
//Zset 对象的元素值
sds ele;
//元素权重值
double score;
//后向指针
struct zskiplistNode *backward;
//节点的level数组,保存每层上的前向指针和跨度
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;Zset 对象要同时保存「元素」和「元素的权重」,对应到跳表节点结构⾥就是 sds 类型的 ele 变量
和 double 类型的 score 变量。每个跳表节点都有⼀个后向 指针(struct zskiplistNode *backward ),指向前⼀个节点,⽬的是为了 ⽅便从跳表的尾节点开始访问节点,这样倒序查找时
很⽅便。
跳表是⼀个带有层级关系的链表,⽽且每⼀层级可以包含多个节点,每⼀个节点通过指针连接起
来,实现这⼀特性就是靠跳表节点结构体中的zskiplistLevel 结构体类型的 level 数组。
level 数组中的每⼀个元素代表跳表的⼀层,也就是由 zskiplistLevel 结构体表⽰,⽐如 leve[0] 就表⽰第⼀层,leve[1] 就表⽰第⼆层。zskiplistLevel 结构体⾥定义了 「指向下⼀个跳表节点的指针」和「跨度」,跨度时⽤来记录两个 节点之间的距离。
⽐如,下⾯这张图,展⽰了各个节点的跨度。

第⼀眼看 到跨度的时候,以为是遍历操作有关,实际上并没有任何 关系,遍历操作只需要⽤前向
指针(struct zskiplistNode *forward )就可以完成了。
Redis 跳表在创建节点的时候,随机⽣成每个节点的层数,并没有严格维持相邻两层的节点数量⽐
例为 2 : 1 的情况。
具体的做法是,跳表在创建节点时候,会⽣成范围为[0-1] 的⼀个随机数,如果这个随机数⼩于
0.25 (相当于概率 25% ),那么层数就增加 1 层,然后继续⽣成下⼀个随机数,直到随机数的结果
⼤于 0.25 结束,最终确定该节点的层数。
这样的做法,相当于每增加⼀层的概率不超过 25% ,层数越⾼,概率越低,层⾼最⼤限制是 64 。
虽然我前⾯讲解跳表的时候,图中的跳表的「头节点」都是 3 层⾼,但是其实如果层⾼最⼤限制
是 64 ,那么在创建跳表「头节点」的时候,就会直接创建 64 层⾼的头节点。
压缩列表
压缩列表是 Redis 为了 节约内存⽽开发的,它是由连续内存块组成的顺序型数据结构,有点类似于
数组。

压缩列表在表头有三个 字段:
zlbytes ,记录整个压缩列表占⽤对内存字 节数;
zltail ,记录压缩列表「尾部」节点距离起始地址 由多少字节,也就是列表尾的偏移量;
zllen ,记录压缩列表包含的节点数量;
zlend ,标记压缩列表的结束点,固定值 0xFF (⼗进制255 )。
在压缩列表中,如果我们要查找定位第⼀个元素和最后⼀个元素,可以通过表头三个 字段(zllen )
的⻓度直接定位,复杂度是 O(1) 。⽽查找其他元素时,就没有这么⾼效了,只能逐个查找,此时
的复杂度就是 O(N) 了,因此压缩列表不适合保存过多的元素。另外,压缩列表节点(entry )的构成如下:

压缩列表节点包含三部分内容:
prevlen ,记录了「前⼀个节点」的⻓度,⽬的是为了 实现从后向 前遍历;
encoding ,记录了当前节点实际数据的「类型和⻓度」,类型主要有两种:字符串和整数 。
data ,记录了当前节点的实际数据,类型和⻓度都由 encoding 决定;
当我们往压缩列表中插⼊数据时,压缩列表就会根据数据类型是字符串还是整数 ,以及数据的⼤
⼩,会使 ⽤不同空间⼤⼩的 prevlen 和 encoding 这两个 元素⾥保存的信息,这种根据数据⼤⼩和
类型进⾏不同的空间⼤⼩分配的设计 思想,正是 Redis 为了 节省内存⽽采⽤的。
压缩列表的缺点是会发⽣连锁更新的问题,因此连锁更新⼀旦发⽣,就会导致压缩列表占⽤的内
存空间要多次重新分配,这就会直接影响到压缩列表的访问性能。
所以说,虽然压缩列表紧凑型的内存布局能节省内存开销,但是如果保存的元素数量增加了,或
是元素变⼤了,会导致内存重新分配,最糟糕 的是会有「连锁更新」的问题。
因此,压缩列表只会⽤于保存的节点数量不多的场景,只要节点数量⾜够⼩,即使发⽣连锁更
新,也是能接受的。
虽说如此,Redis 针对压缩列表在设计 上的不⾜,在后来的版本中,新增设计 了两 种数据结构:
quicklist (Redis 3.2 引⼊) 和 listpack (Redis 5.0 引⼊)。这两种数据结构的设计 ⽬标,就是尽可 能地保持压缩列表节省内存的优势,同时解决压缩列表的「连锁更新」的问题。
如何保 证缓存的⼀致性?

缓存是通过牺牲 强⼀致性来提⾼性能的。这是由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 ,保证数据缓存⼀致性
Java
ConcurrentHashMap 为什么 是线程安全的?
JDK 1.7 ConcurrentHashMap
在 JDK 1.7 中它使⽤的是数组加链表的形式 实现的,⽽数组⼜分为:⼤数组 Segment 和⼩数组
HashEntry 。Segment 是⼀种可重⼊锁(ReentrantLock ),在 ConcurrentHashMap ⾥扮演锁的⻆⾊;HashEntry 则⽤于存储键值对数据。⼀个 ConcurrentHashMap ⾥包含⼀个 Segment 数组,⼀个 Segment ⾥包含⼀个 HashEntry 数组,每个 HashEntry 是⼀个链表结构的元素。

JDK 1.7 ConcurrentHashMap 分段锁技术将数据分成⼀段⼀段的存储,然后给每⼀段数据配⼀把
锁,当⼀个线程占⽤锁访问其中⼀个段数据的时候,其他段的数据也能被其他线程访问,能够实
现真正的并发访问。
JDK 1.8 ConcurrentHashMap
在 JDK 1.7 中,ConcurrentHashMap 虽然是线程安全的,但因为它的底层实现是数组 + 链表的形式,所以在数据⽐较多的情况下访问是很慢的,因为要遍历整个链表,⽽ JDK 1.8 则使⽤了数组 +
链表/红⿊树的⽅式优化了 ConcurrentHashMap 的实现,具体实现结构如下:

JDK 1.8 ConcurrentHashMap JDK 1.8 ConcurrentHashMap 主要通过 volatile + CAS 或者
synchronized 来实现的线程安全的。添加元素时⾸先会判断容器是否为空:
如果为空则使⽤ volatile 加 CAS 来初始化
如果容器不为 空,则根据存储的元素计算该位置是否为空。
如果根据存储的元素计算结果为空,则利 ⽤ CAS 设置该节点;
如果根据存储的元素计算结果不为 空,则使⽤ synchronized ,然后,遍历桶中的数据,并替
换或新增节点到桶中,最后再判断是否需要转为红⿊树,这样就能保证并发访问时的线程安
全了。
如果把上⾯的执⾏⽤⼀句话归纳的话,就相当于是ConcurrentHashMap 通过对头结点加锁来保证
线程安全的,锁的粒度相⽐ Segment 来说更⼩了,发⽣冲突和加锁的频率降低了,并发操作的性
能就提⾼了。⽽且 JDK 1.8 使⽤的是红⿊树优化了之 前的固定链表,那么当数据量⽐较⼤的时候,
查询性能也得到了很⼤的提升,从之 前的 O(n) 优化到了 O(logn) 的时间复杂度。OS
讲⼀下银⾏家算法
系统发⽣死锁是很正常的,我们需要主动去预防死锁,即进⾏有序的资源分配,使⽤银⾏家算
法。银⾏家算法是最有 代表性的避免死锁的算法。为什么 叫银⾏家算法呢?就是这个算法的逻辑
很像银⾏放贷的逻辑,也就是尽可能避免坏账的出现。银⾏家算法的业务逻辑如下。
不负荷执⾏:⼀个进程的最⼤需求量不超过系统拥有的总资源数,才会被接纳执⾏。
可分期:⼀个进程可以分期请求资源,但总请求书不 可超过最⼤需求量。
推迟分配:当系统现有资源数⼩于进程需求时,对进程的需求可以延迟分配,但总让进程在有
限时间内获取资源。
听起来有 点绕,我们还是举个 例⼦来说明。假如系统中有三类互斥资源 R1 、R2 、R3 ,可⽤资源数分别 是 9、8、5,在指定时刻有 P1 、P2 、P3 、P4 和 P5 这五个 进程,这些进程的对三类互斥资源的最⼤需求量和已分配资源数如下表所⽰,那么系统如何先后运⾏这五个 进程,不会发⽣死锁问题?

第⼀步:分析
⾸先分析⾸次需求的资源,系统剩余可⽤资源数分别 是 2、1、0,各进程需要的资源数如下表所

⽰。资源 R1 的剩余可⽤资源数 = 9 - 1 - 2 - 2 - 1 - 1 = 2 。资源 R2 的剩余可⽤资源数 = 8 - 2 - 1 -1 - 2 - 1 = 1 。资源 R3 的剩余可⽤资源数 = 5 - 1 - 1 - 0 - 0 - 3 = 0 。根据银⾏家算法不负荷原则【⼀个进程的最⼤需求量不超过系统拥有的总资源数,才会被接纳执
⾏】,优先给进程 P2 执⾏,因为剩余的 0 1 0 资源够让 P2 执⾏。
第⼆步:执⾏ P2
P2 执⾏之后,释放了刚刚 放⼊的 2 1 0 资源,⽽且可以释放已分配的 2 1 1 资源,所以此时的资源

剩余量。资源 R1 的剩余可⽤资源数 = 原资源数 - 执⾏ P2 消耗数 + P2 执⾏完释放的资源数 = 2 -0 + (2 + 0 ) = 4 。资源 R2 的剩余可⽤资源数 = 原资源数 - 执⾏ P2 消耗数 + P2 执⾏完释放的资
源数 = 1 - 1 + (1 + 1 ) = 2 。资源 R3 的剩余可⽤资源数 = 原资源数 - 执⾏ P2 消耗数 + P2 执⾏
完释放的资源数 = 0 - 0 + (0 + 1 ) = 1 。执⾏完成 P2 后,操作系统剩余可⽤资源数为 4 2 1 。第三步:执⾏ P4
此时操作系统剩余可⽤资源数为 4 2 1 ,只能执⾏进程 P4 ,因为其他进程资源不够。P4 执⾏之
后,释放了刚刚 放⼊的 0 0 1 资源,⽽且可以释放已分配的 1 2 1 资源,所以此时的资源剩余量。

资源 R1 的剩余可⽤资源数 = 原资源数 - 执⾏ P4 消耗数 + P4 执⾏完释放的资源数 = 4 - 0 + (1 + 0) = 5 。资源 R2 的剩余可⽤资源数 = 原资源数 - 执⾏ P4 消耗数 + P4 执⾏完释放的资源数 = 2 -0 + (2 + 0 ) = 4 。资源 R3 的剩余可⽤资源数 = 原资源数 - 执⾏ P4 消耗数 + P4 执⾏完释放的
资源数 = 1 - 1 + (1 + 1 ) = 2 。执⾏完成 P4 后,操作系统剩余可⽤资源数为 5 4 2 。第四步:执⾏ P5
此时操作系统剩余可⽤资源数为 5 4 2 ,只能执⾏进程 P5 ,因为其他进程资源不够。P5 执⾏之
后,释放了刚刚 放⼊的 2 3 1 资源,⽽且可以释放已分配的 1 1 3 资源,所以此时的资源剩余量。

资源 R1 的剩余可⽤资源数 = 原资源数 - 执⾏ P5 消耗数 + P5 执⾏完释放的资源数 = 5 - 2 + (1 + 2) = 6 。资源 R2 的剩余可⽤资源数 = 原资源数 - 执⾏ P5 消耗数 + P5 执⾏完释放的资源数 = 4 -3 + (1 + 3 ) = 5 。资源 R3 的剩余可⽤资源数 = 原资源数 - 执⾏ P5 消耗数 + P5 执⾏完释放的
资源数 = 2 - 1 + (3 + 1 ) = 5 。执⾏完成 P5 后,操作系统剩余可⽤资源数为 6 5 5 。第五步:执⾏ P1 或者 P3

此时操作系统剩余可⽤资源数为 6 5 5 ,可以执⾏ P1 或 P3 。所以安全执⾏顺序为 p2 => p4 => p5 => p1 => p3 或 p2 => p4 => p5 => p3 => p1 。在这⾥插⼊图⽚描述 或

在这⾥插⼊图⽚描述
银⾏家算法总结
银⾏家算法的核⼼思想,就是在分配给进程资源前,⾸先判断这个进程的安全性,也就是预执
⾏,判断分配后是否产⽣死锁现象。如果系统当前资源能满⾜其执⾏,则尝试分配,如果不满⾜
则让该 进程等待。** 通过不断检查剩余可⽤资源是否满⾜某个进程的最⼤需求,如果可以则加 ⼊安全序列,并把该进程当前持有的资源回收;不断重复这个过程,看最后能否实现让所有进程都加
⼊安全序列。** 安全序列⼀定不会发⽣死锁,但没有死锁不⼀定是安全序列。
讲⼀下⻚表
分⻚是把整个虚拟和物理内存空间切成⼀段段 固定尺⼨的⼤⼩。这样⼀个连续并且尺⼨固定的内
存空间,我们叫⻚(Page )。在 Linux 下,每⼀⻚的⼤⼩为 4KB 。虚拟地址 与物理地址 之间通过⻚表来映射,如下图:

⻚表是存储在内存⾥的,内存管理单元 (MMU )就做将虚拟内存地址 转换成物理地址 的⼯作。
⽽当进程访问的虚拟地址在 ⻚表中查不到时,系统会产⽣⼀个缺⻚异常,进⼊系统内核空间分配
物理内存、更新进程⻚表,最后再返回⽤⼾空间,恢复进程的运⾏。
内存分⻚由于内存空间都是预先划分 好的,也就不会像内存分段⼀样,在段与段之间会产⽣间隙
⾮常⼩的内存,这正是分段会产⽣外部内存碎⽚的原因。⽽采⽤了分⻚,⻚与⻚之间是紧密排列
的,所以不会有外部碎⽚。
但是,因为内存分⻚机制分 配内存的最⼩单位是⼀⻚,即使程序不⾜⼀⻚⼤⼩,我们最少只能分
配⼀个⻚,所以⻚内会出现内存浪费,所以针对内存分⻚机制会有内部内存碎⽚的现象。
在分⻚机制下,虚拟地址 分为两 部分,⻚号和⻚内偏移。⻚号作为⻚表的索引,⻚表包含物理⻚
每⻚所在物理内存的基地址 ,这个基地址 与⻚内偏移的组合就形成了物理内存地址 ,⻅下图。

总结⼀下,对于⼀个内存地址 转换,其实就是这样三个 步骤:
把虚拟内存地址 ,切分 成⻚号和 偏移量;
根据⻚号,从⻚表⾥⾯,查询对应的物理⻚号;
直接拿 物理⻚号,加上前⾯的偏移量,就得到了物理内存地址 。
下⾯举个 例⼦,虚拟内存中的⻚通过⻚表映射为了 物理内存中的⻚,如下图:

讲下为什么 进程之下 还要设计 线程,线程之间怎么通信的
为什么 要设计 线程
我们举个 例⼦,假设你要编写⼀个视频播放器软件,那么该软件功能的核⼼模块有三个 :
从视频⽂件当中读取数据;
对读取的数据进⾏解压缩;
把解压缩后的视频数据播放出来;
对于单进程的实现⽅式,我想⼤家都会是以下这个⽅式:

对于单进程的这种⽅式,存在以下问题:
播放出来的画⾯和声⾳会不连贯,因为当 CPU 能⼒不够强的时候,Read 的时候可能进程就等
在这了,这样就会导致等半天才进⾏数据解压和播放;
各个函数之间不是并发执⾏,影响资源的使⽤效率;
那改进成多进程的⽅式:

对于多进程的这种⽅式,依然会存在问题:
进程之间如何通信,共享数据?
维护进程的系统开销较⼤,如创建进程时,分配资源、建⽴ PCB ;终⽌进程时,回收资源、撤
销 PCB ;进程切换时,保存当前进程的状态信息;
那到底如何解决呢?需要有⼀种新的实体,满⾜以下特性:
实体之间可以并发运⾏;
实体之间共享相同的地址 空间;
这个新的实体,就是线程( Thread ),线程之间可以并发运⾏且共享相同的地址 空间。线程间的通信⽅式
Linux 系统提供了五 种⽤于线程通信的⽅式:互斥锁、读写锁、条件变量、⾃旋锁和信号量。
互斥锁(Mutex ):互斥量(mutex) 从本质上说是⼀把锁,在访问共享资源前对互斥量进⾏加锁,在访问完成后释放互斥量上的锁。对互斥量进⾏加锁以后,任何 其他试图再次对互斥锁加
锁的线程将会阻塞直到当前线程释放该互斥锁。如果释放互斥锁时有多个线程阻塞,所有在该
互斥锁上的阻塞线程都会变成可运⾏状态,第⼀个变为运⾏状态的线程可以对互斥锁加锁,其
他线程将会看到互斥锁依然被锁住,只能回去再次等待它重新变为可⽤。
条件变量(Condition Variables ):条件变量(cond) 是在多线程程 序中⽤来实现"等待--》唤醒"逻辑常⽤的⽅法。条件变量利⽤线程间共享的全局变量进⾏同步的⼀种机制,主要包括两个 动
作:⼀个线程等待"条件变量的条件成⽴"⽽挂起;另⼀个线程使“条件成⽴”。为了 防⽌竞争,条
件变量的使⽤总是和⼀个互 斥锁结合在⼀起。线程在改变条件状态前必须⾸先锁住互斥量,函
数pthread_cond_wait 把⾃⼰放到等待条件的线程列表上,然后对互斥锁解锁(这两个 操作是原⼦
操作)。在函数返回时,互斥量再次被锁住。⾃旋锁(Spinlock ):⾃旋锁通过 CPU 提供的 CAS 函数(Compare And Swap ),在「⽤⼾态」
完成加锁和解锁操作,不会主动产⽣线程上下 ⽂切换,所以相⽐互斥锁来说,会快⼀些,开销
也⼩⼀些。⼀般加锁的过程,包含两个 步骤:第⼀步,查看锁的状态,如果锁是空闲的,则执
⾏第⼆步;第⼆步,将锁设置为当前线程持有;使⽤⾃旋锁的时候,当发⽣多线程竞 争锁的情
况,加锁失败的线程会「忙等待」,直到它拿到锁。CAS 函数就把这两个 步骤合并成⼀条硬件级
指令,形成原⼦指令,这样就保证了这两个 步骤是不可分割 的,要么⼀次性执⾏完两个 步骤,
要么两个 步骤都不执⾏。这⾥的「忙等待」可以⽤ while 循环等待实现,不过最好是使⽤ CPU
提供的 PAUSE 指令来实现「忙等待」,因为可以减少循环等待时的耗电量。
信号量(Semaphores ):信号量可以是命名 的(有名信号量)或⽆名的(仅限于当前进程内的
线程),⽤于控制对资源的访问次数。通常信号量表⽰资源的数量,对应的变量是⼀个整型
(sem )变量。另外,还有两个 原⼦操作的系统调⽤函数来控制信号量的,分别 是:P
操作:将
sem 减 1,相减后,如果 sem < 0 ,则进程/线程进⼊阻塞等待,否则继续,表明 P 操作可能会
阻塞;V
操作:将 sem 加 1,相加后,如果 sem <= 0 ,唤醒⼀个等待中的进程/线程,表明 V操作不会阻塞;
读写锁(Read-Write Locks ):读写锁从字⾯意思我们也可以知道,它由「读锁」和「写锁」两
部分构成,如果只读取共享资源⽤「读锁」加锁,如果要修改共享资源则⽤「写锁」加锁。所
以,读写锁适⽤于能明确区分读操作和写操作的场景。读写锁的⼯作原理是:当「写锁」没有
被线程持有时,多个线程能够并发地持有读锁,这⼤⼤提⾼了共享资源的访问效率,因为「读
锁」是⽤于读取共享资源的场景,所以多个线程同时持有读锁也不 会破坏共享资源的数据。但
是,⼀旦「写锁」被线程持有后,读线程的获取读锁的操作会 被阻塞,⽽且其他写线程的获取
写锁的操作也会被阻塞。所以说,写锁是独占锁,因为任何 时刻只能有⼀个线程持有写锁,类
似互斥锁和⾃旋锁,⽽读锁是共享锁,因为读锁可以被多个线程同时持有。知道了读写锁的⼯
作原理后,我们可以发现,读写锁在读多写少的场景,能发挥出优势。
MySQL
讲⼀讲mysql 的引擎吧,你有什么了 解?
MySQL 中常⽤的存储引擎分别 是:MyISAM 存储引擎、innoDB 存储引擎,他们的区别在于:
事务:InnoDB ⽀持事务,MyISAM 不⽀持事务,这是 MySQL 将默认存储引擎从 MyISAM 变成
InnoDB 的重要原因之⼀。
索引结构:InnoDB 是聚簇索引,MyISAM 是⾮聚簇索引。聚簇索引的⽂件存放在主键索引的叶
⼦节点上,因此 InnoDB 必须要有主键,通过主键索引效率很⾼。但是辅助索引需要两次查
询,先查询到主键,然后再通过主键查询到数据。因此,主键不应该过⼤,因为主 键太⼤,其
他索引也都会很⼤。⽽ MyISAM 是⾮聚簇索引,数据⽂件是分离的,索引保存的是数据⽂件的
指针。主键索引和辅助索引是独⽴的。
锁粒度:InnoDB 最⼩的锁粒度是⾏锁,MyISAM 最⼩的锁粒度是表锁。⼀个更新语句会锁住整
张表,导致其他查询和更新都会被阻塞,因此并发访问受限。
count 的效率:InnoDB 不保存表的具体⾏数,执⾏ select count(*) from table 时需要全表扫描。⽽MyISAM ⽤⼀个变量保存了整个表的⾏数,执⾏上述语句时只需要读出该变量即可,速
度很快 。
讲⼀下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 的组合,锁定⼀个范围,并且锁定
记录本⾝。
MySQL 主从 复制了解吗
MySQL 的主从 复制依赖于 binlog ,也就是记录 MySQL 上的所有变化 并以⼆进制形式 保存在磁盘
上。复制的过程就是将 binlog 中的数据从主 库传输到从库上。这个过程⼀般是异步的,也就是主
库上执⾏事务操作的线程不会等待复制 binlog 的线程同步完成。

MySQL 集群的主从 复制过程梳理成 3 个阶段:
写⼊ Binlog :主库写 binlog ⽇志,提交事 务,并更新本地存储数据。
同步 Binlog :把 binlog 复制到 所有从库上,每个从 库把 binlog 写到暂存⽇志中。
回放 Binlog :回放 binlog ,并更新存储引擎中的数据。
具体详细过程如下:
MySQL 主库在收到客⼾端提交事 务的请求之后,会先写 ⼊ binlog ,再提交事 务,更新存储引擎
中的数据,事务提交完成后,返回给客⼾端“操作成功”的响应。
从库会创建⼀个专 ⻔的 I/O 线程,连接主库的 log dump 线程,来接收主库的 binlog ⽇志,再
把 binlog 信息写⼊ relay log 的中继⽇志⾥,再返回给主库“复制成功”的响应。
从库会创建⼀个⽤于回放 binlog 的线程,去读 relay log 中继⽇志,然后回放 binlog 更新存储
引擎中的数据,最终实现主从 的数据⼀致性。
在完成主从 复制之后,你就可以在写数据时只写主库,在读数据时只读从库,这样即使写请求会
锁表或者锁记录,也不 会影响读请 求的执⾏。
主从 延迟都有什么 处理⽅法?
强制⾛主库⽅案:对于⼤事务或资源密集型操作,直接在主库上执⾏,避免从库的额外延迟。
B+ 树的特点是什么 ?
B+ 树是⼀种⾃平衡的多路查找树,所有叶节点都位于同⼀层,保证了树的平衡,使得搜索、插
⼊和删除操作的时间复杂度为对数级别的。
⾮叶节点仅包含索引信息,不存储具体的数据记录,它们只⽤来引导搜索到正确的叶节点。⾮
叶节点的⼦树指针与关键字数量相同,每个⼦树指针指向⼀个⼦树,⼦树中的所有键值都在某
个区间内。
所有数据记录都存储在叶节点中,且叶节点中的数据是按关键字排序的。叶节点包含实际的数
据和关键字,它们是数据存储和检索的实体单元。叶节点之间通过指针相互链接,形成⼀个链
表,便于范围查询和顺序遍历。
⼿撕
最⼩覆盖⼦串
