第 10 章 网络栈微观全景——一个数据包怎么爬进你的 socket

本章追一个问题:软中断把包从网卡捞上来之后,它还要穿过 IP 层、TCP 层,最后爬进你的 recv() 缓冲。这一段路,藏着 TCP 整套精妙(也精分)的设计。 你会看懂三件以前可能”背过但没通”的事:三次握手为什么是三次、accept 队列和 syn 队列为什么要分两级、拥塞控制在赌什么。 然后我们用两个真实事故把它们焊死:一个网关半夜拒绝新连接,一个 5% 重传率找了一周。 参数不单独列——它们长在事故的血肉里,你先看到”为什么会疼”,再看到”内核给了你哪个旋钮、它拿什么换什么”。


一、先把整条路铺开:一个数据包的 IP/TCP 旅程

墨:老哥,前面九章我们把一个程序怎么跑到 CPU 上、怎么 malloc、怎么 write 落盘、网卡怎么把包捞上来,都刨了一遍。现在把最后一块拼上:你 send("你好") 发出去的那几个字节,到对方 recv() 收到,中间到底过了哪些关?

你:应用调 send → 内核协议栈 → 网卡 → 网线 → 对面网卡 → 对面协议栈 → 对面 recv?

墨:骨架对,但”内核协议栈”这四个字里至少有四层,而且每一层都在做一件你意想不到的事。我给你画一张”下行发送”的微观路线图——注意,每一层都在往包上贴自己的信封:

你的进程 send("你好")
   │  (拷贝进内核 sk_buff,进入 TCP 发送缓冲区)
   ▼
[TCP 层]  分段、编号(序号)、算校验和、塞进发送窗口、按拥塞控制决定能发多少
   │  贴 TCP 头(源端口/目的端口/序号/窗口值/标志位 SYN/ACK/FIN...)
   ▼
[IP 层]   查路由表选下一跳、决定出哪个网卡、封装源/目的 IP、算 IP 头校验和
   │  贴 IP 头(协议号=6 表示里面是 TCP)
   ▼
[邻居子系统/ARP]  查目的 IP 的 MAC 地址(ARP 表),没命中就先发 ARP 广播等回复
   │  贴以太网头(目的 MAC / 源 MAC / 类型 0x0800)
   ▼
[网卡驱动 + 队列] 包进网卡发送环形队列(tx ring),DMA 拷到网卡
   ▼
网线 ───────────────────────────────────────────▶ 对面网卡

墨:接收方向是镜像的,但多了一个麻烦:TCP 是面向连接的字节流,不是消息。对面收下来之后,内核要把乱序到达的段排好、去掉重复、按序号拼回连续字节流,再放进你的 recv 缓冲区。你 recv() 拿到的,是已经”整理好”的字节流。

你:等等,“字节流不是消息”是什么意思?我 send 两次,对面 recv 一次不就全收到了?

墨:问得好——这正是 TCP 最大的反直觉。你 send("AB") 再 send("CD")",对面可能第一次 recv()就拿到”ABCD”,也可能拿到 ”AB”再拿到”CD”,也可能拿到 ”A”然后”BCD”。**TCP 不保证你的 send边界和对方的recv` 边界对齐。** 它只保证:最终字节顺序一字不差。这也是为什么应用层协议都得自己定边界(长度前缀、换行分隔、protobuf 的 varint 帧……)。

你:那这跟”懂微观才谈得上优化”有啥关系?

墨:太大了。后面两个事故,一个是因为”连接建立”这一关被冲垮(半夜拒绝新连接),一个是因为”字节流在中途被悄悄重传”(5% 重传找了一周)。这两个坑,背 tcp_* 参数表的人一个都没躲过,因为他们不知道参数守的是哪道门。 我们先从最 mystical 的那道门说起:三次握手。


二、三次握手为什么是三次——它不是仪式,是两次”确认活着”

墨:先别记”三次是因为……“,先想:握手要干什么? 不就是双方互相确认”我能发,你也能收”吗?

你:那两次不就够了?A 说”我在”,B 说”我在”,完事。

墨:差一层。两次握手只能确认单向链路畅通,确认不了双向都畅通。你拆开看:

次数A 确认了什么B 确认了什么
A→B SYN(“我想连,我的初始序号 x”)—B 知道:A 能发,且 B 能收(因为 B 收到了)
B→A SYN+ACK(“我也想连,我的序号 y;确认收到你的 x+1”)A 知道:B 能发、A 能收(A 收到了回包),且 B 收到了 A 的 SYNB 仍只知道自己那半边
A→B ACK(“确认收到你的 y+1”)—B 终于知道:A 能收、B 能发(因为 B 收到了这个 ACK)

墨:看见了吗?前两次握手结束的时候,B 还不知道 A 到底收到了自己的 SYN+ACK。 如果只有两次,万一 B 的 SYN+ACK 半路丢了,A 根本不会重发,B 就一直傻等一个”其实没建立的连接”。第三次 ACK 是让 B 也闭上”双向畅通”这个环。

你:那四次行不行?

墨:第二次 B 的 SYN 和 ACK 是同一时刻、同一包里发的(我想连和我收到了你的想连是强相关的),没必要拆成两个包。拆开反而多一次 RTT,纯浪费。所以三次是”既确认双向、又不浪费”的最优解。

墨:但注意,三次握手只是”确认链路”,它不分配连接资源给应用。真正的资源分配发生在第三次握手之后——这就要引出那个经典的两级队列了。


三、syn 队列与 accept 队列:连接建立的”两道门”

墨:这是全章最容易被误解、也最坑运维的地方。一个 TCP 连接从”客户端发起”到”你的 accept() 拿到它”,中间要过两道门:

客户端 SYN 到达
   │
   ▼
[半连接队列 / syn 队列]   内核回 SYN+ACK,但还不算"连接建立"
   │   ← 此处排队的是"已收到 SYN、还没收到第三次 ACK"的连接
   │      (TCP 协议规定:第三次 ACK 到了,才算 ESTABLISHED)
   ▼
第三次 ACK 到达,连接移入:
[全连接队列 / accept 队列]  此时连接已 ESTABLISHED,但还在内核里排队
   │   ← 此处排队的是"连好了、等你的 accept() 来领"
   ▼
你的进程 accept() 把它取走 → 交给应用层

墨:两道门,两个上限,两个参数。先把它们钉死:

  • syn 队列长度上限:net.ipv4.tcp_max_syn_backlog(老内核还有 net.ipv4.tcp_syncookies 当溢出时的逃生口,后面讲)。
  • accept 队列长度上限:net.core.somaxconn(全局上限),再被你 listen(fd, backlog) 的 backlog 参数取小值封顶。

你:listen 的 backlog 不就是队列长度吗?我设 1024 不就够了吗?

墨:这是第一个大坑。 listen(fd, backlog) 里的 backlog 是给内核的”建议值”,内核会用 min(backlog, somaxconn) 真正生效。而很多语言的运行时/框架默认把 backlog 设得很小——比如 Go 早期、Nginx/Redis 在某些发行版,backlog 默认就是 128 甚至更小。于是你以为能扛 1024,实际 accept 队列只有 128。

你:那满了对会怎样?

墨:问到痛点了。两个队列满,后果不一样,而且各自的”甩锅方式”不同——这正是事故一的关键。我们先记住结论,等讲故事时你会有切肤之感:

