CHAPTER 13 / NETWORK & IO

第 13 篇 · 网络与高性能 IO

本篇从协议与 TLS 走到 NIO/Netty 与零拷贝,再覆盖 WebSocket/DNS、io_uring/系统调用与连接泄漏——先找阻塞与泄漏点,再谈吞吐。

骨架见 M08。本篇只补领域细枝,框在 核心方法论 · 开篇。

题目数:14 | 高级 ×5 | 架构 ×8 | 专家 ×1 | 预计复习:242 分钟
核心知识链

网络/IO 主线是「协议栈与安全握手 → 应用 IO 模型(NIO/Reactor)→ Netty 线程与内存 → 零拷贝与实时通道 → 内核异步与泄漏排障」。

  1. 协议分层与可靠传输:OSI/TCP/IP 封装、TCP 状态机握手挥手、HTTP/1.1·2·3 多路复用与选型(C13.01–03)。
  2. TLS 与证书安全:1.2/1.3 握手差异、证书链与会话恢复对延迟的影响(C13.04)。
  3. Java NIO 与 Netty:Reactor、Pipeline 设计、EventLoop 线程模型、ByteBuf 池化与引用计数(C13.05–07)。
  4. 零拷贝与实时通道:sendfile/mmap/DMA;WebSocket 握手心跳重连;DNS 与智能调度(C13.08–10)。
  5. 内核异步、IO 选型与泄漏:io_uring 与 Loom 边界、系统调用代价、跨平台 IO 模型、连接泄漏排查顺序(C13.11–14)。
工程化要点
  • 对外协议与套件(HTTP 版本、TLS、超时、保活)写入服务契约;变更走灰度,证书到期进日历与演练。
  • Netty/直连缓冲:池化与引用计数有泄漏检测与堆外看板;禁止「只看堆内健康」。
  • EventLoop 禁止阻塞调用;业务阻塞必须切到业务线程池,并有队列与拒绝策略。
  • 零拷贝与大文件路径单独压测与权限校验;错误路径也要保证资源释放。
  • WebSocket/长连接有心跳、空闲超时与风暴重连退避;网关与后端超时对齐。
  • 连接/文件描述符泄漏按标准顺序排查(应用未关 → 池化未还 → 半开连接 → 内核参数);Runbook 进值班。
  • DNS/调度变更可回滚;解析失败与脏缓存有观测与应急。
  • 引入 io_uring/新 IO 路径按平台兼容矩阵验收,回退到 epoll/NIO 路径可一键切换。
本篇思维模式
  • 先协议后框架:延迟与吞吐问题先分清握手、拥塞、应用阻塞还是内存拷贝。
  • EventLoop 保持非阻塞:阻塞一次,整条 Pipeline 尾延迟被放大。
  • 堆外与引用计数是一等公民:ByteBuf 泄漏按缺陷管理,不是调大直接内存能掩盖。
  • 超时端到端对齐:客户端、LB、应用、下游任一环更长都会制造幽灵占用。
  • 长连接当有状态资源:心跳、驱逐、重连预算写进设计,而非只演示连通。
  • 泄漏可复现:连接/FD 增长必须能落到未关闭路径,禁止只重启缓解。
C13.01
OSI 七层与 TCP/IP 四层模型如何映射?各层核心协议与数据封装过程?高级P1
本质问题

这道题考察对 "网络协议为什么要分层,以及每一层的职责边界与数据封装关系" 的体系化认知。核心不在背诵各层名称,而在回答:分层解决了什么问题(复杂度隔离、异构互联)?OSI 七层与 TCP/IP 四层如何映射?各层核心协议(ARP/IP/TCP/HTTP)所处的位置?数据从应用到网卡经历了怎样的封装/解封装过程?从 OSI 到 TCP/IP 到 QUIC 的演进逻辑是什么?

演进脉络

网络协议分层演进:

