Redis 面试笔记

maokaihe maokaihe #Redis #Database #Performance

从内存访问、命令执行模型与 I/O 多路复用梳理 Redis 高性能的原因。

为什么 Redis 很快

Redis 快不是只靠某一个点,主要是下面这些原因:

  1. 主要数据放在内存中:常规命令不需要等待磁盘随机 I/O,内存访问本身的延迟也很低。
  2. 数据结构和执行路径比较高效:Redis 针对字符串、哈希、列表、集合等类型使用了不同的数据结构,很多常用操作的时间复杂度是 O(1) 或 O(log⁡N)。
  3. 命令主要由单线程顺序执行:避免了命令执行过程中大量的锁竞争,也减少了线程调度和上下文切换的开销。
  4. 使用非阻塞 I/O 和 I/O 多路复用:一个事件循环可以同时管理大量客户端连接,不需要为每个连接都创建一个线程。

“单线程”不代表只有一个线程

平时说 Redis 是单线程,主要是指大部分命令由主线程顺序执行,并不是说整个 Redis 进程只能有一个线程。

Redis 还会使用后台线程处理部分耗时任务;从 Redis 6 开始,也可以使用 I/O 线程分担网络读写,但命令执行仍然主要在主线程中完成。

这种方式的优点是模型简单,单条命令在执行过程中不会被其他命令插入。缺点也很明显:如果某一条命令执行时间太长,其他客户端的命令也要跟着等待。

所以 Redis 的性能问题不一定出在网络上,也可能来自大 Key、慢命令、持久化或者系统本身的调度。

下面的 sub blog 会继续整理网络 I/O 模型、select / poll / epoll,以及 Reactor 和 Proactor 的区别。

RDB 和 AOF

RDB是定期进行snaphshot,进行快照之后保存。Redis 通常不会让主线程自己慢慢把所有数据写磁盘,因为这样会长时间阻塞请求,而是通过 fork 创建子进程,由子进程负责把内存中的数据写成 RDB 文件,父进程继续处理客户端请求。 fork 之后父子进程是不是立刻复制一份完整内存?不是。Linux 会用 Copy-On-Write,也就是写时复制。刚 fork 时,父子进程共享同一批物理内存页;如果父进程之后修改了某个页面,操作系统才会复制那个页面出来。所以 RDB 做快照时正常情况下不会直接把内存翻倍,但如果 Redis 数据量很大,同时写请求特别多,就会触发大量 COW,内存开销和 CPU 开销都会增加。这也是为什么大实例执行 RDB 时可能出现延迟抖动。 RDB 最大的优点是文件紧凑、恢复速度快,因为恢复时基本就是把一个完整快照直接加载回内存,很适合做备份、灾备,也方便把文件复制到其他机器。但是他它的缺点就是并不能真正实现数据不丢失,不能实现实时的持久化,如果在下一次快照之前宕机了,数据就真没了

AOF是记录Redis执行过的指令,这个和我学到的minilsm项目里面的思路是一样的,每次把操作记下来,然后直接append-onnly就行。Redis 重启时,不是直接加载一个内存快照,而是把这些命令重新执行一遍,从而把数据库恢复出来. AOF 的关键配置是 appendfsync,它决定数据什么时候真正刷到磁盘。常见有三种策略。always 是每次写命令都立即 fsync,数据最安全,理论上宕机最多丢一条甚至基本不丢,但性能最差,因为磁盘同步刷写非常昂贵。everysec 是最常见的配置,大约每秒刷一次磁盘,性能和可靠性比较均衡,机器突然掉电时一般最多丢 1 秒左右的数据。no 则是不主动调用 fsync,交给操作系统自己决定什么时候刷盘,性能最好,但能丢多少数据就不太可控。

这里还要区分一个细节:写入 AOF 文件和真正刷到磁盘不是一回事。Redis 执行写命令后,通常先把内容写到用户态/内核页缓存里,write 成功并不代表数据已经进入物理磁盘。真正控制数据落盘可靠性的,是 fsync。所以面试官如果问“为什么 everysec 最多可能丢 1 秒数据”,核心就是因为 Redis 大约每秒才调用一次 fsync。

Redis 有 AOF Rewrite,也就是 AOF 重写机制。它不是简单地把旧日志压缩一下,而是根据当前内存中的最终数据状态,重新生成一份更精简的 AOF 文件

当然也有混合机制,很自然的想到就是我一次快照之后到下一次快照之前使用aof不就行了。

Redis 中的 I/O 多路复用

maokaihe maokaihe #Redis #I/O #Networking

对比同步阻塞、同步非阻塞、I/O 多路复用与异步 I/O 四种模型。

常见的 I/O 模型