队列满了之后的默认行为对应参数
syn 队列满新 SYN 被默默丢弃(不回 RST,客户端超时重发,像是”网络卡”)tcp_max_syn_backlog、tcp_syncookies
accept 队列满(第三次 ACK 来了但没位置)默认行为由 net.ip4.tcp_abort_on_overflow 决定:0=丢弃第三次 ACK,装作没收到(客户端以为连上了,服务端其实没建,后续数据会触发 RST)、1=直接回 RST 拒连tcp_abort_on_overflow、somaxconn

墨:注意 syn 队列满时内核的”温柔”——它不回 RST,而是直接丢 SYN。为什么?因为如果回 RST,就等于明说”我满了拒绝你”,客户端会立刻报错;而丢 SYN 让客户端自己超时重试,在网络瞬时抖动时其实是更”体面”的退让。但这也意味着:syn 队列满在客户端看来就是”连得奇慢/偶发超时”,极难定位。 这就是后面事故一我们会撞上的墙。


四、数据怎么流:发送缓冲区、窗口、Nagle、延迟确认

墨:连接建好之后,真正的数据传输才开始,这里又有四件”反直觉”的设计,每一个背后都是一次权衡。

4.1 发送缓冲区不是”立即发”,是”先囤着”

你 send() 的时候,数据先拷贝进内核的 TCP 发送缓冲区(sk_buff),send() 返回的是”我收下了”,不代表对方收到了。真正什么时候发、发多少,由两样东西卡着:

  1. 对方的接收窗口(rwnd):对方说”我 recv 缓冲还剩多少,你最多发这么多”——这是流控,防你把对方撑爆。
  2. 拥塞窗口(cwnd):内核自己对网络容量的估计”我觉得这条路现在能塞多少”——这是拥塞控制,防你把整条网络撑爆。

实际能发的数据量 = min(rwnd, cwnd)。一个是接收方给的”肚量”,一个是发送方自己估的”路况”。

4.2 Nagle 算法:小包合并的”好心”

墨:你连续 send("A") send("B") send("C"),每个 1 字节。如果每个都打一个 TCP 包,包头 40 字节(20 IP + 20 TCP)装 1 字节数据,线利用率 2.4%,纯属给路由器添堵。于是有了 Nagle 算法:默认开启,攒一小批、等凑够一个 MSS(最大报文段)或等到对方的 ACK 再发。

你:那延迟敏感的场景不就完蛋了?比如打字游戏的每个按键?

墨:对,所以内核留了逃生口:TCP_NODELAY(关掉 Nagle)。这是经典的权衡:默认宁可多等几毫秒、把小包并成大包省带宽;你要低延迟就关 Nagle,代价是线利用率下降、包数变多。 Redis、游戏、金融行情这类”小消息高频”场景,几乎都显式设 TCP_NODELAY。

4.3 延迟确认(delayed ACK):接收方的”搭便车”

墨:接收方收到数据,本来该立刻回 ACK 说”收到了”。但 ACK 也是包(也有 40 字节头)。于是 TCP 有个延迟确认:收到数据后等 40ms 左右,看这 40ms 内有没有数据要反向发(刚好”搭便车”把 ACK 捎在数据包上),没有才单独发 ACK。

你:等等,Nagle 在等 ACK,Nagle 的另一边延迟确认在等 40ms 才发 ACK?

墨:你撞上了著名的”Nagle + 延迟确认死锁”。发送方因为 Nagle 在等 ACK 才发下一个小包,接收方因为延迟确认在等 40ms 才发 ACK——结果两边都干等 40ms,一问一答的往返延迟凭空多了一拍。这也是为什么低延迟小包通信必须两端都 TCP_NODELAY:否则你以为网络 1ms,实际每轮多 40ms。

4.4 参数锚点:这些”好心”都能调

行为旋钮默认拿什么换什么
关 NagleTCP_NODELAY(socket 选项,非 sysctl)关(即 Nagle 开)低延迟 ↔ 线利用率/包数
延迟确认时长由 net.ipv4.tcp_delack_min(部分内核)+ 算法决定,通常 ~40ms,可调 tcp_ato_min 等~40ms省 ACK 包 ↔ 确认延迟
发送缓冲上限net.ipv4.tcp_wmem(min default max)视内存突发吞吐 ↔ 内存占用
接收缓冲上限net.ipv4.tcp_rmem(min default max)视内存窗口可撑大 ↔ 内存占用

墨:注意 tcp_wmem/tcp_rmem 是三元组 [min, default, max],且默认开启自动调节(autotuning)——内核会根据实际吞吐动态把缓冲区撑到接近 max。下一节讲”缓冲区撑多大”这件要命的事。


五、拥塞控制:它本质上在赌

墨:这是 TCP 最性感也最被神话的部分。先破一个错觉:拥塞控制不是”测量网络带宽”,它是”在试探中猜”。 它永远在面对一个信息不全的赌局。

你:猜?不是有丢包就说明堵了吗?

墨:丢包确实是最常用的”堵了”信号,但它不是唯一信号,而且滞后。等你看到丢包,网络早已经堵了。TCP 的赌法是这样的:

5.1 慢启动:开局猛踩油门

新连接上来,cwnd 从一个很小的值(现代 Linux 初始 initcwnd 通常 10 个 MSS)开始,每收到一个 ACK,cwnd 翻倍(指数涨)。为什么叫”慢”启动?因为比起”一上来就发满”,10 个 MSS 已经很”慢”了。目的是快速探到网络能扛的量,但别一上来就淹了网络。

5.2 拥塞避免:涨到阈值就改线性

cwnd 涨到 慢启动阈值(ssthresh) 后,从”翻倍”改成”每个 RTT 加一个 MSS”(线性涨)。再不敢指数涨,因为已经接近”不确定安全”的区域了。

5.3 丢包信号:三副本 ACK 或超时

  • 收到 3 个重复 ACK(说明有包丢了,但后面的包还在到,网络没完全死)→ 进入快速重传/快速恢复:ssthresh 砍半,cwnd 降到新 ssthresh 之后线性爬(这是 Reno/CUBIC 的经典动作)。
  • 超时(RTO 到了还没收到任何 ACK) → 最惨:认为”网络可能断了”,ssthresh 砍半,cwnd 直接重置回初始值,重新慢启动。

你:等等,这赌法有问题吧?万一是 WiFi 抖了一下丢了包,不是真堵,它也砍半带宽?

墨:你一针见血,这就是 bufferbloat 灾难的根源,也是 BBR 算法要解决的问题。 传统算法(基于丢包)的逻辑是”丢包=堵了=减速”。但现代网络里,路由器有巨大的缓冲(几 MB 甚至几十 MB),丢包发生之前,延迟已经悄悄涨了几百毫秒——数据包在路由器 buffer 里排长队。基于丢包的算法”感觉不到”这几百毫秒延迟,一直猛发,直到 buffer 满溢出才丢包,于是你看到的现象是:带宽没怎么降,但延迟飙到 1 秒。这叫 bufferbloat(缓冲膨胀)。

5.4 BBR:换一个赌注——赌”带宽-延迟积”

