高性能网络编程面试题 —— 从 IO 模型到 Reactor 架构的深度问答
覆盖五种IO模型、select/poll/epoll原理对比、ET/LT模式、Reactor/Proactor模式、TCP粘包拆包、Nagle/延迟ACK、零拷贝、网络栈优化、高性能框架(Netty/muduo/libevent),25道高频题附架构图
高性能网络编程是后端开发的核心硬实力——面试中能讲清 epoll 的红黑树+就绪链表实现、ET 与 LT 的本质区别、Reactor 多线程模型的设计考量,展示的是对操作系统和网络栈的深层理解。
这篇文章从IO 模型 → IO 多路复用 → 事件驱动架构 → TCP 细节 → 高性能实践五条线展开,每道题都带架构图和性能对比。
📌 关联阅读:操作系统面试题 · 网络协议面试题 · Linux 系统编程面试题
第一部分:IO 模型
Q1:Linux 的五种 IO 模型分别是什么?
记忆点:阻塞/非阻塞看等待方式,同步/异步看通知方式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
五种 IO 模型(以网络 read 为例):
1. 阻塞 IO (Blocking IO)
应用进程 ──read()──→ 内核
阻塞等待... 数据未就绪 → 等待
阻塞等待... 数据到达 → 拷贝到用户空间
返回数据 ←──────── 拷贝完成
2. 非阻塞 IO (Non-blocking IO)
应用进程 ──read()──→ 内核
返回EAGAIN ←───── 数据未就绪
应用进程 ──read()──→ 内核
返回EAGAIN ←───── 数据未就绪(反复轮询)
应用进程 ──read()──→ 内核
阻塞等待... 数据到达 → 拷贝到用户空间
返回数据 ←──────── 拷贝完成
3. IO 多路复用 (IO Multiplexing)
应用进程 ──select()──→ 内核
阻塞在select... 监控多个fd
返回就绪fd ←────── 某fd数据到达
应用进程 ──read()──→ 内核
返回数据 ←──────── 拷贝完成
4. 信号驱动 IO (Signal-driven IO)
应用进程 ──sigaction()→ 内核 注册信号处理
继续其他工作... 数据到达
收到SIGIO信号 ←──── 通知
应用进程 ──read()──→ 内核
返回数据 ←──────── 拷贝完成
5. 异步 IO (Asynchronous IO)
应用进程 ──aio_read()→ 内核
继续其他工作... 数据到达 → 拷贝到用户空间
收到完成通知 ←───── 全部完成(包括拷贝)
| IO 模型 | 等待数据 | 数据拷贝 | 是否阻塞 |
|---|---|---|---|
| 阻塞 IO | 阻塞 | 阻塞 | 全程阻塞 |
| 非阻塞 IO | 轮询(不阻塞) | 阻塞 | 部分阻塞 |
| IO 多路复用 | 阻塞在 select/epoll | 阻塞 | 部分阻塞 |
| 信号驱动 | 不阻塞(信号通知) | 阻塞 | 部分阻塞 |
| 异步 IO | 不阻塞 | 不阻塞 | 完全不阻塞 |
面试加分点:前四种都是同步 IO——数据从内核拷贝到用户空间时进程都要阻塞。只有 AIO 是真正的异步 IO,但 Linux 原生 AIO(io_uring 之前)限制多,实际用 epoll + 非阻塞 IO 模拟。
Q2:同步和异步、阻塞和非阻塞有什么区别?
记忆点:同步异步看内核通知方式,阻塞非阻塞看进程等待方式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
两个维度的组合:
阻塞 非阻塞
┌──────────────┬──────────────────┐
同步 │ 阻塞IO │ 非阻塞IO(轮询) │
│ read()阻塞等 │ read()返回EAGAIN │
├──────────────┼──────────────────┤
异步 │ 不存在 │ 异步IO (AIO) │
│ (无意义) │ 内核完成后通知 │
└──────────────┴──────────────────┘
生活类比:
同步阻塞:在餐厅排队等位,一直站着等(啥也不干)
同步非阻塞:去餐厅看一眼没位,走了;过会儿再去看(反复跑)
异步非阻塞:留个电话,有位了餐厅打电话叫你(去做别的事)
| 维度 | 区分标准 | 关键点 |
|---|---|---|
| 同步/异步 | 数据就绪后谁来完成读取 | 同步:进程自己读;异步:内核代劳 |
| 阻塞/非阻塞 | 数据未就绪时进程是否挂起 | 阻塞:挂起等待;非阻塞:立即返回 |
面试加分点:IO 多路复用(select/epoll)本质是同步阻塞模型——进程阻塞在 select/epoll_wait 上。它的优势不是”异步”,而是一个线程同时监控多个 fd。
Q3:什么是用户态和内核态?数据拷贝为什么是性能瓶颈?
记忆点:网络IO需要两次拷贝:网卡→内核缓冲区→用户缓冲区
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
网络数据读取的完整路径:
┌──────────────────────────────────────────┐
│ 用户态 │
│ ┌──────────────────────┐ │
│ │ 应用程序缓冲区 │ ← ③ 应用处理 │
│ │ char buf[4096] │ │
│ └──────────┬───────────┘ │
├─────────────┼────────────────────────────┤
│ ② 内核→用户 │ 拷贝(CPU 参与,开销大) │
│ ┌──────────┴───────────┐ │
│ │ 内核 Socket 缓冲区 │ 内核态 │
│ │ sk_buff │ │
│ └──────────┬───────────┘ │
│ ① 网卡→内核 │ 拷贝(DMA,CPU 不参与) │
│ ┌──────────┴───────────┐ │
│ │ 网卡 Ring Buffer │ 硬件 │
│ └──────────────────────┘ │
└──────────────────────────────────────────┘
性能瓶颈:
- 每次 read/write 都要 用户态↔内核态 切换(上下文切换)
- 步骤②需要 CPU 拷贝数据(memcpy)
- 大量小包时,系统调用开销占比大
| 开销来源 | 耗时 | 优化方向 |
|---|---|---|
| 系统调用(用户态↔内核态) | ~100ns | 批量操作、io_uring |
| 数据拷贝(内核→用户) | 依数据量 | 零拷贝(mmap/sendfile) |
| 中断处理 | ~5μs | NAPI 中断合并 |
| 协议栈处理 | ~10μs | 内核旁路(DPDK) |
面试加分点:io_uring(Linux 5.1+)通过共享内存环形队列减少系统调用次数——提交和收割都不需要系统调用,性能接近内核旁路方案但保留了内核协议栈的便利。
Q4:什么是零拷贝?有哪些实现方式?
记忆点:减少CPU参与的数据拷贝次数,sendfile/mmap/splice 三板斧
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
传统文件发送(4次拷贝 + 4次上下文切换):
read(file_fd, buf, len) → 2次切换 + 2次拷贝
write(socket_fd, buf, len) → 2次切换 + 2次拷贝
磁盘 →(DMA)→ 内核缓冲区 →(CPU)→ 用户缓冲区
用户缓冲区 →(CPU)→ Socket缓冲区 →(DMA)→ 网卡
sendfile 零拷贝(2次拷贝 + 2次上下文切换):
sendfile(socket_fd, file_fd, offset, len)
磁盘 →(DMA)→ 内核缓冲区 →(CPU)→ Socket缓冲区 →(DMA)→ 网卡
(省掉了用户态拷贝)
sendfile + DMA scatter/gather(2次DMA,0次CPU拷贝):
磁盘 →(DMA)→ 内核缓冲区
│ 只传递fd和offset给Socket缓冲区
└──(DMA gather)──→ 网卡
(真正的零CPU拷贝)
mmap 方式:
addr = mmap(file_fd, ...) → 文件映射到用户空间
write(socket_fd, addr, len) → 内核直接访问
磁盘 →(DMA)→ 内核缓冲区(=用户映射) →(CPU)→ Socket →(DMA)→ 网卡
(省一次拷贝,但仍有一次CPU拷贝)
| 方式 | CPU拷贝 | DMA拷贝 | 适用场景 |
|---|---|---|---|
| 传统 read+write | 2 | 2 | - |
| sendfile | 1 (0*) | 2 | 静态文件下发(Nginx) |
| mmap+write | 1 | 2 | 需要修改数据时 |
| splice | 0 | 2 | 管道中转,两个fd之间 |
*需要网卡支持 scatter/gather DMA
| 使用系统 | 零拷贝方式 |
|---|---|
| Nginx | sendfile on |
| Kafka | sendfile(Java FileChannel.transferTo) |
| RocketMQ | mmap(需要修改消息索引) |
面试加分点:Kafka 的高吞吐秘诀之一就是零拷贝——Consumer 读取消息时用 sendfile 直接把磁盘文件发送到网卡,不经过用户态。配合顺序写入和页缓存,单机可达百万 TPS。
第二部分:IO 多路复用
Q5:select 的原理是什么?有什么局限?
记忆点:fd_set 位图传入传出,每次调用都要拷贝+遍历,上限 1024
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
select 工作流程:
用户态: 内核态:
┌────────────────┐
│ fd_set readfds │ ─── 拷贝 ───→ 遍历所有 fd
│ [1,0,1,1,0...] │ 检查每个 fd 是否就绪
│ (位图,最大1024) │ ↓
└────────────────┘ 有就绪 fd 或超时
┌────────────────┐ ↓
│ fd_set readfds │ ←── 拷贝 ──── 修改位图标记就绪fd
│ [0,0,1,0,0...] │ ← 原位图被修改!
└────────────────┘
用户再遍历找出哪些 fd 就绪
int select(int nfds,
fd_set *readfds, // 读事件
fd_set *writefds, // 写事件
fd_set *exceptfds, // 异常事件
struct timeval *timeout);
| select 的问题 | 说明 |
|---|---|
| fd 上限 1024 | FD_SETSIZE 宏定义,编译期固定 |
| 每次调用拷贝 | fd_set 从用户态拷贝到内核态 |
| 内核遍历 | 线性扫描所有 fd,O(n) |
| 返回后再遍历 | 用户态还要遍历找就绪 fd,O(n) |
| fd_set 被修改 | 每次调用前要重新设置 |
面试加分点:select 的 1024 限制是 FD_SETSIZE 宏,可以重新编译修改但不推荐。select 跨平台性好(Windows/Mac/Linux 都支持),适合连接数少的场景。
Q6:poll 对 select 做了哪些改进?
记忆点:用 pollfd 数组替代位图,没了 1024 限制,但仍然要遍历
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
poll vs select:
select 用位图: poll 用 pollfd 数组:
fd_set [1,0,1,1,0...] struct pollfd fds[N];
最大 1024 fds[0] = {fd=3, events=POLLIN}
修改原位图 fds[1] = {fd=7, events=POLLIN}
revents 返回就绪事件(不修改 events)
int poll(struct pollfd *fds, // pollfd 数组
nfds_t nfds, // 数组长度
int timeout);
struct pollfd {
int fd; // 文件描述符
short events; // 监控的事件(输入)
short revents; // 就绪的事件(输出)← 和 events 分开
};
| 对比 | select | poll |
|---|---|---|
| 数据结构 | 位图 (fd_set) | pollfd 数组 |
| fd 上限 | 1024 | 无限制(受系统限制) |
| 事件分离 | 输入输出共用 fd_set | events/revents 分离 |
| 每次重设 | 需要 | 不需要 |
| 内核遍历 | O(n) | O(n) |
| 用户遍历 | O(n) | O(n) |
| 拷贝开销 | 每次拷贝 | 每次拷贝 |
面试加分点:poll 解决了 select 的 fd 数量限制和每次重设问题,但核心性能瓶颈(线性遍历、每次拷贝)没有解决。真正的质变是 epoll。
Q7:epoll 的原理是什么?为什么性能最好?
记忆点:红黑树存 fd,就绪链表通知,三个系统调用分离注册和等待
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
epoll 三个系统调用:
1. epoll_create() → 创建 epoll 实例(内核对象)
┌──────────────────────────────────┐
│ epoll 实例 │
│ ┌────────────────┐ ┌──────────┐ │
│ │ 红黑树 │ │ 就绪链表 │ │
│ │ (存储所有监控fd) │ │ (就绪fd) │ │
│ └────────────────┘ └──────────┘ │
└──────────────────────────────────┘
2. epoll_ctl(ADD/MOD/DEL) → 增删改监控的 fd
添加 fd → 插入红黑树 O(log n)
同时注册回调函数到内核
3. epoll_wait() → 等待就绪事件
只返回就绪链表中的 fd → O(就绪fd数量)
工作流程:
┌─────────┐ epoll_ctl(ADD, fd=5) ┌──────────────────┐
│ 用户进程 │──────────────────────→│ epoll 红黑树 │
└─────────┘ │ fd=3 │
│ fd=5 (新增) │
│ fd=7 │
└────────┬─────────┘
│
数据到达 fd=5 → 中断 → 回调函数 → │
┌────────↓─────────┐
│ 就绪链表 │
│ fd=5 ──→ 结果 │
└──────────────────┘
│
┌─────────┐ epoll_wait() 返回 ←────────┘
│ 用户进程 │ 只有就绪的 fd(无需遍历)
└─────────┘
| 对比 | select/poll | epoll |
|---|---|---|
| 注册/等待 | 每次调用都传 fd 集合 | 一次注册,多次等待 |
| 内核实现 | 每次线性遍历所有 fd | 回调驱动 + 就绪链表 |
| 返回值 | 所有 fd(需自己遍历) | 只返回就绪 fd |
| 时间复杂度 | O(n) | O(就绪fd数量) |
| fd 拷贝 | 每次拷贝到内核 | mmap 共享内存 |
| fd 上限 | 1024/系统限制 | 系统最大文件数 |
| 适用 | 连接数少,活跃率高 | 连接数多,活跃率低 |
面试加分点:epoll 在连接数大但活跃连接少时优势最大(典型 Web 服务器场景)。如果所有连接都很活跃,epoll 和 poll 性能差距不大,因为就绪链表每次都很长。
Q8:epoll 的 ET 和 LT 模式有什么区别?
记忆点:LT 有数据就通知(多次),ET 新数据来才通知(一次)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
LT (Level-Triggered 水平触发) vs ET (Edge-Triggered 边缘触发):
场景:Socket 收到 2KB 数据,应用只读了 1KB
LT 模式:
epoll_wait() → fd 就绪(有1KB未读)→ 返回
read(1KB)
epoll_wait() → fd 就绪(还有1KB未读)→ 继续返回 ← 重复通知
read(1KB)
epoll_wait() → fd 无数据 → 阻塞
ET 模式:
epoll_wait() → fd 就绪(新数据到达)→ 返回
read(1KB)
epoll_wait() → 阻塞(没有新数据到达) ← 剩余1KB被"遗忘"!
必须一次性读完:while(read() != EAGAIN)
类比:
LT = 门铃一直响(直到你开门)
ET = 门铃只响一下(你必须马上去开门)
| 对比 | LT(默认) | ET |
|---|---|---|
| 通知时机 | fd 可读/可写就通知 | 状态变化时才通知 |
| 通知次数 | 多次(直到处理完) | 一次 |
| 编程难度 | 简单 | 复杂(必须循环读/写到 EAGAIN) |
| 性能 | 多次 epoll_wait 返回 | 更少的 epoll_wait 返回 |
| 必须非阻塞 | 不是必须 | 必须用非阻塞 fd |
| 使用框架 | libevent 默认 | Nginx、Netty |
ET 模式编程要点:
- fd 必须设为非阻塞(O_NONBLOCK)
- 读:循环 read 直到返回 EAGAIN
- 写:循环 write 直到返回 EAGAIN 或写完
- 接受连接:循环 accept 直到返回 EAGAIN
面试加分点:ET 模式性能更好的原因——减少了 epoll_wait 的返回次数和系统调用开销。但 ET 如果处理不当(没读完就不再通知),会导致数据”饿死”。LT 更安全,是 epoll 的默认模式。
Q9:epoll 的惊群问题是什么?如何解决?
记忆点:多进程/线程同时监听一个 fd,事件来时全部唤醒但只有一个处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
惊群问题:
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│Worker 1│ │Worker 2│ │Worker 3│ │Worker 4│
│ epoll │ │ epoll │ │ epoll │ │ epoll │
│ wait.. │ │ wait.. │ │ wait.. │ │ wait.. │
└───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘
│ │ │ │
└─────────┬─┘───────────┘───────────┘
│ 新连接到达 listen_fd
↓
全部唤醒!但只有一个能 accept 成功
其他 3 个白唤醒 → 浪费 CPU
解决方案:
1. EPOLLEXCLUSIVE(Linux 4.5+)
epoll_ctl(EPOLL_CTL_ADD, fd, EPOLLEXCLUSIVE)
→ 内核只唤醒一个等待进程
2. SO_REUSEPORT(Linux 3.9+)
多个 Socket 绑定同一端口,内核负载均衡
┌────────┐ ┌────────┐ ┌────────┐
│Socket 1│ │Socket 2│ │Socket 3│
│:8080 │ │:8080 │ │:8080 │
└───┬────┘ └───┬────┘ └───┬────┘
↑ ↑ ↑
└─── 内核按连接哈希分配 ───┘
3. Nginx 方案:accept_mutex
抢锁成功的 worker 才加入 epoll 监听
| 方案 | 原理 | 缺点 |
|---|---|---|
| EPOLLEXCLUSIVE | 内核只唤醒一个 | 需要 Linux 4.5+ |
| SO_REUSEPORT | 每个进程独立 Socket | 连接迁移问题 |
| accept_mutex | 互斥锁竞争 | 锁竞争开销 |
面试加分点:Nginx 1.11.3+ 默认使用 SO_REUSEPORT,性能提升约 2-3 倍。Go 1.11+ 的 net 包也默认使用 SO_REUSEPORT。
第三部分:事件驱动架构
Q10:Reactor 模式是什么?有哪几种变体?
记忆点:事件驱动 + 非阻塞 IO + 多路复用,三种线程模型逐步演进
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
Reactor 核心思想:
┌──────────────────────────────────────────┐
│ "Don't call us, we'll call you" │
│ 不要轮询检查,事件来了我通知你处理 │
└──────────────────────────────────────────┘
┌─────────┐ ┌───────────┐ ┌────────────┐
│ Event │────→│ Reactor │────→│ Handler │
│ (事件) │ │ (分发器) │ │ (处理器) │
└─────────┘ └───────────┘ └────────────┘
epoll_wait() read/decode/
+ 事件分发 compute/encode
/send
三种 Reactor 线程模型:
1. 单 Reactor 单线程:
┌──────────────────────────────┐
│ 单线程 │
│ Reactor → accept/read/write │
│ → 业务处理 │
└──────────────────────────────┘
代表:Redis 6.0 之前
2. 单 Reactor 多线程:
┌──────────────────────────────┐
│ 主线程: Reactor │
│ accept + read/write │
└──────────┬───────────────────┘
│ 分发
┌──────────↓───────────────────┐
│ 线程池: 业务处理 │
│ Worker1 Worker2 Worker3 │
└──────────────────────────────┘
3. 主从 Reactor 多线程(最优方案):
┌──────────────────────────────┐
│ MainReactor(主线程) │
│ 只负责 accept 新连接 │
└──────────┬───────────────────┘
│ 分发连接
┌──────────↓───────────────────┐
│ SubReactor(IO线程池) │
│ Sub1: read/write (epoll) │
│ Sub2: read/write (epoll) │
│ Sub3: read/write (epoll) │
└──────────┬───────────────────┘
│ 分发业务
┌──────────↓───────────────────┐
│ Worker 线程池(业务处理) │
└──────────────────────────────┘
代表:Netty、Nginx、muduo
| 模型 | 优点 | 缺点 | 代表 |
|---|---|---|---|
| 单 Reactor 单线程 | 简单,无锁 | CPU 利用率低 | Redis |
| 单 Reactor 多线程 | 业务并发 | Reactor 是瓶颈 | - |
| 主从 Reactor 多线程 | IO 和业务都并发 | 复杂 | Netty/Nginx |
面试加分点:Nginx 的模型是 Multi-Process + 每个进程一个 Reactor(epoll),不是多线程。Netty 的 EventLoopGroup 就是主从 Reactor 的实现——bossGroup 负责 accept,workerGroup 负责 IO。
Q11:Proactor 模式和 Reactor 有什么区别?
记忆点:Reactor 通知”可以读了”你自己读,Proactor 通知”读完了”直接给你数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Reactor vs Proactor:
Reactor(同步非阻塞):
① epoll 通知"fd 可读"
② 应用调用 read() 读数据(同步)
③ 处理数据
Reactor Handler
"fd=5可读" ──→ read(fd=5, buf) ← 应用自己读
process(buf)
Proactor(异步):
① 应用提交异步读请求
② 内核完成读取
③ 通知应用"数据已到 buf"
Initiator OS/内核 Handler
aio_read() ──→ [内核读数据到buf] ──→ "buf已填好"
process(buf) ← 直接用
对比图:
┌──────────────────────────────────────────────┐
│ Reactor: │
│ 事件通知 → 应用读取(同步) → 应用处理 │
│ ↑ │
│ epoll_wait │
│ │
│ Proactor: │
│ 提交请求 → 内核读取(异步) → 完成通知 → 应用处理│
│ ↑ │
│ io_uring/IOCP │
└──────────────────────────────────────────────┘
| 对比 | Reactor | Proactor |
|---|---|---|
| IO 操作 | 应用执行(同步非阻塞) | 内核执行(异步) |
| 通知内容 | “可以读了” | “读完了” |
| 数据拷贝 | 应用 read() | 内核代劳 |
| 实现 | epoll (Linux) | IOCP (Windows)、io_uring |
| 编程复杂度 | 中 | 高 |
| 代表框架 | Nginx, libevent | Windows IOCP, Boost.Asio |
面试加分点:Linux 上真正的 Proactor 是 io_uring(Linux 5.1+)。之前的 Linux AIO 限制太多(只支持 Direct IO)。实际上 Boost.Asio 在 Linux 上用 epoll 模拟 Proactor 接口,底层仍是 Reactor。
Q12:什么是 io_uring?它为什么是 Linux IO 的未来?
记忆点:共享内存双环形队列,批量提交+批量收割,统一异步IO接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
io_uring 架构:
用户态 内核态
┌─────────────────┐ 共享内存 ┌──────────────────┐
│ 提交队列 (SQ) │ ←──mmap──→ │ 提交队列 (SQ) │
│ ┌──┬──┬──┬──┐ │ │ 内核消费 SQE │
│ │E1│E2│E3│ │ │ │ 执行 IO 操作 │
│ └──┴──┴──┴──┘ │ └────────┬─────────┘
│ │ │
│ 完成队列 (CQ) │ ┌────────↓─────────┐
│ ┌──┬──┬──┬──┐ │ │ 完成队列 (CQ) │
│ │R1│R2│ │ │ │ ←──mmap──→ │ 填入完成结果 │
│ └──┴──┴──┴──┘ │ │ │
└─────────────────┘ └──────────────────┘
流程:
1. 用户态往 SQ 塞请求(无需系统调用)
2. 可选:io_uring_enter() 通知内核(或内核自己轮询 SQPOLL)
3. 内核执行 IO,结果放入 CQ
4. 用户态从 CQ 取结果(无需系统调用)
传统方式:每次 IO = 1次系统调用
io_uring:N次 IO = 0-1次系统调用(批量+轮询)
| 对比 | epoll + read/write | io_uring |
|---|---|---|
| 模型 | Reactor(同步非阻塞) | Proactor(真异步) |
| 系统调用 | 每次 IO 一次 | 批量提交,可零系统调用 |
| 通知方式 | “可以读了” | “读完了,数据在 buf” |
| 数据拷贝 | 应用 read/write | 内核代劳 |
| 支持操作 | 网络 IO | 网络+文件+Timer 统一 |
| 性能 | 高 | 更高(减少系统调用) |
面试加分点:io_uring 的 SQPOLL 模式——内核线程持续轮询 SQ,用户态甚至不需要调用 io_uring_enter(),实现真正的零系统调用提交。性能接近 DPDK 但保留了内核协议栈。
Q13:C10K 和 C10M 问题分别是什么?如何解决?
记忆点:C10K 用 epoll+事件驱动解决,C10M 要内核旁路(DPDK)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
C10K (1万连接) → C100K → C1M → C10M (1千万连接)
C10K 解决方案演进:
┌──────────────┐
│ 一连接一线程 │ → 线程开销大 → 几百连接就撑不住
└──────┬───────┘
↓
┌──────────────┐
│ select/poll │ → O(n) 遍历 → 1万连接性能急剧下降
└──────┬───────┘
↓
┌──────────────┐
│ epoll │ → O(1) 就绪通知 → 轻松处理 C10K ✓
│ + 非阻塞IO │
│ + Reactor │
└──────┬───────┘
↓
C10M 时 epoll 也不够了:
┌──────────────────────────────────────────┐
│ 瓶颈在内核协议栈: │
│ - 中断处理开销 │
│ - 协议栈处理开销 │
│ - 内核↔用户态数据拷贝 │
│ - 锁竞争 │
└──────┬───────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ 内核旁路方案: │
│ DPDK: 用户态直接操作网卡(轮询模式) │
│ XDP/eBPF: 在网卡驱动层处理包 │
│ io_uring: 减少系统调用开销 │
└──────────────────────────────────────────┘
| 级别 | 挑战 | 解决方案 |
|---|---|---|
| C10K | 1万连接 | epoll + Reactor |
| C100K | 10万连接 | 多核+连接管理优化 |
| C1M | 100万连接 | 内存优化+连接复用 |
| C10M | 1000万连接 | DPDK/XDP 内核旁路 |
面试加分点:实际工程中很少需要 C10M——大多数系统通过横向扩展(加机器+负载均衡)解决,而不是单机承载千万连接。但理解 C10M 的瓶颈分析方法论(中断、协议栈、拷贝、锁)对性能优化很有价值。
第四部分:TCP 编程细节
Q14:TCP 粘包和拆包是什么?怎么解决?
记忆点:TCP 是字节流无边界,粘包=多个消息粘一起,拆包=一个消息被拆开
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
TCP 粘包/拆包:
发送方发了3个消息:
[MSG1][MSG2][MSG3]
接收方可能收到:
场景1(正常): [MSG1] [MSG2] [MSG3] ← 三次 read
场景2(粘包): [MSG1 MSG2] [MSG3] ← 两次 read,1+2粘了
场景3(拆包): [MSG1前半] [MSG1后半MSG2] ← MSG1被拆了
场景4(混合): [MSG1 MSG2前半] [MSG2后半] ← 又粘又拆
原因:
┌──────────────────────────────────────────┐
│ TCP 是面向字节流的协议 │
│ 没有消息边界的概念 │
│ │
│ 发送方:Nagle 算法合并小包 │
│ 接收方:TCP 缓冲区攒数据 │
│ MTU限制:大于 MSS 的包会被分片 │
└──────────────────────────────────────────┘
| 解决方案 | 原理 | 使用场景 |
|---|---|---|
| 定长消息 | 每个消息固定 N 字节 | 简单协议 |
| 分隔符 | 用特殊字符分割(如 \r\n) | HTTP/1.1、Redis RESP |
| 长度前缀 | 消息头包含长度字段 | 最通用,Protobuf、自定义协议 |
| TLV 格式 | Type-Length-Value | 二进制协议 |
1
2
3
4
5
6
7
8
9
10
长度前缀方案(最推荐):
┌──────┬──────────────────┐
│ 4字节│ 消息体 │
│ 长度 │ (长度由前4字节指定) │
└──────┴──────────────────┘
接收端处理逻辑:
1. 先读 4 字节 → 得到消息长度 N
2. 继续读 N 字节 → 得到完整消息
3. 如果不够 N 字节 → 缓存等待下次 read
面试加分点:UDP 没有粘包问题——UDP 是面向数据报的,每次 sendto 就是一个完整的数据报,recvfrom 一定收到完整的一个。但 UDP 有消息丢失和乱序问题。
Q15:Nagle 算法和延迟 ACK 是什么?为什么会互相影响?
记忆点:Nagle 攒小包等 ACK,延迟 ACK 攒确认等数据,凑一起就是 200ms 延迟
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Nagle 算法:
目的:减少网络上的小包数量
规则:有未确认的数据时,新数据先缓存,等收到 ACK 或凑够 MSS 再发
开启 Nagle:
App: write("H") write("e") write("l") write("l") write("o")
TCP: [H]──→ 等ACK... ←ACK [ello]──→ ← 合并发送
减少包数量 ✓
延迟 ACK:
目的:减少纯 ACK 包数量
规则:收到数据后不立即 ACK,等 40-200ms 看有没有数据要发(捎带 ACK)
开启延迟ACK:
收到数据 → 等 200ms → 没有数据要发 → 发纯 ACK
两者冲突(Write-Write-Read 模式):
┌────────┐ ┌────────┐
│ Client │ │ Server │
│ │── 请求头(小包) ────────→│ │
│ │ Nagle: 等ACK再发body │ │ 延迟ACK: 等200ms
│ │ │ │ 再确认
│ 等ACK │ ...200ms 僵持... │ 等数据 │
│ │←─── ACK(200ms后)───── │ │
│ │── 请求体 ──────────────→│ │
└────────┘ └────────┘
结果:每个请求多 200ms 延迟!
| 选项 | 作用 | 场景 |
|---|---|---|
| TCP_NODELAY | 关闭 Nagle(立即发送) | 低延迟场景(游戏/交易) |
| TCP_QUICKACK | 关闭延迟 ACK | 配合 TCP_NODELAY 使用 |
| TCP_CORK | 更激进地合并(等填满或超时) | 大文件传输 |
面试加分点:几乎所有高性能网络框架都默认设置 TCP_NODELAY——Netty、gRPC、Redis、Nginx 都是。延迟 ACK 无法彻底关闭(TCP_QUICKACK 只影响下一个 ACK),但设置 TCP_NODELAY 已足够解决大部分延迟问题。
Q16:TCP Keep-Alive 和应用层心跳有什么区别?
记忆点:TCP Keep-Alive 太慢(默认2h),应用层心跳可控且能检测业务存活
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
TCP Keep-Alive(内核层):
默认参数:
tcp_keepalive_time = 7200s (2小时才开始探测!)
tcp_keepalive_intvl = 75s (探测间隔)
tcp_keepalive_probes = 9 (探测次数)
总检测时间 = 7200 + 75×9 = 7875s ≈ 2.2小时
时间线:
最后一次数据 ──2小时──→ 第1次探测 ──75s──→ 第2次 ...
太慢了!连接断了2小时才能发现
应用层心跳(业务层):
设计:
┌────────┐ PING(每10s) ┌────────┐
│ Client │──────────────→│ Server │
│ │←──────────────│ │
│ │ PONG │ │
└────────┘ └────────┘
3次PING无PONG → 判定断线 → 重连
检测时间 = 30s(远快于TCP Keep-Alive)
| 对比 | TCP Keep-Alive | 应用层心跳 |
|---|---|---|
| 检测速度 | 慢(默认2小时) | 快(可配秒级) |
| 检测内容 | TCP 连接存活 | 业务进程存活 |
| NAT 保活 | 可以 | 可以 |
| 代理穿透 | 可能被代理拦截 | 能穿透代理 |
| 实现成本 | 零(内核参数) | 需编码 |
| 精确度 | 只知道 TCP 层活着 | 知道业务层活着 |
面试加分点:TCP Keep-Alive 无法检测”假死”——进程卡死(死锁、100% CPU)但 TCP 连接仍在。应用层心跳可以在 PONG 中携带业务状态(如负载信息),更灵活。gRPC 的 HTTP/2 PING 帧就是应用层心跳。
Q17:TIME_WAIT 状态有什么作用?过多怎么处理?
记忆点:等 2MSL 确保旧连接的包全消亡,太多就用 tcp_tw_reuse
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
TIME_WAIT 产生位置:
主动关闭方(通常是客户端):
Client Server
│── FIN ──────────────→│ ① 主动关闭
│←──── ACK ────────────│ ② 确认
│←──── FIN ────────────│ ③ 被动关闭
│── ACK ──────────────→│ ④ 确认
│ │
│ TIME_WAIT (2MSL) │ ← 主动关闭方进入
│ 等待 60s (Linux) │
│ │
│ CLOSED │ CLOSED
为什么需要 TIME_WAIT:
1. 确保最后一个 ACK 到达对端
ACK丢失 → 对端重发FIN → TIME_WAIT状态可以重发ACK
2. 让旧连接的迷途报文消亡
旧连接的包可能在网络中延迟
新连接复用相同四元组 → 收到旧包 → 数据混乱
等 2MSL → 旧包全部过期
| 问题场景 | 说明 |
|---|---|
| 高并发短连接 | 大量 TIME_WAIT 占用端口(最多 65535) |
| 服务端主动关闭 | HTTP/1.0 服务端关闭连接 → TIME_WAIT 在服务端 |
| 解决方案 | 说明 | 风险 |
|---|---|---|
| tcp_tw_reuse | 允许复用 TIME_WAIT 连接(客户端) | 安全(有时间戳保护) |
| tcp_tw_recycle | 快速回收(已废弃 Linux 4.12+) | NAT 下丢包 |
| SO_LINGER(0) | 直接 RST,跳过 TIME_WAIT | 可能丢数据 |
| 长连接 | 减少连接建立/关闭 | 最佳方案 |
| 连接池 | 复用已有连接 | 需管理 |
面试加分点:tcp_tw_recycle 在 NAT 环境下会导致 SYN 被丢弃(因为 NAT 后面不同客户端的时间戳不一致),Linux 4.12 已经删除此选项。最好的方案是使用长连接+连接池,而不是调内核参数。
Q18:TCP 半连接队列和全连接队列是什么?SYN Flood 攻击怎么防?
记忆点:半连接队列存 SYN_RECV,全连接队列存 ESTABLISHED,SYN Cookie 防攻击
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
两个队列:
Client Server
│── SYN ──────────────→ │ 放入半连接队列 (SYN Queue)
│ │ 状态: SYN_RECV
│←── SYN+ACK ────────── │
│── ACK ──────────────→ │ 从半连接队列取出
│ │ 放入全连接队列 (Accept Queue)
│ │ 状态: ESTABLISHED
│ │
│ accept() 从全连接队列取出
┌─────────────────────────────────────────┐
│ 半连接队列 (SYN Queue) │
│ 大小: tcp_max_syn_backlog (默认128) │
│ 存储: 收到SYN但未完成握手的连接 │
│ │
│ 全连接队列 (Accept Queue) │
│ 大小: min(backlog, somaxconn) │
│ 存储: 完成三次握手等待 accept 的连接 │
└─────────────────────────────────────────┘
SYN Flood 攻击:
攻击者用伪造 IP 大量发 SYN → 半连接队列满 → 正常连接被拒绝
防护方案:
┌──────────────────────────────────────────┐
│ SYN Cookie(最有效): │
│ 不在队列中保存半连接信息 │
│ 将状态编码进 SYN+ACK 的序列号中 │
│ 收到 ACK 时从序列号还原状态 │
│ → 无需队列,无法被耗尽 │
└──────────────────────────────────────────┘
| 防护方案 | 原理 | 配置 |
|---|---|---|
| SYN Cookie | 序列号编码状态,无需队列 | net.ipv4.tcp_syncookies=1 |
| 增大队列 | 增大半连接队列容量 | tcp_max_syn_backlog |
| 减少重试 | 减少 SYN+ACK 重试次数 | tcp_synack_retries=1 |
| 限速 | iptables 限制 SYN 速率 | iptables -m limit |
面试加分点:全连接队列满时(accept 太慢),内核默认丢弃新连接的 ACK(客户端以为建立成功但服务端没有)。设置 tcp_abort_on_overflow=1 可以让内核发 RST,客户端立即重连而不是等超时。
第五部分:高性能实践
Q19:Netty 的线程模型是什么?为什么高性能?
记忆点:主从 Reactor + Channel Pipeline + 零拷贝 ByteBuf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Netty 线程模型(主从 Reactor):
┌──────────────────────────────────────────────────┐
│ BossGroup (MainReactor) │
│ ┌────────────┐ │
│ │ EventLoop │ ← NioServerSocketChannel │
│ │ (1个线程) │ 只处理 accept │
│ └─────┬──────┘ │
│ │ 新连接分发 │
│ ┌─────↓──────────────────────────────────────┐ │
│ │ WorkerGroup (SubReactor) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │EventLoop │ │EventLoop │ │EventLoop │ │ │
│ │ │(线程1) │ │(线程2) │ │(线程N) │ │ │
│ │ │ epoll │ │ epoll │ │ epoll │ │ │
│ │ │ Channel │ │ Channel │ │ Channel │ │ │
│ │ │ 1,4,7... │ │ 2,5,8... │ │ 3,6,9... │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
每个 Channel 绑定一个 EventLoop(线程),生命周期内不变
→ 同一个 Channel 的所有操作在同一线程 → 无需加锁!
Channel Pipeline(责任链模式):
┌─────────────────────────────────────────────────┐
│ Inbound: 解码器 → 业务Handler → ... │
│ Outbound: ... → 编码器 → 写出 │
│ │
│ 入站: ByteBuf → Decoder → BizHandler │
│ 出站: BizHandler → Encoder → ByteBuf │
└─────────────────────────────────────────────────┘
| Netty 高性能要素 | 说明 |
|---|---|
| 主从 Reactor | IO 线程和 accept 分离 |
| EventLoop 绑定 | 一个 Channel 一个线程,无锁 |
| 零拷贝 ByteBuf | CompositeByteBuf 合并,避免拷贝 |
| 内存池 | PooledByteBufAllocator,减少 GC |
| Pipeline | Handler 责任链,灵活组合 |
| 直接内存 | DirectByteBuf 减少一次拷贝 |
面试加分点:Netty 的 EventLoop 设计保证了 Channel 上的所有操作(read/write/编解码/业务逻辑)都在同一个线程,避免了多线程竞争。如果业务处理耗时长,应该用 DefaultEventExecutorGroup 将业务 Handler 放到单独的线程池,避免阻塞 IO 线程。
Q20:Nginx 的高性能架构是怎样的?
记忆点:Master-Worker 多进程 + 每个 Worker 一个 epoll + 事件驱动
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Nginx 进程模型:
┌───────────────────────────────────────┐
│ Master 进程 │
│ - 读取配置 │
│ - 管理 Worker(fork/signal) │
│ - 不处理请求 │
└──────────┬────────────────────────────┘
│ fork
┌──────────┼──────────────────────────────┐
│ ↓ ↓ ↓ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐│
│ │ Worker 1 │ │ Worker 2 │ │ Worker N ││
│ │ epoll │ │ epoll │ │ epoll ││
│ │ 事件循环 │ │ 事件循环 │ │ 事件循环 ││
│ │ 非阻塞IO │ │ 非阻塞IO │ │ 非阻塞IO ││
│ └──────────┘ └──────────┘ └──────────┘│
│ worker_processes = CPU核数 │
└─────────────────────────────────────────┘
每个 Worker 处理请求的流程(事件驱动):
┌──────────────────────────────────────────┐
│ epoll_wait() → 有事件 │
│ ├── accept 事件 → 接受新连接 │
│ ├── read 事件 → 读取请求 │
│ │ ├── 解析 HTTP → 匹配 location │
│ │ ├── 如需反向代理 → 连接上游(非阻塞) │
│ │ └── 如是静态文件 → sendfile 发送 │
│ └── write 事件 → 发送响应 │
│ 回到 epoll_wait() │
└──────────────────────────────────────────┘
| 设计要点 | 说明 |
|---|---|
| 多进程 | 不用多线程,避免锁竞争 |
| CPU 亲和 | worker_cpu_affinity 绑核 |
| 事件驱动 | 单线程处理数万连接 |
| sendfile | 静态文件零拷贝 |
| SO_REUSEPORT | 每个 Worker 独立 Socket |
| 连接池 | 复用上游连接 |
面试加分点:Nginx 的”非阻塞”不仅仅是 IO——连接上游服务器、读取文件、DNS 解析都是非阻塞的。如果某个操作阻塞(如磁盘 IO),会使用线程池(aio threads)异步处理,不阻塞事件循环。
Q21:如何设计一个高性能的网络服务器框架?
记忆点:接收线程 + IO 线程组 + 业务线程池,三层分离
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
高性能网络服务器通用架构:
┌──────────────────────────────────────────────────┐
│ Layer 1: 接收层 (Acceptor) │
│ - 1个线程/进程 │
│ - listen + accept │
│ - 新连接分发到 IO 线程(Round-Robin) │
└────────────────────┬─────────────────────────────┘
│ 分发连接
┌────────────────────↓─────────────────────────────┐
│ Layer 2: IO 层 (EventLoop 线程组) │
│ - N 个线程(N = CPU 核数) │
│ - 每个线程一个 epoll │
│ - 负责 read/write + 编解码 │
│ - 一个连接绑定一个线程(无锁) │
└────────────────────┬─────────────────────────────┘
│ 提交任务
┌────────────────────↓─────────────────────────────┐
│ Layer 3: 业务层 (Worker 线程池) │
│ - M 个线程(M 根据业务耗时调整) │
│ - 执行业务逻辑(DB 查询、RPC 调用等) │
│ - 处理完通过 IO 线程发送响应 │
└──────────────────────────────────────────────────┘
关键设计决策:
┌────────────────────────────────────────────────┐
│ 1. IO 线程数 = CPU 核数(IO 密集型) │
│ 2. 业务线程数根据阻塞程度调整 │
│ - CPU 密集:线程数 = 核数 │
│ - IO 密集:线程数 = 核数 × (1 + IO时间/CPU时间)│
│ 3. 连接绑定线程,避免锁竞争 │
│ 4. 线程间通信用无锁队列 │
└────────────────────────────────────────────────┘
| 框架 | 语言 | 架构模型 |
|---|---|---|
| Netty | Java | 主从 Reactor + Pipeline |
| muduo | C++ | one loop per thread |
| libevent | C | 单 Reactor(可配多线程) |
| libuv | C | 单 EventLoop + 线程池 |
| Tokio | Rust | 多线程异步运行时 |
| Go net | Go | goroutine per connection |
面试加分点:Go 的网络模型是”每连接一个 goroutine”——看起来像一连接一线程,但 goroutine 极轻量(2KB 栈),底层仍然用 epoll 做多路复用。这是用编译器/运行时的魔法让同步编程模型获得异步性能。
Q22:什么是协程?协程和线程在网络编程中有什么区别?
记忆点:协程=用户态轻量线程,同步写法+异步性能
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
线程 vs 协程:
线程模型(1万连接 = 1万线程):
┌────────┐ ┌────────┐ ┌────────┐
│Thread 1│ │Thread 2│ ... │Thread N│ ← 内核调度
│ 8MB栈 │ │ 8MB栈 │ │ 8MB栈 │ ← 内存大
└────────┘ └────────┘ └────────┘
上下文切换:~1-10μs(内核态)
1万线程内存:80GB ← 不可行
协程模型(1万连接 = 1万协程 + 少量线程):
┌──────────────────────────────────┐
│ OS Thread 1 │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │Coro 1│ │Coro 2│ │Coro 3│ │ ← 用户态调度
│ │ 2KB │ │ 2KB │ │ 2KB │ │ ← 内存小
│ └──────┘ └──────┘ └──────┘ │
└──────────────────────────────────┘
上下文切换:~100ns(用户态)
1万协程内存:20MB ← 完全可行
协程的 IO 调度:
coroutine_read(fd) {
向 epoll 注册 fd
yield(让出执行权,切换到其他协程)
// ... 其他协程运行 ...
fd 就绪 → resume(恢复执行)
return data
}
对程序员来说:像同步代码一样写
对系统来说:底层仍然是 epoll 异步
| 对比 | 线程 | 协程 |
|---|---|---|
| 调度 | 内核抢占式 | 用户态协作式 |
| 栈大小 | 8MB(默认) | 2-8KB |
| 切换开销 | ~1-10μs | ~100ns |
| 创建开销 | ~100μs | ~1μs |
| 10万并发 | 不可行 | 轻松 |
| 编程模型 | 同步(简单) | 同步写法(简单) |
| 语言/框架 | 协程实现 |
|---|---|
| Go | goroutine(GMP 调度器) |
| Kotlin | coroutine |
| Python | asyncio |
| Rust | async/await + Tokio |
| C++ 20 | co_await/co_yield |
| C | libco (腾讯)、libaco |
面试加分点:Go 的 goroutine 是 M:N 模型——M 个 goroutine 映射到 N 个 OS 线程。GMP 调度器(G=goroutine, M=Machine/OS线程, P=Processor/逻辑处理器)实现了工作窃取(work-stealing),当一个 P 上的 G 都阻塞时,其他 P 可以窃取 G 来执行。
Q23:网络服务器的性能调优有哪些关键参数?
记忆点:文件描述符、TCP 缓冲区、队列大小、中断亲和四大类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Linux 网络性能调优全景:
┌──────────────────────────────────────────────┐
│ 1. 文件描述符 │
│ ulimit -n 1048576 (单进程最大fd) │
│ fs.file-max (系统最大fd) │
│ fs.nr_open (单进程上限) │
├──────────────────────────────────────────────┤
│ 2. TCP 连接相关 │
│ net.core.somaxconn = 65535 (全连接队列) │
│ net.ipv4.tcp_max_syn_backlog = 65535 │
│ net.ipv4.tcp_tw_reuse = 1 (复用TIME_WAIT)│
│ net.ipv4.ip_local_port_range = 1024 65535 │
├──────────────────────────────────────────────┤
│ 3. TCP 缓冲区 │
│ net.core.rmem_max = 16777216 (接收缓冲) │
│ net.core.wmem_max = 16777216 (发送缓冲) │
│ net.ipv4.tcp_rmem = 4096 87380 16777216 │
│ net.ipv4.tcp_wmem = 4096 65536 16777216 │
├──────────────────────────────────────────────┤
│ 4. 中断与网卡 │
│ RSS: 网卡多队列 + 中断亲和 │
│ RPS/RFS: 软中断负载均衡 │
│ GRO/GSO: 合并包减少处理次数 │
└──────────────────────────────────────────────┘
| 调优方向 | 关键参数 | 默认值 → 建议值 |
|---|---|---|
| 最大连接数 | somaxconn | 128 → 65535 |
| 端口范围 | ip_local_port_range | 32768-60999 → 1024-65535 |
| TIME_WAIT | tcp_tw_reuse | 0 → 1 |
| 接收缓冲 | tcp_rmem | 默认87KB → 按需调 |
| SYN 防护 | tcp_syncookies | 0 → 1 |
| 文件描述符 | ulimit -n | 1024 → 1048576 |
面试加分点:调参只是最后一步——先确认瓶颈在哪(CPU/内存/IO/网络),再针对性调优。perf、strace、ss、netstat、sar 是定位网络性能问题的核心工具链。
Q24:DPDK 是什么?为什么能实现超高性能?
记忆点:绕过内核直接操作网卡,用户态轮询替代中断,零拷贝
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
传统网络收包 vs DPDK:
传统路径:
网卡 →(中断)→ 内核驱动 → 协议栈 → Socket缓冲区 → 用户态
涉及:中断、上下文切换、内核协议栈、数据拷贝
DPDK 路径:
网卡 →(DMA)→ 大页内存 → 用户态应用(直接处理)
┌──────────────────────────────────────────┐
│ 用户态 (DPDK 应用) │
│ ┌─────────┐ ┌──────────┐ ┌─────────┐ │
│ │ PMD驱动 │ │ 收包线程 │ │ 处理逻辑│ │
│ │(轮询模式)│ │ (busy poll)│ │ │ │
│ └────┬────┘ └──────────┘ └─────────┘ │
│ │ DMA │
│ ┌────↓────┐ │
│ │ 大页内存 │ ← mmap hugepage │
│ └────┬────┘ │
├───────│──────────────────────────────────┤
│ ┌────↓────┐ │
│ │ 网卡 │ ← UIO/VFIO 绑定到用户态 │
│ └─────────┘ 硬件 │
└──────────────────────────────────────────┘
注意:完全绕过内核!
| DPDK 核心技术 | 说明 |
|---|---|
| UIO/VFIO | 网卡驱动在用户态 |
| PMD (Poll Mode Driver) | 轮询替代中断,零延迟 |
| 大页内存 (Hugepage) | 减少 TLB Miss |
| 无锁环形队列 | 线程间高效传递数据包 |
| CPU 亲和 | 绑核运行,减少缓存失效 |
| 批量处理 | 一次处理多个包 |
| 对比 | 内核协议栈 | DPDK |
|---|---|---|
| 包处理性能 | ~1M pps | ~80M pps |
| 延迟 | ~10μs | ~1μs |
| CPU 利用 | 中断驱动 | 100% 轮询 |
| 协议支持 | 完整 TCP/UDP | 需自己实现 |
| 开发难度 | 低 | 极高 |
面试加分点:DPDK 的代价是独占 CPU 核心(busy poll 100% CPU 占用)和需要自己实现协议栈。适用于网络功能虚拟化(NFV)、高频交易、CDN 等对延迟极其敏感的场景。一般业务系统用 epoll + io_uring 足够。
Q25:如何分析和定位网络性能问题?
记忆点:ss 看连接状态,perf 看 CPU 热点,tcpdump 抓包分析
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
网络性能问题排查工具链:
┌──────────────────────────────────────────┐
│ 问题现象 │
│ 连接超时?延迟高?吞吐低?丢包? │
└──────────┬───────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ Step 1: 看整体指标 │
│ sar -n DEV 1 → 网卡流量/丢包 │
│ ss -s → 连接状态统计 │
│ netstat -s → 协议栈统计 │
│ top/htop → CPU 使用率 │
└──────────┬───────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ Step 2: 定位瓶颈 │
│ ss -tnp → 连接队列/状态 │
│ cat /proc/net/softnet_stat → 软中断丢包 │
│ ethtool -S eth0 → 网卡统计 │
│ perf top → CPU 热点函数 │
└──────────┬───────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ Step 3: 深入分析 │
│ tcpdump -i eth0 -w capture.pcap → 抓包 │
│ strace -p PID → 系统调用追踪 │
│ perf record/report → 火焰图 │
│ bpftrace → eBPF 动态追踪 │
└──────────────────────────────────────────┘
| 问题 | 排查工具 | 关键指标 |
|---|---|---|
| 连接数过多 | ss -s | TIME_WAIT、ESTABLISHED 数量 |
| 全连接队列溢出 | ss -tnl | Recv-Q 接近 backlog |
| 丢包 | netstat -s | TCPLoss、retransmit |
| 延迟高 | tcpdump + Wireshark | RTT、重传时间 |
| CPU 瓶颈 | perf top | 内核函数占比 |
| 软中断不均衡 | /proc/interrupts | 某个 CPU 处理过多中断 |
1
2
3
4
5
6
常用命令速查:
ss -tnlp → 查看监听端口和全连接队列
ss -tn state time-wait | wc -l → TIME_WAIT 数量
cat /proc/net/snmp | grep Tcp → TCP 重传等统计
ethtool -g eth0 → 网卡 Ring Buffer 大小
sysctl -a | grep net.core → 核心网络参数
面试加分点:现代 Linux 性能分析的趋势是 eBPF/bpftrace——可以在内核任意函数挂探针,零开销追踪。Brendan Gregg 的《BPF Performance Tools》是这个领域的圣经。比如用 bpftrace 统计每个连接的延迟分布、追踪 TCP 重传的原因等。