I/O 模型有几种:

  1. 同步阻塞 I/O
  2. 同步非阻塞 I/O
  3. I/O 多路复用
  4. 异步非阻塞 I/O,也就是 Async I/O

按照这个分类,只有第四种是异步 I/O,前面三种都是同步 I/O。

同步阻塞 I/O:在线程处理过程中,如果涉及 I/O 操作,那么当前线程会被阻塞,直到 I/O 处理完成,线程才接着处理后续流程。如果使用一个连接对应一个线程的模式,大量连接会带来线程调度和上下文切换的开销。

同步非阻塞 I/O:在线程处理过程中,如果涉及 I/O 操作,当前线程不会因为等待数据一直阻塞,而是直接返回,之后再来查询 I/O 是否已经就绪。

真实的数据处理逻辑大致是这样的:

网络数据 → 网卡 → 内核 socket 接收缓冲区 → 用户缓冲区 → 程序处理

同步阻塞 I/O 中,如果没有数据,调用线程就会挂起。

同步非阻塞 I/O 的流程大概是:

用户线程 内核
recv() ----------> 数据没有
<----------- EAGAIN
recv() ----------> 数据没有
<----------- EAGAIN
recv() ----------> 数据来了
copy
<----------- 返回数据

它和同步阻塞 I/O 的区别是:没有数据时会直接返回。但是为什么说它还是同步的?

因为真正的数据读取仍然由当前线程通过 recv() 发起,并等待这次调用完成。

整个接收数据的逻辑可以分成两个阶段。第一阶段是等待数据,对于同步非阻塞 I/O 来说,这个阶段不再阻塞;第二阶段是把数据交给用户缓冲区,这一步仍然在 recv() 调用中同步完成。

同步非阻塞 I/O 最大的问题在于轮询会消耗大量 CPU。如果有很多 socket,就需要一个个检查,产生大量系统调用,其中很多只是返回 EAGAIN,消耗了不必要的资源。

I/O 多路复用

这里主要讲 Linux 平台的三个 API:select、poll 和 epoll。

多路复用的含义是一个线程可以同时监听多个 socket。线程只阻塞一次,等待大量 fd 中的任意一个就绪。

为什么说它还是同步 I/O?因为 epoll 只是让线程知道某个 socket 就绪了,真正读取数据还是依赖一次 recv() 调用。

epoll_wait()
↓
内核:fd3 ready
↓
用户线程 recv(fd3)
↓
内核把数据交给用户缓冲区
↓
recv() 返回

在等待数据到来的时候由 epoll 帮忙等待;数据就绪之后,仍然需要用户线程调用 recv()。

异步 I/O

异步 I/O 和前面的同步 I/O 有本质区别:应用发起异步读取后可以继续执行,等数据已经进入指定的用户缓冲区之后,再收到完成通知。

用户线程 内核
async_read()
|
|-------------------------->
|
| 立即返回
↓
继续处理其他任务 等待并完成 I/O
|
<----------------------
通知程序

收到通知之后,用户不需要再为同一次操作调用读取函数,直接处理缓冲区中的数据即可。

select、poll 和 epoll

maokaihe maokaihe #Redis #I/O #Networking

简单梳理 Linux 下 select、poll、epoll 的区别,以及 epoll 的 LT 和 ET 模式。

每一个 socket 在进程中都通过一个 fd 来引用。常说“Linux 中一切皆文件”,可以把它理解成 Linux 为 socket、管道、普通文件等对象提供了统一的 fd 接口,但它们的具体行为并不完全相同。

select

select 接收一批 fd,让内核从 fd_set 中遍历并找到 ready 的 fd 返回。

每次调用都需要把整个 fd 集合传给内核,并遍历检查,复杂度是 O(n)。

在 Linux 中,可以把“感知就绪”分成扫描和唤醒两步:

  1. 内核扫描 fd_set 中置位的 fd,查询每个 fd 对应对象当前是否满足可读、可写或异常条件。
  2. 如果一个都没有就绪且调用允许阻塞,内核会把当前任务挂到这些 fd 对应的等待队列并让它睡眠。I/O 状态变化时,相关等待队列唤醒任务,select 再扫描一次并把结果写回 fd_set。

所以 select 不是在用户态不断调用 recv() 轮询,也不是内核直接执行用户态回调。返回后,应用通过 FD_ISSET() 检查哪些 fd 仍在结果集合中。Linux 的具体扫描与等待队列逻辑可以在 fs/select.c 中看到。

另外,select 的 fd 编号有限制。它本质上使用位图,在 glibc 中默认是:

#define FD_SETSIZE 1024

这里限制的是 fd 编号必须小于 1024,不是说程序一定最多只能存在 1024 个连接。