Google 的 BBR 不靠丢包判断,它主动测量两条线:BtlBw(瓶颈带宽)和 RTprop(最小往返时延),目标是把在途数据量维持在网络能顺畅搬运的 BDP(带宽×延迟积) 附近,既不空转也不堆 buffer。换成 net.ipv4.tcp_congestion_control=bbr 即可(需内核支持,通常 4.9+)。

你:那我所有机器都换 BBR 不就完事了?

墨:别。BBR 在你是发送方且网络有 bufferbloat时收益最大(长胖管道、跨 IDC、视频/下载)。但在短连接、小请求、和你抢带宽的是基于丢包的老算法时,BBR 的”温和”反而可能让你被 Reno/CUBIC 挤占(它不 aggressively 抢)。这是又一个权衡:不是新算法全面碾压旧算法,而是各自主场不同。 内核默认 tcp_congestion_control 通常是 cubic(Linux 长期默认),你按业务选。

5.5 参数总锚点(拥塞控制相关)

参数作用拿什么换什么
net.ipv4.tcp_congestion_control选算法(cubic/bbr/reno…)不同网络环境下吞吐/延迟/公平性取舍
net.ipv4.tcp_slow_start_after_idle空闲后是否重置 cwnd 回初始值短连接复用省内存 ↔ 长连接持续高吞吐
net.ipv4.tcp_notsent_lowat控制”还有多少未发送数据就阻塞应用写”降低缓冲延迟 ↔ 吞吐
初始窗口ip route ... initcwnd N(路由层,非 sysctl)首 RTT 吞吐 ↔ 突发冲击

六、TIME_WAIT:为什么让人恨,又为什么不能删

墨:连接关闭时,主动关的那一方会进入 TIME_WAIT,默认要等 2×MSL(报文最大生存时间,Linux 上 MSL 通常取 60s,所以 TIME_WAIT 约 60s 不是 2×60)——准确说 Linux 默认 net.ipv4.tcp_fin_timeout 是 FIN 超时,而 TIME_WAIT 时长由 TCP_TIMEWAIT_LEN(编译期,通常 60s)决定,sysctl 调不了(老内核 tcp_tw_recycle 能,但已删除,后面讲)。

你:TIME_WAIT 留着干嘛?连接都关了,占着端口不浪费吗?

墨:占着是有道理的,而且道理很硬:1) 防旧连接的迟到报文段污染新连接(如果端口立刻复用,上一次连接晚到的包会被误认成新连接的数据);2) 确保最后一次 ACK 真的到对方**(如果对方没收到 FIN 的 ACK,会重发 FIN,你得还在才能回 ACK)**。

你:那高并发短连接服务器,端口被 TIME_WAIT 占满,新连接建不了,怎么办?这是真痛点。

墨:经典痛点。先说错误答案(很多人干过):net.ipv4.tcp_tw_recycle=1。它在 NAT 环境(比如你前面有 LVS/负载均衡、容器、云 NAT 网关)下会让同源不同主机的连接因为时间戳错乱被直接丢弃,RST 拒连,引发诡异的”部分客户端连不上”。这个参数在 4.12+ 内核已经被彻底删除,别再搜它了。

正确的三个旋钮(按推荐顺序):

  1. net.ipv4.tcp_tw_reuse=1:只对方是客户端、主动发起连接时,允许复用 TIME_WAIT 状态的出站 socket(安全,因为它基于时间戳,且只用于新连接当客户端)。这是服务端作为客户端去连别人时的正解。
  2. net.ipv4.tcp_max_tw_buckets:限制系统里 TIME_WAIT 总量的硬上限,超了就直接回收最老的。是”实在扛不住时的泄洪阀”,不是根治。
  3. 根源上:用长连接 / 连接池替代疯狂短连接,或让客户端来主动关闭(把 TIME_WAIT 转移到客户端)。服务端自己是主动关闭方时,才是把端口占满的元凶。

墨:看见没有?TIME_WAIT 的每个”限制”都是在安全和资源之间拉锯。删掉它(老 tw_recycle)换来的资源释放,代价是连接正确性;而 tcp_tw_reuse 是”在带时间戳的前提下安全复用”,代价是需要较新内核 + 只适用于客户端角色。


七、缓冲区与 bufferbloat:tcp_rmem / tcp_wmem / tcp_mem

墨:前面说”实际能发 = min(rwnd, cwnd)“。rwnd 来自对方的接收缓冲区,而接收缓冲区多大,直接决定 rwnd 能开多大,进而决定长肥管道(Long Fat Network,高带宽高延迟,如跨洲专线)能把吞吐跑满。

你:接收缓冲大=窗口大=吞吐高?那我直接把 tcp_rmem 的 max 调到 16MB?

墨:对一半,但有个反直觉:接收缓冲区太大,在 bufferbloat 场景里反而放大延迟。因为大缓冲=路由器/你这侧能囤更多在途数据=队列更深=延迟更高。所以内核默认开 autotuning:tcp_rmem/tcp_wmem 的 default 是起始值,内核动态往 max 撑,但不会超过对端窗口能承认的范围,也不会无限涨。tcp_mem(注意没有 tcp_ 前缀是 net.ipv4.tcp_mem,三元组 [min, pressure, max],单位是页)是整个系统所有 TCP 缓冲加起来的总内存硬约束:超过 pressure 内核开始施压回收,超过 max 开始丢数据/拒绝分配。

参数单位作用拿什么换什么
net.ipv4.tcp_rmem字节(min def max)接收缓冲 autotuning 区间长肥管道吞吐 ↔ 内存/延迟
net.ipv4.tcp_wmem字节(min def max)发送缓冲 autotuning 区间同上
net.ipv4.tcp_mem页(min pressure max)系统级 TCP 内存总闸并发连接数 ↔ OOM 风险
net.ipv4.tcp_window_scaling0/1窗口缩放(突破 65535 字节窗口上限)必备,关掉则长肥管道永远跑不满

墨:tcp_window_scaling=1 是现代网络必开的(默认开),否则窗口字段只有 16 位,最大 64KB 窗口,跨洲 100Mbps 以上直接废掉。它本质是”用一点协议复杂度,换长肥管道的吞吐”,典型”通用机制 + 标准扩展”。


八、重传与保活:连上了不代表永远在

墨:最后一个微观机制:TCP 怎么发现对端”挂了”?

  • 重传:发了一个包没收到 ACK,等 RTO(重传超时,随测量 RTT 自适应)到了就重发,重发次数由 net.ipv4.tcp_retries2(默认 15)控制——不是 15 次就放弃那么简单,而是”指数退避累计到约这个数对应的时间(默认约 13~30 分钟)后判死”。
  • 保活(keepalive):TCP 连接默认没有心跳。两边都不发数据,连接可以”假死”几年。要探活得开 SO_KEEPALIVE,相关:net.ipv4.tcp_keepalive_time(多久不发数据开始探,默认 7200s=2 小时!)、tcp_keepalive_intvl(探测间隔)、tcp_keepalive_probes(探测几次判死)。

你:2 小时才开始探?那我半路拔了网线,服务端要 2 小时才知道?

墨:对,这就是默认值的荒谬但合理之处:TCP 设计在”稳定链路”假设上,认为”不发包=没事”。2 小时是怕频繁心跳浪费带宽。应用层心跳(如 gRPC/HTTP2 ping、Redis 的 tcp-keepalive 配置、自写 ping)才是正解,别依赖内核那个 2 小时的保活。