flowchart LR O["OSI 七层模型
(1984, ISO)
理论完备"] --> T["TCP/IP 四层模型
(1983, DARPA)
工程落地"] T --> Q["QUIC/HTTP3
(2022, RFC 9000)
用户空间传输"] style O fill:#dbeafe,stroke:#2563eb style T fill:#dcfce7,stroke:#059669 style Q fill:#f3e8ff,stroke:#7c3aed
图 13.1-1 网络协议模型演进:OSI(理论)→ TCP/IP(工程)→ QUIC(应用层接管传输)

OSI 七层模型(1984,ISO 推出):物理层→数据链路层→网络层→传输层→会话层→表示层→应用层。理论完备、层次清晰,但实现过于复杂,会话层与表示层在工程中常被合并到应用层。

TCP/IP 四层模型(1983,DARPA 实践):网络接口层→网际层→传输层→应用层。与 OSI 的核心差异是"工程简化"——把 OSI 上三层合并为应用层,下两层合并为网络接口层,更贴合实际操作系统协议栈实现(Linux/Mac/Windows 均按四层或五层实现)。

QUIC(2021, RFC 9000)/ HTTP/3(2022, RFC 9114):Google 主导的 QUIC 协议把传输层功能(拥塞控制、可靠传输、连接迁移)从内核 TCP 搬到用户空间的 UDP 之上,HTTP/3 基于 QUIC 运行。这标志着"应用层开始接管传统传输层职责",绕过内核 TCP 栈的性能与部署瓶颈。

演进驱动力:① 简化落地——OSI 太理论,TCP/IP 更工程;② 性能瓶颈——内核 TCP 栈难以快速迭代,QUIC 在用户空间实现灵活优化;③ 移动场景——TCP 连接随 IP 变化断开,QUIC 连接 ID 实现网络切换不断连。

第一性原理

OSI 七层与 TCP/IP 四层映射关系:

flowchart TD subgraph OSI ["OSI 七层"] O7["7 应用层 HTTP/FTP/DNS/SMTP"] O6["6 表示层 TLS/SSL/ASCII/JSON"] O5["5 会话层 NetBIOS/RPC"] O4["4 传输层 TCP/UDP"] O3["3 网络层 IP/ICMP/ARP/OSPF"] O2["2 数据链路层 Ethernet/WiFi/PPP"] O1["1 物理层 光纤/双绞线/无线电"] end subgraph TCP ["TCP/IP 四层"] T4["应用层 HTTP/FTP/DNS/RPC/TLS"] T3["传输层 TCP/UDP"] T2["网际层 IP/ICMP/ARP/IGMP"] T1["网络接口层 Ethernet/WiFi/驱动"] end O7 --> T4 O6 --> T4 O5 --> T4 O4 --> T3 O3 --> T2 O2 --> T1 O1 --> T1 style T4 fill:#dbeafe,stroke:#2563eb style T3 fill:#dcfce7,stroke:#059669 style T2 fill:#fef3c7,stroke:#d97706
图 13.1-2 OSI 七层与 TCP/IP 四层映射关系

各层核心协议与 PDU(协议数据单元):

OSI 层 TCP/IP 层 核心协议 PDU 核心职责
7 应用层 应用层 HTTP/HTTPS, FTP, SMTP, DNS, SSH, RPC 报文 Message 为用户应用提供网络服务接口
6 表示层 TLS/SSL, ASCII, JSON, Protobuf, JPEG 报文 Message 数据格式转换、加密压缩、序列化
5 会话层 NetBIOS, RPC 会话管理, SIP 报文 Message 建立、管理、终止会话
4 传输层 传输层 TCP, UDP, SCTP, DCCP 段 Segment / 报文 Datagram 端到端可靠/不可靠传输、端口寻址
3 网络层 网际层 IP, ICMP, ARP, OSPF, BGP, NAT 包 Packet 逻辑寻址、路由选择、分组转发
2 数据链路层 网络接口层 Ethernet, WiFi(802.11), PPP, MAC 帧 Frame 物理寻址(MAC)、帧同步、差错检测
1 物理层 光纤、双绞线、无线电、Hub 比特 Bit 比特流传输、电气/光学信号

数据封装与解封装过程:发送端自上而下逐层封装(加头部/尾部),接收端自下而上逐层解封装(拆头部)。以 HTTP 请求为例:

  1. 应用层:HTTP 报文 → 加 TLS 头(HTTPS 时)→ 交给传输层
  2. 传输层:加 TCP 头(源/目的端口、序号、确认号、标志位)→ 形成 TCP Segment → 交给网络层
  3. 网络层:加 IP 头(源/目的 IP、TTL、协议类型)→ 形成 IP Packet → 交给数据链路层
  4. 数据链路层:加以太网头(源/目的 MAC、类型)+ FCS 尾(CRC 校验)→ 形成 Frame → 交给物理层
  5. 物理层:比特流通过网卡转换为电/光信号发送

接收端逆序解封装,每层检查对应头部,剥离后上交上一层,直到应用层拿到原始 HTTP 报文。分层本质是"关注点分离"——每层只处理自己的头部,不感知上下层细节。

ARP 协议的关键位置:ARP(地址解析协议)处于网际层与网络接口层交界——它用 IP 地址查询 MAC 地址,使 IP 包能封装成以太网帧。ARP 是局域网内广播查询("谁的 IP 是 192.168.1.1?"),目标主机单播回复 MAC 地址,随后缓存到 ARP 表避免重复广播。

工程实践

"四层/七层" 快速定位法:遇到网络问题先定层级——"不通"先看物理/链路(网线、网卡、MAC);"能 ping 通但连不上"看网络/传输(IP、路由、端口、防火墙);"连接成功但数据错"看应用(HTTP 状态码、TLS 握手、应用协议)。

Linux 协议栈观察:

# 查看路由表(网络层)
$ ip route
# 查看 ARP 缓存(网际层/链路层交界)
$ ip neigh
# 查看 TCP 连接状态(传输层)
$ ss -ant | head
# 抓包观察完整封装(全栈)
$ tcpdump -i eth0 -nn host 192.168.1.1
最佳实践

架构师讲网络分层四要点:① 分层是复杂度隔离——每层只关心对等层协议,实现异构互联;② 封装是头部堆叠——发送加头、接收拆头,MTU(1500B)限制来自链路层;③ ARP 是 IP 到 MAC 的桥梁——局域网通信最终靠 MAC,IP 是逻辑抽象;④ QUIC 是应用层接管传输的趋势——HTTP/3 绕过内核 TCP,预示未来更多传输逻辑上移用户空间。

深度追问

追问 1:为什么说 TCP/IP 是四层,但教材常讲五层?

五层是教学折中——把 TCP/IP 的"网络接口层"拆回"数据链路层+物理层",既保留 TCP/IP 的工程实用性,又保留 OSI 下两层分离的教学清晰度。Linux 内核协议栈实际按四层实现(应用层、INET 层、网络设备层、驱动层),但网工排查时仍习惯区分物理/链路。

追问 2:MTU 与 MSS 的关系?为什么 TCP 三次握手要协商 MSS?

MTU(Maximum Transmission Unit)是链路层最大帧载荷(以太网默认 1500B),MSS(Maximum Segment Size)是 TCP 层最大段载荷(不含 TCP/IP 头)。MSS = MTU - IP 头(20B) - TCP 头(20B) = 1460B。TCP 三次握手时双方通过 SYN 包的 MSS 选项告知各自能接收的最大段大小,避免发送方发过大段导致 IP 层分片(分片有性能损耗、丢一片全重传、中间设备可能禁止分片)。

常见陷阱
易错点
  • 混淆 OSI 与 TCP/IP 层数:说"HTTP 在传输层"——HTTP 在应用层(OSI 第 7 层 / TCP/IP 第 4 层),TCP 才是传输层。
  • 认为 ARP 是网络层协议:ARP 跨网际层与链路层,用 IP 查 MAC,目的是服务链路层封装,严格说属于网际层边界协议。
  • 忽视封装开销:以太网头 14B + FCS 4B + IP 头 20B + TCP 头 20B = 58B 开销,小数据包效率极低。
  • 把 QUIC 当成传输层协议:QUIC 基于 UDP(传输层),但拥塞控制/可靠传输/连接管理都在用户空间实现,严格说是"应用层实现的传输协议"。
  • 认为分层是严格隔离:实际有跨层设计(如 TCP 的 RTT 测量会参考 IP 层的 TTL、QUIC 直接跨越传输/会话/表示层)。
C13.02
TCP 三次握手与四次挥手完整状态机?TIME_WAIT 与 CLOSE_WAIT 根因?高级P1
本质问题

这道题考察对 "TCP 可靠传输的底层机制与连接生命周期管理" 的深入理解。核心不在背诵状态机,而在回答:三次握手为什么是三次(不是两次/四次)?四次挥手为什么是四次?TIME_WAIT 的 2MSL 意义?CLOSE_WAIT 堆积的根因与危害?TCP 拥塞控制如何从 Tahoe 演进到 BBR?这是网络方向最高频的"状态机"题。

演进脉络

TCP 拥塞控制演进:

flowchart LR T["Tahoe (1988)
慢启动+拥塞避免
+快速重传"] --> R["Reno (1990)
+快速恢复"] R --> N["NewReno (1995)
部分 ACK 优化"] N --> C["CUBIC (2006)
三次方增长
Linux 默认"] C --> B["BBR (2016)
基于瓶颈带宽
+RTT 探测"] style C fill:#dbeafe,stroke:#2563eb style B fill:#dcfce7,stroke:#059669
图 13.2-1 TCP 拥塞控制算法演进:Tahoe → Reno → NewReno → CUBIC → BBR

Tahoe(1988,Van Jacobson):引入慢启动(Slow Start,指数增)、拥塞避免(Congestion Avoidance,线性增)、快速重传(Fast Retransmit,3 个 dup ACK 触发重传)。拥塞窗口 cwnd 在丢包后直接降到 1,过于激进。

Reno(1990):新增快速恢复(Fast Recovery)——丢包后不降到 1,而是降到 cwnd/2 + 3,保持管道中有数据 inflight,避免吞吐量断崖。但多个包同时丢时恢复效率低。

NewReno(1995):优化部分 ACK 处理——在一个窗口内多个包丢失时,每收到一个部分 ACK 就重传一个丢失包,直到所有丢失包补完,避免 Reno 的窗口膨胀问题。

CUBIC(2006,Linux 2.6.19+ 默认):用三次方函数替代线性增长,在远离上一次丢包窗口时增长快、接近时增长慢,兼顾高带宽长肥网络的利用率与稳定性。但基于丢包的探测在高随机丢包网络(如 WiFi)表现差。

BBR(2016,Google):不依赖丢包,而是主动探测瓶颈带宽(BtlBw)和最小 RTT(MinRTT),发送速率 = BtlBw,inflight = BtlBw × MinRTT。在高丢包网络(如蜂窝/卫星)表现远超 CUBIC,Google 内部测试显示在高丢包/长肥网络下较 CUBIC 吞吐显著提升(官方文献报告可达两位数百分比量级,约 2~3 成),但非所有网络都占优,需结合 RTT 与丢包率评估。

演进驱动力:从"丢包=拥塞"(Tahoe/Reno 假设)到"主动测量网络容量"(BBR),反映网络环境从低丢包有线到高丢包无线/长距离的变化。

第一性原理

三次握手的本质:同步初始序号 + 双向确认

TCP 是全双工可靠传输,双方都需要确认:① 自己能发;② 自己能收;③ 对方能收发。两次握手只能确认单向,四次可以但冗余。

sequenceDiagram participant C as Client participant S as Server C->>S: SYN=1, seq=x S->>C: SYN=1, ACK=1, seq=y, ack=x+1 C->>S: ACK=1, seq=x+1, ack=y+1 Note over C,S: ESTABLISHED (双向确认完成)
图 13.2-2 TCP 三次握手时序
  • 第一次(SYN):Client 发 SYN,进入 SYN_SENT,告知 Server 自己的初始序号 x。
  • 第二次(SYN+ACK):Server 发 SYN+ACK,进入 SYN_RCVD,确认收到 x(ack=x+1),并告知自己的初始序号 y。
  • 第三次(ACK):Client 发 ACK,进入 ESTABLISHED,确认收到 y(ack=y+1)。Server 收到后进入 ESTABLISHED。

为什么是三次不是两次? 两次握手时,若 Client 的第一个 SYN 在网络中延迟到达,Client 已放弃并重连,Server 收到旧 SYN 会建立"幽灵连接",浪费资源。第三次 ACK 让 Server 确认 Client 确实活跃。

四次挥手的本质:全双工连接需分别关闭

sequenceDiagram participant C as Client participant S as Server C->>S: FIN=1, seq=u S->>C: ACK=1, ack=u+1 Note over S: CLOSE_WAIT (被动方继续发数据) S->>C: FIN=1, seq=w C->>S: ACK=1, ack=w+1 Note over C: TIME_WAIT (2MSL)
图 13.2-3 TCP 四次挥手时序
  • 第一次(FIN):主动方发 FIN,进入 FIN_WAIT_1,表示"我数据发完了"。
  • 第二次(ACK):被动方回 ACK,进入 CLOSE_WAIT,表示"知道你发完了,但我可能还有数据要发"。
  • 第三次(FIN):被动方数据发完,发 FIN,进入 LAST_ACK,表示"我也发完了"。
  • 第四次(ACK):主动方回 ACK,进入 TIME_WAIT,等待 2MSL 后关闭。被动方收到后关闭。

为什么是四次不是三次? 被动方收到 FIN 时可能还有数据未发完,不能立即回 FIN,必须先 ACK 确认收到 FIN,等数据发完再发 FIN。所以 ACK 与 FIN 不能合并,必须是四次。

TIME_WAIT(2MSL)的双重使命:

  1. 可靠终止:最后一个 ACK 可能丢失,被动方会重传 FIN,TIME_WAIT 让主动方有机会重发 ACK。MSL(Maximum Segment Lifetime)是报文最大存活时间,2MSL 确保来回都有时间。
  2. 防止旧连接数据包干扰新连接:若立即复用端口,网络中延迟的旧数据包可能被新连接误收。等待 2MSL 让旧包全部消亡。

CLOSE_WAIT 堆积根因:被动方收到 FIN 发了 ACK,但应用层未调用 close(),导致状态卡在 CLOSE_WAIT。常见原因:① 程序 bug 未关闭 Socket;② 后端处理耗时,应用层数据未发完;③ 连接池泄漏。危害:每个 CLOSE_WAIT 占用一个文件描述符,大量堆积会耗尽句柄,导致“Too many open files”。

TIME_WAIT 关键辨析:主动关闭方、端口复用、内核参数与负载均衡影响:

  • 主动关闭方视角:TIME_WAIT 只出现在主动发起 FIN 并发送最后 ACK 的那一方(通常是先调用 close() 的一方)。被动关闭方收到最后 ACK 后即进入 CLOSED,不会停留 TIME_WAIT。短连接场景若由客户端先关闭,TIME_WAIT 落在客户端;若服务端主动关闭(如超时踢掉空闲连接),TIME_WAIT 就落在服务端,高并发短连接下服务端会堆积大量 TIME_WAIT。
  • 端口复用:SO_REUSEADDR 允许绑定处于 TIME_WAIT 状态的本地地址,使服务进程重启时能立即 bind 同一端口(否则需等 2MSL);SO_REUSEPORT(Linux 3.9+)允许多个进程/线程绑定同一端口、由内核分发连接,现代多核服务(Nginx、Netty 的 ChannelOption.SO_REUSEPORT)借此把连接摊到各核,缓解单套接字 TIME_WAIT 堆积。二者语义不同:SO_REUSEADDR 只解决"地址可复用",SO_REUSEPORT 才允许多进程监听同端口。
  • 内核参数:net.ipv4.tcp_tw_reuse(默认 0,仅对客户端出站连接生效,需配合时间戳选项)可复用 TIME_WAIT 套接字发起新出站连接;net.ipv4.tcp_fin_timeout 控制 FIN_WAIT_2 超时;net.ipv4.tcp_max_tw_buckets 限制 TIME_WAIT 总量,超量后内核直接回收(可能丢包);net.ipv4.tcp_tw_recycle 已在 Linux 4.12 移除,且启用后在 NAT 场景会因时间戳错乱导致连接失败,切勿再使用。
  • 负载均衡影响:四层负载均衡(L4 LB)做 SNAT 时,真实服务器看到的源地址是 LB,大量来自不同客户端的连接复用同一 (LB IP, LB 端口),会使 realserver 上同一本地端口对应的 TIME_WAIT 急剧放大;反之网关/反向代理自身作为客户端向上游发起连接时,TIME_WAIT 落在本机客户端侧,此时 tcp_tw_reuse 才有意义。高并发服务端更稳妥的做法是让客户端先关(被动关闭)或开启 SO_REUSEPORT,而非盲目调小 2MSL。

拥塞控制核心机制(慢启动 / 拥塞避免 / 快速重传):发送方维护拥塞窗口 cwnd,实际发送窗口 = min(cwnd, 接收窗口 rwnd)。① 慢启动:初始 cwnd 较小,每收到一个 ACK 指数增长(×2),快速探明可用带宽;② 拥塞避免:cwnd 达到慢启动阈值 ssthresh 后转线性增长(+1/cwnd 每 RTT),避免激进;③ 丢包响应:Reno 类在收到 3 个重复 ACK(快速重传)后进入快速恢复,cwnd 降到 ssthresh(约 cwnd/2)再线性恢复;④ 超时:cwnd 直接归 1 重新慢启动(最严重)。

维度 CUBIC(基于丢包,Linux 默认) BBR(基于测量,Google)
探测依据 丢包即拥塞,降窗 主动测瓶颈带宽 BtlBw + 最小 RTT
高丢包网络 频繁降窗,吞吐骤降(WiFi/蜂窝差) 几乎不受随机丢包影响
bufferbloat 易填满缓冲区,RTT 膨胀 限制 inflight,缓解 bufferbloat
公平性 与同类公平 共享瓶颈可能过度占用
典型场景 数据中心有线低丢包 长距离/无线/高丢包
关键结论

拥塞控制回答"网络能承受多少流量":传统算法把丢包当拥塞信号(CUBIC 三次方回退),BBR 改为直接测量瓶颈带宽与最小 RTT,在高丢包/长肥网络下吞吐与延迟显著占优,但共享瓶颈下需与 CUBIC 权衡公平性。

工程实践

高频命令:TCP 状态排查

# 统计各状态连接数
$ ss -ant | awk '{print $1}' | sort | uniq -c
# 查看 CLOSE_WAIT 详情(定位进程)
$ ss -antp | grep CLOSE_WAIT
# 查看 TIME_WAIT 数量
$ ss -ant | grep TIME_WAIT | wc -l
# 调整内核参数减少 TIME_WAIT(谨慎)
$ sysctl net.ipv4.tcp_tw_reuse=1
$ sysctl net.ipv4.tcp_fin_timeout=30

Java 排查 CLOSE_WAIT:用 jstack dump 线程栈,搜索与 Socket 相关的代码路径;检查 HTTP 客户端(Apache HttpClient/OkHttp)是否正确关闭 Response Body;检查数据库连接池是否配置合理。

最佳实践

TCP 状态机四要点:① 三次握手 = 双向确认初始序号,两次不够(幽灵连接)、四次冗余;② 四次挥手 = 全双工分别关闭,被动方可能有数据要发,ACK 与 FIN 不能合并;③ TIME_WAIT 是主动关闭方的保护机制,2MSL 防旧包干扰+丢 ACK 重传,高并发服务端可用 tcp_tw_reuse 复用;④ CLOSE_WAIT 是应用层不 close 的锅,排查代码资源释放,不是内核问题。

深度追问

追问 1:滑动窗口与流量控制、拥塞控制的关系?

滑动窗口是 TCP 可靠传输的核心机制——发送方维护一个窗口,窗口内的包可以连续发送无需等待确认,收到 ACK 后窗口右移。流量控制(Flow Control)是接收方通过 TCP 头部的 Window 字段告知发送方"我还能收多少",防止发送方压垮接收方缓存(接收端限速)。拥塞控制(Congestion Control)是发送方根据网络拥塞程度(丢包/延迟/显式信号)自我限速,防止压垮网络(网络端限速)。实际发送窗口 = min(拥塞窗口 cwnd, 接收窗口 rwnd)。

追问 2:SYN Flood 攻击原理与防御?

攻击者伪造大量源 IP 向 Server 发 SYN,Server 回 SYN+ACK 后进入 SYN_RCVD 状态等待 ACK,但伪造 IP 不会回 ACK,导致 SYN 半连接队列满,拒绝正常连接。防御:① SYN Cookie——不分配资源,把连接信息编码到 SYN+ACK 的序号中,收到 ACK 再还原;② SYN Proxy——防火墙代理完成三次握手后再透传;③ 缩短 SYN Timeout;④ 增大 tcp_max_syn_backlog。

常见陷阱
易错点
  • 认为三次握手是"确认双方存在":本质是同步初始序号(ISN),序号机制是可靠传输与乱序重组的基础,不只是"打招呼"。
  • 把 TIME_WAIT 当异常状态清除:TIME_WAIT 是正常且必要的状态,盲目开启 tcp_tw_recycle(Linux 4.12 已移除)会导致 NAT 场景丢包。
  • CLOSE_WAIT 怪内核:CLOSE_WAIT 100% 是应用层未 close Socket,内核已完成职责,应查代码。
  • 混淆流量控制与拥塞控制:流量控制是端到端的(接收方限速),拥塞控制是全局网络感知的(发送方自我限速)。
  • 认为 BBR 一定优于 CUBIC:BBR 在共享瓶颈且 bufferbloat 严重时可能抢带宽过度,与 CUBIC 混布需权衡。
C13.03
HTTP/1.1 vs HTTP/2 vs HTTP/3 核心差异与选型依据?高级P1
本质问题

这道题考察对 "Web 传输协议如何解决队头阻塞、延迟与连接效率问题" 的演进理解。核心不在罗列特性,而在回答:HTTP/1.1 的瓶颈到底是什么(队头阻塞、连接数限制)?HTTP/2 的多路复用如何解决、又引入了什么新问题(TCP 层队头阻塞)?HTTP/3 为什么基于 UDP/QUIC、解决了什么根本问题?三者选型依据是什么?

演进脉络

HTTP 协议演进时间线:

flowchart LR H1["HTTP/1.1 (1997)
持久连接+管道
队头阻塞严重"] --> H2["HTTP/2 (2015)
二进制分帧+多路复用
TCP 层队头阻塞"] H2 --> H3["HTTP/3 (2022)
QUIC+UDP
无队头阻塞+0-RTT"] style H1 fill:#dbeafe,stroke:#2563eb style H2 fill:#dcfce7,stroke:#059669 style H3 fill:#f3e8ff,stroke:#7c3aed
图 13.3-1 HTTP 协议演进:HTTP/1.1 → HTTP/2 → HTTP/3

HTTP/1.0(1996):每个请求独立 TCP 连接,三次握手开销大,无法复用。

HTTP/1.1(1997 RFC 2068 首版;1999 RFC 2616 修订):引入持久连接(Connection: keep-alive,默认开启)、管道化(Pipelining,允许连续发请求不等待响应)、分块传输(Chunked Transfer)、Host 头(支持虚拟主机)。但管道化有响应顺序依赖,未被广泛实现;浏览器仍限制同域名 6-8 个并发连接。

HTTP/2(2015,RFC 7540):Google SPDY 标准化。核心创新:二进制分帧(替代文本报文)、多路复用(单连接并发多请求)、头部压缩 HPACK、Server Push(已废弃:HTTP/3 已移除 Server Push,命中率低、缓存复杂,推荐用 Preload / Link 头替代)。但所有流共享一个 TCP 连接,TCP 层丢包会阻塞所有流(TCP 队头阻塞)。

HTTP/3(2022,RFC 9114):基于 QUIC(UDP 之上实现可靠传输)。QUIC 把"连接"从四元组(源 IP/端口+目的 IP/端口)解耦为连接 ID,网络切换(WiFi↔4G)不断连;每个流独立拥塞控制与丢包恢复,彻底消除队头阻塞;支持 0-RTT/1-RTT 握手;头部压缩升级为 QPACK。

演进驱动力:从"连接复用"(HTTP/1.1)到"连接内并发"(HTTP/2)到"无阻塞+连接迁移"(HTTP/3),每一步都在降低延迟、提升连接鲁棒性,适应移动互联网与实时交互场景。

第一性原理

HTTP/1.1 的核心瓶颈:队头阻塞 + 连接数限制

HTTP/1.1 是文本协议,请求-响应串行(即使 keep-alive,下一个请求也必须等上一个响应完)。浏览器同域名只能开 6-8 个 TCP 连接,页面资源一多(现代网页 100+ 资源)只能排队。前端 workaround:域名分片(sharding)、资源内联、雪碧图——都是绕过协议限制的 hack。

HTTP/2 的多路复用:二进制分帧 + 流

HTTP/2 把请求/响应切分为二进制帧(Frame),不同请求的帧可以交错发送,通过流标识符(Stream ID)区分。单条 TCP 连接上可同时跑多条流(Stream),彻底解决应用层队头阻塞。

特性 HTTP/1.1 HTTP/2 HTTP/3
传输层 TCP TCP UDP + QUIC
队头阻塞 应用层严重 应用层消除,TCP 层存在 完全消除
连接数 同域 6-8 个 单连接多路复用 单连接多路复用
握手延迟(首字节) 明文 1-RTT(TCP);TLS 1.2 共 3-RTT、TLS 1.3 共 2-RTT 与 HTTP/1.1 一致(复用同一 TCP+TLS 连接,无额外 RTT) 1-RTT(QUIC 内含 TLS 1.3);重连可用 0-RTT(PSK)
连接迁移 四元组变则断 四元组变则断 连接 ID 不变不断
头部压缩 无(gzip 压 body) HPACK(静态+动态表) QPACK(避免队头阻塞)
Server Push 无 有(实际少用) 移除(推荐 Preload / Link 头替代)

HTTP/2 的 TCP 队头阻塞:为什么 TCP 丢包会阻塞所有流?

TCP 是字节流协议,必须按序交付。若某个 TCP 包丢失,后续所有包即使已到达也不能上交应用层,导致该连接上所有 HTTP/2 流一起等待重传。这是 HTTP/2 的致命伤——多路复用的收益被 TCP 的严格有序性抵消。

HTTP/3 的 QUIC 如何解决?

QUIC 在 UDP 之上实现了类似 TCP 的可靠传输,但每个流有独立的序号与丢包恢复。流 A 丢包只阻塞流 A,流 B/C 不受影响。QUIC 还内嵌 TLS 1.3,握手与加密合并,实现 1-RTT(首次)或 0-RTT(重连)建立安全连接。

HPACK vs QPACK:头部压缩的演进

HTTP/2 的 HPACK 用静态表(预定义常见头)+ 动态表(连接级共享,先进先出)压缩头部。但动态表更新必须按序,若某个包丢失,后续依赖动态表的头部无法解码,形成压缩层队头阻塞。HTTP/3 的 QPACK 把动态表更新与请求流解耦,用独立单向流传递表更新,消除此阻塞。

TLS 集成与版本边界(容易被忽略):三个版本对 TLS 的要求不同——HTTP/1.1 可跑在任意 TLS 版本(甚至明文);HTTP/2 在浏览器中强制 TLS 1.2+(明文 h2c 仅用于服务端间内部通信,浏览器不支持),且必须通过 ALPN 协商出 h2 协议标识;HTTP/3 的 QUIC 强制内嵌 TLS 1.3,没有明文等价物。因此"升级到 HTTP/3 就一定更快"的前提是客户端与服务端都支持 TLS 1.3 与 UDP/QUIC,且中间网络不阻断 UDP。

工程实践

选型决策树:

  1. HTTP/1.1:仅在对极老旧客户端兼容时使用,或内部简单 API 网关。现代 Web 不推荐。
  2. HTTP/2:当前主流,Nginx/Envoy/Caddy 均支持。适合浏览器-Web、服务间 gRPC(基于 HTTP/2)。注意:若网络丢包率高(弱网/移动),TCP 队头阻塞会放大延迟。
  3. HTTP/3:推荐用于移动端、实时音视频、全球加速。Cloudflare/Google/Facebook 已大规模部署。Nginx 1.25+ 实验性支持,Caddy 原生支持。注意:中间设备(企业防火墙/NAT)可能限制 UDP,需 fallback 到 HTTP/2。

Nginx 开启 HTTP/2 与 HTTP/3:

# HTTP/2 (Nginx 1.9.5+)
listen 443 ssl http2;

# HTTP/3 (Nginx 1.25.0+ 实验性)
listen 443 quic reuseport;
listen 443 ssl;
add_header Alt-Svc 'h3=":443"; ma=86400';
ssl_early_data on;  # 0-RTT
最佳实践

HTTP 协议选型三要点:① HTTP/2 是当前默认——多路复用+HPACK 大幅降低延迟,gRPC 基于 HTTP/2;② HTTP/3 是移动端/弱网优选——QUIC 消除 TCP 队头阻塞+连接迁移,但需确认 UDP 可达性;③ 不要轻易 Server Push——HTTP/2 Push 命中率低、缓存复杂,HTTP/3 已移除,用 Preload 提示替代。

深度追问

追问 1:HTTP/2 的 Server Push 为什么实际很少用?

Server Push 让服务器主动推资源(如 HTML 里引用的 CSS/JS),但存在三问题:① 缓存难处理——浏览器可能已缓存该资源,推送浪费带宽;② 优先级冲突——推送可能抢占关键资源带宽;③ 命中率低——服务器猜浏览器需要什么,常猜错。HTTP/3 已移除 Server Push,改用 103 Early Hints 或 Preload 链接头让浏览器主动请求。

追问 2:gRPC 为什么基于 HTTP/2?HTTP/3 对 gRPC 有何影响?

gRPC 需要双向流、头部压缩、多路复用,HTTP/2 的 Stream 恰好提供双向全双工虚拟通道,HPACK 压缩 protobuf 元数据高效。HTTP/3 对 gRPC 理论上更优(无队头阻塞、0-RTT),但 gRPC over QUIC 仍在标准化中(gRPC-QUIC 实验性项目)。当前生产环境仍用 HTTP/2,未来若 QUIC 普及,gRPC 将获得移动端/弱网场景的巨大延迟优势。

常见陷阱
易错点
  • 认为 HTTP/2 解决了所有队头阻塞:HTTP/2 只解决应用层队头阻塞,TCP 层丢包仍阻塞所有流,这是 HTTP/3 诞生的原因。
  • 认为 HTTP/3 不基于 TCP 就不可靠:QUIC 在 UDP 之上实现了完整可靠传输(重传、拥塞控制、按序交付),只是实现在用户空间而非内核。
  • 混淆 HTTP/2 与 HTTPS:HTTP/2 可以跑在明文 TCP 上(h2c),但浏览器只支持 TLS 上的 HTTP/2(h2)。
  • 认为多路复用 = 并行下载无限快:单连接带宽仍受物理限制,HTTP/2 解决的是"等待"而非"加速",大文件下载速度不会质变。
  • 忽视 UDP 被中间设备限制:企业防火墙/NAT 可能阻 UDP/限制速率,HTTP/3 需 graceful fallback。
C13.04
HTTPS/TLS 1.2 与 1.3 握手流程差异?证书链验证与前向安全?高级P1
本质问题

这道题考察对 "HTTPS 安全传输的密钥交换机制与信任链建立过程" 的深入理解。核心不在背诵握手步骤,而在回答:TLS 握手到底在协商什么(密钥套件、随机数、证书)?TLS 1.3 如何把 2-RTT 降到 1-RTT/0-RTT?证书链如何验证?什么是前向安全(PFS)?Session Resumption 的原理与风险?

演进脉络

TLS 协议演进:

flowchart LR T10["TLS 1.0 (1999)
SSL 3.1
已废弃"] --> T11["TLS 1.1 (2006)
CBC 防护
已废弃"] T11 --> T12["TLS 1.2 (2008)
AES-GCM/SHA-256
2-RTT 握手"] T12 --> T13["TLS 1.3 (2018)
简化握手
1-RTT/0-RTT"] style T12 fill:#dbeafe,stroke:#2563eb style T13 fill:#dcfce7,stroke:#059669
图 13.4-1 TLS 协议演进:1.0→1.1→1.2→1.3

TLS 1.0/1.1(已废弃):基于 SSL 3.0,存在 BEAST、POODLE、RC4 弱点,主流浏览器 2020 起禁用,当前推荐 TLS 1.3。

TLS 1.2(2008,当前兼容主流):支持 AEAD 加密(AES-GCM)、SHA-256/384、灵活的密码套件协商。完整握手需 2 次 TLS 往返(ClientHello→ServerHello(+Cert+KeyExch)+ServerHelloDone 为 1 RTT,ClientKeyExchange+Finished→Server Finished 为第 2 RTT);叠加 TCP 三次握手 1 RTT,首字节约 3 RTT。

TLS 1.3(2018,RFC 8446):最大变革是握手简化——把协商轮次从 2-RTT 降到 1-RTT,重连可 0-RTT;废除 RSA 密钥交换(无前向安全,仅支持前向安全的 ECDHE)及大量旧密码套件(CBC/MD5/SHA-1,仅保留 AEAD);密码套件从几十种精简到 5 种(减少攻击面);加密更多握手字段(除 ClientHello 外几乎全部加密)。

演进驱动力:① 延迟敏感——移动互联网要求首包极致快,1-RTT/0-RTT 直接提升用户体验;② 安全加固——RSA 无 PFS,泄露私钥可解密历史流量,必须淘汰;③ 减少攻击面——TLS 1.2 的密码套件太多,很多已不安全,1.3 大刀阔斧精简。

第一性原理

TLS 1.2 完整握手(2-RTT):

sequenceDiagram participant C as Client participant S as Server C->>S: ClientHello (支持的密码套件+随机数) S->>C: ServerHello + Certificate + ServerKeyExchange + ServerHelloDone C->>S: ClientKeyExchange + ChangeCipherSpec + Finished S->>C: ChangeCipherSpec + Finished Note over C,S: 应用数据 (2-RTT 后)
图 13.4-2 TLS 1.2 完整握手(2-RTT)
  • 第一次 RTT:ClientHello(Client Random + 支持的密码套件/SNI)→ ServerHello(选定套件 + Server Random)+ Certificate(服务器证书链)+ ServerKeyExchange(ECDHE 公钥)+ ServerHelloDone。
  • 第二次 RTT:Client 验证证书 → 生成 Pre-Master Secret → ClientKeyExchange(ECDHE 公钥)→ ChangeCipherSpec(通知后续加密)→ Finished(握手摘要校验)。Server 回 ChangeCipherSpec + Finished。

密钥推导:Client Random + Server Random + Pre-Master Secret → 通过 PRF 生成 Master Secret → 再派生对称加密密钥(AES)+ MAC 密钥(HMAC)。后续应用数据用对称密钥加密。

TLS 1.3 握手(1-RTT):

sequenceDiagram participant C as Client participant S as Server C->>S: ClientHello + key_share + {extensions} S->>C: ServerHello + EncryptedExtensions + Certificate + Finished C->>S: Finished + 应用数据 Note over C,S: 应用数据 (1-RTT 后)
图 13.4-3 TLS 1.3 握手(1-RTT)

TLS 1.3 把 ServerHello 之后的大部分消息加密(EncryptedExtensions),并在 ClientHello 中直接携带 key_share(ECDHE 公钥猜测),Server 回复时直接用匹配的公钥完成密钥协商,省掉一轮往返。重连时用 PSK(Pre-Shared Key)可实现 0-RTT——Client 在第一个包就携带应用数据(基于上次会话的 PSK 加密)。

证书链验证:

  1. 浏览器/客户端内置根 CA 证书(如 DigiCert、Let's Encrypt)。
  2. 服务器返回证书链:叶子证书(服务器域名)→ 中间 CA 证书 → 根 CA 证书(通常不发,客户端已内置)。
  3. 客户端从下往上验证:① 叶子证书域名与访问域名匹配(SNI);② 每个证书的签名用上级的公钥验证;③ 检查有效期、吊销状态(CRL 或 OCSP);④ 根 CA 在信任库中。

OCSP Stapling:传统 OCSP 需客户端额外发请求查证书是否吊销,增加延迟。OCSP Stapling 让服务器定期向 CA 查询 OCSP 响应并在 TLS 握手时附带,客户端直接验证,省一次往返。

SNI 与 ALPN:握手阶段的两个关键扩展:SNI(Server Name Indication)在 ClientHello 中携带请求域名,让一台 IP 上托管多个证书的服务器知道该返回哪张证书(否则只能返回默认证书),是 HTTPS 虚拟主机的基础。ALPN(Application-Layer Protocol Negotiation)在 TLS 握手内协商上层应用协议,HTTP/2 必须通过 ALPN 协商出 h2、HTTP/3 协商出 h3,否则浏览器会拒绝用该版本通信。二者都在握手早期完成、不增加额外 RTT:SNI 解决"返回哪张证书",ALPN 解决"用哪个应用协议"。

前向安全(Perfect Forward Secrecy, PFS):

传统 RSA 密钥交换(TLS 1.3 已废弃,无前向安全)中,Pre-Master Secret 用服务器公钥加密传输。若服务器私钥未来泄露,攻击者可解密所有历史流量。ECDHE(临时椭圆曲线 Diffie-Hellman)密钥交换每次握手生成临时密钥对,会话密钥派生后临时密钥即丢弃。即使私钥泄露,也无法推算历史会话密钥——保护的是"过去"。

工程实践

Nginx TLS 1.3 配置与优化:

server {
    listen 443 ssl;
    ssl_certificate     /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
    ssl_prefer_server_ciphers on;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;
    # OCSP Stapling
    ssl_stapling on;
    ssl_stapling_verify on;
}

Java 排查 TLS 握手问题:启用 -Djavax.net.debug=ssl,handshake 可打印完整 TLS 握手日志,观察密码套件协商、证书链、ALPN 等。Spring Boot 内嵌 Tomcat 的 TLS 配置在 server.ssl.*。

最佳实践

TLS 配置四要点:① 强制 TLS 1.2+,禁用 1.0/1.1/SSLv3;② 启用 ECDHE 密钥交换确保前向安全,禁用 RSA 静态密钥交换;③ 开启 OCSP Stapling减少握手延迟;④ 0-RTT 谨慎开启——TLS 1.3 的 0-RTT 有重放攻击风险,金融/支付场景建议关闭或做重放防护。

深度追问

追问 1:TLS 1.3 的 0-RTT 有什么风险?如何防护?

0-RTT 用上次会话的 PSK 派生密钥,Client 在第一个包就发应用数据。风险是重放攻击——攻击者截获 0-RTT 数据包可重复发送,若服务端操作非幂等(如转账、下单)则危险。防护:① 0-RTT 数据只允许读操作(GET/HEAD);② 服务端对 0-RTT 请求做 token/nonce 去重;③ 敏感业务禁用 0-RTT。

追问 2:中间人攻击(MITM)如何发生?证书固定(Pinning)有什么用?

MITM 攻击者截断连接,用自己证书冒充服务器。若客户端不验证证书或信任攻击者根 CA,则通信被窃听。证书固定(Certificate Pinning):客户端预置服务器证书/公钥指纹,握手时只信任匹配证书,即使系统信任库被篡改也不受影响。移动 App 常用,但证书轮换时需同步更新 App,运维复杂。

常见陷阱
易错点
  • 认为 HTTPS "加密了"就绝对安全:HTTPS 防窃听和篡改,但不防服务端被入侵、客户端被植入木马、社工钓鱼。
  • 混淆对称加密与非对称加密的使用场景:TLS 握手阶段用非对称(ECDHE/RSA)协商密钥,应用数据传输用对称(AES-GCM),因为对称加密快 1000 倍以上。
  • 认为 TLS 1.3 一定比 1.2 快:1-RTT 优势在首次连接,若网络 RTT 本身很小(同机房 <1ms),差异不明显;且 TLS 1.3 兼容性仍需关注老旧客户端。
  • 忽视证书过期:Let's Encrypt 90 天有效期,自动化续期(certbot/acme.sh)必须到位,生产事故常见。
  • OCSP Stapling 配置错:证书链不完整或 OCSP URI 不可达会导致 stapling 失败,握手反而更慢。
C13.05
Java NIO 核心原理:Selector、Channel、Buffer 如何协同工作?架构P0
本质问题

这道题考察对 "Java 如何从阻塞 IO 演进到非阻塞 IO,以及 NIO 三大核心组件的协作机制" 的架构理解。核心不在罗列 API,而在回答:BIO 的瓶颈是什么(一线程一连接)?NIO 如何用单线程管理多连接?Selector 的 epoll/kqueue 底层原理?Buffer 的 flip/clear 状态机?从 BIO 到 NIO 到 AIO 的演进逻辑?

演进脉络

Java IO 模型演进:

flowchart LR B["BIO (JDK 1.0)
阻塞+一连接一线程
简单但扩展差"] --> N["NIO (JDK 1.4)
非阻塞+Selector
Reactor 模式"] N --> A["AIO/NIO.2 (JDK 7)
异步+回调
Proactor 模式"] A --> V["Virtual Threads (JDK 21)
阻塞代码+轻量线程
回归同步编程模型"] style N fill:#dbeafe,stroke:#2563eb style V fill:#dcfce7,stroke:#059669
图 13.5-1 Java IO 模型演进:BIO → NIO → AIO → Virtual Threads

BIO(Blocking IO,JDK 1.0):ServerSocket.accept() 阻塞等待连接,InputStream.read() 阻塞等待数据。每连接一个线程,C10K(1万连接)时线程上下文切换开销巨大,内存耗尽。

NIO(Non-blocking IO,JDK 1.4,java.nio):引入 Channel、Buffer、Selector。Channel 可配置非阻塞(configureBlocking(false)),Selector 用 IO 多路复用(Linux epoll / macOS kqueue)单线程监听大量 Channel 的就绪事件。解决 C10K 问题,Netty 基于 NIO 构建。

AIO/NIO.2(Asynchronous IO,JDK 7):真正异步——AsynchronousServerSocketChannel 注册 CompletionHandler 回调,数据就绪后内核主动通知,无需轮询。Proactor 模式。但 Linux 底层实现仍用 epoll+线程池模拟,性能优势不明显,采用率低。

Virtual Threads(JDK 21):Project Loom 产物,轻量级线程(M:N 调度),阻塞操作自动挂起不占用 OS 线程。让 BIO 代码跑出海量并发,回归同步阻塞编程模型但享受 NIO 级性能。Tomcat 10.1+ / Spring Boot 3.2+ 已支持。

演进驱动力:从"阻塞+多线程"(BIO)到"非阻塞+单线程多路复用"(NIO)到"异步+回调"(AIO)到"阻塞语法+异步底层"(Virtual Threads),核心矛盾是"编程模型简单性"vs"并发 scalability",Virtual Threads 试图兼得。

第一性原理

Selector 的底层:IO 多路复用

Selector 是 Java 对操作系统就绪通知式(readiness) IO 多路复用的封装:Linux 用 epoll、macOS/BSD 用 kqueue、Windows 由 JDK 通过辅助线程模拟就绪(历史上并非直接复用 IOCP,IOCP 是完成驱动模型、由 Java AIO 使用)。核心思想:用一个系统调用同时监视多个文件描述符的可读/可写状态,只有就绪的才处理,避免为每个连接浪费线程。

  • select/poll(旧):遍历所有 fd,O(n) 复杂度,fd 数量受限(1024)。
  • epoll(Linux 2.6+):事件驱动,内核维护就绪事件列表,O(1) 触发,fd 数量只受内存限制。epoll_create 创建实例 → epoll_ctl 注册 fd → epoll_wait 阻塞等待事件。
  • kqueue(BSD/macOS):类似 epoll,但更早出现,支持更多事件类型(文件、进程、信号)。
深入解析

平台差异与就绪语义(避免"epoll 即 Selector"的过度对应):① 就绪 vs 完成——epoll/kqueue 是"事件驱动/就绪通知",内核告诉你"fd 可读了",你仍需自己 read;Windows 的 IOCP 是"完成驱动",内核直接把数据搬到缓冲区再通知,二者模型不同,Java NIO 的 Selector 抽象统一为就绪模型;② Windows 实现——JDK 在 Windows 上并非直接走 IOCP,而是用 WindowsSelectorProvider 借助辅助线程模拟就绪(WEP),因此 Linux 特有的"epoll 空轮询 bug"在 Windows 不存在,但 Windows 实现有自身开销;③ 就绪集合语义——selectedKeys() 返回的是"自上次 select 以来就绪的键集合",必须遍历并 remove(),否则会重复处理(事件重复);若只读了部分数据却不再注册 OP_READ,则可能错过后续可读事件(事件丢失);④ 事件丢失/重复——Netty 等框架通过"读到底(read 返回 0/EAGAIN 才退出循环)"和显式重设 interestOps 来规避,纯 JDK NIO 需开发者自己保证。

NIO 三大组件协作:

flowchart TD S["Selector
选择器
监听就绪事件"] C["Channel
通道
数据来源/去向"] B["Buffer
缓冲区
数据读写容器"] S -->|"select() 返回
SelectionKey"| K["SelectionKey
就绪事件+Channel"] K -->|"attachment()"| H["Handler
业务处理"] C <-->|"read/write"| B H -->|"解码Buffer
处理业务
编码Buffer"| B style S fill:#dbeafe,stroke:#2563eb style C fill:#dcfce7,stroke:#059669 style B fill:#fef3c7,stroke:#d97706
图 13.5-2 NIO 三大核心组件:Selector、Channel、Buffer 协作关系
  • Channel:双向通道,对应底层 fd。ServerSocketChannel 监听新连接,SocketChannel 传输数据,FileChannel 文件操作。非阻塞模式下,读/写立即返回(有数据则读/写,无则返回 0)。
  • Buffer:数据容器,所有 NIO 数据读写都经过 Buffer。核心属性:capacity(容量)、position(当前读写位置)、limit(可用上限)、mark(标记位)。flip() 从写模式切换到读模式(limit=position, position=0),clear() 清空重置(position=0, limit=capacity)。
  • Selector:多路复用器,注册多个 Channel 的感兴趣事件(OP_ACCEPT/OP_READ/OP_WRITE/OP_CONNECT)。select() 阻塞直到有事件就绪,返回 SelectionKey 集合,遍历处理。

Reactor 模式在 NIO 中的体现:

NIO 是 Reactor 模式的经典实现:

  1. 单 Reactor 单线程:一个 Selector 处理所有 accept/read/write,业务也在同线程。简单但业务阻塞会拖垮 IO。
  2. 单 Reactor 多线程:Selector 只负责 IO 事件分发,业务逻辑提交线程池。Netty 的 EventLoop 本质如此——每个 Channel 绑定一个 EventLoop 线程,IO 与业务串行执行但非阻塞。
  3. 主从 Reactor 多线程:主 Selector 只 accept 新连接,把 SocketChannel 注册到从 Selector。Nginx/Netty 默认采用。

DirectByteBuffer 与零拷贝:

ByteBuffer.allocateDirect() 在堆外(Off-Heap)分配内存,不受 GC 影响,且可直接被 JNI 传递给内核,避免 HeapByteBuffer 的两次拷贝(堆内→堆外→内核)。配合 FileChannel.transferTo() 可实现 sendfile 零拷贝。

工程实践

简易 NIO Echo Server 骨架:

public class NioEchoServer {
    public static void main(String[] args) throws IOException {
        Selector selector = Selector.open();
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.bind(new InetSocketAddress(8080));
        ssc.configureBlocking(false);
        ssc.register(selector, SelectionKey.OP_ACCEPT);

        while (true) {
            selector.select(); // 阻塞等待就绪事件
            Iterator<SelectionKey> it = selector.selectedKeys().iterator();
            while (it.hasNext()) {
                SelectionKey key = it.next();
                it.remove();
                if (key.isAcceptable()) {
                    SocketChannel sc = ssc.accept();
                    sc.configureBlocking(false);
                    sc.register(selector, SelectionKey.OP_READ);
                } else if (key.isReadable()) {
                    SocketChannel sc = (SocketChannel) key.channel();
                    ByteBuffer buf = ByteBuffer.allocate(1024);
                    int n = sc.read(buf);
                    if (n == -1) { sc.close(); continue; }
                    buf.flip(); // 写→读
                    sc.write(buf); // echo
                }
            }
        }
    }
}
最佳实践

NIO 架构师四要点:① Selector 是 epoll/kqueue 的封装,单线程可管理数万连接,解决 C10K;② Buffer 的 flip/clear 是状态机,flip 切读、clear 重置,必须熟练;③ DirectByteBuffer 避 GC+省一次拷贝,高吞吐场景优先;④ JDK 21 Virtual Threads 让 BIO 回归——写阻塞代码跑百万并发,但 NIO 仍是中间件/网关的核心(Netty 不会过时)。

深度追问

追问 1:epoll 的 LT(水平触发)与 ET(边缘触发)区别?Java NIO 用哪种?

LT(Level Triggered):只要 fd 还有数据可读,epoll_wait 就会一直返回该 fd。编程简单,但可能重复触发。ET(Edge Triggered):仅在 fd 状态变化时触发一次,要求应用必须一次性读完(循环 read 到 EAGAIN),否则数据滞留。ET 效率更高(减少 epoll 返回次数),但编程复杂。Java NIO 的 Selector 默认用 LT,因为跨平台兼容性优先。

追问 2:NIO 的空轮询 bug 是什么?Netty 如何解决?

JDK NIO 在 Linux 上曾有 epoll 空轮询 bug——Selector.select() 无阻塞地立刻返回 0,导致 CPU 100%。根因是内核 epoll 与 Java 状态不一致。Netty 的 workaround:① 检测空轮询次数,超过阈值(默认 512)则重建 Selector(selectRebuildThreshold);② 用 epoll 原生 JNI 实现(Netty 的 epoll transport)绕过 JDK NIO 层。

常见陷阱
易错点
  • 认为 NIO = 异步 IO:NIO 是非阻塞 IO(Non-blocking),不是异步。异步 IO 是 AIO(JDK 7),内核完成后回调。
  • 忘记 remove SelectedKey:selectedKeys() 集合不会自动清理,不 remove 会导致重复处理。
  • 在 Selector 线程做阻塞业务:一个 Channel 的 read 处理阻塞,会卡住 Selector 对其他 Channel 的响应,应异步化或用独立线程池。
  • Buffer 忘记 flip:写完后直接读,position 在末尾,读不到数据。flip 是 NIO 新手最常见 bug。
  • DirectByteBuffer 内存泄漏:堆外内存不受 GC 管理,Cleaner 虽会在 Buffer 被回收时释放,但强引用堆积会导致堆外 OOM。
生产实践

上线门禁:连接泄漏检测、TLS 证书日历、超时分层。表达提纲:阻塞点→协议/线程证据→修复。

P0 表达提纲

先给可观测证据与门禁,再谈实现细节;避免只报组件名。

C13.06
Netty 线程模型与 ChannelPipeline 设计哲学?架构P1
本质问题

这道题考察对 "Netty 如何用事件驱动模型解决高并发网络编程复杂性" 的架构认知。核心不在罗列组件,而在回答:Netty 的线程模型与 Tomcat 有何本质差异?EventLoop 为什么是单线程?ChannelPipeline 的责任链如何解耦编解码与业务?ByteBuf 为什么比 Java NIO ByteBuffer 更高效?

演进脉络

Netty 版本演进:

Netty 2.x(2004):JBoss 内部项目,基于 Java NIO,API 粗糙。

Netty 3.x(2008,已淘汰):独立于 JBoss,引入 Channel、ChannelPipeline、ChannelHandler 核心抽象。但存在 inbound/outbound 概念混淆、线程模型不够清晰的问题,当前主流为 4.1.x。

Netty 4.x(2013):重大重构——用统一的 EventLoop/EventLoopGroup 抽象取代 3.x 中分离的 Boss/Worker 线程池类型(注意:主从 Reactor 的 Boss 负责 accept、Worker 负责 IO 这一分工模型并未废弃,Netty 4 至今仍是 Boss EventLoopGroup + Worker EventLoopGroup);引入 ByteBuf(替代 ByteBuffer)、简化 Handler 接口(ChannelInboundHandler/ChannelOutboundHandler 分离清晰)。成为业界标准,RocketMQ、Dubbo、gRPC、Elasticsearch 均基于 Netty 4。

Netty 5.x(暂缓):曾计划统一 Buffer API、优化内存管理,但因改动大、收益不确定,官方已搁置,4.x 持续演进(当前 4.1.x)。

演进驱动力:从"NIO 编程太复杂"到"事件驱动+责任链+内存池"的工程化封装,Netty 把 Java NIO 的底层细节(epoll bug、半包粘包、ByteBuffer 限制)全部屏蔽,让开发者聚焦业务。

第一性原理

Netty vs Tomcat 线程模型对比:

维度 Tomcat(BIO/NIO) Netty
模型 一请求一线程(线程池) EventLoop 单线程多路复用
连接管理 Acceptor 接收 → 线程池处理 Boss EventLoop accept → Worker EventLoop IO
阻塞容忍 业务阻塞只占用一个线程 EventLoop 阻塞会卡住该线程所有 Channel
适用场景 HTTP 短连接、同步 Servlet TCP 长连接、异步协议、网关、IM
编程范式 同步阻塞(可配 NIO) 异步事件驱动

核心差异:Tomcat 为 HTTP 请求-响应设计,线程池模型对短连接友好;Netty 为长连接、高并发、自定义协议设计,EventLoop 单线程模型避免上下文切换,但对 Handler 有"不能阻塞"的强约束。

EventLoopGroup 与 EventLoop:

flowchart TD subgraph BG ["Boss EventLoopGroup
通常 1 个线程"] B1["EventLoop
监听 ACCEPT"] end subgraph WG ["Worker EventLoopGroup
默认 CPU*2 线程"] W1["EventLoop-1
管理 Channel A,B"] W2["EventLoop-2
管理 Channel C,D"] W3["EventLoop-N
..."] end B1 -->|"注册新连接"| W1 B1 -->|"注册新连接"| W2 style BG fill:#dbeafe,stroke:#2563eb style WG fill:#dcfce7,stroke:#059669
图 13.6-1 Netty 主从 Reactor 线程模型
  • EventLoop:本质是单线程 Executor,内部维护一个 Selector(epoll/kqueue)和一个任务队列。一个 EventLoop 可注册多个 Channel,但这些 Channel 的所有 IO 事件(read/write/connect)都在同一线程串行处理。
  • EventLoopGroup:EventLoop 的池,负责分配 EventLoop 给新 Channel(默认轮询)。BossGroup 只处理 accept,WorkerGroup 处理 IO。
  • 单线程的好处:① 无锁——同一 Channel 的 IO 与业务串行,无需同步;② 无上下文切换——事件处理在同一线程;③ 缓存友好——数据局部性好。

ChannelPipeline:责任链模式

Pipeline 是 ChannelHandler 的双向链表,数据入站(Inbound:read→decode→business→)和出站(Outbound:←encode←write)各走一遍。

// Pipeline 典型组装:解码 → 业务 → 编码
ch.pipeline()
    .addLast("frameDecoder", new LengthFieldBasedFrameDecoder(65536, 0, 4))
    .addLast("protobufDecoder", new ProtobufDecoder(MyMessage.getDefaultInstance()))
    .addLast("bizHandler", new BusinessHandler())
    .addLast("protobufEncoder", new ProtobufEncoder());

设计哲学:① 关注点分离——编解码、日志、心跳、限流、业务各用一个 Handler,复用组合;② 双向链表——Inbound 从头向后,Outbound 从尾向前,天然支持拦截器模式;③ 上下文传递——ChannelHandlerContext 持有 Channel、Pipeline、EventLoop 引用,Handler 间通过 fireChannelRead() 或 write() 传递事件。

Pipeline 中的异常传播:入站异常(如解码失败、Handler 抛出 RuntimeException)会沿 Pipeline 向后(从当前 Inbound Handler 往前)触发各 Inbound Handler 的 exceptionCaught(),直到某个 Handler 处理(消费)它;若一路无人处理,默认由 TailContext 记录并丢弃(生产环境应至少有一个 Handler 兜底日志)。出站异常(如 write 失败)不走到 exceptionCaught,而是通过返回的 ChannelFuture/ChannelPromise 的 addListener 或 cause() 暴露。理解这点才能正确排查"异常被吞掉"类问题。

工程实践

Netty Server 启动模板:

EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup worker = new NioEventLoopGroup();
try {
    ServerBootstrap b = new ServerBootstrap();
    b.group(boss, worker)
     .channel(NioServerSocketChannel.class)
     .childHandler(new ChannelInitializer<SocketChannel>() {
         @Override
         protected void initChannel(SocketChannel ch) {
             ch.pipeline().addLast(new LoggingHandler(LogLevel.INFO));
             ch.pipeline().addLast(new BusinessHandler());
         }
     });
    ChannelFuture f = b.bind(8080).sync();
    f.channel().closeFuture().sync();
} finally {
    boss.shutdownGracefully();
    worker.shutdownGracefully();
}
最佳实践

Netty 架构设计四要点:① EventLoop 单线程无锁——同一 Channel 的所有事件串行,Handler 严禁阻塞;② 业务解耦到独立线程池——耗时业务用 EventExecutorGroup 异步处理,不占用 IO 线程;③ Pipeline 是责任链——编解码、业务、日志分层组装,可插拔复用;④ 优先用 Netty 的 ByteBuf——引用计数+池化+复合缓冲区,比 JDK ByteBuffer 高效且安全。

深度追问

追问 1:Netty 的 EventLoop 阻塞了会怎样?如何排查?

EventLoop 阻塞会导致该线程下所有 Channel 的读写超时、心跳失效、甚至连接断开。排查:① 开启 io.netty.leakDetectionLevel 和日志;② 用 BlockHound(Netty 出品的阻塞检测库)自动检测阻塞调用;③ 在 Handler 中埋点监控处理耗时;④ 业务异步化,用独立线程池处理。

追问 2:Netty 的 ByteBuf 为什么比 JDK ByteBuffer 好?

① 读写分离:ByteBuf 用 readerIndex/writerIndex 替代 position,读写互不干扰,无需 flip;② 引用计数:refCnt() + release() 显式回收,配合池化避免 GC;③ 复合缓冲区:CompositeByteBuf 零拷贝拼接多个 Buffer;④ 容量自动扩展:write 时自动扩容,无需手动计算;⑤ 池化:PooledByteBufAllocator 基于 jemalloc 算法管理内存,大幅减少分配开销(见 C13.07)。

常见陷阱
易错点
  • 在 ChannelHandler 中阻塞:sleep、DB 查询、RPC 调用会卡住 EventLoop,必须用异步线程池。
  • 忘记释放 ByteBuf:引用计数到 0 才回收,不 release 会内存泄漏。Inbound 消息通常由最后一个 Handler 释放,Outbound 由 Netty 在写完成后释放。
  • Handler 顺序错误:解码器必须在业务 Handler 之前,编码器在之后。顺序反了会导致类型转换异常。
  • 认为 Netty 只能做 Server:Netty 的 Bootstrap 同样用于 Client,Dubbo Consumer、gRPC Client 都基于 Netty。
  • 忽视 Backlog 与 SO_BACKLOG:全连接队列(accept 队列)满后,内核会丢弃客户端发来的 ACK(或按 tcp_abort_on_overflow 回 RST),新连接建不上,高并发场景需调大 ServerBootstrap.option(ChannelOption.SO_BACKLOG, 1024)(注意是父 Channel 的 option,不是 childOption)。
扩展补全

Netty Pipeline 中第一道关卡就是解决 TCP 流式协议的粘包/拆包问题,并配合写水位背压防止下游消费慢导致 OOM。

  • 粘包/拆包根因:TCP 是流式协议,无消息边界。粘包——多个小消息合并成一个 TCP 段发送;拆包——一个大消息被拆成多个 TCP 段发送。需在应用层定义消息边界。
  • Netty 解码器:LengthFieldBasedFrameDecoder 基于长度域半包解码,核心参数:maxFrameLength(最大帧长)、lengthFieldOffset(长度域偏移)、lengthFieldLength(长度域字节数)、lengthAdjustment(长度修正)、initialBytesToStrip(跳过的头部字节数)。如 new LengthFieldBasedFrameDecoder(1024*1024, 0, 4, 0, 4) = 最大 1MB,长度域在偏移 0 占 4 字节,读完跳过 4 字节头。另:LineBasedFrameDecoder 按换行符分割(文本协议)、DelimiterBasedFrameDecoder 按自定义分隔符、FixedLengthFrameDecoder 定长帧。
  • 写水位背压(WriteBufferWaterMark):WriteBufferWaterMark(low, high),当 Channel 未发送队列字节数超过 high(默认 64KB)时 Channel.isWritable() 返回 false。应用层应检查 isWritable(),false 时停止写入避免 OOM。channelWritabilityChanged 事件在水位变化时回调,在此恢复或暂停写入。
深入解析

背压是网关场景的核心防线:当下游服务消费慢(如 DB 慢查询、下游 RPC 延迟升高),Channel 的发送队列会堆积,越过 high 水位后 isWritable() 为 false,此时应停止从上游读取或向下游写入,将压力向上游传递(反压),而非在本地堆积消息导致 OOM。典型模式:在 channelWritabilityChanged 中调用 ctx.channel().config().setAutoRead(false/true) 控制上游读取开关。这与 Reactive Streams 的背压思想一致——消费者按能力拉取,而非生产者无脑推送。解码器参数调优则需对齐协议格式:lengthAdjustment 用于长度域值不含自身长度的场景,initialBytesToStrip 避免业务 Handler 重复处理头部。

C13.07
Netty 内存管理:ByteBuf 池化、引用计数与内存泄漏排查?专家P1
本质问题

这道题考察对 "Netty 如何在高并发场景下管理堆外内存、避免 GC 抖动与内存泄漏" 的底层理解。核心不在知道有池化,而在回答:PooledByteBufAllocator 的 jemalloc 算法如何分档管理内存?引用计数的生命周期?内存泄漏如何检测与定位?这是 Netty 生产环境 OOM 与内存泄漏的根因题。

演进脉络

Netty 内存管理演进:

Netty 3.x(已淘汰):使用 JDK ByteBuffer 或自定义 ChannelBuffer,无统一内存池,高并发下 GC 压力大,当前主流为 Netty 4.1。

Netty 4.0(2013):引入 ByteBuf 统一抽象,提供 Unpooled(非池化)与 Pooled(池化)两种分配器。PoolArena 借鉴 jemalloc 思想,解决堆外内存管理问题。

Netty 4.1(当前主流):优化 PoolArena 的并发分配(每个线程绑定 PoolThreadCache 减少锁竞争)、引入 Tiny/Small 分类、完善内存泄漏检测(ResourceLeakDetector)。

演进驱动力:堆外内存不受 JVM GC 管理,手动 malloc/free 易泄漏且分配慢;jemalloc 风格思想(按大小分档、线程缓存、buddy 伙伴分配)被引入 Netty 自研的 PoolArena(并非直接链接 C 语言 jemalloc 库),实现“GC 无关+高吞吐+低碎片”的内存管理。

第一性原理

PooledByteBufAllocator 的 jemalloc 算法:

flowchart TD A["PooledByteBufAllocator"] A -->|"每个线程"| C["PoolThreadCache
线程本地缓存"] A -->|"全局"| R["PoolArena
arenas 数组"] R --> D["Tiny Subpages
(< 512B)"] R --> E["Small Subpages
(512B ~ 8KB)"] R --> F["Normal Runs
(8KB ~ 16MB)"] R --> G["Huge
(> 16MB)
直接分配"] style C fill:#dbeafe,stroke:#2563eb style R fill:#dcfce7,stroke:#059669
图 13.7-1 Netty 内存池化架构:PoolThreadCache → PoolArena → 分级分配
  • PoolArena:内存分配的竞技场,默认按 CPU 核心数 × 2 创建(DirectArena + HeapArena)。每个 Arena 管理若干 Chunk(默认 16MB)。
  • Chunk:大内存块,由一棵平衡二叉树(完全二叉树)管理,每个节点代表一个内存块,buddy 分配算法实现合并与拆分。
  • 分级:Tiny(<512B,16B 递增)、Small(512B~8KB,512B 递增)、Normal(8KB~16MB,按 Chunk 树分配)、Huge(>16MB,直接申请不受池化管理)。
  • PoolThreadCache:每个线程的本地缓存,释放的内存先回缓存而非 Arena,下次分配优先从缓存取,避免线程竞争。

引用计数(Reference Counting):

Netty 的 ByteBuf 继承 ReferenceCounted,初始 refCnt=1。每传递给下一个 Handler 或输出时,需按规则释放:

  • 规则:谁最后消费谁释放。Inbound 消息在 Pipeline 尾端释放;若中途截获(如解码后替换为新对象),需手动释放原对象。
  • retain():传递前增加计数,确保下游有独立引用。
  • release():计数减 1,到 0 时归还给 Pool(池化)或 GC(非池化)。

内存泄漏检测:ResourceLeakDetector

Netty 通过弱引用跟踪 ByteBuf 生命周期。若 ByteBuf 被 GC 但 refCnt 未归零,说明泄漏。检测级别:

  • DISABLED:关闭。
  • SIMPLE(默认):抽样 1%,泄漏时打印栈轨迹。
  • ADVANCED:抽样 1%,记录完整访问路径。
  • PARANOID:100% 检测,性能损耗大,仅调试。

开启方式:-Dio.netty.leakDetection.level=advanced(旧属性名 io.netty.leakDetectionLevel 仍兼容)。泄漏日志会打印 ByteBuf 创建时的栈轨迹,定位未 release 的代码路径。

工程实践

内存泄漏排查实战:

  1. 开启 -Dio.netty.leakDetectionLevel=advanced,复现问题。
  2. 观察日志中的泄漏栈轨迹,定位创建 ByteBuf 的 Handler。
  3. 检查该 Handler 是否:① 未调用 release();② 异常分支跳过释放;③ 异步线程持有引用未释放。
  4. 用 ByteBuf.retain() 时需配对 release(),可用 try-finally 或 Netty 的 ReferenceCountUtil.release()。
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
    try {
        // 处理 msg...
        ctx.fireChannelRead(msg.retain()); // 传递下游,引用+1
    } finally {
        msg.release(); // 本 Handler 释放
    }
}
最佳实践

Netty 内存管理四要点:① 生产环境用 PooledByteBufAllocator(默认已开启),Unpooled 仅在测试或极短生命周期使用;② 引用计数必须成对——retain/release 像 malloc/free,漏一个就泄漏;③ 用 ResourceLeakDetector 做 CI 回归——自动化测试阶段设为 PARANOID;④ 监控堆外内存——通过 PlatformDependent.usedDirectMemory() 或 Native Memory Tracking(NMT)观察趋势。

深度追问

追问 1:PoolThreadCache 的缓存大小如何调优?过大或过小的问题?

PoolThreadCache 默认缓存 Tiny 512 个、Small 256 个、Normal 64 个。过大:线程退出时缓存全部回收到 Arena,产生瞬时 CPU/内存抖动;过小:缓存命中率低,频繁回 Arena 分配增加锁竞争。可通过 -Dio.netty.allocator.tinyCacheSize 等参数调整,或基于压测观察 PooledByteBufAllocator.DEFAULT.metric() 的分配/命中统计。

追问 2:Netty 的 CompositeByteBuf 如何实现零拷贝?

传统方式拼接两个 ByteBuf 需创建新 Buffer 并拷贝数据。CompositeByteBuf 是虚拟 Buffer,内部维护一个 Component 列表,每个 Component 引用原始 ByteBuf 的某段范围,对外表现为一个连续逻辑 Buffer。写操作转为对多个底层 Buffer 的分散写(Gathering Write),配合 FileChannel.write(ByteBuffer[]) 或 sendfile,实现真正的零拷贝数据组装。

常见陷阱
易错点
  • 认为池化内存不用释放:池化只是复用,不 release 会占满 Pool 导致新分配失败或降级到 Unpooled。
  • 在异常分支忘记释放:try-finally 是最佳实践,确保任何路径都 release。
  • 跨线程传递 ByteBuf 不 retain:ByteBuf 与创建它的 EventLoop 绑定,跨线程传递前必须 retain,否则可能在目标线程使用前被原线程释放。
  • 忽视 SIMPLE 模式的抽样率:默认 1%,小流量泄漏可能抽不到,怀疑泄漏时应切 ADVANCED/PARANOID。
  • 混用 Pooled 与 Unpooled:Unpooled 的 ByteBuf 也支持引用计数,但归还给 GC 而非 Pool,频繁使用会触发 Full GC。
  • 在 SimpleChannelInboundHandler 中重复 release:该基类在 channelRead0 返回后会自动 release 入站消息;若已手动 release 或想向下游传递,必须先 retain(),否则会触发 "refCnt: 0,already released" 或计数异常。
C13.08
零拷贝技术深度:sendfile、mmap、DMA 在 Java 中的实现?架构P1
本质问题

这道题考察对 "数据传输中如何避免 CPU 参与、减少内存拷贝次数" 的底层理解。核心不在知道名词,而在回答:传统 read/write 为什么有 4 次拷贝?sendfile 如何降到 2 次?mmap 的原理与适用场景?DMA 在零拷贝中扮演什么角色?Java FileChannel.transferTo 的底层真相?

演进脉络

数据传输拷贝次数演进:

flowchart LR T4["传统 read/write
4 次拷贝
4 次上下文切换"] --> S2["sendfile
1×CPU + 2×DMA
2 次切换"] S2 --> M2["mmap
1×CPU + 1×DMA
4 次切换"] S2 --> G0["sendfile + DMA gather
0×CPU + 2×DMA
2 次切换"] G0 --> Z["理想零拷贝
0 次 CPU 拷贝
内核直接收发"] style S2 fill:#dbeafe,stroke:#2563eb style G0 fill:#dcfce7,stroke:#059669
图 13.8-1 零拷贝技术演进:关注 CPU 拷贝次数;gather 为 0 CPU + 2 DMA

传统 read/write(4 次拷贝,4 次上下文切换):磁盘 → 内核页缓存 → 用户空间 Buffer → Socket 缓冲区 → 网卡。CPU 全程参与拷贝。

mmap(2 次拷贝,4 次切换):把文件映射到用户虚拟地址空间,用户直接读写映射区,实际由内核按需换页。省一次内核→用户的显式拷贝,但仍需 CPU 把数据从页缓存拷到 Socket 缓冲区。

sendfile(2 次拷贝,2 次切换):Linux 2.1 引入,内核态直接把页缓存数据发送到 Socket,无需经过用户空间。拷贝 = 1 次 DMA(磁盘→页缓存)+ 1 次 CPU(页缓存→Socket 缓冲区),上下文切换从 4 次降到 2 次。

sendfile + DMA gather(0 次 CPU 拷贝,2 次 DMA):Linux 2.4+,网卡支持 scatter-gather,内核把页缓存的内存地址和长度描述符传给网卡,DMA 直接从页缓存取数据发网卡,CPU 完全不参与数据拷贝。路径仍含磁盘→页缓存(DMA)与页缓存→网卡(DMA),合计 2 次 DMA、0 次 CPU 拷贝。

演进驱动力:CPU 参与内存拷贝是浪费——数据根本不经过业务逻辑,只是在内核与用户空间之间"搬运"。零拷贝把搬运工作交给 DMA 和内核管道,释放 CPU 做有用功。

第一性原理

传统 read/write 的 4 次拷贝详解:

  1. DMA 拷贝:磁盘 → 内核 Page Cache(CPU 不参与,DMA 负责)。
  2. CPU 拷贝:内核 Page Cache → 用户空间 Buffer(read() 系统调用)。
  3. CPU 拷贝:用户空间 Buffer → Socket 内核缓冲区(write() 系统调用)。
  4. DMA 拷贝:Socket 缓冲区 → 网卡(DMA 负责)。

其中 2、3 是 CPU 拷贝,且涉及 4 次用户态/内核态上下文切换(read 前后、write 前后)。

sendfile 原理(2 次拷贝):

flowchart LR D["磁盘"] -->|"DMA"| K["内核 Page Cache"] K -->|"CPU
sendfile"| S["Socket 缓冲区"] S -->|"DMA"| N["网卡"] style K fill:#dbeafe,stroke:#2563eb style S fill:#dcfce7,stroke:#059669
图 13.8-2 sendfile 数据传输路径:内核态直通,不经过用户空间

sendfile(int out_fd, int in_fd, off_t* offset, size_t count) 在内核态完成 in_fd(文件)到 out_fd(Socket)的数据搬运。Java 中对应 FileChannel.transferTo(position, count, SocketChannel)。

DMA Gather Copy(1 次拷贝):

若网卡支持 scatter-gather,sendfile 不再把数据从 Page Cache 拷到 Socket 缓冲区,而是只传递文件描述符+偏移+长度的内存地址描述符给网卡,网卡 DMA 引擎直接从 Page Cache 取数据发送。CPU 全程不参与数据拷贝,仅剩磁盘→内存的一次 DMA 拷贝。

mmap 的原理与局限:

mmap 把文件映射到用户虚拟内存空间,用户读写映射区触发缺页中断,内核按需从磁盘加载页。适合随机读写文件(如数据库、索引),因为用户可灵活访问任意偏移。但 mmap 仍有一次 CPU 拷贝(Page Cache → Socket 缓冲区),不适合纯文件发送场景。此外,mmap 占用虚拟地址空间,大文件映射可能导致 32 位系统地址耗尽。

Java 中的零拷贝实现:

  • FileChannel.transferTo() / transferFrom():底层调用 sendfile,文件→Socket / Socket→文件。
  • FileChannel.map():底层调用 mmap,文件→用户虚拟内存。
  • MappedByteBuffer:mmap 的 Java 封装,force() 刷盘。
  • Netty CompositeByteBuf:逻辑零拷贝(不移动数据,只组装描述符)。
深入解析

零拷贝的平台 / API 生效条件(避免混写):① sendfile 是 Linux 特有系统调用(BSD/macOS 也有同名但语义略异,Windows 对应 TransmitFile);Java 的 FileChannel.transferTo() 在 Linux 走 sendfile、Windows 走 TransmitFile,不支持的平台会自动降级为 read+write 循环,此时不再是零拷贝。② mmap / FileChannel.map() 基于 POSIX mmap,各 Unix 通用,Windows 用 MapViewOfFile,映射大文件会占用大量虚拟地址空间。③ DMA gather(sendfile + SG) 需网卡支持 scatter-gather,否则内核退回"CPU 拷到 Socket 缓冲区"的 2 拷贝版本。④ 加密/压缩会让零拷贝失效——数据必须进用户空间才能被 TLS/应用处理(除非用 kTLS 下沉加密)。因此"用 transferTo 就一定零拷贝"只在"同构 Linux + 明文/ kTLS + 网卡支持 SG"时才严格成立。

工程实践

Kafka 的零拷贝应用:Kafka Broker 发消息给 Consumer 时,用 FileChannel.transferTo() 直接把日志段文件从磁盘经 Page Cache 发到 Socket,避免数据进入 JVM 堆。这是 Kafka 高吞吐的核心设计之一。

// Java sendfile 示例:文件发送到 Socket
try (FileChannel fc = new FileInputStream("file.zip").getChannel();
     SocketChannel sc = SocketChannel.open()) {
    sc.connect(new InetSocketAddress("host", 8080));
    long pos = 0;
    long size = fc.size();
    while (pos < size) {
        pos += fc.transferTo(pos, size - pos, sc); // 零拷贝发送
    }
}
最佳实践

零拷贝选型三要点:① 纯文件发送用 sendfile(transferTo)——Kafka/Nginx 均如此,最高效;② 随机读写文件用 mmap——数据库索引、日志回放场景;③ 零拷贝的前提是不修改数据——若需 SSL 加密、数据转换,数据必须进用户空间,零拷贝失效,此时可用 CompositeByteBuf 做逻辑零拷贝减少拼接开销。

深度追问

追问 1:Page Cache 在零拷贝中扮演什么角色?Direct IO 为什么反而可能更慢?

Page Cache 是内核缓存热点文件数据的内存区域,sendfile 和 mmap 都利用它避免重复磁盘 IO。若用 Direct IO(O_DIRECT)绕过 Page Cache,数据直接从磁盘到用户空间,看似省了内核拷贝,但失去了缓存红利——同一份数据反复读时每次都要磁盘 IO,整体更慢。Direct IO 仅在数据库等"自己管理缓存"的场景有意义。

追问 2:Java NIO 的 DirectByteBuffer 算不算零拷贝?

不算真正的零拷贝,只是"少一次拷贝"。HeapByteBuffer 读取时需先把数据从内核 Page Cache 拷到堆外临时缓冲区(JNI 无法直接访问 Java 堆),再拷到堆内。DirectByteBuffer 在堆外分配,JNI 可直接传递其地址给内核,省了"堆外临时→堆内"这一步。但数据仍从 Page Cache 到了用户空间,sendfile 才是内核态直通。

常见陷阱
易错点
  • 认为零拷贝 = 不拷贝:零拷贝指"CPU 不拷贝",DMA 拷贝仍存在(磁盘↔内存)。
  • 在 SSL/TLS 场景用 sendfile:传统软件 TLS 下 sendfile 数据不进用户空间、无法加密,HTTPS 文件下载不能直接用 sendfile。但 内核 TLS(kTLS,Linux 4.13+)把 TLS 记录加密下沉到内核,配合支持 kTLS 的 sendfile(如 Nginx 的 ssl_sendfile)即可实现"加密 + 零拷贝",前提是内核、TLS 库与网卡都支持。
  • 认为 mmap 一定比 read 快:mmap 适合随机访问,顺序大文件读用 sendfile 更优;mmap 缺页中断有开销。
  • 忽视 transferTo 的大小限制:Linux 2.4.8 前 transferTo 一次最多 2GB,需循环发送。
  • 把 DirectByteBuffer 当万能解:DirectByteBuffer 分配/释放比 Heap 慢,小数据量或短生命周期用 Heap 更优。
C13.09
WebSocket 握手、心跳与断线重连如何设计?架构P1
本质问题

这道题考察对 "WebSocket 长连接的全生命周期管理" 的工程理解。核心不在知道 API,而在回答:WebSocket 为什么需要 HTTP Upgrade 握手?心跳的必要性与实现?断线重连的退避策略?从轮询到长轮询到 SSE 到 WebSocket 的演进逻辑?Netty 实现 WebSocket 的关键 Handler?

演进脉络

实时通信技术演进:

flowchart LR P["轮询 Polling
简单但延迟高
浪费带宽"] --> LP["长轮询 Long Polling
延迟降低
服务端 hold 连接"] LP --> SSE["SSE
服务端单向推送
HTTP 流"] SSE --> WS["WebSocket
全双工长连接
低延迟双向"] style LP fill:#dbeafe,stroke:#2563eb style WS fill:#dcfce7,stroke:#059669
图 13.9-1 实时通信演进:轮询 → 长轮询 → SSE → WebSocket

轮询(Polling):客户端定时发 HTTP 请求问"有消息吗?",服务端立即回(无则空响应)。实现简单,但延迟取决于轮询间隔,且大量空请求浪费带宽。

长轮询(Long Polling):客户端发请求后,服务端无消息时不立即回,hold 连接直到有消息或超时。比轮询延迟低,但服务端需维护大量挂起连接,且每次推送后客户端需重新发起请求。

SSE(Server-Sent Events,HTTP 流):客户端发一次 HTTP 请求,服务端保持响应流开放,持续推送 text/event-stream 格式数据。适合服务端→客户端单向推送(股票行情、新闻推送),但 HTTP/1.1 仍受同域连接数限制。

WebSocket(RFC 6455,2011):基于 TCP 的全双工长连接,首次通过 HTTP Upgrade 握手,之后转私有帧协议。客户端和服务端可随时主动发消息,延迟最低,是 IM、游戏、协同编辑的首选。

演进驱动力:从"客户端主动拉"到"服务端主动推"到"双向实时流",核心是解决延迟与连接效率,HTTP 的无状态/半双工特性成为瓶颈,WebSocket 用独立协议突破。

第一性原理

WebSocket 握手:HTTP Upgrade

WebSocket 不能直接用 TCP 连接,因为防火墙/代理可能只放行 80/443 端口 HTTP 流量。握手伪装成 HTTP,让中间设备放行:

// 客户端请求
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

// 服务端响应(101 Switching Protocols)
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Sec-WebSocket-Accept 计算:将客户端 Key 与固定 GUID(258EAFA5-E914-47DA-95CA-C5AB0DC85B11)拼接,SHA-1 哈希后 Base64。这是为了防止中间设备误把非 WebSocket 连接当 WebSocket 处理。

WebSocket 帧结构:

握手后数据以帧(Frame)为单位传输,每帧包含:FIN(是否最后一帧)、Opcode(文本/二进制/关闭/心跳)、MASK(客户端必须掩码)、Payload Length、Masking Key、Payload Data。帧可以分片(一个消息拆多帧),支持交错传输控制帧(心跳/关闭可插队)。

心跳设计:Ping/Pong 帧

WebSocket 协议内置心跳机制——服务端发 Ping 帧(Opcode 0x09),客户端必须回 Pong 帧(Opcode 0x0A)。心跳作用:① 检测连接是否存活(NAT/防火墙会踢掉空闲 TCP);② 防止 TCP 半开连接堆积。心跳间隔通常 30s~60s,需配合超时重试策略。

断线重连策略:

  1. 事件触发:onclose/onerror 触发重连。
  2. 退避重试:首次立即重连,失败后用指数退避(1s, 2s, 4s, 8s...),最大间隔封顶(如 60s),避免雪崩。
  3. 抖动随机:退避基础上加随机 jitter(±20%),防止所有客户端同时重连压垮服务端。
  4. 重连次数上限:超过阈值(如 10 次)标记为不可用,通知用户或降级。
  5. 消息去重/补发:重连后服务端推送离线消息,客户端用 msgId 去重。
工程实践

Netty WebSocket Server 关键 Handler:

ch.pipeline()
    .addLast(new HttpServerCodec())
    .addLast(new HttpObjectAggregator(65536))
    // 处理 WebSocket 握手
    .addLast(new WebSocketServerProtocolHandler("/ws", null, true))
    // 业务 Handler:处理 TextWebSocketFrame
    .addLast(new ChatHandler());

// ChatHandler 示例
public class ChatHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx,
                                  TextWebSocketFrame frame) {
        String text = frame.text();
        // 广播给所有连接
        channels.writeAndFlush(new TextWebSocketFrame(text));
    }
}
最佳实践

WebSocket 工程四要点:① 心跳必须做——NAT/防火墙超时通常 5min,心跳间隔建议 30s;② 重连用指数退避+jitter——防服务端被重连风暴压垮;③ 服务端要防单节点连接数过载——Netty 的 WebSocket 连接数可达百万级,但需调优 fd 限制与内存;④ 消息需有序列号与 ACK——WebSocket 基于 TCP 是有序的,但重连后需补发离线消息,msgId 去重必不可少。

深度追问

追问 1:WebSocket 与 TCP Socket 有什么区别?为什么不能直接用 TCP?

WebSocket 基于 TCP,但增加了:① HTTP Upgrade 握手——穿透防火墙/代理(443 端口 HTTPS 隧道几乎必通);② 帧协议——自带消息边界(TCP 是字节流,需自己处理粘包拆包);③ 内置心跳/关闭——Ping/Pong/Close 帧标准化;④ 浏览器原生支持——JS 直接 new WebSocket(),TCP Socket 在浏览器中不可用(安全沙箱)。

追问 2:SSE 与 WebSocket 如何选型?

SSE:单向推送(服务端→客户端)、基于 HTTP/自动重连/支持断点续传(Last-Event-ID)、穿透性好(普通 HTTP)。适合股票行情、日志流、新闻推送。WebSocket:全双工、帧协议轻量、支持二进制。适合 IM、游戏、协同编辑、实时控制。若只需服务端推,SSE 更简单可靠;需双向实时交互选 WebSocket。

常见陷阱
易错点
  • 忽视 WebSocket 的握手是 HTTP:若前置 Nginx 做反向代理,需配置 proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; 否则握手失败。
  • 心跳用业务消息代替协议 Ping:自定义心跳消息需业务层解析,不如协议级 Ping/Pong 轻量且标准化。
  • 重连无退避导致雪崩:断网恢复时所有客户端同时重连,瞬间打满服务端连接队列。
  • 认为 WebSocket 永远在线:移动端 App 后台冻结、网络切换、NAT 超时都会导致断连,必须有重连+补发机制。
  • 忽视消息大小限制:Netty 的 WebSocket 默认单帧最大 64KB,超大消息需分片或调参。
生产量化证据(压测 / 命中率 / 容量)

WebSocket 长连接的稳定性与容量选型最终要由"心跳超时比 / 重连退避上限 / 单节点连接数 / 写水位拒绝率"四类量化指标背书,不能只靠经验值。

指标 参考基准(业界常见量级) 说明
心跳 ping/pong 与超时 30s 心跳,60s 超时 NAT/防火墙超时通常 5min 内,需小于该值保活
重连指数退避 base 1s,max 30s(±jitter) 防断网恢复时全量重连雪崩打满连接队列
写水位 high/low 64KB / 32KB 触发 high 暂停写,背压拒绝率 <0.1%
单节点连接数 ~10w 连接(每连接 10–50KB) Netty 可达百万级,需调优 fd 上限与内存
10w 连接压测内存 ~2–5 GB 容量规划与 JVM Xmx 调优基准
关键结论

心跳 30s / 超时 60s 配合 base 1s、max 30s 的指数退避防雪崩重连;单节点按 ~10w 连接(压测内存 2–5GB)规划容量,写水位 64/32KB 下背压拒绝率 <0.1% 即可稳承载。以上为参考基准,需以自身生产数据替换。

扩展补全

WebSocket 长连接的容量规划离不开网络层压测,需从连接数、QPS、带宽三维度分析瓶颈并据此做容量规划。

  • 瓶颈分析三维度:连接数瓶颈受 somaxconn(全连接队列)、tcp_max_syn_backlog(半连接队列)、文件描述符 ulimit -n 限制;QPS 瓶颈受 CPU(SSL 握手、序列化)与应用逻辑耗时限制;带宽瓶颈受网卡带宽(1Gbps ≈ 125MB/s)与内核网络栈处理速度限制。
  • TCP 全连接队列:somaxconn(默认 128,生产建议 65535)是操作系统级上限,backlog(listen 参数)是应用级上限,实际取 min(somaxconn, backlog)。队列满后新连接被丢弃或发 RST,客户端表现为 "Connection refused"。
  • TIME_WAIT 优化:高 QPS 短连接场景产生大量 TIME_WAIT(主动关闭方保持 2MSL ≈ 60s)。net.ipv4.tcp_tw_reuse=1 允许 TIME_WAIT 用于新出站连接;tcp_max_tw_buckets(默认 4096,生产建议 1048576)限制数量。最佳实践是用长连接(连接池)从根源减少 TIME_WAIT。
  • 压测工具选型:wrk 做 HTTP 压测(支持 Lua 脚本,单机可达百万 QPS);tcpdump 抓包分析协议层问题;ss -s 查看 TCP 连接状态分布(ESTABLISHED/TIME_WAIT/CLOSE_WAIT)。
  • 容量规划公式:所需服务器数 = 目标 QPS / 单机 QPS × 安全系数(1.5)
深入解析

压测的核心价值是找到单机天花板并据此做容量规划,而非只看"能跑多少 QPS"。典型流程:① 调优系统参数(somaxconn、ulimit -n、tcp_max_tw_buckets);② 用 wrk 逐步加压,监控 CPU/内存/网卡/连接数四指标;③ 找到首个瓶颈点(CPU 满 / 内存涨 / 连接拒绝 / 带宽打满)即单机上限;④ 按公式推算集群规模。ss -s 在压测中用于诊断 CLOSE_WAIT 堆积(应用层 bug,未 close 连接)与 TIME_WAIT 堆积(短连接过多),两者表现相似但根因与解法完全不同。

C13.10
DNS 解析原理与智能调度:递归/迭代查询、HTTPDNS、CDN 调度?架构P2
本质问题

这道题考察对 "域名解析的全链路过程与智能调度机制" 的系统理解。核心不在知道 DNS 端口,而在回答:递归查询与迭代查询的区别?DNS 缓存层级与 TTL?DNS 劫持如何发生?HTTPDNS 为什么能绕过劫持?CDN 的 GSLB 如何基于 DNS 做全局负载均衡?EDNS Client Subnet 的作用?

演进脉络

DNS 技术演进:

flowchart LR D["传统 DNS
(UDP 53)
递归+缓存"] --> H["DNSSEC
签名防篡改
(2005)"] H --> HD["HTTPDNS
HTTP 绕过劫持
精准调度"] HD --> E["EDNS Client Subnet
传递用户子网
精准 CDN 调度"] style HD fill:#dbeafe,stroke:#2563eb style E fill:#dcfce7,stroke:#059669
图 13.10-1 DNS 技术演进:传统 DNS → DNSSEC → HTTPDNS → EDNS

传统 DNS(UDP 53):简单高效,但明文传输易被劫持/篡改,且调度精度低(只能根据 Local DNS 位置判断用户位置)。

DNSSEC(2005):用数字签名保护 DNS 记录完整性,防篡改。但部署复杂、增加包大小(可能超 UDP 512B 需转 TCP),且不能防劫持(只是让客户端发现被篡改后拒绝),普及率不高。

HTTPDNS(2010s,阿里/腾讯/HTTPDNS 服务商推出):客户端绕过系统 DNS,直接通过 HTTP/HTTPS 向 DNS 服务商 API 查询域名 IP。优势:① 防 DNS 劫持(HTTPS 加密);② 精准调度(获取客户端真实 IP 而非 Local DNS IP);③ 灵活控制(按版本/地域/运营商精细化调度)。

EDNS Client Subnet(ECS,2011):DNS 协议扩展,递归 DNS 在查询时携带用户 IP 的子网信息(如 /24)给权威 DNS,权威 DNS 据此返回更精准的 CDN 节点。解决了传统 DNS"调度看 Local DNS 位置而非用户位置"的问题。

演进驱动力:① 安全——DNS 劫持频发,需要加密/验证机制;② 精准调度——移动互联网时代用户位置与 Local DNS 位置偏差大,需要更精确的 CDN 调度;③ 可控性——App 需要灰度、AB 测试、熔断等精细化流量控制。

第一性原理

DNS 查询的完整链路:

sequenceDiagram participant C as 客户端 participant L as Local DNS
递归解析器 participant R as 根 DNS
(.) participant T as 顶级域 DNS
(.com) participant A as 权威 DNS
(example.com) C->>L: 查询 www.example.com L->>R: 迭代查询 .com 谁管? R->>L: 返回 .com NS L->>T: 迭代查询 example.com 谁管? T->>L: 返回 example.com NS L->>A: 迭代查询 www 的 A 记录 A->>L: 返回 IP + TTL L->>C: 返回 IP + TTL
图 13.10-2 DNS 迭代查询完整链路

递归 vs 迭代:递归(Recursive)是 Local DNS 帮客户端一层层查到底,客户端只等最终结果;迭代(Iterative)是 Local DNS 向上级逐个问,上级只返回知道的信息("我不知道,但你去问 X"),Local DNS 自己继续追问。实际中,客户端→Local DNS 是递归,Local DNS→根/顶级/权威 是迭代。

DNS 缓存层级:

  1. 浏览器缓存:Chrome 约 1min,不受 TTL 控制。
  2. 操作系统缓存:Windows DNS Client 服务、Linux nscd/systemd-resolved。受 TTL 约束。
  3. Local DNS 缓存:运营商/企业 DNS 服务器,缓存大量域名,TTL 到期前直接返回。是 DNS 劫持的主要位置。
  4. 递归 DNS 缓存:公共 DNS(8.8.8.8/114.114.114.114)也有缓存。

TTL(Time To Live)是权威 DNS 设置的缓存时间,单位秒。TTL 越长,解析越快、DNS 服务器压力越小,但故障切换越慢。典型值:静态资源 300s~600s,CDN 调度域名 60s~300s。

DNS 劫持与 HTTPDNS 绕过:

DNS 劫持发生在 Local DNS 层——运营商/公共 WiFi/恶意软件篡改解析结果,把正常域名指向钓鱼 IP。HTTPDNS 绕过原理:客户端不走系统 DNS,直接发 HTTPS 请求到 HTTPDNS 服务端(如 https://dns.google/resolve?name=example.com),HTTPS 加密防中间篡改,且 HTTPDNS 服务端看到的是客户端真实 IP,调度更精准。

CDN 全局负载均衡(GSLB):

CDN 的 GSLB 通常基于 DNS 实现:权威 DNS 根据查询来源(Local DNS IP 或 ECS 中的用户子网)返回最优边缘节点 IP。调度维度:① 地理位置——就近接入;② 运营商——同网优先减少跨网结算;③ 节点负载——负载高时返回备选节点;④ 节点健康——故障节点自动剔除。HTTPDNS 更进一步,可结合客户端版本、用户 ID、AB 测试做细粒度调度。

工程实践

HTTPDNS 集成示例(移动端):

// 伪代码:App 启动时预解析关键域名
public String resolve(String host) {
    // 1. 查本地缓存
    String ip = cache.get(host);
    if (ip != null && !expired) return ip;

    // 2. HTTPDNS 查询(HTTPS 防劫持)
    HttpResponse resp = httpClient.get(
        "https://dns.example.com/resolve?name=" + host);
    DnsRecord record = parse(resp);
    cache.put(host, record.ip, record.ttl);
    return record.ip;
}

// 3. HTTP 请求时替换 Host 头
Request req = new Request("https://" + ip + "/api");
req.header("Host", host); // SNI 与证书验证仍需原域名

dig 命令排查 DNS:

# 查看完整解析路径
$ dig +trace www.example.com
# 查看 ECS 信息(若支持)
$ dig @8.8.8.8 www.example.com +subnet=192.168.1.0/24
# 查看 TTL
$ dig www.example.com | grep "ANSWER SECTION" -A 2
最佳实践

DNS 架构四要点:① 移动端/高安全场景用 HTTPDNS——防劫持+精准调度,但需处理 HTTPS SNI 与证书校验;② TTL 设置权衡——太长故障切换慢,太短 DNS 压力大,CDN 域名建议 60~300s;③ 多 A 记录+健康检查——权威 DNS 返回多个 IP,客户端 failover;④ EDNS Client Subnet 提升 CDN 命中率——尤其适合运营商 Local DNS 集中出口场景。

深度追问

追问 1:DNS 用 UDP 还是 TCP?什么情况下用 TCP?

DNS 默认用 UDP(53 端口),因为查询响应小、无连接开销。以下情况用 TCP:① 响应超过 512B(EDNS 扩展前),UDP 放不下;② DNSSEC 签名后包变大;③ 区域传输(AXFR/IXFR,主从 DNS 同步);④ 某些防火墙限制 UDP 53 时 fallback。现代 DNS 实现(如 BIND、dnsmasq)支持 UDP 和 TCP 同时监听。

追问 2:Anycast 与 DNS 调度有什么关系?

Anycast 是路由层技术——多个地理位置不同的服务器共享同一个 IP,BGP 路由把用户流量导向"最近"的节点(按 AS-Path 或 IGP 度量)。Google DNS(8.8.8.8)、Cloudflare(1.1.1.1)都用 Anycast。与 DNS GSLB 的关系:Anycast 解决"用户到 DNS 服务器"的就近问题,DNS GSLB 解决"DNS 返回的业务 IP"的就近问题,二者叠加实现全链路最优。

常见陷阱
易错点
  • 认为 DNS 只是"域名转 IP":DNS 是现代互联网调度中枢,CDN、负载均衡、灰度发布、故障切换都依赖 DNS。
  • HTTPDNS 后不做 Host 头/SNI 处理:HTTPS 请求用 IP 直连时,TLS 握手仍需 SNI 指定域名,否则证书校验失败。
  • 忽视 Local DNS 缓存:修改 DNS 记录后,全球生效需等待旧 TTL 过期,紧急切换可提前调低 TTL(提前量 = 原 TTL)。
  • 把 DNS 当健康检查:DNS 轮询返回多 IP 但不感知节点健康,需客户端或 LB 做 failover。
  • 认为 DNSSEC 能防劫持:DNSSEC 只防篡改(签名验证失败则拒绝),不防劫持(攻击者直接返回假 IP,无签名,客户端若无 DNSSEC 验证则无法识别)。
C13.11
Linux io_uring 异步 IO 接口与 JDK Project Loom 的关系?架构P1
本质问题

这道题考察对 Linux IO 模型从"事件驱动"到"完成驱动"演进 的底层理解,以及 Java 生态对它的跟进节奏。核心要回答清楚四点:① io_uring 的核心设计(SQ/CQ 环形队列)与 epoll 事件驱动模型的本质差异;② io_uring 比 epoll 快在哪里;③ JDK Project Loom(虚拟线程)底层到底用的是 epoll 还是 io_uring;④ Java 生态何时能原生吃到 io_uring 的红利。

演进脉络

Linux 异步 IO 接口演进:select(1024 fd 上限、O(n) 遍历)→ poll(无 fd 上限但仍 O(n))→ epoll(O(1) 事件通知、C10K 时代主力)→ io_uring(Linux 5.1,2019,共享内存环形队列、零拷贝、完成驱动,面向 C10M)。

Java 侧演进:BIO(一连接一线程)→ NIO(Selector 基于 epoll,Reactor 模式)→ Netty(多路复用 + EventLoop)→ Project Loom / JDK 21 虚拟线程(用同步阻塞写法承载海量并发,底层调度器仍依赖 epoll 的 EPollSelectorImpl)→ 未来:JDK 社区探索对 io_uring 的原生支持(Project Loom 早期曾评估 io_uring,当前主线仍走 epoll),第三方 netty-incubator-transport-io_uring 已提供实验性封装。

第一性原理

io_uring 核心设计:两个共享内存环形队列。Submission Queue(SQ)+ Completion Queue(CQ),用户态与内核态共享同一块内存。

  • SQE(Submission Queue Entry):用户态把 IO 请求(如读文件、建立连接)写入 SQ,内核消费。
  • CQE(Completion Queue Entry):IO 完成后内核把结果写入 CQ,用户态轮询 CQ 获取结果。
  • 共享内存零拷贝:SQE/CQE 通过 mmap 共享,提交和完成都不需要拷贝请求结构,也无需每次系统调用传参。
  • io_uring_enter:唯一的系统调用入口,批量提交 SQE 并等待 CQE;SQ 非空时可不调用,由内核轮询消费。

两种工作模式:① 中断模式(默认)——内核完成 IO 后通过 CQ 通知;② 轮询模式(IOPOLL)——内核主动轮询 SQ(适合 NVMe 等高速块设备,延迟最低但空耗 CPU)。还可注册 Buffer Ring(Provided Buffers)预先注册缓冲区,进一步减少运行时分配。

与 epoll 的本质对比:epoll 是事件驱动——告诉你"fd 就绪了",你还得自己调 read/write 把数据搬完(仍可能阻塞、仍需系统调用);io_uring 是完成驱动——你只管提交"帮我读这块数据",完成时数据已在缓冲区,全程可彻底异步、零系统调用(轮询模式下)。io_uring 把"就绪通知 + 数据搬运"合一,省去了 epoll 就绪后再 read 的那次系统调用和可能的阻塞。

JDK 虚拟线程的真相:JDK 21 虚拟线程在 IO 阻塞时会被调度器 unmount,底层靠 Selector(Linux 上是 EPollSelectorImpl,基于 epoll)监听就绪事件,就绪后重新 mount 执行。所以虚拟线程本质仍是 epoll 事件驱动,只是把异步回调的复杂度封装成了同步阻塞的写法——它并不直接使用 io_uring。

工程实践

高级开发视角:了解 io_uring 的存在与原理即可。Netty 通过 netty-incubator-transport-io_uring 模块提供实验性支持,底层用 JNI 直接调用 liburing 或裸 io_uring_setup/io_uring_enter 系统调用,可替换默认的 NioEventLoop(epoll)。使用时注意:模块仍在 incubator,API 不稳定;要求内核 ≥ 5.1(轮询模式等特性需更高版本)。

// Netty io_uring 实验性用法(incubator 模块)
EventLoopGroup group = new IOUringEventLoopGroup(4);
try {
    ServerBootstrap b = new ServerBootstrap()
        .group(group)
        .channel(IOUringServerSocketChannel.class)
        .childHandler(new MyInitializer());
    ChannelFuture f = b.bind(8080).sync();
    f.channel().closeFuture().sync();
} finally {
    group.shutdownGracefully();
}
// 注意:仅 Linux 内核 5.1+ 可用;生产前需压测对比 epoll 的实际收益

架构师视角:选型时关注三点:① io_uring vs epoll 性能对比——在高并发短连接、海量文件 IO、NVMe 存储场景 io_uring 优势明显(系统调用数和上下文切换大幅减少),但普通网络业务、连接数适中时 epoll 已足够,差距不大;② Java 采纳路线——主线 JDK 短期内不会原生切换到 io_uring,虚拟线程 + epoll 已能满足绝大多数高并发场景;重度依赖 io_uring 的可走 Netty incubator 或 JNI 自封装;③ 虚拟线程是否替代异步 IO——虚拟线程用同步写法降低编程复杂度,吞吐上接近回调式异步,但极端高并发(百万连接 + 密集 IO)下 io_uring 的完成驱动模型仍有结构优势,二者不互斥,未来可叠加。

选型建议

普通业务网络服务:JDK 21 虚拟线程 / Netty NIO(epoll)已足够,无需追新。存储密集型、网关代理、数据库引擎等对 IO 延迟极致敏感的场景:可评估 Netty io_uring transport,但必须锁定内核版本、做好压测和回退到 epoll 的开关。生产环境不要把 incubator 模块当稳定依赖。

深度追问

追问 1:io_uring 比 epoll 快在哪里?

三点结构性优势:① 系统调用更少——epoll 就绪后仍需 read/write 系统调用完成数据搬运,io_uring 提交后内核异步完成,轮询模式下连 io_uring_enter 都可省;② 共享内存零拷贝——SQE/CQE 走 mmap,请求结构不在用户态和内核态间复制;③ 完成驱动——epoll 是"就绪通知",就绪到完成中间还有一次用户态发起的读,io_uring 是"完成通知",数据直接就位。但在连接数不多、IO 不密集时,这些优势被日常开销淹没,差距不明显。

追问 2:JDK 虚拟线程底层用 epoll 还是 io_uring?

用 epoll。JDK 21 在 Linux 上的虚拟线程调度依赖 EPollSelectorImpl(基于 epoll)。虚拟线程在 IO 阻塞时被 unmount,由 epoll 监听就绪事件,就绪后重新 mount。Project Loom 早期曾评估过 io_uring,但考虑到内核版本兼容性(io_uring 需 5.1+)、稳定性和跨平台一致性,主线最终选择 epoll 作为底层多路复用器。所以"虚拟线程用了 io_uring"是常见误解。

追问 3:io_uring 是否会取代 epoll?

短期不会,长期会共存分工。epoll 是网络 IO 的事实标准,内核成熟、生态完善、跨发行版无版本门槛;io_uring 在存储、高密度网络、内核 5.1+ 环境下有性能优势但依赖新内核。大量存量服务器和容器内核版本未必达标,且网络中间件(如 Nginx、Redis)已深度优化 epoll。更可能的趋势是:网络通用场景 epoll 为主,存储和极致性能场景 io_uring 渗透,二者在内核侧长期并存,应用层按场景选型。

常见陷阱
易错点
  • 混淆 io_uring 默认模式与轮询模式(IOPOLL):默认中断模式靠 CQ 通知完成,不空耗 CPU;IOPOLL 模式内核主动轮询 SQ,延迟最低但持续吃 CPU,仅适合 NVMe 等高速块设备。两者适用场景完全不同,不能混为一谈。
  • 误以为 JDK 已原生支持 io_uring:JDK 21 虚拟线程底层仍用 epoll,主线 JDK 没有原生 io_uring 支持。要用 io_uring 得靠 Netty incubator 模块或 JNI 自封装,别在选型或技术交流中说"JDK 21 用了 io_uring"。
  • 忽略 io_uring 的内核版本要求:io_uring 自 Linux 5.1 引入,但 SQPOLL、Buffer Ring 等高级特性需要更高内核版本(5.6+/5.13+)。生产部署前必须核对宿主机/容器内核版本,否则运行时报 ENOSYS。
  • 混淆事件驱动(epoll)与完成驱动(io_uring):epoll 告诉你"fd 就绪",你还得自己 read;io_uring 告诉你"IO 已完成",数据已在缓冲区。把 io_uring 当成"另一个 epoll"来用,会错失它完成驱动的核心价值。
C13.12
操作系统系统调用(System Call)与内核交互:用户态/内核态切换、read/write/epoll/sendfile 等系统调用如何支撑 Java 高性能网络 IO?架构P1
本质问题

这道题考察Java 高性能 IO 之上那层"不可见的契约":你写的 SocketChannel.write()、Netty 的 epoll、零拷贝的 sendfile——最终都通过 系统调用穿过用户态/内核态边界进入内核。能讲清 syscall 是什么、代价在哪、epoll/sendfile 等核心调用解决什么问题,才能解释"为什么 NIO 比 BIO 快、为什么零拷贝省内存、为什么跨语言底层都依赖同一批内核原语"。

演进脉络

为什么需要系统调用:出于安全隔离,用户程序不能直接访问硬件/内核数据结构。凡涉及文件、网络、内存分配、进程线程、时间的操作都要陷入内核,通过 syscall 请求内核代为完成。

IO 模型背后的 syscall 演进:阻塞 read/write → 多路复用 select/poll → epoll(事件驱动)→ 异步完成 io_uring。每一步都降低"用户态-内核态往返"与"忙碌轮询"的代价。

数据拷贝 syscall 演进:传统 read+write 多次用户态/内核态拷贝 → sendfile 内核内直接搬移 → mmap 让用户态直接映射内核页 → DMA 让硬件直接访问内存,拷贝次数逐级下降。

统一入口:现代内核用 syscall 指令 + syscall table(x86-64 的 syscall 指令更快,替代旧 int 0x80),libc/JVM 通过该机制完成一切系统级服务请求。

第一性原理

用户态/内核态切换:CPU 通过特权级隔离——用户态(Ring 3)无法执行特权指令,syscall 指令触发陷入(trap)切换到内核态(Ring 0)执行,用 syscall number + 参数寄存器 定位内核函数,返回时再切回用户态。切换有 TLB/流水线开销,故业界追求减少系统调用次数。

epoll 为何高效:epoll_create/epoll_ctl/epoll_wait 三调用。内核维护兴趣列表(红黑树)+ 就绪队列(链表),数据到达由内核事件回调放入就绪队列,用户 epoll_wait 只取结果,O(1) 感知就绪 fd,无需像 select 那样线性扫描并反复传整个 fd 集。

sendfile 零拷贝本质:一次 sendfile 调用在内核态完成 文件→page cache→socket 的数次拷贝(配合 DMA 无需经过用户态缓冲区),把"用户态往返 + 用户缓冲拷贝"省掉。Java 的 FileChannel.transferTo() 底层即在 Linux 上走 sendfile。

系统调用向量对照:BIO 的 read 阻塞、NIO 用 epoll_wait 多路复用、Netty 通过 epoll 事件驱动、零拷贝用 sendfile/mmap——Java 各层框架只是把不同的内核 syscall 封装得更易用,性能差距的根源在底层调用选择。

// Java 层对应内核系统调用的映射
SocketChannel.read(...)          // -> read() / recvfrom()
SelectableChannel.register(...)   // NIO: epoll_ctl(Linux)
Selector.select()                // NIO: epoll_wait(Linux)
FileChannel.transferTo(...)      // 零拷贝: sendfile(Linux)
MappedByteBuffer.map(...)        // 零拷贝: mmap(Linux)
关键结论

"高性能网络编程"比拼的核心之一,就是选对并减少系统调用:epoll 减少轮询、sendfile 减少拷贝、io_uring 减少"就绪后再发起 IO"的多次往返——理解 syscall 代价,就理解了所有高并发框架为何如此设计。

工程实践

选择正确的 IO 通道:长连接/高并发网络服务用 NIO(Base 上 epoll)+ 多路复用,而非每连接一线程的 BIO;文件大传输用 transferTo 走零拷贝,避免在大文件上反复 read/write 造成用户态拷贝。

关注 syscalls 指标:用 strace -f -c -p <pid> 统计进程系统调用分布,定位是否因大量重复 syscall(如频繁 read/write 小包)而性能虚高耗时;perf trace 可看每调用耗时。

Netty 底层开关:Netty 可通过 EpollEventLoopGroup(优于默认 NIO 的 epoll 路径)、开启 ByteBuf allocateDirect(减少堆外/堆内拷贝),从"调用选择"层面榨取 syscall 性价比。

io_uring 的新选择:通过 Netty incubator transport(底层 JNI 调用 liburing)或在 C 层直接使用 io_uring,可获得比 epoll 更低的延迟与更高吞吐;需注意虚拟线程(Project Loom)本身仍基于 epoll,并不直接使用 io_uring,二者是内核侧两条独立演进路线,选型时不可混为一谈(见 C13.11)。Java 生态对 io_uring 的原生支持仍有限,主要作为选型论证点。

深度追问

追问 1:为什么 select 在 fd 多时低效? select 每次调用需把整个 fd 集合拷贝进内核并线性扫描,fd 多时拷贝与遍历开销线性增长;epoll 只在注册/取消时传 fd,就绪由内核回调维持,复杂度与 fd 总数关系更优。

追问 2:mmap 与 sendfile 有何取舍? mmap 适合读写双向、用户态可直接访问映射页(如缓存文件、MQ disk 索引);sendfile 只适合只读向 socket 发送、无法写文件。用错场景反而引入 page fault 或语义限制。

追问 3:为什么系统调用比普通函数调用慢? 需切换特权级、保存/恢复上下文、可能触发 TLB 失效与流水线停顿;现代 CPU 的 syscall 指令已优化,但每次仍有几十到数百周期的固有开销,故高频路径要批量/异步化(如 io_uring、sendfile 一次性做多步)。

常见陷阱
易错点
  • "NIO 靠魔法变快":本质是底层改用 epoll 等多路复用 syscall,减少阻塞与轮询,而非 Java 自身优化。
  • 混淆"零拷贝"含义:通常指避免用户态/内核态间拷贝,显著减少 CPU 拷贝,而非完全不拷贝。
  • 误以为 syscall 免费:每次陷入内核都有切换开销,高频调用应合并/异步化应对。
  • select/epoll 混为一谈:二者实现与复杂度不同,需要讲清 Red-Black 树 + 就绪队列差异。
  • 忽略跨语言一致性:Go/Java/Rust 底层最终都调到同一批内核 syscall,选型差异只在"谁封装得更好"。
C13.13
IO 模型全景与跨平台选型:UNP 五模型、signal-driven IO、IOCP/Proactor 与全接口横向对比?架构P1
本质问题

这道题考察站在操作系统视角把"IO 模型"这张大图补全的能力。C13.05 讲了 Java NIO/Reactor、C13.11 讲了 io_uring、C13.12 讲了 syscall,但 UNP(Unix Network Programming)定义的五种经典 IO 模型里 signal-driven IO 一直没讲,且Windows 的 IOCP(完成端口,Proactor 模型)只在 C13.12 一笔带过。能讲清"五种模型各自何时通知、谁来做数据拷贝、跨平台底层分别落到哪个系统调用",才算把 IO 模型讲圆。

演进脉络

UNP 五种经典 IO 模型:① 阻塞 IO(recv 卡住,一线程一连接);② 非阻塞 IO(轮询,忙 CPU);③ IO 多路复用(select/poll/epoll/kqueue,单线程管万连);④ 信号驱动 IO(SIGIO,内核发信号通知);⑤ 异步 IO(内核做完数据拷贝后通知"已完成")。前四种都是同步 IO(read/write 仍需你同步发起),只有第⑤种是真正异步。

平台专属高性能接口:Linux 用 epoll(2.6+)/ io_uring(5.1+);BSD/macOS 用 kqueue;Windows 用 IOCP;老 Solaris 用 /dev/poll。Linux 原生 AIO(libaio)只支持磁盘文件、不支持 socket,常被误当网络异步方案。

跨平台封装层:libevent(select→poll→epoll/kqueue 自适应)、libuv(Node.js 底层:Linux-epoll / macOS-kqueue / Windows-IOCP)、Boost.Asio、Netty——它们不是系统调用,只是按平台选最优底层。

第一性原理

signal-driven IO(SIGIO):fcntl(fd, F_SETOWN, pid) + fcntl(fd, O_ASYNC) 开启,fd 就绪时内核向进程发 SIGIO 信号;信号处理函数里再 recv。致命缺点:信号是进程级全局的,多个 fd 就绪时分不清是哪个 socket,高并发几乎没人用,故 UNP 把它列为"存在但不实用"的一档。

IOCP / Proactor(完成驱动):与 epoll 的 Reactor("fd 就绪了你来 recv")本质不同——你提前 WSARecv 把缓冲区投递给内核,内核收完数据并完成用户态拷贝后才通知你"数据已经读完"。你全程不阻塞、不二次系统调用。Windows 高并发服务器唯一选择,Node.js/libuv/C# 底层皆是它。

Reactor vs Proactor 对照:Reactor=等待事件就绪、由应用发起 IO(select/poll/epoll/kqueue),同步;Proactor=内核执行完 IO、完成后通知应用(IOCP、io_uring),异步。io_uring 默认中断模式更像 Proactor(靠 CQ 通知完成、不空耗 CPU),IOPOLL 模式才主动轮询。

关键结论

"多路复用≠异步":epoll/kqueue 是同步 Reactor(只通知就绪);IOCP/io_uring 是异步 Proactor(连数据拷贝都帮你做完)。把 io_uring 当"另一个 epoll"来用,会错失它完成驱动的核心价值(同见 C13.11)。

工程实践

跨平台选型:Linux 服务默认 epoll + Reactor(Netty NIO / JDK NIO);macOS/BSD 用 kqueue(Nginx 编译自动降级);Windows 服务端用 IOCP(libuv 封装)。Java NIO Selector 底层映射:Linux=epoll、macOS=kqueue、Windows=select(历史实现,性能最差),所以跨平台网络框架在 Windows 上要特别注意。

何时选谁:连接少(几百)select/poll 够用、优先跨平台;海量长连接 Linux 用 epoll、macOS 用 kqueue;存储/网关/代理等极致 IO 敏感场景评估 io_uring(须锁内核 5.1+,做好回退 epoll 的开关)。

避坑:别在 Windows 上指望 Java NIO 吃到 epoll——它底层是 select,fd 上限仅 64,高并发直接翻车;跨平台库(libuv/Netty)才是正确抽象层。

深度追问

追问 1:epoll ET 边缘触发为什么必须用非阻塞 IO? ET 只在状态变化瞬间通知一次,若用阻塞 read,缓冲区剩余数据读完会卡住整线程;非阻塞 read 循环读到 EAGAIN 代表空了才退出,故 ET 必须配非阻塞 + 循环读。

追问 2:Reactor 和 Proactor 根本区别? 前者就绪通知、应用自己拷;后者完成通知、内核拷完。前者同步、后者异步;epoll 属前者,IOCP/io_uring 属后者。

追问 3:Java NIO Selector 是不是 epoll? Linux 下是 epoll、macOS 下是 kqueue、Windows 下是 select——Selector 只是上层封装,真正多路复用由 OS 提供(见 C13.05)。

追问 4:io_uring 比 epoll 快在哪? epoll 只告诉你 fd 就绪,read 仍由用户态发起(一次 syscall);io_uring 把 recv/send 直接提交内核队列、内核完成后再通知,省去"就绪后再发 IO"的往返(见 C13.11)。

常见陷阱
易错点
  • 认为 IO 多路复用=异步 IO:select/poll/epoll/kqueue 全是同步 Reactor,只通知就绪;真正异步是 IOCP/io_uring/libaio。
  • 误以为 JDK 已原生用 io_uring:JDK 21 虚拟线程底层仍 epoll,主线无原生 io_uring(见 C13.11)。
  • 忽略 io_uring 内核版本:5.1+ 才有,SQPOLL/Buffer Ring 需更高版本,否则 ENOSYS。
  • 混淆事件驱动与完成驱动:epoll 说"可读了"、io_uring 说"已读完",把 io_uring 当 epoll 用会错失价值。
  • Windows 上误用 Java NIO 当 epoll:Windows Selector 底层是 select(fd 上限 64),高并发必翻车;应走 libuv/Netty 的 IOCP 路径。
  • 以为 libaio 能做网络异步:Linux 原生 AIO 只支持磁盘文件,不支持 socket,网络异步得用 io_uring。
全接口横向对比总表
接口 平台 类型 核心特点
select 全平台 多路复用(Reactor) 位图、fd 上限 1024、O(N) 遍历
poll/ppoll Linux/UNIX 多路复用(Reactor) 数组无上限、仍 O(N)
epoll Linux 2.6+ 多路复用(Reactor) 红黑树+就绪链表、LT/ET、O(就绪数)
kqueue macOS/BSD 多路复用(Reactor) 事件过滤器、LT/ET
io_uring Linux 5.1+ 异步 IO(Proactor) SQ/CQ 共享内存、提交即内核执行、新一代
IOCP Windows 异步 IO(Proactor) 完成端口、内核做完拷贝再通知、Windows 首选
libaio Linux 异步 IO 仅磁盘文件、不支持 socket
/dev/poll Solaris(古董) 多路复用 ≈epoll,已淘汰
select/poll/epoll 底层数据结构与运行流程全解

① select:位图 + 三套集合。fd_set 本质是一块 bit 数组,每一位代表一个 fd(bit 5 置 1 → 监听 fd=5)。宏 FD_SETSIZE=1024 决定位图固定长度,故硬限制只能监听 0~1023 的 fd;且有 readfds/writefds/exceptfds 三套位图。运行流程:FD_ZERO 清零 → FD_SET(fd) 置位 → select() 把整张位图拷贝进内核并阻塞 → 内核遍历所有置 1 的 fd 检查就绪 → 内核改写位图(就绪 bit 保持 1、其余清 0)→ 返回 → 用户 FD_ISSET 循环遍历 0~max_fd 找就绪(O(N))→ 下一轮必须重新 FD_ZERO+FD_SET 重建(位图已被破坏、不能复用)。痛点:1024 上限、每次整张拷贝、返回后 O(N) 全扫、下一轮必须重建。

② poll:结构体数组,去掉 1024 上限。改用 struct pollfd { int fd; short events; short revents; } 数组:events 是用户关心事件、不被内核修改;revents 是内核回写的就绪结果;数组长度自定、无 1024 硬上限。运行流程:构建 pollfd[] → poll() 把整个数组拷贝进内核并阻塞 → 内核遍历扫描 → 就绪者写 revents → 返回 → 用户遍历 revents 非 0 → 下一轮 events 完好、无需重建直接再 poll。改进:去 1024 上限、events 不被破坏(不用每次重建);遗留:整数组仍每次用户→内核拷贝、返回仍 O(N) 遍历(1 万连接只 2 个就绪也要循环 1 万次)。select/poll 共同点:都没有内核侧长期保存的监听集合,每次系统调用都要把监听列表完整交给内核。

③ epoll(Linux 专属):监听集合放内核长期保存。内核维护两套结构——红黑树保存所有被监听 fd(epoll_ctl(ADD/MOD/DEL) 增删改,仅注册/取消时一次用户→内核拷贝);就绪双向链表存放已 IO 就绪的 fd(数据到达由内核回调驱动入链,epoll_wait 直接取)。三步流程:epoll_create() 建 epoll 对象(红黑树+就绪链表,返回 epfd)→ epoll_ctl(ADD, fd, event) 登记(只拷一次)→ epoll_wait(epfd, events, maxevents, timeout) 阻塞取就绪链表,events[] 只收已就绪结果,O(就绪数) 遍历、不遍历全部监听 fd。与 select/poll 根本区别:监听集合在内核长期保存,wait 不再拷贝全部 fd 列表。

LT/ET 与工程实例。LT(水平触发,默认)只要缓冲区还有数据就反复通知,编程简单、不易丢事件——Redis 用 LT;ET(边缘触发)只在状态变化瞬间通知一次,必须配非阻塞 socket + 循环 read 到 EAGAIN一次性读完,否则丢事件——Nginx 用 ET(减少事件触发次数、追求性能)。三者本质都是同步 IO 多路复用(Reactor):内核只通知 fd 就绪,真正 read/recv 仍要用户自己调系统调用;io_uring/IOCP 才是真正异步(已完成驱动)。

记忆顺口溜

select 位图 1024,拷贝遍历全来撸;
poll 数组去掉上限,遍历拷贝依旧苦;
epoll 内核花名册,红黑树存 fd 库;
就绪链表直接取,万条空闲也不误;
LT 有事反复呼,ET 跳变只一顾。

C13.14
连接泄漏排查顺序:从症状到句柄与代码点?高级P1
本质问题

连接泄漏表现:句柄上涨、偶发 ConnectException、池耗尽。要有固定排查路径。

原理映射

连接必须有明确生命周期(try-with-resources / finally / 超时关闭)。异步回调与过滤器链路是高发泄漏点。

落地排查

顺序:

  1. 看 FD/连接池活跃与空闲。
  2. lsof/管理端看连接归属。
  3. 对照最近变更(客户端/超时/finally)。
  4. 临时抬池或重启止血。
  5. 修泄漏点后再把池调回。
参数与开关

信号:池 waiting、leakDetection 日志、CLOSE_WAIT 堆积。开关:临时 max 池、强制超时。

失败模式与回滚

失败模式:只重启不定位;抬池掩盖泄漏;异步回调未关闭响应。回滚点:回退嫌疑变更,恢复池上限。

连接泄漏症状:池耗尽、大量 CLOSE_WAIT、FD 上升。顺序:指标→堆栈/泄漏检测→代码未关闭点→临时扩池(有上限)。

修复后要回归「借还平衡」指标;禁止只重启当根治。

量化:FD 数 30 分钟单调上涨、池 waiting > 0 持续,或 CLOSE_WAIT 占比 > 20% 即判泄漏;leakDetection 阈值设 30s(低于业务最长 RT 会误报)。演进:把「借还平衡」做成常驻指标(active + idle = max 恒等式);临时扩池只允许在泄漏点定位前进行,且必须设上限与时限,每次扩池挂自动回收工单。