更准确地说,glibc 默认的一个 fd_set 能表示 fd 0-1023。select() 的 nfds 参数应是三个集合中最大 fd 编号加 1,它确定扫描上界,不等于 socket 数量。一次调用实际监听多少个 socket,取决于集合中置位了多少个 socket fd;程序即使拥有更多连接,也不能用这个默认 fd_set 表示编号大于 1023 的 fd。

poll

poll 是 select 的改进版本,接收的不再是位图,而是一个 pollfd 结构体数组,解决了 FD_SETSIZE 的固定限制。

不过 poll 每次调用仍然需要传入并遍历整个数组,所以 fd 很多时依然有线性扫描的开销。

epoll

epoll 是 Linux 用来处理大量 fd 事件的机制,主要有三个 API:

epoll_create1()
epoll_ctl()
epoll_wait()

epoll 的思路和 select、poll 不同,更像一种注册机制:先告诉内核需要关注哪些 fd,等这些 fd ready 时,再由 epoll_wait() 返回就绪事件。

int epfd = epoll_create1(0);

可以把 epoll 实例理解成维护了一个 fd 关注列表和一个 ready list。

epoll_ctl() 负责告诉内核监听哪个 fd,epoll_wait() 则直接返回一批已经就绪的事件,不需要应用再次扫描全部 fd。

这里不是触发用户态回调,也不一定只返回一个 fd;一次 epoll_wait() 可以返回多个就绪事件。

epoll 的两种模式:LT、ET

LT 是 level-triggered,水平触发。只要条件仍然满足,就会继续通知。

ET 是 edge-triggered,边缘触发。它只在状态发生变化时通知。实际使用 ET 时应该配合非阻塞 socket,并且一次读到 EAGAIN 为止,否则数据没有读完时可能收不到新的通知。

Reactor 和 Proactor

maokaihe maokaihe #Redis #I/O #Networking

通过就绪事件和完成事件理解 Reactor、Proactor,以及它们和 epoll、IOCP、io_uring 的关系。

二者的核心目的都是为了使用较少的线程来处理大量连接。

Reactor 模式

Reactor 和 epoll 的关系很大。

在 Reactor 模式中,应用程序负责监听就绪事件。当 I/O 就绪时,Reactor 将事件分发给对应的 Handler,由 Handler 自己执行真正的同步读写操作。

AI 给我写了一个流程,感觉可以帮助理解:

  1. 注册事件:Handler 向多路复用器(如 epoll)注册“关注 socket 的可读事件”。
  2. 事件监听:Reactor 线程阻塞在 epoll_wait() 上,等待事件发生。
  3. 事件就绪:当客户端发送的数据到达后,内核将 socket 标记为可读,epoll_wait() 返回。
  4. 事件分发:Reactor 将可读事件分发给对应的 Handler。
  5. 执行 I/O(关键):Handler 调用 read(),把数据读取到用户缓冲区。socket 一般会设置为非阻塞,代码仍然需要处理短读和 EAGAIN。
  6. 业务处理:Handler 处理读取到的业务数据。

epoll + non-blocking socket 是现代网络并发的基石。

缺点是读写操作(read() / write())仍然在用户线程中同步执行,如果处理不当,可能会影响事件循环的效率。

Proactor 模式

Proactor 面向的是完成事件,在概念上对应真正的异步 I/O。

应用程序发起一个异步 I/O 之后,可以直接去干别的事,不需要等待。直到操作完成,系统发送一个完成事件,然后 Proactor 再将完成事件分发给 Handler。此时 Handler 拿到的已经是读写完成的结果。

  1. 发起异步读:Handler 调用异步读 API,例如 Windows 中带 OVERLAPPED 结构的 ReadFile,告诉系统把 socket 数据读到指定 Buffer。
  2. 执行 I/O:系统在后台完成读取,应用线程继续执行其他逻辑。
  3. 完成事件入队:操作完成后,一个完成事件进入 Completion Queue。
  4. 获取完成事件:Proactor 线程从队列中获取完成事件。
  5. 事件分发:Proactor 将完成事件分发给对应的 Handler。
  6. 业务处理(关键):Handler 不需要再为同一次操作调用 read(),直接处理 Buffer 中已经就绪的数据。

这个模式的缺点是编程比较复杂,而且不同操作系统的支持程度不同。Windows 的 Overlapped I/O + IOCP(I/O Completion Port)是典型的 Proactor 实现。

Linux 在 5.1 中引入了 io_uring,它使用提交队列和完成队列,在模型上和 Proactor 很接近。不过它不是所有异步运行时的默认实现,很多能力也依赖较新的内核版本。

Rust 的普通 Tokio 网络 I/O 主要还是就绪事件模型。要使用 io_uring,需要启用对应的可选支持或使用 tokio-uring 这类额外依赖,并不是把现有代码无缝切换一下就可以。