九、事故一:半夜网关拒绝新连接

完整迭代环:现象 → 推断(可能推错)→ 调整 → 再观察 → 改进

9.1 现象

墨:一个对外网关(Go 写的 API 网关,前面 Nginx 反代,单机承载约 8000 QPS),凌晨 2 点开始,客户端大量报”连接超时 / connection refused”,但机器 CPU、内存、load 都正常,已建立连接数也不高。 持续十几分钟后自行恢复,白天又正常。运维被叫起来,一脸懵:资源都没满,凭什么拒连?

9.2 第一推断(推错):是不是 syn 被洪水打了?

你:拒连嘛,第一反应肯定是”被打了”或者”CPU 满了”。先查 netstat -s | grep -i listen 看 SYNs to listened 和 times the listen queue 溢出计数。

墨:对,我们真去看了。发现有 times the listen queue 溢出计数在涨。第一推断:syn 队列满了,被人打了/或瞬时洪峰。于是第一次调整:把 net.ipv4.tcp_max_syn_backlog 从 1024 调到 8192,并开 net.ipv4.tcp_syncookies=1(syn 队列满时用 cookie 替代存半连接状态,防 SYN flood 也防溢出)。

9.3 再观察:调了没用,溢出还在涨

墨:调整后观察第二天凌晨——溢出计数还在涨,而且我们 tcpdump 抓到:客户端发的 SYN 服务端收到了(网卡层面有),但没回 SYN+ACK。这说明根本不是 syn 队列(syn 队列满的表现是”不回 SYN+ACK,让客户端超时”,看着像这样)。等等,那 accept 队列呢?

9.4 第二推断(接近了):accept 队列满,第三次 ACK 被丢

你:accept 队列满了,第三次握手 ACK 到了没位置,内核按 tcp_abort_on_overflow=0 默认”丢弃 ACK 装作没收到”——客户端以为连上了等着发请求,服务端没建连接,后续客户端数据触发 RST。所以客户端看到的是”偶发连接被拒/超时”。

墨:方向对了,但为什么半夜才满、白天不满? 这才是关键。我们查 ss -lnt 看 Recv-Q(当前 accept 队列积压)和 Send-Q(accept 队列上限):

State  Recv-Q Send-Q  Local Address:Port
LISTEN 128    128      :::8080

Recv-Q == Send-Q == 128——accept 队列满了,且上限就是 128! 而我们 listen 的 backlog 配的是 1024,为什么生效只有 128?因为 net.core.somaxconn 默认是 128,min(backlog, somaxconn)=128。

9.5 为什么半夜?——找到真正的根因

墨:白天 QPS 稳定,accept 队列能及时被 accept() 消费掉;半夜有一个定时任务(批量回源、日志归档)短暂占满 worker 线程,accept 消费变慢,半秒积压就堆满 128 的队列——而半夜这个窗口恰恰是客户端重试最猛(移动端心跳重连风暴)的时候。白天没这冲突,所以白天正常。

你:所以根因是 somaxconn 默认 128 太小 + 半夜 worker 被定时任务卡住 + 客户端重连风暴,三件事撞一起?

墨:精准。三次推错才到这:第一次以为是 syn flood(错,因为 tcpdump 显示 SYN 收到了),第二次以为是单纯 accept 队列满(对了一半,但没解释”为什么半夜”),第三次才把”半夜定时任务 + 客户端重连 + somaxconn 128”三件拼成一个偶发窗口。

9.6 改进(完整闭环)

我们做了四件事,按”治标→治本”:

  1. net.core.somaxconn=65535(全局 accept 队列上限拉大),并保证应用 listen backlog ≥ 它。
  2. net.ipv4.tcp_max_syn_backlog=8192 + 保持 tcp_syncookies=1(双保险)。
  3. net.ipv4.tcp_abort_on_overflow=1:accept 队列满时直接回 RST 明确拒连,让客户端立刻失败重试,而不是”装作没收到”让客户端干等超时(在网关场景,快速失败优于假连接)。
  4. 根因:把半夜的批量定时任务错峰/限流,避免 worker 线程被瞬间占满,从应用层消除 accept 消费停滞。

墨:你看,这一连串参数——somaxconn、tcp_max_syn_backlog、tcp_syncookies、tcp_abort_on_overflow——每一个都不是”优化开关”,而是某道门溢出时的逃生阀。你不知道门在哪,调它们就是盲调。


十、事故二:重传率 5%,找了一周

完整迭代环:现象 → 推断(可能推错)→ 调整 → 再观察 → 改进

10.1 现象

墨:一个跨机房的服务(IDC-A 的 Go 服务调用 IDC-B 的同服务,走专线,RTT 约 8ms),监控发现 TCP 重传率稳定 5%,吞吐比理论值低约 30%。但 ping 延迟正常、丢包率(ICMP)显示 0%、iperf 测带宽也接近满速。所有”常规”网络指标都正常,只有 TCP 重传率不对劲。 这一周,他们换了网卡驱动、调了队列、甚至怀疑过光纤,全白费。

10.2 第一推断(推错):网卡/链路丢包

你:重传率高=包丢了=要么网卡、要么链路。先 ethtool -S 看网卡 rx_errors/tx_errors/dropped,再看交换机端口 CRC 错。

墨:都干净。ICMP 0 丢包但 TCP 5% 重传——这已经是个强信号:不是”所有包都丢”,是”特定特征的包丢”。但我们当时没悟到,浪费了两天在网卡和光纤上。

10.3 第二推断(半对):大包丢、小包不丢

你:会不会是 MTU 问题?有的链路 MTU 比 1500 小(比如中间过了 GRE/IPsec 隧道、VXLAN、或者老交换机的 jumbo 没配对),大包过不去但被悄悄丢掉,而 ICMP 小包(默认 64 字节)能过?

墨:这个推断对了一半,而且指向了正确的方向,但我们还缺一块拼图:为什么大包”被悄悄丢掉”而不是”被分片”? 我们 tcpdump 在两端抓,发现:小于 1400 字节的段,100% 到达;大于 1400 字节的段,大量没收到 ACK、触发重传。 中间某段链路 MTU 是 1400(后来查到是 IDC 之间一段 GRE 隧道,外层多封装了 40 字节,没开 jumbo)。

但关键问题是:既然中间 MTU 只有 1400,为什么发包方还发 1500 的包? 按理说 PMTU 发现(Path MTU Discovery)应该让发送方自动缩小 MSS 到 1400。

10.4 第三推断(命中):PMTU 被防火墙吃了

墨:PMTU 的工作原理是:中间路由器发现包太大又设了 DF(Don’t Fragment)位,就回一个 ICMP “Fragmentation Needed but DF set” 给发送方,发送方据此把 MSS 调小。问题来了——那个 GRE 隧道入口的路由器确实回了 ICMP,但客户在 IDC-A 出口防火墙上把 ICMP 整个禁了(安全基线”禁 ping”)。于是发送方永远收不到”把包改小”的通知,只能继续发 1500 的包,中间默默丢弃,TCP 只能靠重传(把大段重发,还是 1500,还是丢)硬扛——重传率卡在 5% 就是因为”恰好 5% 的数据段超过 1400 字节”。

你:那为什么不是 100% 重传?

