滑动窗口详解
滑动窗口是 TCP 可靠性和流控的核心机制,同时也是发送速率的根本限制。发送窗口由接收方的流控窗口(rwnd)和发送方的拥塞窗口(cwnd)共同决定。
| 概念 | 含义 | 谁决定 |
| rwnd(接收窗口) | 接收方缓冲区剩余空间,防止发送方打爆接收方 | 接收方在 TCP 头部 Window Size 字段通告 |
| cwnd(拥塞窗口) | 发送方根据网络状况估计的可发送量,防止打爆网络 | 发送方拥塞控制算法维护 |
| swnd(发送窗口) | 实际可以发送但未经确认的数据量上限 | swnd = min(rwnd, cwnd) |
序列号与窗口指针数学
TCP 发送缓冲区维护三个关键指针:
| 指针 | 含义 |
SND.UNA | 已发送但尚未收到 ACK 的第一个字节(最老未确认字节) |
SND.NXT | 下一个要发送的字节序号 |
SND.UNA + swnd | 发送窗口右边界(最大可发字节序号) |
可用窗口大小 = (SND.UNA + swnd) - SND.NXT:即还能立即发送多少新数据。当收到 ACK 时,SND.UNA 向前滑动,窗口开放,可以发送更多数据。
Window Scale 选项:TCP 头部 Window Size 字段只有 16 位(最大 65535 字节),在高带宽延迟积(BDP)网络中远远不够。TCP 选项中的 Window Scale 允许将窗口值左移 shift.count 位(最多 14 位),最大窗口可达 1GB(65535 × 2^14),在三次握手时协商。
零窗口探测(Zero Window Probe)
当接收方缓冲区满了(rwnd=0),发送方停止发送数据。但如果接收方之后发送的 Window Update 丢包了,双方会永久死等——发送方等窗口开放,接收方等数据。Zero Window Probe 机制:发送方周期性发送 1 字节探测数据(即使 rwnd=0 也允许),强制接收方重新通告窗口,打破死锁。
拥塞控制算法演进
拥塞控制的核心目标:最大化利用带宽,同时不造成网络拥塞崩溃。从 1980 年代至今算法不断演进,面试要能讲出每个算法的关键改进。
Tahoe(1988)—— 最基础
| 阶段 | cwnd 变化 | 触发条件 |
|---|
| 慢启动(Slow Start) | cwnd += 1 per ACK(指数增长:每 RTT 翻倍) | 连接开始或超时后,直到 cwnd >= ssthresh |
| 拥塞避免(Congestion Avoidance) | cwnd += 1/cwnd per ACK(线性增长:每 RTT +1) | cwnd >= ssthresh 后 |
| 丢包事件 | ssthresh = cwnd/2,cwnd = 1(回到慢启动!) | 超时(RTO) |
Tahoe 最保守:任何丢包都把 cwnd 暴力砍到 1,重新慢启动,浪费带宽。
Reno(1990)—— 快重传 + 快恢复
Reno 在 Tahoe 基础上增加了两个关键机制:
- 快重传(Fast Retransmit):收到 3 个重复 ACK(dup ACK)就立即重传丢失的包,不等待超时(RTO 通常很长,200ms+)。3 dup ACK 说明网络还在通(只是乱序或丢了一个包),不用等超时。
- 快恢复(Fast Recovery):3 dup ACK 触发时,ssthresh = cwnd/2,cwnd = ssthresh(不是降到 1!),直接进入拥塞避免。因为 dup ACK 说明数据还在流动,网络没完全堵死,不必从 1 开始慢启动。
Reno 的问题:一个窗口内如果丢了多个包,Reno 处理不好——只重传第一个丢失的包,收到部分 ACK(partial ACK,确认了新数据但不是全部已发送数据)后会退出快恢复,可能导致超时。
NewReno —— 改进快恢复
NewReno 修复了 Reno 在多丢包场景的问题:收到 partial ACK 时不退出快恢复,而是持续重传丢失的包,直到所有丢失的包都被确认(收到恢复点之前的 ACK)才退出快恢复。
CUBIC:Linux 默认拥塞控制(2.6.19+)
CUBIC 是 Linux 内核从 2007 年起的默认拥塞控制算法(之前是 BIC),专门为高带宽长距离网络设计。
核心改进:与 RTT 解耦的窗口增长
- Reno/NewReno 的窗口增长依赖 ACK 驱动(cwnd 每收到一个 ACK 加 1/cwnd),RTT 短的流增长更快(因为同样时间内能收到更多 ACK),对长 RTT 流不公平。
- CUBIC 使用三次函数(cubic function)基于距离上次丢包的时间来增长窗口,不依赖 ACK 频率,因此不同 RTT 的流之间更公平。
| 参数 | 默认值 | 含义 |
| β_cubic(乘性减小因子) | 0.7 | 丢包后 cwnd = cwnd × β(Reno 是 0.5),更温和 |
| C(增长速率参数) | 0.4 | 三次函数的增长 aggressiveness |
| W_max | 动态记录 | 上次丢包前的窗口大小,三次函数围绕 W_max 增长(先缓慢接近,然后快速探索,接近饱和时变慢) |
CUBIC 名字来自三次函数(CUBIC function)。窗口增长曲线是围绕 W_max 的三次函数:刚丢包后增长缓慢(稳定区),中间快速增长(探测区),接近前次最大窗口时再次放缓(饱和区)。β=0.7 比 Reno 的 0.5 更激进,能更好利用高带宽链路。
BBR:基于模型的拥塞控制(Google 2016)
BBR(Bottleneck Bandwidth and RTT)从根本上改变了拥塞控制的思路:不以丢包作为拥塞信号,而是主动测量网络的瓶颈带宽(BtlBw)和最小 RTT,运行在带宽延迟积(BDP)附近。
| 特性 | 传统算法(Reno/CUBIC) | BBR |
| 拥塞信号 | 丢包(RTO/3 dup ACK) | 排队延迟(RTT 增加) |
| 模型 | 无显式模型,AIMD 试探 | 显式测量 BtlBw × minRTT = BDP |
| 目标操作点 | 尽量填满缓冲区(导致 bufferbloat) | 运行在 BDP(刚好填满管道不排队) |
| 丢包反应 | 立即减窗 | 丢包≠拥塞(浅缓冲区随机丢包),除非确认是拥塞导致 |
BBR 四个阶段
| 阶段 | 行为 |
|---|
| Startup(启动) | 类似慢启动,指数增长发送速率,直到发现带宽不再增长(说明管道已满) |
| Drain(排空) | 以 Startup 结束时的速率发送一段时间,排空 Startup 阶段产生的队列 |
| ProbeBW(带宽探测) | 稳态阶段,以 8 个 RTT 为周期:6 个 RTT 以 BDP 速率发送,1 个 RTT 加速 25% 探测是否有更多带宽,1 个 RTT 减速排空队列 |
| ProbeRTT(RTT 探测) | 每 10 秒,如果没有测得新的更小 RTT,短暂(约 200ms)把发送量降到 4 个 packet,排空队列测量真实的 propagation min RTT |
BBR 的优势和局限
优势:
- 长肥管道(长距离高带宽)吞吐量比 CUBIC 高几个数量级
- 浅缓冲区数据中心中,随机丢包不会触发不必要的减窗
- 显著降低延迟(不填满缓冲区,减少 bufferbloat)
局限:
- BBR 在混合部署中对 CUBIC 流不公平(BBR 不反应丢包,会抢占更多带宽)
- ProbeRTT 阶段可能造成短暂的吞吐量下降
- 在深缓冲区网络中,BBR 的优势不明显甚至可能更差
- 算法复杂度高,参数调优难度大
BBR 的核心洞察:在现代网络(数据中心、光纤)中,丢包通常不意味着拥塞(可能是随机丢包或浅缓冲区溢出),RTT 增加(排队)才是真正的拥塞信号。BBR 追求「运行在 Kleinrock 最佳操作点」——最大吞吐、最小延迟。
Nagle 算法与 Delayed ACK 的交互
| 机制 | 行为 | 目的 | 问题 |
| Nagle 算法(TCP_NODELAY 关闭时启用) | 如果有未确认的在途数据,小于 MSS 的小数据要等收到 ACK 或凑满 MSS 才发 | 合并小包(tinygram),减少网络上的微型数据包数量 | 小包场景增加延迟 |
| Delayed ACK | 接收方收到数据后不立即回 ACK,延迟最多 200ms,等自己有响应数据捎带 ACK | 减少纯 ACK 包数量,捎带确认提高效率 | 增加延迟 |
Nagle + Delayed ACK 死锁:这是经典问题。发送方发了一个小包,等 ACK 再发下一个(Nagle);接收方收到小包,等有数据捎带才回 ACK(Delayed ACK)。双方互等,最多拖 200ms(Delayed ACK 超时)才解死锁。
实时系统(RPC、消息队列、游戏、数据库)几乎 universally 启用 TCP_NODELAY(禁用 Nagle),因为延迟比包数量更重要。同时可以启用 TCP_QUICKACK 禁用延迟 ACK(但 QUICKACK 是 per-send 提示,不是持久设置,每次收包后可能被内核重置)。
BDP(带宽延迟积)与窗口配置
BDP(Bandwidth-Delay Product)是理解 TCP 性能的关键概念:BDP = 瓶颈带宽 × 最小 RTT,表示「填满网络管道」需要多少字节的数据在途。
| 网络场景 | 带宽 | RTT | BDP | 需要的窗口大小 |
| 同机房 | 10 Gbps | 0.1 ms | 125 KB | 125 KB(默认 rwnd 64KB 刚好够用) |
| 跨城 | 1 Gbps | 10 ms | 1.25 MB | 1.25 MB(需要 Window Scale) |
| 跨洋(中美) | 1 Gbps | 150 ms | 18.75 MB | 18.75 MB(必须启用 Window Scale,否则最大 64KB 打不满带宽) |
| 长肥管道(LFN) | 10 Gbps | 150 ms | 187.5 MB | 187.5 MB(需要大窗口 + BBR/CUBIC) |
BDP 直接决定你需要多大的发送窗口才能跑满带宽。如果 swnd(min(rwnd, cwnd))< BDP,管道永远填不满,吞吐量上不去。跨洋长 RTT 场景如果不开启 Window Scale,TCP 最多跑到 64KB / 0.15s ≈ 427 KB/s ≈ 3.4 Mbps,即使带宽有 1 Gbps。
SACK、DSACK 与选择性确认
原始 TCP 只有累积 ACK(确认到序号 X 表示 X 之前的所有字节都收到了),这导致一个窗口内丢多个包时,发送方无法知道哪些包到了哪些没到,只能超时重传或者靠 dup ACK 猜。
| 机制 | 说明 |
| SACK(Selective ACK) | 接收方在 ACK 中通过 TCP 选项告诉发送方「哪些不连续的块已经收到了」,发送方据此只重传真正丢失的包,不用重传已收到的数据 |
| DSACK(Duplicate SACK) | SACK 的扩展,接收方用 SACK 块报告「收到了重复的包」,发送方据此判断是丢包了还是只是乱序/重复,避免不必要的重传 |
| FACK(Forward ACK) | 结合 SACK 更精确地计算拥塞窗口,Linux 中使用 |
SACK 在三次握手时协商,现在几乎所有 TCP 连接都启用。没有 SACK,一个窗口丢多个包会导致严重的性能问题(Go-Back-N 效应)。
MSS、MTU 与 PMTUD
| 概念 | 含义 | 典型值 |
| MTU(最大传输单元) | 链路层一次能传的最大帧大小(含 IP 头+TCP 头+数据) | 以太网 1500 字节 |
| MSS(最大段大小) | TCP 报文段中数据部分的最大大小(不含 TCP/IP 头) | 1500 - 20(IP) - 20(TCP) = 1460 字节 |
| PMTUD(路径 MTU 发现) | 发现整条路径上最小 MTU,避免 IP 分片 | 通过设置 DF(Don't Fragment)位,收到 ICMP "Fragmentation Needed" 后减小 MSS |
IP 分片的问题:如果发送的包大于路径 MTU 且没设 DF 位,IP 层会分片;但只要一个分片丢失,整个包都要重传,效率极低。TCP 通过 PMTUD 动态调整 MSS,尽量避免分片。
常见问题:ICMP 被防火墙拦截会导致 PMTUD 黑洞——大包丢了但发送方收不到 ICMP 消息,一直重传大包失败,连接卡死。解决方案:启用 tcp_mtu_probing(开启后内核会自动探测 MSS,不依赖 ICMP)。
ECN(显式拥塞通知)与 TCP 时间戳
| 机制 | 说明 |
| ECN | 允许中间路由器在拥塞即将发生时标记 ECN 位(而不是直接丢包),接收方把 ECN 标记回传给发送方,发送方据此减窗。把「丢包作为拥塞信号」提前为「标记作为拥塞信号」,减少不必要的丢包。需要两端和中间网络设备都支持。 |
| TCP Timestamps | TCP 选项,两个作用:(1) 更精确的 RTT 测量(每个报文带时间戳,RTT 计算不再依赖重传歧义问题 PAWS);(2) PAWS(Protection Against Wrapped Sequences):防止序列号回绕后旧报文被当成新报文(高速网络序列号回绕很快)。tcp_tw_reuse 依赖 Timestamps 工作。 |
其他 TCP 优化机制
| 机制 | 说明 |
| TCP Fast Open(TFO) | 允许在 SYN 包中携带数据(需要 TFO cookie 验证),重复连接时省去一次 RTT。首次连接还是正常握手,Server 在第一次响应中设置 cookie,之后客户端 SYN 可以带 cookie + 数据。 |
| TCP Keepalive | 不是心跳机制!默认空闲 2 小时才发送探测包,用于检测已死的对端(如对端崩溃/断电/网线拔了)。通过 SO_KEEPALIVE 开启,可以调整 tcp_keepalive_time、tcp_keepalive_intvl、tcp_keepalive_probes 参数。对于需要快速检测死连接的场景,应用层心跳更合适。 |
| SYN Cookie | 防御 SYN flood 攻击的内核机制。正常情况下收到 SYN 要分配连接状态(半开连接放入 SYN queue);SYN flood 时攻击者发送大量 SYN 不完成握手,填满 SYN queue。开启 syncookies 后,内核不为 SYN 分配状态,而是把连接信息编码在 ISN(初始序列号)中,收到合法 ACK 时再重建状态。不需要存储半开连接,天然抗 SYN flood。代价是部分 TCP 选项信息可能丢失。 |
Q: Tahoe 和 Reno 的区别?
Tahoe 和 Reno 的核心区别在于丢包后的处理:
| 方面 | Tahoe | Reno |
|---|
| 慢启动 | cwnd 指数增长到 ssthresh | 相同 |
| 拥塞避免 | cwnd 线性增长 | 相同 |
| 丢包检测 | 仅靠超时(RTO) | 超时 + 3 dup ACK(快重传) |
| 超时后行为 | ssthresh = cwnd/2, cwnd = 1,慢启动 | 相同 |
| 3 dup ACK 后行为 | 和超时一样:cwnd = 1,慢启动 | 快恢复:ssthresh = cwnd/2, cwnd = ssthresh,直接拥塞避免(不用从 1 开始) |
为什么 3 dup ACK 和超时不同?收到重复 ACK 说明网络仍然在传数据(dup ACK 是因为收到了乱序的后续包),只是丢了一个包,不算严重拥塞;而超时意味着「长时间没有任何包通过」,网络可能严重拥塞,需要更激进地减窗。
Reno 在 Tahoe 基础上加了快重传(3 dup ACK 立即重传不等超时)和快恢复(3 dup ACK 后 cwnd 减半到 ssthresh 而非砍到 1),利用「dup ACK 意味着网络还活着」的信号避免过度减窗。
Q: BBR 为什么不用丢包作为拥塞信号?
BBR 的设计哲学基于以下观察:
1. 现代网络中丢包 ≠ 拥塞:
- 数据中心交换机通常使用浅缓冲区(shared buffer),端口竞争时会随机丢包,但链路带宽并未饱和
- WiFi/无线链路有固有误码率(BER),随机丢包和拥塞无关
- 链路层 FEC/校验失败导致的丢包也不是拥塞
2. 丢包驱动的算法(Reno/CUBIC)会填满缓冲区:
- Reno/CUBIC 不断增加 cwnd 直到丢包才减窗,这意味着它们总是试图把缓冲区填满
- 这导致 bufferbloat(缓冲区膨胀):延迟显著增加(排队时延),但吞吐并没有提升
- 在深缓冲区网络中尤其严重——延迟可能增加几十倍
3. BBR 的真正拥塞信号是 RTT 增加:
- 当发送速率超过瓶颈带宽时,数据开始在瓶颈处排队,RTT 增加
- RTT 增加(排队)才是拥塞的早期信号,丢包是晚期信号
- BBR 通过测量 BtlBw(带宽瓶颈)和 minRTT(传播延迟)来运行在 BDP 点——刚好填满管道不排队
BBR 认为丢包在现代网络(浅缓冲数据中心、无线)中不代表拥塞,RTT 上升(排队)才是真正的拥塞信号;它显式测量带宽和最小 RTT,运行在 BDP(Kleinrock 最佳点)而不是靠丢包来发现拥塞。
Q: Nagle 算法解决什么问题?为什么要禁掉?
Nagle 解决什么:Nagle 算法(RFC 896)解决的是「小包问题」(tinygram problem)。如果应用每次只发 1 字节(如 telnet/SSH 每个按键发一个包),每个字节要带 40 字节的 TCP+IP 头(20+20),有效载荷只有 1/41 ≈ 2.4%,浪费带宽。Nagle 规定:如果有未确认的在途数据,后续小于 MSS 的数据必须等收到 ACK 或者数据攒满 MSS 才能发送,通过延迟发送来合并小包提高效率。
为什么要禁掉(TCP_NODELAY):
- 延迟敏感场景:RPC、数据库查询、实时游戏、消息系统——每次请求/响应通常都小于 MSS,但需要立即发送,延迟比带宽效率重要
- Nagle + Delayed ACK 死锁:发送方等 ACK 再发下一个包(Nagle),接收方等数据捎带才回 ACK(最多等 200ms),双方互等造成明显延迟
- 现代网络带宽远高于 1980 年代,小包的带宽浪费问题不再严重
替代方案:如果既要减少包数又要低延迟,不要依赖 Nagle,而是在应用层做 writev/gather write 或缓冲合并(Nagle 是内核级自动合并,应用层控制更精准)。
Nagle 合并小包提高带宽效率(telnet 时代重要),但增加延迟;现代 RPC/数据库/实时系统普遍用 TCP_NODELAY 禁用 Nagle,因为低延迟比省几个包更重要,尤其要避免 Nagle+Delayed ACK 死锁。
Q: 滑动窗口 rwnd=0 会怎样?
当接收方缓冲区满了,会通告 rwnd=0(Zero Window),发送方停止发送新数据(但已发送未确认的数据继续等待 ACK)。
正常情况下:接收方应用程序读取了缓冲区数据后,内核发送 Window Update 报文告知新的窗口大小,发送方恢复发送。
问题场景:如果 Window Update 报文丢包了,双方会陷入死锁:
- 发送方认为 rwnd=0,一直等窗口开放
- 接收方以为已经通告了窗口,等发送方发数据
- 没有任何触发机制打破这个等待
解决方案:Zero Window Probe(持续计时器)
- 发送方在 rwnd=0 时启动持续计时器(persist timer)
- 周期性发送1 字节探测数据(即使窗口为 0,TCP 规范允许发送 1 字节紧急探测数据)
- 接收方收到探测数据后必须重新通告当前窗口大小
- 如果窗口还是 0,重置计时器继续探测;如果窗口已开放,恢复正常发送
工程视角:rwnd=0 通常意味着接收方应用程序处理太慢(消费速度 < 生产速度),是背压信号。需要检查接收方是否阻塞、应用是否在处理其他事情、是否有 GC 停顿等。
rwnd=0 时发送方停止发送;如果 Window Update 丢包会死锁,Zero Window Probe 通过周期性 1 字节探测打破死锁;rwnd=0 本质是接收方背压信号,需要排查接收端处理速度。
Q: CUBIC 为什么对不同 RTT 的流更公平?
CUBIC 对不同 RTT 流更公平的根本原因:窗口增长函数基于时间而非 ACK 时钟。
Reno 的问题(RTT 不公平):
- Reno 在拥塞避免阶段:每收到一个 ACK,cwnd += 1/cwnd(即每个 RTT cwnd 增加约 1 MSS)
- 但 ACK 到达频率和 RTT 成反比:RTT 短的流在相同时间内收到更多 ACK
- 结果:RTT=10ms 的流比 RTT=100ms 的流窗口增长快 10 倍,短 RTT 流抢占更多带宽
CUBIC 的解决方案:
- CUBIC 的窗口大小是距离上次丢包的时间 t(以秒为单位)的函数:
W(t) = C(t-K)³ + W_max(K 是到 W_max 的时间) - 窗口增长只和经过了多少时间有关,和收到多少 ACK、RTT 多长无关
- 无论 RTT 是 10ms 还是 100ms,同样时间内窗口增长量相同
- 这让不同 RTT 的流在竞争带宽时更公平
类比:Reno 像「按步数发工资」,腿短的人(短 RTT)迈得快拿得多;CUBIC 像「按时间发工资」,不管走得快慢,同样时间涨一样的窗口。
Reno 的窗口增长由 ACK 驱动,短 RTT 流 ACK 来得快、窗口增长快,造成 RTT 不公平;CUBIC 用基于时间的三次函数增长窗口,与 ACK 频率/RTT 解耦,不同 RTT 流增长速率相同,因此更公平。
TCP 状态机总览
TCP 连接从建立到关闭共 11 个状态,理解状态机是排查网络问题的基础。
| 状态 | 含义 | 谁处于此状态 |
| LISTEN | 服务端监听中,等待客户端连接 | 服务端 |
| SYN_SENT | 客户端发了 SYN,等待服务端 SYN+ACK | 客户端(主动打开) |
| SYN_RCVD | 服务端收到 SYN,发了 SYN+ACK,等待客户端 ACK | 服务端 |
| ESTABLISHED | 连接建立,数据可以双向传输 | 双方 |
| FIN_WAIT_1 | 主动关闭方发了 FIN,等待 ACK 或对端 FIN | 主动关闭方 |
| FIN_WAIT_2 | 主动关闭方收到 ACK,等待对端 FIN | 主动关闭方 |
| TIME_WAIT | 收到对端 FIN,发了 ACK,等待 2MSL 后关闭 | 主动关闭方 |
| CLOSING | 双方同时关闭:发了 FIN 也收到了 FIN,但没收到 ACK | 双方(罕见) |
| CLOSE_WAIT | 被动关闭方收到 FIN,发了 ACK,等待应用 close | 被动关闭方 |
| LAST_ACK | 被动关闭方发了自己的 FIN,等待最后一个 ACK | 被动关闭方 |
| CLOSED | 连接完全关闭(无状态) | - |
关闭路径:主动关闭 vs 被动关闭
TCP 关闭的关键不对称:谁先调 close()(或发 FIN),谁就进入 TIME_WAIT。
01
FIN_WAIT_1
主动方发 FIN → 等待 ACK
02
FIN_WAIT_2
收到 ACK → 等待被动方发 FIN
03
TIME_WAIT
收到被动方 FIN → 发 ACK → 等 2MSL
被动关闭路径:
01
CLOSE_WAIT
收到 FIN 并发 ACK → 等待应用层 close()
02
LAST_ACK
应用 close(),发 FIN → 等待最后 ACK
面试常考:HTTP 短连接中,通常是服务端先关闭(因为响应发完就 close),所以服务端 TIME_WAIT 更多;HTTP keep-alive 连接中,谁先关看超时配置。
TIME_WAIT 深度解析
TIME_WAIT 是主动关闭方必须经过的最终状态,停留时间 2MSL(Maximum Segment Lifetime),Linux 中 MSL 默认 60s,所以 TIME_WAIT 默认 120s(可通过 net.ipv4.tcp_fin_timeout 调整,但不建议改)。
TIME_WAIT 存在的两个原因
- 保证全双工可靠终止:主动方发的最后一个 ACK 如果丢了,被动方会重发 FIN;主动方在 2MSL 内重发 ACK,而不是直接 CLOSED 后回 RST 导致被动方异常关闭。
- 让旧连接的延迟报文消亡:网络中可能存在属于旧连接的流浪报文(迟到的数据包);等 2MSL(足够报文在网络中最长生存时间的两倍)后,这些旧报文自然消亡,不会污染使用相同四元组的新连接。
TIME_WAIT 过多的问题
- 端口耗尽:客户端主动关闭大量短连接时,(src_ip, src_port, dst_ip, dst_port) 四元组中的 src_port(临时端口)被大量 TIME_WAIT 占用。临时端口范围默认
net.ipv4.ip_local_port_range = 32768-60999(约 28000 个),并发短连接超过这个数会报 Cannot assign requested address。 - 内存占用:每个 TIME_WAIT 连接占用内核内存(tcp_timewait_bucket),大量堆积增加内存压力。
TIME_WAIT 解决方案(按推荐顺序)
| 方案 | 说明 | 推荐度 |
|---|
| 连接池/长连接 | 复用连接而不是每次新建+关闭,根本上减少 TIME_WAIT 产生。HTTP keep-alive、gRPC 长连接、数据库连接池都是这个思路。 | ⭐⭐⭐⭐⭐ 首选 |
SO_REUSEADDR | 服务端 bind 时允许绑定 TIME_WAIT 状态的端口(监听 socket 专用),解决服务端重启「Address already in use」。对客户端端口耗尽无效。 | ⭐⭐⭐⭐ 服务端必开 |
net.ipv4.tcp_tw_reuse | 客户端对外连时允许复用 TIME_WAIT 状态超过 1s 的端口,必须配合 TCP timestamps 使用(net.ipv4.tcp_timestamps=1,默认开启)。仅对出站连接有效,不影响入站。 | ⭐⭐⭐ 出站可开 |
| 增大端口范围 | net.ipv4.ip_local_port_range = 1024 65535,增加可用临时端口数。 | ⭐⭐ 辅助手段 |
tcp_tw_recycle | NAT 环境下会导致问题(不同客户端时间戳不一致导致丢 SYN),内核 4.12 已移除。绝对不要用。 | ❌ 已废弃 |
面试回答顺序:首选连接池/长连接(从源头减少);服务端开 SO_REUSEADDR;客户端端口耗尽可开 tcp_tw_reuse(有 timestamps 前提);不要用 tcp_tw_recycle。
CLOSE_WAIT 积累:永远是应用 bug
CLOSE_WAIT 状态表示:内核已经收到对端 FIN 并发了 ACK,但应用层没有调用 close()。内核在等应用程序关闭 socket。
根因只有一个:应用程序没有 close() 这个 socket。
- 代码 bug:读完数据后忘了 close(异常路径没关 fd)
- 死锁/阻塞:应用线程卡住了(如阻塞在另一个 IO、GC 停顿、锁竞争),没机会执行 close
- 连接泄漏:连接池/引用计数 bug,fd 被持有没释放
检测命令:
# 查看所有 CLOSE_WAIT 连接
ss -t state close-wait
# 或者 netstat
netstat -anp | grep CLOSE_WAIT
# 统计各状态数量
ss -ant | awk '{print $1}' | sort | uniq -c | sort -rn
这不是内核问题,不是网络问题,是应用程序 bug。大量 CLOSE_WAIT 会导致 fd 泄漏,最终触发「Too many open files」。
CLOSE_WAIT 积累的诊断思路:ss 看 CLOSE_WAIT 连接 → lsof 看对应进程 → strace/gdb 看进程在干什么 → 检查代码路径是否所有分支都 close() 了 socket。常见于异常/错误路径漏关连接。
其他异常状态
| 状态 | 原因 | 处理 |
| FIN_WAIT_2 堆积 | 主动关闭方发了 FIN 并收到 ACK,但被动方一直不发 FIN(对端崩溃/死机/代码 bug 不关连接) | 内核孤儿 socket 由 tcp_fin_timeout(默认 60s)管理,超时后内核强制回收。应用层应该设置超时。 |
| ESTABLISHED 但半开(half-open) | 一方 ESTABLISHED,另一方已经崩溃/断网/断电,没有发 FIN。TCP 本身不检测这种情况,因为没有数据传输就不会发现问题。 | TCP Keepalive(默认 2h 才探测,太慢)或应用层心跳(推荐,秒级检测) |
| SYN_RECV 堆积 | 可能是 SYN flood 攻击,或服务端 backlog 满了 | 开启 syncookies(net.ipv4.tcp_syncookies=1),增大 net.core.somaxconn 和 tcp_max_syn_backlog |
AI Infra 常见网络问题
| 现象 | 根因 | 解决方案 |
| Connection reset by peer | 对端发了 RST:对端崩溃重启、SO_LINGER timeout=0(主动发 RST 而非 FIN)、防火墙/iptables 发 RST、往已关闭 socket 写 | 检查对端日志、检查防火墙规则、检查应用是否在写已关闭连接 |
| Broken pipe(SIGPIPE) | 对端已经关闭连接(收了 RST 或 FIN),但本端还在写数据,内核发 SIGPIPE 信号 | 忽略 SIGPIPE(服务端程序常规操作),写时处理 EPIPE 错误 |
| Address already in use | 端口上有 TIME_WAIT 连接,新进程 bind 失败 | 服务端开 SO_REUSEADDR;或者等 TIME_WAIT 超时 |
| Too many open files | 进程打开 fd 数超过 ulimit(nofile),通常是连接泄漏(大量 CLOSE_WAIT) | ulimit -n 查看并调大;排查 CLOSE_WAIT/连接泄漏根因;检查 fs.file-max |
| Cannot assign requested address | 临时端口耗尽:客户端 TIME_WAIT 太多,无可用端口发起新连接 | 连接池/长连接首选;开 tcp_tw_reuse;增大 ip_local_port_range |
重要 Socket 选项
| 选项 | 作用 | 何时用 |
SO_REUSEADDR | 允许 bind 到 TIME_WAIT 状态的端口 | 所有服务端必开 |
SO_REUSEPORT | 多个进程/线程 bind 同一端口,内核做负载均衡 | 多进程服务(如 Nginx worker、gRPC 多进程) |
SO_KEEPALIVE | 开启 TCP Keepalive(默认 2h 空闲才探测) | 长连接场景检测死连接(不如应用心跳) |
TCP_NODELAY | 禁用 Nagle 算法,立即发送小包 | RPC、数据库、实时通信必开 |
TCP_QUICKACK | 立即发 ACK 不 delay(每次收发后可能被重置) | 需要极低延迟时用 |
SO_LINGER | 控制 close() 行为:timeout=0 发 RST 直接断(避免 TIME_WAIT,但粗暴);timeout>0 等数据发完 | 特殊场景慎用;timeout=0 跳过 TIME_WAIT 但可能丢数据 |
SO_RCVBUF/SO_SNDBUF | 设置接收/发送缓冲区大小(影响 rwnd 上限) | 高带宽长肥管道调大(注意也受内核参数限制) |
网络排障命令清单
| 命令 | 用途 |
ss -ti | 显示 TCP 连接详情,包括 RTT、rttvar、cwnd、ssthresh、mss 等内部参数(排障神器!) |
ss -t -a -s | 显示所有 TCP 连接状态统计(各状态数量) |
ss -t state established | 只看 ESTABLISHED 连接 |
netstat -anp | 经典但慢,显示所有连接和进程 PID(新系统推荐 ss) |
tcpdump -i eth0 -w capture.pcap port 8080 | 抓包保存到 pcap 文件,用 Wireshark 分析 |
tcpdump -i any -nn 'host x.x.x.x and port 1234' -A | 实时抓包并以 ASCII 显示内容(快速调试) |
ip route | 查看路由表 |
tc qdisc show | 查看流量控制队列规则(tc 还可以模拟延迟/丢包) |
ping / mtr / traceroute | 基础连通性和路径诊断 |
lsof -i :port | 查看哪个进程占用端口 |
排障优先级:ss 看状态 → ss -ti 看 TCP 内部参数(RTT 是否异常、cwnd 是否卡住)→ tcpdump 抓包确认报文交互 → dmesg/日志看内核信息。
Q: 服务器出现大量 CLOSE_WAIT 是什么问题?
100% 是应用程序 bug,不是内核问题,不是网络问题。
CLOSE_WAIT 状态的含义:对端已经发 FIN 关闭了连接(内核收到并回了 ACK),但本地应用程序没有调用 close() 来关闭自己这一侧的 socket。内核在等应用层关闭。
常见原因:
- 异常路径漏关连接:代码 try-catch/error 处理路径里忘了 close fd
- 线程阻塞/死锁:处理连接的线程卡在其他地方(如另一个 IO、GC stop-the-world、锁竞争),没机会执行 close
- 连接池泄漏:连接从池里借出来但没还回去,或引用计数错误导致 fd 永远不释放
- 框架 bug:某些异步框架中 response 没消费完导致连接没被回收
排查步骤:
ss -t state close-wait 或 netstat -anp | grep CLOSE_WAIT 看 CLOSE_WAIT 连接的目标端口和进程 PIDls -l /proc/<pid>/fd | wc -l 确认 fd 数量是否持续增长strace -p <pid> 或 gdb -p <pid> 看进程/线程卡在哪里- 检查代码:所有 read/write 错误路径、异常处理分支是否都有 close
- 检查是否有连接池泄漏(借了不还)
大量 CLOSE_WAIT = 应用程序没 close() socket,根因通常是异常路径漏关、线程阻塞或连接泄漏;用 ss + lsof + strace 定位到具体代码。
Q: TIME_WAIT 太多怎么处理?
先搞清楚是哪一侧产生 TIME_WAIT:
- 服务端 TIME_WAIT 多:通常是服务端主动关闭(短连接、HTTP/1.0 无 keep-alive)
- 客户端 TIME_WAIT 多:客户端大量短连接压测/调用下游
解决方案按优先级:
1. 长连接/连接池(最推荐,根治):
- HTTP 用 keep-alive,gRPC/Thrift 天然长连接
- 数据库、Redis 客户端都用连接池
- 从源头减少连接创建/关闭次数
2. SO_REUSEADDR(服务端必开):
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
允许 bind 到 TIME_WAIT 端口,解决服务重启「Address already in use」。
3. tcp_tw_reuse(出站连接可开):
sysctl -w net.ipv4.tcp_tw_reuse=1
# 确保 timestamps 开启(默认开)
sysctl -w net.ipv4.tcp_timestamps=1
允许出站连接复用超过 1 秒的 TIME_WAIT 端口。依赖 TCP timestamps 防止旧报文干扰。注意:仅对主动连接(connect 方)有效,不影响入站。
4. 增大端口范围(辅助):
sysctl -w net.ipv4.ip_local_port_range="1024 65535"
不要做的事:
tcp_tw_recycle:内核 4.12 已移除,NAT 环境导致随机丢包,坚决不用- 改小 MSL/tcp_fin_timeout:破坏 TCP 协议语义
SO_LINGER timeout=0:发 RST 粗暴关闭,可能丢数据,只在特定测试场景用
TIME_WAIT 处理优先级:长连接/连接池(根治)→ SO_REUSEADDR(服务端)→ tcp_tw_reuse(客户端出站)→ 增大端口范围;不要改 MSL 或用 tcp_tw_recycle。
Q: 怎么排查 TCP 连接问题?
排障路径(从粗到细):
现象确认 → 状态检查 → 内核参数 → 抓包分析 → 应用定位
Step 1:确认现象
- 是连接建立失败?超时?重置?还是已建立但数据传不过去?
- 是所有连接都有问题还是个别?是偶发还是必现?
Step 2:ss 看连接状态
ss -s # 各状态连接统计
ss -ti state established '( dport = :8080 or sport = :8080 )' # 看具体连接的 RTT/cwnd/rto/mss
ss -tan | awk '{print $1}' | grep -v State | sort | uniq -c # 各状态计数
重点看 ss -ti 输出:rto(重传超时)是否过大、cwnd 是否被打低(可能丢包拥塞)、rtt/rttvar 是否异常(网络延迟/抖动)、retrans 是否有重传。
Step 3:检查系统资源
ulimit -n # fd 上限
cat /proc/sys/fs/file-nr # 已用/最大文件句柄
dmesg | tail -50 # 内核日志(OOM、nf_conntrack full 等)
sysctl net.ipv4.tcp_mem # TCP 内存限制
Step 4:tcpdump 抓包确认报文级行为
tcpdump -i any -nn 'host 10.0.0.1 and tcp port 8080' -w /tmp/cap.pcap
# 或实时看
tcpdump -i any -nnA 'tcp port 8080' | head -200
抓包看三次握手是否完成、有没有重传、有没有 RST、RTT 多大。
Step 5:定位应用层
lsof -i :<port> 查进程strace -p <pid> 跟踪系统调用(看卡在 send/recv/connect 哪个调用)- 检查应用日志、GC 日志、线程 dump
排障路径:ss 看状态和 TCP 参数(rtt/cwnd/retrans)→ 检查系统资源(fd/nf_conntrack/内核日志)→ tcpdump 抓包确认报文 → strace/lsof 定位到具体应用逻辑。ss -ti 是 TCP 排障最有用的命令。
Q: TCP keepalive 和应用层心跳的区别?
| 维度 | TCP Keepalive | 应用层心跳 |
|---|
| 层级 | 内核 TCP 协议栈 | 应用协议(如 HTTP/2 ping、gRPC keepalive、WebSocket ping) |
| 默认时间 | 空闲 2 小时才开始探测(tcp_keepalive_time=7200),太慢 | 通常 10s-60s 间隔,可配置 |
| 检测内容 | 只检测 TCP 连接是否存活(内核层面) | 检测应用是否真的活着(线程没卡、没 GC 停顿、能处理请求) |
| 能否检测假死 | 不能:应用死锁/GC 停顿但内核 TCP 栈还在,keepalive 仍然正常响应 | 能:心跳需要应用层响应,假死时心跳超时 |
| 防火墙/NAT 保活 | 可以(有报文就不会清状态表) | 可以 |
| 负载均衡 | 四层 LB 可能不转发 keepalive 探针 | 应用层数据一定会被转发和处理 |
| 跨语言 | 统一内核行为,与应用无关 | 需要应用协议支持,各框架实现不同 |
结论:生产环境的健康检查和死连接检测应该用应用层心跳,而不是依赖 TCP Keepalive。TCP Keepalive 默认间隔 2 小时对生产完全无用,即使调短了也无法检测应用层假死(死锁、GC、阻塞)。
TCP Keepalive 适用于:你不控制应用协议、没有应用层心跳机制、作为最后的兜底检测。
TCP Keepalive 是内核级检测(默认 2h,太慢,且无法检测应用假死),应用层心跳是应用级检测(秒级,能检测假死/GC/死锁),生产环境优先用应用层心跳,Keepalive 只做兜底。
Q: SYN flood 怎么防御?
SYN flood 攻击原理:攻击者发送大量伪造源 IP 的 SYN 包,服务端收到后为每个 SYN 分配内核状态(放入 SYN 队列),回 SYN+ACK,但永远收不到 ACK(因为源 IP 是伪造的),最终 SYN 队列被占满,合法连接被拒绝。
防御方案:
1. SYN Cookies(最核心)
- 内核不为半开连接分配任何状态(不存 SYN 队列)
- Instead,把连接信息(源/目的 IP/端口、MSS、时间戳等)通过密码学哈希编码到 ISN(初始序列号)中
- 当收到合法 ACK 时,从 ACK 号反解出原始信息,重建连接状态
- 不需要存储半开连接,从根本上抗 SYN flood
sysctl -w net.ipv4.tcp_syncookies=1 # 默认已开启
2. 增大 SYN 队列和 backlog
sysctl -w net.ipv4.tcp_max_syn_backlog=65535
sysctl -w net.core.somaxconn=65535
3. 减少 SYN+ACK 重试次数
sysctl -w net.ipv4.tcp_synack_retries=2 # 默认 5,改小快速释放无效半开连接
4. 网络层防御
- 防火墙/iptables 限制 SYN 包速率
- 云厂商的 DDoS 防护/Anti-DDoS 服务(清洗流量)
- BPF/XDP 在内核层早期丢弃异常 SYN
SYN Cookies 的代价:因为不保存 SYN 阶段的状态,部分 TCP 选项(如 Window Scale、SACK 选择性确认)可能无法正确协商;但在正常负载下不会启用 syncookies,只有 SYN 队列满时才触发,是 tradeoff。
SYN flood 核心防御是 tcp_syncookies(不存半开连接状态,信息编码到 ISN),辅以增大 syn_backlog/somaxconn、减少 synack_retries 和网络层 DDoS 清洗。
DNS:域名系统
DNS(Domain Name System)是互联网的电话簿:将域名翻译为 IP 地址。DNS 是一个分层的分布式数据库系统。
| 概念 | 说明 |
| 层级结构 | 根域(.)→ 顶级域(TLD,如 .com/.cn/.org)→ 二级域(如 bytedance.com)→ 子域(如 www.bytedance.com) |
| 递归查询 | 客户端 → Local DNS(如 8.8.8.8、运营商 DNS),要求返回最终答案(或报错) |
| 迭代查询 | Local DNS → 根 → TLD → 权威 DNS,每级返回下一级地址,Local DNS 自己一步步问 |
| 缓存机制 | DNS 记录有 TTL(Time To Live),各级 DNS 缓存记录直到 TTL 过期,减少重复查询 |
常见 DNS 记录类型:
| 类型 | 用途 |
| A | 域名 → IPv4 地址 |
| AAAA | 域名 → IPv6 地址 |
| CNAME | 域名别名(指向另一个域名) |
| MX | 邮件服务器地址 |
| TXT | 任意文本记录(SPF/DKIM/域名验证等) |
| SRV | 服务定位(服务发现,如 LDAP/K8s headless service) |
| PTR | 反向解析:IP → 域名 |
DNS 传输协议:UDP vs TCP
DNS 主要使用 UDP 53 端口:简单、快速、无连接开销。但当响应报文超过 512 字节(原始 DNS 限制)时会被截断(TC 标志位 = 1),客户端需要改用 TCP 53 端口重新查询。现代 DNS 支持 EDNS0(Extension Mechanisms for DNS)将 UDP 响应大小提升到 4096 字节或更高。
加密 DNS:
| 协议 | 端口 | 特点 |
| DNS over HTTPS (DoH) | 443 (HTTPS) | DNS 查询包装在 HTTPS 中,和普通 Web 流量混在一起,防 ISP 窃听/篡改,但也可能被企业防火墙阻拦 |
| DNS over TLS (DoT) | 853 (专用端口) | TLS 加密 DNS,专用端口容易被识别和封锁 |
传统 DNS 明文传输(UDP 53)容易被窃听和篡改(DNS 劫持/污染);DoH/DoT 加密 DNS 保证隐私,但 DoH 因为跑在 443 端口更难被封锁。
K8s 中 DNS 常见问题
Kubernetes 集群中 CoreDNS 是最常见的性能瓶颈之一,面试常考。
ndots:5 导致的 search domain 扩展问题:
Pod 的 /etc/resolv.conf 默认配置:
nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5
ndots:5 含义:域名中点(.)的数量少于 5 个时,会依次拼接 search domain 列表中的后缀尝试解析(最多 5-1=4 个?实际是 search 列表长度次),最后才用绝对域名(加末尾点)查询。例如查询 my-service:
my-service.default.svc.cluster.localmy-service.svc.cluster.localmy-service.cluster.localmy-service(绝对域名)
问题:每个外部域名(如 api.example.com 只有 2 个点)都会先触发 3-4 次无效的内部查询,再查外部域名。DNS 超时默认 5s,这些无效查询会显著增加延迟。
解决方案:
- 使用 FQDN(全限定域名),末尾加
.(如 api.example.com.),跳过多余 search - 调小
ndots(如 ndots:2) - 使用 NodeLocal DNSCache(DaemonSet 节点级 DNS 缓存)减少到 CoreDNS 的跳数
- 优化 CoreDNS 配置和副本数
TLS 1.2 握手:2-RTT
TLS(Transport Layer Security)是 HTTPS 的加密层,在 TCP 连接建立后进行握手协商密钥。TLS 1.2 需要 2-RTT 才能完成握手开始加密通信。
01
ClientHello
客户端发:支持的 TLS 版本、密码套件列表、随机数 Client Random、可选 Session ID
02
ServerHello + 证书
服务端回:选定密码套件、随机数 Server Random、证书(Certificate)、ServerKeyExchange(密钥交换参数,如 ECDHE)、ServerHelloDone
03
ClientKeyExchange + 切换密钥
客户端:验证证书 → 生成 Pre-Master Secret(用服务端公钥加密或 ECDHE 计算)→ 发 ClientKeyExchange → ChangeCipherSpec(之后用协商密钥加密)→ Finished(握手完整性验证)
04
服务端切换密钥
服务端:ChangeCipherSpec → Finished。握手完成,开始加密通信
密钥计算:Client Random + Server Random + Pre-Master Secret → Master Secret → 对称加密密钥。RSA 密钥交换中 Pre-Master Secret 用服务端公钥加密(不支持前向保密);ECDHE 中双方通过椭圆曲线 Diffie-Hellman 交换临时公钥,各自计算出相同的共享密钥(支持前向保密 PFS)。
TLS 1.2 总共 2-RTT(TCP 三次握手后再加 2 个 TLS RTT = 总共 3-RTT 才能发加密请求)。RSA 密钥交换不支持 PFS,ECDHE 支持 PFS(即使服务端私钥泄露也不能解密历史流量)。
TLS 1.3 握手:1-RTT 与 0-RTT
TLS 1.3(RFC 8446,2018)是重大改进,将握手延迟减半,并大幅简化密码套件。
1-RTT 握手(新连接):
01
ClientHello + key_share
客户端一次性发送:ClientHello + 猜测的密钥共享参数(key_share,支持的 ECDHE 组的临时公钥)+ 支持的密码套件。不需要等 ServerHello 就能发密钥材料
02
ServerHello + 证书 + Finished
服务端:选择 key_share → 自己的 key_share + 证书 + CertificateVerify + Finished(全部加密!)。客户端收到后可以直接发应用数据
关键改进:
- 1-RTT 完成握手:客户端在第一条消息就带上密钥猜测,省去了 TLS 1.2 的 ServerKeyExchange/ClientKeyExchange 来回
- 删除不安全的算法:移除 RSA 密钥交换(无 PFS)、移除 RC4/3DES/SHA-1 等弱密码套件,只支持 ECDHE/DHE 密钥交换 + AEAD 加密(AES-GCM/ChaCha20-Poly1305)
- Server 端消息加密:ServerHello 之后的所有消息(证书等)都是加密的,TLS 1.2 中证书是明文
0-RTT(PSK 恢复):
如果客户端之前连接过服务器并持有 PSK(Pre-Shared Key,通过 Session Ticket 或 PSK 建立),可以在第一个 ClientHello 中直接携带应用数据,不需要等握手完成,实现 0-RTT 数据发送。
TLS 1.3 将新连接握手从 2-RTT 降到 1-RTT(总延迟从 TCP 3-RTT + TLS 2-RTT = 5-RTT → TCP 3-RTT + TLS 1-RTT = 4-RTT),重连 0-RTT 可以立即发数据。删除了所有不支持前向保密的密钥交换。
0-RTT 的安全风险:重放攻击
0-RTT 数据虽然快,但有重要安全缺陷:不提供重放保护(non-replayability)。
原因:0-RTT 数据是用 PSK 加密的,同一个 PSK 可以被重复使用;攻击者如果截获了 0-RTT 数据包,可以重放它,服务器可能重复执行操作(如重复转账、重复下单)。
缓解措施:
- 0-RTT 数据只用于幂等请求(如 GET/HEAD),绝对不能用于 POST/PUT/DELETE 等非幂等操作
- 服务端记录 anti-replay token/nonce,拒绝重复的 0-RTT 数据
- 正常 1-RTT 握手完成后的流量是完全安全的,只有 0-RTT 早期数据有风险
TLS 会话恢复与终止
会话恢复(Session Resumption):
| 机制 | TLS 版本 | 原理 |
| Session ID | TLS 1.2 | 服务端存储会话状态(Session ID → Master Secret),客户端复用 Session ID,服务端查缓存恢复 |
| Session Ticket | TLS 1.2 | 服务端将会话状态加密成 blob(Session Ticket)发给客户端,客户端下次带回,服务端解密恢复。服务端无状态(类似 HTTP Cookie) |
| PSK (Pre-Shared Key) | TLS 1.3 | 统一的 PSK 机制,可以通过 Session Ticket 或外部建立(如 out-of-band),支持 0-RTT |
TLS 终止位置:
- LB 层终止(SSL Offload):最常见。负载均衡器做 TLS 解密,证书集中管理,后端用明文 HTTP(性能好但内网无加密)
- Sidecar 终止:Service Mesh(Istio/Linkerd)中,Envoy sidecar 做 mTLS,服务间通信加密
- 应用层终止:应用自己处理 TLS,最安全但消耗应用 CPU
mTLS(双向 TLS):不仅服务端出示证书,客户端也要出示证书,用于服务间身份认证。Service Mesh 中 Istio 通过 mTLS 实现服务到服务的零信任安全。
QUIC:基于 UDP 的新一代传输协议
QUIC(Quick UDP Internet Connections)由 Google 设计,IETF 标准化,运行在 UDP 之上,HTTP/3 就是 HTTP/2 over QUIC。
为什么基于 UDP 而不是新协议或 SCTP?
- 中间盒兼容性:互联网上的 NAT/防火墙只普遍放行 TCP 和 UDP,新 IP 协议号会被大量设备丢弃
- 用户空间实现:QUIC 在用户空间实现(不需要内核修改),可以快速迭代升级;TCP 实现在操作系统内核,升级极慢(设备换内核/系统要几年)
- UDP 就是最小特性的传输层:UDP 只提供端口复用,QUIC 在其上实现自己需要的可靠性、拥塞控制、流控、多路复用
- SCTP 虽然解决了队头阻塞,但不被 NAT/防火墙广泛支持,且同样在内核中难以迭代
QUIC 核心特性
| 特性 | 说明 |
| 多路复用 Streams | 一个 QUIC 连接包含多个独立的 byte stream,每个 stream 独立可靠、独立流量控制。单个 stream 丢包只重传该 stream,不阻塞其他 stream——从根本上解决了 TCP 层队头阻塞 |
| 连接迁移 | 连接由 Connection ID 标识(不是传统的四元组 src_ip:src_port-dst_ip:dst_port)。客户端从 WiFi 切换到 4G(IP 变化)时,Connection ID 不变,连接可以继续不中断(对移动设备极其友好) |
| 内置 TLS 1.3 | QUIC 将 TLS 1.3 集成在握手层(不跑在 QUIC 之上),1-RTT 握手建立加密连接;支持 0-RTT 数据。QUIC 始终加密,没有明文版本 |
| 自建可靠性层 | QUIC 在 UDP 上自己实现重传、拥塞控制(可插拔,默认 CUBIC 或 BBR)、流量控制 |
| 两级流控 | per-stream 流控(每个 stream 有独立窗口)+ connection-level 流控(整个连接总窗口),比 TCP 单窗口更精细 |
| 改进的握手 | QUIC 握手同时完成传输层和加密层握手,1-RTT 完成;0-RTT 支持重连时直接发数据。相比 TCP + TLS 1.2(3+2=5 RTT)快很多 |
HTTP/1.1 vs HTTP/2 vs HTTP/3 对比
| 维度 | HTTP/1.1 | HTTP/2 | HTTP/3 |
| 底层传输 | TCP | TCP + TLS | QUIC (UDP) + TLS 1.3 |
| 多路复用 | 无(一个连接同一时刻一个请求,靠多连接并发) | 有(binary framing,多请求共享 TCP 连接) | 有(QUIC streams) |
| 队头阻塞 | 应用层 HoL(一个请求阻塞同连接其他请求) | TCP 层 HoL(丢包阻塞所有 stream) | 无传输层 HoL(stream 独立丢包重传) |
| 握手 RTT | TCP 3-RTT + TLS 1.2 2-RTT = 5-RTT | 同 HTTP/1.1(HTTP/2 在 TLS 之上) | QUIC+TLS 1-RTT(总共 1-RTT,因为传输和加密握手合并) |
| 连接迁移 | 不支持(四元组变了连接就断) | 不支持(同 TCP) | 支持(Connection ID) |
| 加密 | 可选(HTTPS 才有) | 实际生产中必需 TLS | 始终加密(强制) |
| 头部压缩 | 无(每次重复发完整 headers) | HPACK | QPACK(适配 QUIC 流) |
HTTP/2 解决了 HTTP/1.1 的应用层队头阻塞(多路复用),但仍跑在 TCP 上,TCP 层队头阻塞无法解决;HTTP/3 基于 QUIC,彻底解决队头阻塞(每个 stream 独立)+ 更快握手 + 连接迁移。
Q: TLS 1.3 相比 1.2 改进了什么?
1. 握手延迟减半:2-RTT → 1-RTT
TLS 1.2 需要 ServerHello → ClientKeyExchange 两轮协商密钥材料;TLS 1.3 客户端在第一个 ClientHello 就带上 key_share(ECDHE 临时公钥),服务端直接回复自己的 key_share 并完成密钥协商,省去一个 RTT。加上 TCP 三次握手,完整建连从 5-RTT 降到 4-RTT。
2. 0-RTT 恢复
持有 PSK(会话票据)的重连客户端可以在第一个包里直接发送加密的应用数据,无需等待握手完成。
3. 强制前向保密(PFS)
删除了 RSA 静态密钥交换(不支持 PFS,如果服务端私钥泄露,历史流量都能解密),只保留 ECDHE/DHE 临时密钥交换。即使服务端长期私钥泄露,每次会话的临时密钥独立,无法解密历史流量。
4. 删除不安全算法
移除了 RC4、3DES、SHA-1、MD5、CBC 模式等弱算法,只保留 AEAD 加密套件(AES-GCM、ChaCha20-Poly1305)。
5. 握手加密
TLS 1.2 中 ServerHello 之后的证书等信息仍是明文传输(被动监听者可以看到你访问了哪个网站);TLS 1.3 中 ServerHello 之后的所有消息都是加密的(但 SNI 仍然明文,ESNI/ECH 正在解决这个问题)。
6. 密码套件大幅简化
TLS 1.2 有数百种密码套件组合(密钥交换+加密+MAC+PRF 各选),TLS 1.3 简化到只有几个套件,减少协商复杂度和实现 bug。
TLS 1.3 核心改进:1-RTT 握手(快一倍)+ 0-RTT 恢复 + 强制前向保密 + 删除弱算法 + 握手加密;代价是 0-RTT 有重放风险,需要服务端做幂等性保障。
Q: QUIC 为什么基于 UDP 而不是 SCTP 或新协议?
核心原因是互联网中间盒(middlebox)的现实约束和可部署性:
1. 中间盒兼容性
互联网上有大量 NAT 设备、防火墙、负载均衡器,它们大多只识别 TCP 和 UDP。一个新的 IP 协议号(如 SCTP 是 132)会被大量中间设备直接丢弃。UDP 端口普遍开放,几乎 100% 能通过。
2. 用户空间可演进性
TCP 实现在操作系统内核中,升级 TCP 意味着升级操作系统——从 Windows/Linux 内核补丁到终端用户更新,周期长达数年。QUIC 在用户空间实现(浏览器、CDN、客户端库),协议迭代只需要更新应用,不需要等待内核更新。这也是为什么 QUIC 能快速部署而 TCP 改进(如 Multipath TCP)推广很慢。
3. SCTP 的问题
SCTP(Stream Control Transmission Protocol,RFC 4960)确实原生支持多流和不队头阻塞,但:
- 同样在内核中实现,部署和升级慢
- NAT/防火墙穿透性差(协议号 132 不被普遍支持)
- 没有 TLS 集成,加密仍需在其上叠加
- 连接迁移等 QUIC 的移动性特性 SCTP 没有
4. UDP 足够「瘦」
UDP 只提供端口复用和 best-effort 数据报,QUIC 需要的所有特性(可靠、有序、拥塞控制、流控、多路复用、加密、连接迁移)都可以在用户空间 UDP 上实现,不需要内核支持。
QUIC 选 UDP 主要因为中间盒兼容性(UDP 普遍放行,新 IP 协议被丢弃)和用户空间可演进(不需要内核升级,快速迭代);SCTP 虽然有多流但同样在内核、穿透性差、推广难。
Q: HTTP/3 解决了什么 HTTP/2 解决不了的问题?
最核心:TCP 层队头阻塞(HoL Blocking)
HTTP/2 虽然在应用层做了多路复用(一个 TCP 连接上跑多个请求/响应),但底层是 TCP 字节流。TCP 要求有序交付,一个包丢了,所有后续包(即使属于其他 HTTP/2 stream)都要等重传——这就是传输层队头阻塞。在丢包率高的网络(如 WiFi、蜂窝网络)中,HTTP/2 多路复用的效果甚至可能比 HTTP/1.1 多连接还差。
HTTP/3 (QUIC) 中每个 stream 是独立的,一个 stream 丢包只影响该 stream 的重传和排序,其他 stream 可以继续传输。
其他 HTTP/2 无法解决的问题:
1. 连接建立慢:HTTP/2 跑在 TCP + TLS 上,需要 TCP 三次握手(1.5-RTT)+ TLS 握手(TLS 1.2 是 2-RTT,1.3 是 1-RTT),总共 3-4 RTT 才能发请求。QUIC 将传输握手和 TLS 握手合并,新连接 1-RTT 即可(0-RTT 恢复可立即发数据)。
2. 连接迁移:HTTP/2 over TCP 连接由四元组标识,IP 变化(WiFi 切 4G)连接就断了;QUIC 用 Connection ID 标识连接,网络切换不中断,对移动设备用户体验提升显著。
3. 连接迁移对 NAT 重绑定更鲁棒:NAT 设备可能重新映射端口(NAT rebinding),TCP 连接因此中断,QUIC 的 Connection ID 机制不受影响。
HTTP/3(QUIC)解决 HTTP/2 的 TCP 层队头阻塞(QUIC stream 独立丢包不阻塞其他流)、握手慢(合并传输+TLS握手 1-RTT/0-RTT)、无连接迁移(Connection ID 支持网络切换不断连)三个根本问题。这三个问题 HTTP/2 因为依赖 TCP 无法在不换传输协议的情况下解决。
Q: DNS 在 K8s 中为什么慢?怎么优化?
K8s DNS 慢的主要原因:
1. ndots:5 导致 search domain 扩展
默认 ndots:5 意味着任何域名中点数少于 5 的查询都会先拼上 search domain 列表(svc.cluster.local 等)依次尝试,最后才查原始域名。访问外部域名时会产生多次无效查询,每次查询超时默认 5 秒。
2. CoreDNS 单点/性能瓶颈
所有 Pod 的 DNS 查询都发往 CoreDNS Service(ClusterIP),CoreDNS 副本不足或配置不当会成为瓶颈。conntrack 表满也会导致 DNS 丢包。
3. 到 CoreDNS 多跳网络
Pod → iptables/IPVS → CoreDNS Pod,每一跳都有延迟。
优化方案:
1. 使用 FQDN 或调小 ndots
dnsConfig:
options:
- name: ndots
value: "2"
访问外部服务用完整域名加末尾点(api.example.com.)跳过多余 search。
2. NodeLocal DNSCache
以 DaemonSet 在每个节点运行 DNS 缓存,Pod 的 DNS 查询先到节点本地缓存(169.254.x.x),命中直接返回,未命中才转发给 CoreDNS。减少到 CoreDNS 的跳数和压力。
3. CoreDNS 扩容与优化
- 增加 CoreDNS 副本数(根据集群规模)
- 开启 CoreDNS cache 插件
- 配置 NodeLocal + CoreDNS 二级缓存架构
4. 应用侧连接池/DNS 缓存
- 应用层缓存 DNS 结果(JVM TTL、Go resolver 缓存、连接池复用长连接)
- 减少 DNS 查询频率
5. 检查 conntrack 表
高并发下 nf_conntrack 表满会丢包,导致 DNS 超时:sysctl net.netfilter.nf_conntrack_max 调大。
K8s DNS 慢的主因是 ndots:5 触发多余 search domain 查询 + CoreDNS 集中式瓶颈;优化:调小 ndots 或用 FQDN + NodeLocal DNSCache 本地缓存 + CoreDNS 扩容 + 应用层 DNS 缓存。
Q: 0-RTT 有什么安全风险?
0-RTT 的安全风险核心是重放攻击(Replay Attack)。
为什么会有重放风险:
- 0-RTT 数据使用 PSK(Pre-Shared Key)加密,PSK 来自之前的会话(Session Ticket)
- 与 1-RTT 握手不同,0-RTT 数据不包含双方新生成的随机 Nonce 交换,缺乏防重放的上下文
- 攻击者捕获了 0-RTT 数据包后,可以在另一个连接中重放(resend)同一个加密数据包,服务器用同一个 PSK 能解密并处理
- 这可能导致非幂等操作被重复执行:重复转账、重复下单、重复提交表单
1-RTT 之后的数据为什么安全:1-RTT 握手完成后,双方通过 (Client Hello + Server Hello) 交换了新的随机数,会话密钥是新鲜的,重放旧数据包密钥不对。0-RTT 数据是在握手中途发送的,没有这个新鲜度保证。
缓解措施:
- 0-RTT 只用于幂等请求:客户端只在 GET/HEAD/OPTIONS 等幂等方法使用 0-RTT 发送数据;POST/PUT/DELETE 等非幂等请求等 1-RTT 握手完成后再发
- 服务端 anti-replay 机制:服务端记录 0-RTT 数据的唯一标识(nonce/timestamp),在窗口内拒绝重复的 0-RTT 数据。TLS 1.3 规范中服务端可以通过单飘带(single-use)ticket 或时间窗口来防重放
- 客户端控制:应用层可以决定是否启用 0-RTT(不是必须),敏感操作禁用 0-RTT
0-RTT 的安全风险是重放攻击:攻击者可以重放捕获的 0-RTT 数据导致非幂等操作重复执行(因为没有双方新鲜随机数交换)。缓解:0-RTT 只发幂等请求(GET)+ 服务端 anti-replay 机制(记录 nonce 拒绝重复)。