操作系统与网络基础:从 Redis 请求处理流程说起
操作系统与网络基础:从 Redis 请求处理流程说起
本文不是一份操作系统/计算机网络的通用教材,而是聚焦在"一次 Redis 客户端-服务端交互"这个具体场景里用到的那部分基础知识——socket、TCP 握手挥手、用户态/内核态、I/O 多路复用——把这些经常被面试问到、但很多人只会背概念不知道"为什么这么设计"的知识点讲透。
目录
每节标题保持简短,核心结论以 要点提示放在每节正文开头。
- 一、Socket 是什么:应用程序和网络之间的桥梁
- 二、TCP 三次握手:连接是怎么建立的
- 三、TCP 四次挥手:连接是怎么断开的
- 四、用户态与内核态:为什么"读数据"这个动作有开销
- 五、I/O 多路复用:epoll 到底解决了什么问题
- 六、BIO / NIO / AIO 和上面这套模型的对应关系
- 七、粘包与拆包:RESP 协议的
$长度前缀在解决什么问题 - 结语:这些概念怎么串成"Redis 为什么响应快"的完整答案
一、Socket 是什么:应用程序和网络之间的桥梁
要点:应用程序不会直接操作网卡,操作系统提供了 socket 这层抽象——本质上是一个特殊的文件描述符(fd),应用对着这个 fd 调用
read()/write(),操作系统负责把读写动作翻译成真正的网络收发。
Linux 有一句名言"一切皆文件",socket 就是这句话的字面体现:无论是监听新连接的 listening socket,还是某个客户端连上来之后对应的 client socket,本质上都是一个文件描述符(file descriptor,简称 fd)——一个进程用来标识"我正在读写哪个资源"的整数编号。应用程序不需要关心网卡驱动、以太网帧这些底层细节,只需要对着这个 fd 调用标准的 read()/write() 这类系统调用,操作系统内核会在背后完成"把这些字节通过网络真正发出去/收进来"的全部工作。
这也是为什么 Redis 处理一个新连接时,第一步是调用 accept() 拿到一个新的 fd,之后所有和这个客户端的交互(读命令、写结果),都是对着这个 fd 做操作。
二、TCP 三次握手:连接是怎么建立的
要点:客户端和服务端各自证明"我发的消息你收到了、我也收到了你的确认"需要三次交互;只握手两次的话,网络里一个延迟到达的过期连接请求可能让服务端误建立一个不会再有后续通信的"僵尸连接",第三次 ACK 正是为了避免这个问题。
客户端 服务端
|──SYN(seq=x)───────────────────>| 客户端:我要建连接,我的初始序号是 x
|<─SYN+ACK(seq=y, ack=x+1)───────| 服务端:收到,我的初始序号是 y,确认你的 x
|──ACK(ack=y+1)──────────────────>| 客户端:确认收到你的 y
为什么必须是三次,两次不行吗:如果只握手两次(客户端发 SYN,服务端回 SYN+ACK 就算连接建立),会有一个漏洞——如果客户端的第一个 SYN 包在网络里延迟了很久才到(比如网络拥堵),客户端可能早就因为超时又重发了一个新的 SYN、并且已经握手成功、通信完毕、断开了连接。这时候那个"迟到"的旧 SYN 包才姗姗来迟到达服务端,服务端会误以为这是一次新的连接请求,直接回 SYN+ACK 并单方面进入"已连接"状态,白白占用资源等一个根本不会再有数据过来的"僵尸连接"。第三次 ACK 的作用是让客户端有机会确认"这次握手是不是我刚发起的那次",避免服务端被这种迟到的过期请求误导,也让双方在正式通信前,各自确认了"我发的、你收的"和"你发的、我收的"两个方向都没问题。
现实案例
TCP 三次握手的机制也被利用来做攻击——SYN 洪泛攻击(SYN Flood):攻击者伪造大量源 IP 疯狂发送 SYN 包,服务端每收到一个 SYN 就要分配资源、进入"半连接"状态等待第三次 ACK,但攻击者根本不会回这个 ACK。大量半连接堆积会耗尽服务端的连接资源,导致正常用户的连接请求无法被处理。这也是为什么了解握手机制不只是背协议,还能帮你理解一类真实存在的网络攻击原理。
三、TCP 四次挥手:连接是怎么断开的
要点:TCP 连接是全双工的,两个方向的数据流要分别关闭——客户端说"我不发了"不代表服务端也没数据要发,所以服务端要先确认、再等自己数据发完了才发起关闭,这两步不能合并,所以是四次而不是三次。
客户端 服务端
|──FIN──────────────────────────>| 客户端:我这边没数据要发了
|<─ACK────────────────────────────| 服务端:收到,但我这边可能还有数据没发完
|<─FIN────────────────────────────| 服务端:我这边也发完了
|──ACK──────────────────────────>| 客户端:收到,连接可以关了
为什么是四次不是三次:因为 TCP 连接是全双工的(两个方向可以同时独立收发数据),客户端说"我不发了"(FIN)不代表服务端也没数据要发了,所以服务端要先 ACK 确认收到,等自己这边数据也发完了才发第二次 FIN。这两步中间可能间隔一段时间(服务端还在处理未发完的数据),不能像三次握手那样合并成一步,所以挥手比握手多了一次。
四、用户态与内核态:为什么"读数据"这个动作有开销
要点:操作系统把运行环境分成内核态(能直接操作硬件)和用户态(普通程序运行的地方)两个特权级别;应用程序想读网络数据,必须通过系统调用让内核把数据从内核缓冲区拷贝到应用自己的内存里,这个"陷入内核态再切回来"的过程本身有 CPU 开销。
网卡收到数据后,是操作系统内核先把数据放进它自己的一块内存(内核缓冲区),应用程序作为用户态的普通进程,不能直接访问硬件、也不能直接读内核的内存,必须发起一次 read() 系统调用,"请求"内核把这份数据从内核缓冲区拷贝一份到应用自己的用户态内存里,程序才能真正拿到这份数据去处理。
网卡收到数据 → 内核缓冲区(内核态) ──[read()系统调用,触发一次内核态/用户态切换]──> 应用自己的缓冲区(用户态)
这个"用户态发起系统调用 → CPU 切换到内核态 → 内核完成拷贝等处理 → 切回用户态"的过程,需要保存和恢复寄存器现场、切换内存映射等,本身是有实打实的 CPU 开销的。这也是为什么"减少不必要的系统调用次数""减少内核态和用户态之间的数据拷贝次数"是很多高性能中间件、以及像 sendfile、零拷贝(Zero-Copy) 这类技术要解决的核心问题——虽然这块展开就是另一个专题了,这里先建立"系统调用不是免费的"这个认知。
五、I/O 多路复用:epoll 到底解决了什么问题
要点:I/O 多路复用让内核帮应用"盯着"一批连接、谁有数据就通知谁,应用不用为每个连接单开线程死等、也不用自己写循环空转查询;epoll 相比更早的 select/poll,靠"事件回调 + 只返回就绪列表"把检查效率从 O(n) 降到了 O(1),这正是一个线程能同时服务成千上万个连接的根本原因。
理解 epoll 为什么这么设计,最好先看没有它的时候多糟糕。
5.1 阻塞 I/O(BIO):一个连接一个线程的老路子
每来一个客户端连接就开一个线程,这个线程调用 read() 之后就阻塞在那里,操作系统会把这个线程挂起、不占用 CPU,直到这个连接真的有数据到达才被唤醒继续跑。1000 个连接就要开 1000 个线程,线程本身占内存(每个线程的栈通常几百 KB 到几 MB),而且大部分线程大部分时间都在"傻等",一旦有数据到达需要唤醒线程,操作系统还要在大量线程之间来回切换调度,开销很大——这是早期很多服务器软件的模式,连接数一大就扛不住,业界把这类问题统称为 C10K 问题(一万个并发连接就让服务器难以承受)。
5.2 非阻塞 I/O:自己死循环轮询的空转问题
把 read() 改成非阻塞的,没数据立刻返回一个"暂无数据"的错误码,应用自己写一个循环不停地挨个问"你有数据了吗、你有数据了吗……"。这样确实不用阻塞线程了,但变成了应用自己在空转浪费 CPU——尤其连接数一多,光是循环问一遍所有连接的状态就要耗费不少 CPU 时间,而且大部分时候问了也是白问(没数据)。
5.3 select / poll / epoll 的演进
思路转变成:不要应用自己一个个去问,而是把一批 fd 一次性告诉内核,"帮我盯着这些连接,谁有数据了你告诉我",应用只需要调用一次系统调用等内核的通知,不用自己空转。三个具体实现是一条效率不断改进的技术演进线:
- select:能监视的 fd 数量有上限(通常 1024),而且每次调用都要把整个 fd 集合从用户态完整拷贝给内核,内核还要遍历一遍所有 fd 逐个检查状态(时间复杂度 O(n)),返回结果时应用也要再遍历一遍才能找出到底是哪几个就绪了——fd 一多,这个"每次全量遍历"的开销就很明显。
- poll:用链表取代了 select 的位图结构,去掉了 fd 数量上限,但本质上还是"每次调用都要遍历所有 fd 检查状态",没解决核心的效率问题。
- epoll(Linux 下 Redis/Nginx/Netty 实际使用的):关键改进是把"轮询检查"反转成"事件回调"——不再是"应用每次都问一遍所有 fd 状态",而是内核用回调机制,某个 fd 真的有数据到达时,内核主动把这个 fd 加进一个"就绪列表";应用调用
epoll_wait()时,内核直接返回这份就绪列表,不需要遍历全部 fd 去筛选谁是谁不是。而且注册"我关心哪些 fd"(epoll_ctl)只需要做一次,不用像 select 那样每次调用都重新拷贝整个 fd 集合给内核。
Redis 事件循环里"epoll_wait() 检测到某个 socket 可读",背后就是这套机制:主线程调用一次 epoll_wait(),内核直接把"这次到底是哪几个连接有事件"的答案给它,不管当前挂着多少个连接,只有真正有数据的那几个才会被返回处理——这正是一个线程能同时服务成千上万个连接、且不做无谓空转的根本原因。
六、BIO / NIO / AIO 和上面这套模型的对应关系
要点:Java 生态常说的 BIO/NIO/AIO 分别对应"一个连接一个线程阻塞等待""基于 epoll 的多路复用+非阻塞"和"内核完成数据拷贝后主动回调通知"三种模型;Redis/Nginx/Netty 走的都是 NIO 这条路线,严格意义上的 AIO 在 Linux 生产环境里用得并不多。
- BIO(Blocking I/O)→ 对应"5.1 阻塞 I/O",一个连接一个线程阻塞等待,简单但扩展性差。
- NIO(Non-blocking I/O)→ Java 的 NIO 库底层在 Linux 上就是基于
epoll实现的(Selector这个类背后包装的正是 epoll),对应"5.3 I/O 多路复用"。Netty、Redis、Nginx 走的都是这条路线。 - AIO(Asynchronous I/O)→ 比 NIO 更进一步:连"数据从内核缓冲区拷贝到用户态"这一步都不用应用自己发起调用,内核完成拷贝后直接回调通知应用"数据已经在你的缓冲区里了"。但 Linux 下原生 AIO 的支持一直不够成熟、性能优势不明显,实际生产里很少有主流中间件真的基于它构建。所以严格来说,Redis/Nginx/Netty 这些高性能中间件本质上都是 NIO(I/O 多路复用)+ 非阻塞 I/O 模拟出的"事件驱动"效果,并不是真正意义上的 AIO——这是一个经常被混淆但值得讲清楚的点。
七、粘包与拆包:RESP 协议的 $长度 前缀在解决什么问题
要点:TCP 是"流"协议,只保证字节顺序、不保证消息边界,一次
read()可能读到半条消息(拆包)也可能读到好几条消息粘在一起(粘包);Redis 的 RESP 协议用"先说清楚这段内容有多长"的长度前缀格式,让接收方明确知道读到哪里算一条完整字段,这是解决粘包/拆包问题的标准思路之一。
TCP 传输的是没有天然边界的字节流,应用层如果不自己想办法标记"一条消息从哪里到哪里",接收方读到的数据可能不是"刚好一条完整消息":
- 拆包:一条消息因为太大或者网络原因被拆成了好几次
read()才收全。 - 粘包:好几条连续发送的小消息,因为发送/接收时机凑巧挨在一起,被一次
read()全部读了进来,分不清哪里是第一条消息的结尾、哪里是第二条消息的开头。
Redis 的 RESP 协议用 $长度\r\n内容\r\n 这种格式(比如 $5\r\nhello\r\n),接收方读到 $5 就明确知道"后面紧跟着的 5 个字节就是这个字段的完整内容,读够 5 个字节就结束",不需要靠猜测或者遇到某个特殊字符才能确定消息边界。这是解决粘包/拆包问题的两大标准思路之一(另一种常见思路是用固定的分隔符,比如 HTTP 头用 \r\n 分隔,但分隔符本身有可能出现在正文内容里造成歧义,长度前缀的方式更可靠、也更适合传输任意二进制内容)。
结语:这些概念怎么串成"Redis 为什么响应快"的完整答案
回过头看,一次 Redis 请求处理流程里的每一步,背后都能对应上一个操作系统/网络的基础概念:socket 是应用和网络交互的入口,TCP 三次握手建立起可靠的连接通道,用户态/内核态的切换解释了"读数据"为什么不是零成本的,epoll 这类 I/O 多路复用解释了单线程为什么能同时服务大量连接而不用傻等或空转,RESP 协议的长度前缀解决了 TCP 流式传输天然存在的消息边界问题。
把这些点串起来,"Redis 响应快、能扛高并发"就不再是一句抽象的结论,而是能从底层机制上一步步推导出来的必然结果——这也是这类问题在面试里想考察的真正能力:不是背出"Redis 用了 epoll"这几个字,而是能讲清楚 epoll 具体解决了什么问题、为什么比 select/poll 更好。

浙公网安备 33010602011771号