墨:因为不是每段都超 1400。你的请求/响应里,小消息(元数据、ACK、短响应)都没超,正常到;只有携带大 payload 的段超了被丢。所以是”部分重传”,看起来像 5% 随机丢包,实则是”所有大段必丢”。

10.5 调整与改进

三种解法,我们按”代价”选:

  1. (治本,但难推动)放开 PMTU 所需的 ICMP:在防火墙放行 ICMP type 3 code 4(目的不可达-需要分片)。最干净,让协议自己工作。但安全团队一开始拒绝(“禁 ICMP 是铁律”)。
  2. (临时正解)关 DF 让中间分片:ip route add ... advmss 1400 或在发送方设 net.ipv4.tcp_mtu_probing=1(TCP 主动探测 MSS,不依赖 ICMP)+ 路由上 mtu 1400。tcp_mtu_probing 是内核给”ICMP 被墙”场景的逃生口:它不靠路由器回 ICMP,而是自己发试探包找能通的最大段。
  3. (最直白)两端 ip link set dev eth0 mtu 1400 或调整 tcp_mtu_probing + advmss,让 MSS 主动适配。

最终落地:与安全团队达成一致放行 ICMP type 3/4(仅这类,不是全放开 ping),同时发送方开 net.ipv4.tcp_mtu_probing=2(黑洞探测,遇到重传超阈值自动降 MSS)当长期保险。重传率当天从 5% 降到 0.02%,吞吐恢复到理论值。

墨:这个事故把三个微观机制焊在一起:MTU/MSS、DF 位与 IP 分片、PMTU 发现依赖 ICMP。而 tcp_mtu_probing 这个参数,你背参数表时根本不会注意它——它守的就是”ICMP 被墙导致大包黑洞”这道门。


十一、net.ipv4.tcp_* 参数总表(拿什么换什么)

墨:把本章涉及的 tcp_*(及少数 net.core.*)列成一张”守门人”清单。记住:每个都是某道门溢出/异常时的逃生阀,不是日常调优旋钮。

参数守的门默认(典型)拿什么换什么
tcp_max_syn_backlogsyn 半连接队列上限1024(随内存)抗 SYN 洪峰 ↔ 内存
tcp_syncookiessyn 队列满时 cookie 替代存状态1(开)抗洪峰/防溢出 ↔ 失去部分 TCP 选项(如大窗口)
net.core.somaxconnaccept 全连接队列全局上限128高并发建连 ↔ 内存
tcp_abort_on_overflowaccept 队列满时行为(0 丢弃ACK装死/1 回RST)0快速失败(RST) ↔ 客户端伪连接干等
tcp_tw_reuse复用 TIME_WAIT(出站客户端)0省端口 ↔ 需较新内核+仅客户端角色
tcp_tw_recycle(已删除,4.12+)—切勿再使用,NAT 下致命
tcp_max_tw_bucketsTIME_WAIT 总量硬上限视内存防端口耗尽 ↔ 过量时丢老连接
tcp_fin_timeoutFIN 等待超时60s资源回收快 ↔ 偶发旧包污染风险
tcp_congestion_control拥塞算法(cubic/bbr)cubic吞吐/延迟/公平性取舍
tcp_wmem / tcp_rmem收发缓冲 autotuning 区间视内存长肥管道吞吐 ↔ 内存/延迟
tcp_mem系统级 TCP 内存总闸(页)视内存并发连接数 ↔ OOM
tcp_window_scaling窗口缩放(突破 64KB)1长肥管道吞吐 ↔ 协议复杂度
tcp_slow_start_after_idle空闲后重置 cwnd1短连接省内存 ↔ 长连接持续吞吐
tcp_retries2重传放弃前退避次数15容忍抖动 ↔ 判死慢(最长约数十分钟)
tcp_keepalive_time多久不发数据开始探活7200s早发现问题 ↔ 心跳带宽
tcp_mtu_probingICMP 被墙时自探 MSS0抗 MTU 黑洞 ↔ 探测开销
tcp_notsent_lowat未发数据阈值阻塞写视实现降低缓冲延迟 ↔ 吞吐
tcp_timestamps时间戳(PAWS/tw_reuse 依赖)1防回绕/复用安全 ↔ 8 字节头

十二、🔧 思考题(都配参考答案)

思考题 1(基础): 为什么说”TCP 是字节流不是消息”?这个特性对你的应用层协议设计提出了什么硬性要求?

【参考答案】

TCP 只保证字节顺序一字不差,不保证 send 和 recv 的边界对齐:你两次 send,对方可能一次 recv 全拿到,也可能拆成多次、或一次拿一半。这要求应用层协议必须自己定义消息边界——常见做法:长度前缀(先发 4 字节 body 长度再发 body)、分隔符(如 HTTP 用 \r\n\r\n 分头、行协议用 \n)、或定长帧;并且 recv 后必须做拆包/粘包处理:循环读直到凑够一个完整消息,缓存半包留到下次拼。凡是”我 send 一次对方就该 recv 一次”的假设,都是 bug 温床。

思考题 2(深入): 你的服务半夜偶发”客户端连不上”,tcpdump 看到 SYN 到了但没回 SYN+ACK,netstat -s 里 listen queue 溢出在涨。你第一反应是”syn 队列满了,调 tcp_max_syn_backlog”。请指出这个推断可能漏掉的关键排查,以及正确的处置顺序。

【参考答案】

SYN 到了不回 SYN+ACK,有两种可能:(a) syn 半连接队列满(丢弃 SYN,不回);(b) accept 全连接队列满——第三次 ACK 到了但没位置,按 tcp_abort_on_overflow=0 默认”丢弃 ACK 装作没收到”,客户端于是重发 SYN,服务端再次收到 SYN 却不回(因为第三次 ACK 阶段就被吃了)。两种在 tcpdump 上看起来一样(都有 SYN 无 SYN+ACK),但门不同!

正确排查:用 ss -lnt 看 Recv-Q(当前全连接积压)和 Send-Q(accept 队列上限 somaxconn)。若 Recv-Q 顶到 Send-Q 且 Send-Q=128(somaxconn 默认),根因是 accept 队列满 + somaxconn 太小,不是 syn 队列。处置顺序应:(1) 调 net.core.somaxconn 并确认应用 listen backlog 不小于它;(2) 视场景设 tcp_abort_on_overflow=1 让失败变明确;(3) 双保险 tcp_max_syn_backlog + tcp_syncookies;(4) 查应用层为什么 accept 消费变慢(worker 被占/定时任务错峰)——那才是半夜偶发的真正窗口。盲调 syn 队列只会掩盖、不解决。

思考题 3(Nagle 实战): 你做一个”每次发 1 字节指令”的低延迟控制协议,发现每轮往返莫名其妙多 ~40ms。已知网络 RTT 只有 1ms。你怀疑什么?怎么验证?怎么解?

【参考答案】

这是经典的 Nagle + 延迟确认互锁:发送方因 Nagle 在等对方 ACK 才发下一个小包(默认 Nagle 开),接收方因延迟确认在等 ~40ms 才发 ACK(想搭便车),双方都干等,每轮凭空多一拍 40ms。验证:tcpdump 看发送方是否在收到上一个 ACK 之前迟迟不发下一个小包、接收方 ACK 是否延迟约 40ms 才到。

解:两端都设 TCP_NODELAY(关 Nagle),让小包立即发;接收方延迟确认因无数据可搭便车才单独发 ACK,链路恢复 1ms 级。代价:包数变多、线利用率下降——但低延迟控制场景,延迟远比省带重要。注意只改一端不够:只改发送端关 Nagle,接收端仍延迟确认,你仍等 40ms 才拿到 ACK 才发下一个——所以必须两端都关 Nagle(或发送端关 Nagle 且接收端也关延迟确认)。

思考题 4(进阶总账): 综合第 08、09、10 章:一个高并发网关”吞吐上不去”。请你列出从网卡→软中断→TCP 队列→应用 accept 这条链路上,每一级”可能堵住”的信号点、对应的观察命令、以及对应的参数逃生阀(至少各举一例)。

【参考答案】

全链路排查清单(自底向上):

  • 网卡/队列层(第 09 章):多队列没开导致单核软中断打满 → 观察 ethtool -l eth0(combined 队列数)、top 的 si 单列、丢包计数;逃生阀:开多队列 ethtool -L combined N + 绑中断亲和 irqbalance/手动 smp_affinity。
  • 软中断层(第 09 章):netdev_budget/netdev_max_backlog 不够导致 softnet 丢包 → 观察 /proc/net/softnet_stat 第三列丢弃;逃生阀:调 net.core.netdev_budget、net.core.netdev_max_backlog。
  • TCP syn 队列(第 10 章):SYN flood/洪峰 → 观察 netstat -s 的 listen queue 溢出;逃生阀 tcp_max_syn_backlog + tcp_syncookies。
  • TCP accept 队列(第 10 章):应用 accept 慢 → 观察 ss -lnt 的 Recv-Q 顶 Send-Q;逃生阀 net.core.somaxconn、应用 listen backlog、tcp_abort_on_overflow。
  • 应用层(第 04~05 章):worker 线程被锁/被调度饿死 → 观察 perf、/proc/softirq、CFS throttling(/proc/sched_debug 的 nr_throttled);逃生阀 cgroup CPU 配额、MALLOC_ARENA_MAX、futex 优化。

核心心法:每一级都有自己的”队列 + 溢出信号 + 逃生阀”,先靠观察命令定位是哪一级的队列在溢,再动对应参数——而不是”吞吐上不去就盲调 tcp_rmem”。



实战补遗一:NAPI 与 GRO 的”收包魔法”——为何小包吞吐反而掉

墨:第 10 章讲了网络栈从网卡到应用的路径。收包侧有个关键机制叫 NAPI:网卡中断触发后,内核关中断、转轮询(poll)连续收包,直到收完或达预算。还有 GRO(Generic Receive Offload):把多个小包合并成一个大包再上协议栈。它们都是为”少开销”设计的,但你知道 GRO 在哪种场景会反噬吗?

你:GRO 把小包合成大包,减少协议栈处理次数,不是应该更快吗?

墨:对”大包少、小包多”的常规流量是更快。但有个经典反噬:TCP 分片重组 + GRO 的”包合并边界”问题,加上一种更隐蔽的——GRO 把”属于不同流”的包合并,导致后续 RPS/哈希散列失效。讲个真实事故。

一个网关,开启 GRO 后,小包(如 64 字节的 ACK、小请求)吞吐反而下降,且 iperf 测小包 PPS 上不去。查 ethtool -S 的 rx_oversize_errors/rx_dropped 没异常,/proc/softirqs 的 NET_RX 却因 GRO 合并后单包处理成本上升(合并逻辑要遍历 skb、重组),加上 GRO 合并后包变”大”,触发了驱动 DMA 聚合的边界对齐问题,某些网卡在 GRO 大包下反而降速。

推断:GRO 的收益前提是”合并后的大包能被高效处理”。但当流量是海量小包且对延迟极敏感(如游戏、金融行情、ACK 风暴),GRO 的合并延迟(要等凑够几个包才上交协议栈)反而增加首包延迟,且合并逻辑本身吃 CPU。验证:关 GRO 后小包 PPS 回升、延迟下降;开 GRO 后大文件传输吞吐升、小包延迟升——证实 GRO 是”大包友好、小包有害”。

调整:按流量性格分场景——

  • 大文件/视频/批量传输(大包为主):开 GRO/LRO,吞吐吃满;
  • 低延迟小包(ACK 密集、行情):关 GRO(ethtool -K eth0 gro off)、甚至关 LRO,宁可多处理几个小包也要低延迟;
  • 更精细:用 ethtool -K eth0 gro on 配合 generic-receive-offload 只在内核层做(避免网卡 LRO 的边界问题),或用 XDP(eXpress Data Path,第 10 章提过)在驱动层直接决策,绕过 GRO 的合并延迟。

再观察:关 GRO 后小包 PPS 翻倍、ACK 延迟从 200µs 降到 50µs;大包场景另开 GRO,吞吐不降反升。一张网卡两种配置,按业务切。

你:NAPI 的”中断转轮询”和老式”每包一个中断”比,优势到底在哪?

墨:老式网卡每来一个包就触发一次硬件中断,CPU 被打断去处理——10 万 PPS 就是 10 万次中断/秒,上下文切换开销能把 CPU 吃光(第 9 章讲的”中断风暴”前身)。NAPI 的聪明在:第一个包触发中断后,内核立即关掉该队列的中断、转成轮询模式,在一个软中断轮次里用 poll() 连续把网卡 DMA 环里的包一次性全收上来,收完或达 netdev_budget(第 9 章)再重新开中断。这样:

  • 高流量时:中断次数从”每包一次”降到”每轮一次”,开销骤降(这是现代网卡扛百万 PPS 的基础);
  • 低流量时:NAPI 退化为”中断驱动”,延迟不增(没包时不开轮询,来包即中断,响应快)。

所以 NAPI 是”自适应:忙时轮询省中断、闲时中断保延迟”。GRO 是在 NAPI 轮询基础上”再合并一层”减协议栈开销,但合并有延迟代价——两者都是”用批量换开销”,代价都是”批量带来的延迟”,所以延迟敏感场景要懂得关。理解这一层,你才真正懂”为什么网卡参数要按流量性格调”,而不是照帖子全开。

🔧 思考题:ethtool -K eth0 tso on/gso on(TCP Segmentation Offload / Generic Segmentation Offload)和 GRO 是对称的两个方向吗?发送侧也讲”卸载”吗?

【参考答案】

对称但有方向区别:

  • 收包侧:GRO/LRO(大包进协议栈前合并),减少”收”的处理次数;
  • 发送侧:TSO/GSO(大包出协议栈后由网卡/内核拆分),减少”发”的处理次数。

TSO(TCP Segmentation Offload):应用层交一个大 TCP 段(如 64K)给内核,内核不分段,直接交给网卡,由网卡硬件按 MU 切成多个 1500 字节帧发出——把”分段”这活从 CPU 卸载到网卡,省 CPU。GSO 是软件版(网卡不支持 TSO 时内核代劳)。

所以发送侧确实也讲”卸载”:开 TSO/GSO,CPU 少干分段活,吞吐高、CPU 省;但和 GRO 一样,某些极端延迟敏感 + 小包发送场景(每段都很小),TSO 的”攒大段再让网卡拆”反而引入一点延迟,可关。一般生产发送侧 TSO/GSO 默认开且收益明确,不像 GRO 那样常需为延迟关。口诀:收包 GRO 看流量性格(小包可关)、发送 TSO 基本常开,两者都是”批量/卸载换 CPU”,理解对称关系你就不用死记每个开关。



实战补遗二:零拷贝 sendfile/splice 与 TSO/GSO——Go 的 io.Copy 为何慢;分段 offload 的隐形红利

墨:老哥,你要把一个 2G 文件通过 HTTP 发给客户端。用 Go 写 io.Copy(w, f),一行搞定。有人让你”上 sendfile 零拷贝”。你先想:慢在哪,sendfile 又省了哪一步?

你:io.Copy 要把文件读到用户态缓冲区再写 socket,多一次拷贝;sendfile 直接内核里文件→socket。

墨:对。但今天我要补一层你多半没注意的——即使走零拷贝,真正的大头往往不是拷贝,而是”分段”。讲清这个,你对”网络栈微观”的理解才完整。

现象:一个静态文件服务,用 io.Copy 发大文件,CPU 占用高,单机吞吐到 4Gbps 就上不去了。换成 Nginx(sendfile on)同样硬件能跑满 10Gbps。

推断:是 io.Copy 的用户态中转拷贝吃了 CPU。

可能推错:拷贝确实吃 CPU,但还有个更大的隐形消耗——TCP 分段。不管你用 io.Copy 还是 sendfile,最终网卡发的是 1500 字节的帧(MTU)。如果你一次提交 64K 数据,协议栈要把它切成几十个 TCP 段,每个段算校验和、填头——这活默认在 CPU 上做,叫 GSO(Generic Segmentation Offload)的软件部分。CPU 瓶颈常常在这,不在拷贝。

调整:看网卡 offload 开关 ethtool -k eth0,关注这几项:

  • tcp-segmentation-offload (TSO):发送端把”大段”直接丢给网卡,由网卡硬件切段+算校验和,CPU 省一大笔。
  • generic-segmentation-offload (GSO):硬件不支持时内核软件做(仍比逐包应用层切好)。
  • generic-receive-offload (GRO):接收端网卡/内核把小包合并成大段再上交,减少协议栈处理次数(见第 10 章 NAPI 实战补遗)。
  • tx-checksumming:校验和也甩给网卡算。

再观察:开了 TSO+GRO+checksum offload 后,同样 io.Copy 的吞吐从 4G 涨到 8G,CPU 掉一半——因为分段和校验和从 CPU 挪到了网卡。这比”换成 sendfile”收益还明显(sendfile 只省拷贝,offload 省的是分段计算)。

改进:

  • 发大文件优先 sendfile/splice 省用户态拷贝(Go 标准库 net/http 对 *os.File 已自动用 sendfile)。
  • 务必确认网卡 offload 全开(云厂商有时默认关 TSO 导致吞吐腰斩)。
  • 容器/隧道(VXLAN)场景 offload 可能被层层剥掉,要逐层查。

🔧 思考题:既然 TSO 把分段甩给网卡,那”MTU 1500 发大包”还会不会造成 IP 分片(fragmentation)?TSO 和 IP fragmentation 是一回事吗?

【参考答案】

不是一回事,差着两层。

IP fragmentation 是网络层概念:一个 IP 包超过路径 MTU,路由器被迫把它切成多个 IP 分片,对端重组。分片有代价(任一片丢全盘重传、重组消耗),且常被防火墙嫌弃。正常 TCP 通信不会触发 IP 分片,因为 TCP 的 MSS 协商时就已经按 MTU 减头算出(默认 1460),发送端主动把段控制在不分片大小内——这是 TCP 层的事,跟 TSO 无关。

TSO(TCP Segmentation Offload)是发送端网卡的能力:内核协议栈往网卡喂一个”逻辑大段”(比如 64K),由网卡硬件按 MSS 切成真实 TCP 段、逐段算 TCP 校验和再发出。对 CPU 而言,它只提交了一个大 buffer;对网络和接收端而言,看到的仍是标准 1460 字节的 TCP 段,完全合规、不分片。TSO 省的是”CPU 做分段+校验和”这一步。

所以:TSO 开着,你发 64K,线上跑的还是一堆 1460 小段(没有 IP 分片),只是分段活从 CPU 移到网卡。代价:网卡万一不支持 TSO,这部分回到 CPU,吞吐掉、CPU 涨——这就是为什么云上”关了 TSO 的机器发大文件特别吃 CPU”。用 ethtool -K eth0 tso on gso on gro on 打开(注意容器 veth 的 offload 在宿主侧与 virtio 层,易失效)。


实战补遗三:半连接队列 SYN backlog 与 syn flood——新连接上不去,未必是全连接队列的锅

墨:老哥,你 TCP 新连接上不去、客户端超时,第一反应查 somaxconn 对吧?

你:对,全连接队列(accept 队列)满就丢新连接。

墨:但你有没有想过——还没到 accept 队列之前,还有个”半连接队列(SYN backlog)“,它满了表现类似但调参完全不同。这节把三次握手的两个队列讲清,补第 15 章 somaxconn 那节没覆盖的”握手前半段”。

现象:某服务遭遇突发新连接,客户端大量 connection timeout,但 ss -lnt 看 Recv-Q(全连接队列)没满,somaxconn 也够大。运维调大 somaxconn 没用。实际是 SYN 队列(半连接)满了,客户端 SYN 被悄悄丢,重传,表现就是”建连慢/超时”。

推断:是全连接队列(accept)满。

可能推错:TCP 三次握手有两道队列,顺序是不同的:

  1. 半连接队列(SYN queue / syn backlog):收到客户端 SYN、服务端回 SYN+ACK 后、客户端 ACK 没到之前,这个”半成品连接”存在半连接队列(内核里由 tcp_max_syn_backlog 控制大小,且受 max_qlen 与 somaxconn 取最小值影响)。
  2. 全连接队列(accept queue):三次握手完成、连接进 ESTABLISHED 等应用 accept(),由 somaxconn/net.core.somaxconn 控制。

客户端超时可能是半连接队列满:服务端收到 SYN 但半连接队列满了,按策略要么丢 SYN(客户端重传、握手慢),要么(开了 tcp_syncookies)回 SYN cookie 不进队列(防 syn flood 的兜底)。你调 somaxconn 只影响全连接队列,半连接队列满它管不到。

调整:看半连接队列状态:

  • cat /proc/net/netstat | grep TcpExt | awk '{print $2}' 里 ListenOverflows(全连接队列溢出次数)、ListenDrops(监听队列丢连接)、TCPBacklogDrop(半连接相关丢弃)。
  • nstat -az | grep -E 'ListenOverflows|ListenDrops|TCPBacklogDrop|SYN' 更直观。
  • net.ipv4.tcp_max_syn_backlog(半连接队列上限,默认常 1024~8192)调大;它和 somaxconn 的较小值才是实际半连接队列容量。

再观察:那次”调 somaxconn 没用”的根因是半连接队列满(突发 SYN 太多),调 tcp_max_syn_backlog 才解决。更隐蔽的是 SYN flood 攻击/异常客户端会让半连接队列被大量”已完成第一次握手但迟迟不发 ACK”的伪造连接占满(不刷 ACK 就一直占着),这时该开 tcp_syncookies=1(用 cookie 代替存半连接状态,队列满也不丢合法连接),而不是一味调大队列(调大反而给攻击留更多空间)。

改进:

  • 新连接上不去先分清楚”半连接满”还是”全连接满”:ListenOverflows 是全连接、TCPBacklogDrop/半连接相关是半连接。
  • 半连接满:调 tcp_max_syn_backlog,并配合 somaxconn(取 min)。
  • 遭 SYN flood 或异常客户端:开 tcp_syncookies=1(防御),别盲目调大队列。
  • 合法突发:tcp_max_syn_backlog + somaxconn 都调大 + 应用层 accept() 要及时(accept 慢会让全连接队列堆满,连带影响握手后半段)。
  • 监控 nstat 的 ListenDrops/SYN 系列,比凭感觉调。

🔧 思考题:tcp_syncookies 为什么能”队列满了也不丢合法连接”?它牺牲了什么(为什么老内核默认关、且某些场景不建议开)?

【参考答案】

正常三次握手,服务端收到 SYN 后要在半连接队列里存一份”半成品连接”状态(含客户端信息),等客户端 ACK 来时匹配、建全连接。SYN flood 下攻击方发海量伪造 SYN(源 IP 假、永不回 ACK),半连接队列被这些”永远完不成握手”的假连接占满,合法客户端的 SYN 进不来就被丢——这是半连接队列的 DoS 面。

tcp_syncookies 的巧思:不存半连接状态。服务端收到 SYN 时不入队,而是根据 SYN 里的源/目的/序列号等算一个加密 cookie,把 cookie 编进回给客户端的 SYN+ACK 的”初始序列号”里。客户端回 ACK 时,服务端从 ACK 的确认号反解出 cookie、验证合法(说明这客户端真收到了我的 SYN+ACK、是真实握手的第二步),就直接建全连接——整个过程不需要半连接队列存状态,所以队列满也不丢合法连接(合法客户端能正常走完 cookie 验证)。

牺牲什么(为何老内核默认关):

  1. 某些 TCP 选项失效:cookie 编码空间有限,开启 syncookies 时服务端可能不处理某些 TCP 选项(如 wscale 窗口缩放、SACK、TCP 时间戳),因为选项信息没地方存进 cookie——这会牺牲一部分性能/功能(如窗口缩放受影响,高 BDP 链路吞吐降,见第 21 章 BBR 实战补遗里窗口缩放的重要性)。
  2. 计算开销:每次 SYN 算/验 cookie 有哈希计算成本。
  3. 只防 SYN flood,不防其他:它解决的是”半连接队列被假 SYN 占满”,对业务真实的突发合法连接,正确做法是调大 tcp_max_syn_backlog + somaxconn,syncookies 是”被攻击时的兜底”而非日常优化。

所以现代内核(3.12+)tcp_syncookies=1(defer 模式)成为默认——平时正常握手入队,只有半连接队列真满了才自动切 cookie 模式,兼顾日常性能与抗 flood。手动设 =1(强制)会一直走 cookie、牺牲选项;设 =2(defer,默认推荐)才是”平时正常、溢出才 cookie”。这正是 syncookies 的”按需启用”智慧:别为了防攻击日常就牺牲 wscale/SACK。

延伸:半连接 vs 全连接队列是两个独立阀门,排查”建连慢”要 nstat 看 ListenOverflows(全连接) vs TCPBacklogDrop/SYN 相关(半连接),调对参数(somaxconn vs tcp_max_syn_backlog)才有效——这跟第 15 章”调 somaxconn 没用”的事故是同一类”调错层级”问题(半连接满你调全连接的参数,当然无效)。

下一章预告

墨:到第 10 章,我们把一个数据包从网卡一路刨到了你的 recv() 缓冲,顺手把 tcp_* 家族在两次真实事故里焊死了。但你有没有发现一个更大的盲区:前面十章,每一章都在讲”某一层”——虚拟内存、调度、文件系统、块层、中断、TCP。可真实世界的故障,从来不跟你”分章”来。

下一章(第 11 章)我们做一次全链路综合:选一个最生活化的场景——“你点了一下网页上的按钮,到页面刷新出来”——把从你手指按下、浏览器、内核协议栈、网卡、对面网关、Go 服务的 accept、调度器挑线程、malloc 一块内存、查缓存、write 回响应、再原路返回……所有章节的知识串成一条 30 秒内的微观时间线。你会看到:一次”页面卡了 2 秒”,到底卡在链路的哪一段,以及为什么”懂微观”才能把那 2 秒拆开、定位、各归各的优化。


本章完。下章见。


实战补遗四:用 ss + tcpdump 还原一次”建连慢”的真实链路

墨:第 10 章把 TCP 从三次握手到数据传输的微观旅程讲透了。这一节把那些机制变成你手上的排障刀——给你一个”建连慢”的真实案例,演示怎么用第 10 章的机制 + 第 25 章的工具,从现象反推到内核哪一段。

📦 案例:某服务,客户端反映"偶尔连上要 1~3 秒",但连上之后传输飞快。服务端 CPU/内存/磁盘都闲。

补遗.1 现象与机制对应

你:连上之后快,说明不是传输/带宽问题(第 10 章的”数据传输”段没问题);“建连慢、偶尔”指向建连这段(三次握手 / accept 队列),而不是已建立的连接。

补遗.2 第一刀:ss 看队列

墨:第 10 章讲过:全连接队列(Recv-Q 接近 Send-Q 在 LISTEN 状态)溢出会让新握手被丢弃、客户端重传、表现为”建连慢”。ss -lnt 看 LISTEN 端口:

State  Recv-Q  Send-Q  Local Address:Port
LISTEN  128     128     :::8080

Recv-Q(当前全连接队列长度)频繁顶到 Send-Q(= net.core.somaxconn + 应用 backlog 上限)→ 全连接队列在溢。这立刻对应第 10 章”事故一”和第 15 章 somaxconn。

补遗.3 第二刀:tcpdump 看握手重传

墨:抓包确认:tcpdump -i any -n 'tcp[tcpflags] & (tcp-syn) != 0',看到客户端 SYN 发出后,服务端有时不回 SYN-ACK,客户端等 1s 后重传 SYN——正是队列溢出时内核丢弃握手、靠客户端重传兜底的典型(第 10 章三次握手的”半/全连接队列”机制)。偶尔(队列没满时)就正常,所以”偶尔慢”。

补遗.4 调整

墨:两剂药(第 10/15 章组合):

  1. net.core.somaxconn=65535 + 应用 listen(backlog) 跟到 65535(第 15 章陷阱 2:只调 somaxconn 不调应用 backlog 也白搭);
  2. net.ipv4.tcp_max_syn_backlog=8192 + tcp_syncookies=1(抗 syn 队列满);
  3. 根因:业务有突发建连尖峰(如重试风暴),队列默认 128 顶不住——队列深度要匹配建连尖峰,不是匹配平均值。

补遗.5 复盘

墨:这案例把第 10 章的”三次握手队列机制”、第 15 章的”somaxconn 与 backlog 联动”、第 25 章的”ss+tcpdump 两把刀”焊成了一条完整链路:现象(建连偶尔慢)→ 机制(全连接队列溢出丢握手)→ 工具(ss 看队列 / tcpdump 看重传)→ 参数(somaxconn + backlog + syn_backlog)。这就是”懂机制”在排障时的样子——你不是在猜,是在用机制的链条,一节一节地把故障钉死。