目录

网络-10 TCP 实战调优

前置阅读:网络-07 TCP 连接管理网络-09 TCP 流量控制与拥塞控制

前面几篇讲的是 TCP 的原理,这一篇讲线上真出问题时怎么定位和处置。原理部分不再重复,重点是命令、参数和判断依据。

开篇一句话原则:绝大多数 TCP “调优"需求,真正的病根在应用层。看到 CLOSE_WAIT 堆积就去改内核参数,几乎一定是错的方向——先找代码里的 bug。

1. 先看清现状

# 各状态连接数统计,排查第一条命令
ss -ant | awk 'NR>1{s[$1]++} END{for(k in s) print k, s[k]}' | sort -k2 -rn

# 输出示例
# ESTAB      1842
# TIME-WAIT  9631      ← 要判断是否正常
# CLOSE-WAIT 3204      ← 几乎一定是 bug
# LISTEN     12
# 摘要视图
ss -s

# 看具体是哪个对端占了大量连接
ss -ant | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn | head

# LISTEN 状态下 Recv-Q 是当前 accept 队列长度,Send-Q 是队列上限
ss -lnt

# 全连接队列溢出计数(持续增长说明 accept 太慢)
netstat -s | grep -i "listen"

# 重传相关统计
netstat -s | grep -iE "retrans|timeout"

ssnetstat 快得多(前者读 netlink,后者遍历 /proc),连接数上万时差距非常明显,优先用 ss

2. CLOSE_WAIT 堆积

这是最容易判断的一类问题:CLOSE_WAIT 大量堆积,几乎可以直接断定是应用层 bug。

回顾 网络-07 的四次挥手:对方发来 FIN 后,内核自动回 ACK,连接进入 CLOSE_WAIT,此时等的是你的应用调用 close()

对端 ──FIN──► 你   内核自动回 ACK,进入 CLOSE_WAIT
                   ▲
                   └── 卡在这里说明:你的代码没有 close()

所以 CLOSE_WAIT 堆积的含义非常明确:对方已经关闭了连接,而你的程序没有关。常见成因:

  • 忘记 defer resp.Body.Close() / defer conn.Close()
  • 异常分支里提前 return,跳过了关闭逻辑;
  • 连接池实现有缺陷,连接归还后没有正确关闭;
  • 业务逻辑阻塞(如卡在一个没有超时的下游调用),压根没走到 close()

定位方法

# 找出是哪个进程持有这些连接
ss -antp state close-wait | head -20

# 统计各进程的 CLOSE_WAIT 数量
ss -ant state close-wait -p 2>/dev/null | grep -oP 'pid=\K[0-9]+' | sort | uniq -c | sort -rn

# 确认到进程后,看它的 fd 使用情况(是否接近上限)
ls /proc/<PID>/fd | wc -l
cat /proc/<PID>/limits | grep "open files"

Go 里最典型的两个坑

// ❌ 错误:Body 没关,连接无法复用也无法释放,最终 CLOSE_WAIT 堆积
resp, err := http.Get(url)
if err != nil {
    return err
}
data, _ := io.ReadAll(resp.Body)

// ✅ 正确:err 判断之后立刻 defer Close
resp, err := http.Get(url)
if err != nil {
    return err          // 注意:err != nil 时 resp 可能为 nil,不能在此之前 defer
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
// ❌ 更隐蔽的坑:Body 关了,但没读完
// 未读完的连接无法放回连接池复用,等于每次请求都新建连接
resp, err := http.Get(url)
defer resp.Body.Close()
if resp.StatusCode != 200 {
    return errors.New("bad status")   // ← body 没读,连接被丢弃
}

// ✅ 正确:即使不需要 body 内容,也要读干净
defer func() {
    io.Copy(io.Discard, resp.Body)
    resp.Body.Close()
}()

结论:CLOSE_WAIT 没有内核参数可以"调优”。 唯一的解法是修代码。硬要说有相关参数,只有 tcp_fin_timeout 影响 FIN_WAIT_2(对端那边),管不到你的 CLOSE_WAIT

3. TIME_WAIT 堆积

CLOSE_WAIT 不同,TIME_WAIT 多本身往往是正常的——它出现在主动关闭方,需要等 2MSL(Linux 固定 60s)以确保对端收到最后的 ACK(原理见 网络-07)。

一台高频发起短连接的机器有几万个 TIME_WAIT 是很正常的。判断标准不是数量,而是有没有造成实际问题

场景 是否有害
服务端有大量 TIME_WAIT 通常无害,只占少量内存。除非把端口/内存耗尽
客户端有大量 TIME_WAIT 可能有害——本地端口耗尽,报 cannot assign requested address

为什么客户端更危险? 因为客户端每建一条连接要占一个本地临时端口,而端口范围有限:

# 可用临时端口范围,默认约 28000 个
sysctl net.ipv4.ip_local_port_range     # 32768 60999

一条连接被 TIME_WAIT 占住 60s,如果每秒新建 500 条连接,60 秒就是 3 万条——超过端口上限,新连接直接建不出来。

注意端口是按四元组区分的:只有在目标 IP + 端口完全相同时才会真正竞争同一批本地端口。所以问题最典型的场景是"应用高频访问同一个下游服务"(比如短连接连同一个 Redis 或同一个 API 网关)。

3.1 处置手段(按推荐顺序)

① 用长连接(根治)

这是唯一的根本解法。TIME_WAIT 的成因是"连接建了又关",改成复用连接后问题自然消失。

// Go 的 http.Client 默认已启用长连接,但默认的连接池上限偏小
transport := &http.Transport{
    MaxIdleConns:        200,              // 全局空闲连接数上限
    MaxIdleConnsPerHost: 100,              // ★ 关键:默认只有 2!
    IdleConnTimeout:     90 * time.Second,
}
client := &http.Client{Transport: transport, Timeout: 5 * time.Second}

MaxIdleConnsPerHost 默认值是 2,这是 Go 里一个非常常见的性能陷阱:高并发访问同一个下游时,超过 2 条的空闲连接会被直接关闭,导致大量 TIME_WAIT 和重复握手。压测发现 QPS 上不去、TIME_WAIT 暴涨,先查这个值。

另外要复用 http.Client,不要每次请求都新建——新建 Client 意味着新的连接池,完全失去复用意义。

tcp_tw_reuse(较安全)

sysctl -w net.ipv4.tcp_tw_reuse=1

允许作为客户端发起新连接时,复用处于 TIME_WAIT 且超过 1 秒的连接。它依赖 TCP 时间戳(tcp_timestamps=1,默认开启)来识别过期报文,所以是相对安全的。

关键限制:它只对主动发起连接(outbound)有效,对服务端接受连接无效。 所以它能救的是"客户端端口耗尽",救不了服务端的 TIME_WAIT 堆积。

③ 调大端口范围(缓解)

sysctl -w net.ipv4.ip_local_port_range="1024 65535"

治标不治本,但见效快,适合应急。

tcp_tw_recycle(绝对不要用)

这个参数在 Linux 4.12 中已被彻底移除,在更早的版本上也绝不该开启。

原因是它会启用一个基于对端 IP 的时间戳递增检查——而在 NAT 环境下,同一个出口 IP 后面是无数台不同的客户端,它们的时间戳彼此独立、不满足递增关系。结果就是服务端会随机丢弃部分客户端的 SYN,表现为"部分用户偶发连接超时",且极难排查。

网上很多老文章还在推荐 tcp_tw_recycle=1这是过时且危险的建议。看到就跳过。

4. keepalive

TCP 连接可能"已死但双方都不知道"——对端主机断电、网线被拔、中间 NAT 超时清了映射,这些情况下不会有任何 FIN 或 RST 发出。此时双方的 socket 依然显示 ESTABLISHED,实际已经是"半开连接"。

TCP keepalive 就是探活机制,默认是关闭的,需要应用显式启用。三个参数:

sysctl net.ipv4.tcp_keepalive_time      # 7200  空闲多久后开始探测(默认 2 小时)
sysctl net.ipv4.tcp_keepalive_intvl     # 75    探测间隔
sysctl net.ipv4.tcp_keepalive_probes    # 9     连续失败几次判定连接已死

默认值几乎没有实用价值7200 + 75×9 ≈ 2 小时 11 分钟才能发现一个死连接。而中间的 NAT 网关通常几分钟就把映射清了。所以要么调小内核参数,要么在应用层做心跳。

# 更实用的值:5 分钟空闲后开始探测,每 30 秒一次,3 次失败即判死
sysctl -w net.ipv4.tcp_keepalive_time=300
sysctl -w net.ipv4.tcp_keepalive_intvl=30
sysctl -w net.ipv4.tcp_keepalive_probes=3

Go 里默认已为 TCP 连接启用 keepalive(15 秒),也可以显式控制:

// 服务端
lc := net.ListenConfig{KeepAlive: 30 * time.Second}
ln, _ := lc.Listen(context.Background(), "tcp", ":8080")

// 单个连接
tcpConn.SetKeepAlive(true)
tcpConn.SetKeepAlivePeriod(30 * time.Second)

TCP keepalive vs 应用层心跳:前者由内核处理、零业务代码,但只能确认"TCP 通道还活着";后者能确认"对端应用还能正常处理请求"(进程可能已经假死但 TCP 栈仍在响应)。要求高的场景应该用应用层心跳——WebSocket 的 Ping/Pong 就是典型例子,见 网络-19

5. 缓冲区与队列

5.1 队列参数

三个队列的位置和作用(原理见 网络-07):

# 半连接队列(SYN queue)
sysctl net.ipv4.tcp_max_syn_backlog        # 建议 8192 以上

# 全连接队列上限(与 listen(fd, backlog) 取较小值)
sysctl net.core.somaxconn                  # 默认 4096(新内核),老内核仅 128

# 网卡接收队列,网络包进入协议栈前的缓冲
sysctl net.core.netdev_max_backlog

somaxconn 是一个经典陷阱:老内核默认值只有 128,即使你在代码里 listen(fd, 65535),实际生效的也是 128。高并发场景下队列瞬间打满,客户端表现为莫名超时而服务端日志毫无异常(原因见 网络-07)。

Nginx 需要在 listen 指令上显式指定,它不会自动读 somaxconn

listen 80 backlog=8192;

5.2 缓冲区参数

# 三个值分别是 min / default / max,内核会在此范围内自动调整
sysctl net.ipv4.tcp_rmem     # 4096  131072  6291456
sysctl net.ipv4.tcp_wmem     # 4096  16384   4194304

# 自动调整开关,默认开启,不要关
sysctl net.ipv4.tcp_moderate_rcvbuf   # 1

多数情况下不需要手动调缓冲区——内核的自动调整(auto-tuning)做得比手工设置好。真正需要调大的是高带宽 × 高延迟的场景(跨国专线、大文件传输),因为要装满一条链路需要的窗口大小由**带宽时延积(BDP)**决定:

BDP = 带宽 × RTT
例:1 Gbps × 100ms = 12.5 MB

也就是说跨国 1G 链路要跑满,接收窗口得有 12.5MB 量级。默认的 6MB 上限会成为瓶颈——此时限制吞吐的不是带宽,而是窗口

注意:如果代码里显式调用了 setsockopt(SO_RCVBUF)会关闭内核的自动调整,反而可能更慢。别画蛇添足。

6. 拥塞控制算法与 BBR

网络-09 讲的慢开始、拥塞避免、快重传快恢复是**经典算法(Reno 系)**的框架。现代内核有更好的选择。

# 当前使用的算法
sysctl net.ipv4.tcp_congestion_control     # 通常是 cubic

# 可用算法
sysctl net.ipv4.tcp_available_congestion_control
算法 判断拥塞的依据 适用场景
Reno 丢包 教科书算法,已不用
CUBIC 丢包(用三次函数控制窗口增长) Linux 默认,通用场景
BBR 带宽与 RTT 的实测值 高丢包、长肥管道(跨国、弱网)

BBR 的关键区别:传统算法把丢包当作拥塞信号,但这个假设在现代网络里不成立——无线网络、跨国链路的丢包很多是传输错误而非拥塞。此时 CUBIC 会误判并大幅降速,导致带宽白白浪费。

BBR 不看丢包,而是主动测量瓶颈带宽和最小 RTT,据此计算最优发送速率。在跨国、移动网络等高丢包环境下,吞吐提升往往是数倍级别。

启用 BBR(需内核 4.9+):

# 临时生效
sysctl -w net.core.default_qdisc=fq
sysctl -w net.ipv4.tcp_congestion_control=bbr

# 永久生效
cat >> /etc/sysctl.conf <<'EOF'
net.core.default_qdisc=fq
net.ipv4.tcp_congestion_control=bbr
EOF
sysctl -p

# 验证
sysctl net.ipv4.tcp_congestion_control

注意 fq 队列调度是 BBR 的配套要求,只改 tcp_congestion_control 而不设 default_qdisc=fq 效果会打折。

BBR 不是万灵药:它在低丢包的数据中心内网里,相比 CUBIC 提升有限;而且 BBR 有"抢占性"——它和 CUBIC 流共存时会占据更多带宽,这在共享链路上未必是好事。内网服务保持 CUBIC,跨公网/跨国链路上用 BBR是比较稳妥的策略。

7. 一份参考配置

不要无脑照抄,每一项都要先确认自己有对应的问题

# /etc/sysctl.conf

# --- 连接队列(高并发服务端必调)---
net.core.somaxconn = 8192
net.ipv4.tcp_max_syn_backlog = 8192
net.core.netdev_max_backlog = 16384

# --- TIME_WAIT(客户端型服务/高频外调时需要)---
net.ipv4.tcp_tw_reuse = 1
net.ipv4.ip_local_port_range = 1024 65535
# 切记:不要设置 tcp_tw_recycle,它已被移除且在 NAT 下有害

# --- keepalive(默认 2 小时太长)---
net.ipv4.tcp_keepalive_time = 300
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 3

# --- 抗 SYN Flood ---
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_synack_retries = 2

# --- 拥塞控制(跨公网链路推荐)---
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr

# --- 文件描述符(连接数上限的硬约束)---
fs.file-max = 2000000

配合 ulimit,否则进程级 fd 上限会先撞墙:

# /etc/security/limits.conf
* soft nofile 1000000
* hard nofile 1000000

容器环境要特别注意:容器默认共享宿主机的网络命名空间参数(除非用了 --net=none 或独立 netns)。在 K8s 里改这些参数要用 securityContext.sysctls 或 initContainer,直接在 Pod 里 sysctl -w 通常会因为只读文件系统而失败。

8. 排查思路小结

遇到"连接相关"的线上问题,按这个顺序走:

1. ss -ant 统计各状态 → 看哪个状态异常堆积
      │
      ├─ CLOSE_WAIT 多  → 100% 应用层没 close(),去修代码,别碰内核参数
      │
      ├─ TIME_WAIT 多   → 判断是客户端还是服务端
      │                    客户端 + 报 cannot assign requested address
      │                      → 改长连接(根治)/ tcp_tw_reuse / 扩端口范围
      │                    服务端 → 通常无害,确认是否真的有影响
      │
      ├─ SYN_RECV 多    → 可能是 SYN Flood,开 tcp_syncookies
      │
      └─ ESTABLISHED 正常但客户端超时
                        → 查 ss -lnt 的 Recv-Q 是否顶到 Send-Q(accept 队列满)
                        → netstat -s | grep listen 看溢出计数
                        → 根因通常是应用 accept 太慢或业务阻塞

2. netstat -s | grep -iE "retrans" → 重传率高说明链路质量问题
                        → 跨公网考虑 BBR;内网查网卡/交换机

3. 吞吐上不去但不丢包 → 算一下 BDP,可能是窗口不够(长肥管道)

最后重申开篇那句:内核参数能解决的问题很少,绝大多数"TCP 问题"的根因在应用代码——没关连接、没设超时、连接池配错、accept 太慢。先查代码,再动 sysctl。

9. 本章面试题

大量 CLOSE_WAIT 是什么原因?怎么解决?

含义非常明确:对方已经关闭连接(发了 FIN),而你的程序没有调用 close() 内核自动回了 ACK 进入 CLOSE_WAIT,之后就一直等应用关闭。

常见成因:忘记 defer Close()、异常分支提前 return 跳过关闭、连接池实现有缺陷、业务逻辑阻塞在没有超时的下游调用上。

解决办法只有修代码,没有任何内核参数能调。 定位用 ss -antp state close-wait 找到进程,再查 /proc/<PID>/fd 看是否接近 fd 上限。

Go 里最典型两个坑:① resp.Body 没 Close;② Close 了但没读完 body,导致连接无法放回池中复用。

TIME_WAIT 过多有什么危害?怎么处理?

先判断是客户端还是服务端——这决定了危害程度:

  • 服务端 TIME_WAIT 多通常无害,只占少量内存;
  • 客户端危险:每条连接占一个本地临时端口(默认范围约 28000 个),被占 60s(2MSL)。高频短连接会耗尽端口,报 cannot assign requested address

处置顺序

  1. 改长连接(根治)——Go 里注意 MaxIdleConnsPerHost 默认只有 2;
  2. tcp_tw_reuse=1——但只对主动发起连接有效,救不了服务端;
  3. 扩大 ip_local_port_range(治标);
  4. 绝不要用 tcp_tw_recycle——Linux 4.12 已移除,且在 NAT 环境下会随机丢弃 SYN。
为什么 tcp_tw_recycle 不能开?

它启用基于对端 IP 的时间戳递增检查。而 NAT 环境下同一个出口 IP 后面是无数台不同客户端,它们的时间戳彼此独立、不满足递增关系。

结果:服务端随机丢弃部分客户端的 SYN,表现为"部分用户偶发连接超时",极难排查。

该参数在 Linux 4.12 已被彻底移除。 网上仍在推荐它的文章都是过时且危险的。

TCP keepalive 和应用层心跳的区别?该用哪个?
  • TCP keepalive:内核实现,零业务代码,但默认关闭且默认值无实用价值7200 + 75×9 ≈ 2 小时 11 分才发现死连接,而 NAT 映射几分钟就清了)。它只能确认 TCP 通道存活
  • 应用层心跳:能确认对端应用还能正常处理请求——进程可能已假死但 TCP 栈仍在响应 keepalive 探测。

要求高的场景用应用层心跳(如 WebSocket 的 Ping/Pong)。用 keepalive 时务必调小 tcp_keepalive_time 等参数。

BBR 和 CUBIC 的区别?什么时候该用 BBR?

判断拥塞的依据不同

  • CUBIC(Linux 默认)把丢包当拥塞信号;
  • BBR 主动测量瓶颈带宽和最小 RTT,据此算最优发送速率。

为什么 BBR 更好:现代网络(无线、跨国)的丢包很多是传输错误而非拥塞,CUBIC 会误判并大幅降速,浪费带宽。BBR 在这类场景吞吐提升可达数倍。

选择:跨公网/跨国链路用 BBR,数据中心内网保持 CUBIC(BBR 提升有限,且它有抢占性,共享链路上会占据更多带宽)。

启用需内核 4.9+,且必须配 net.core.default_qdisc=fq,否则效果打折。

somaxconn 为什么重要?配了 listen(fd, 65535) 为什么不生效?

全连接队列的实际上限是 min(listen 的 backlog, net.core.somaxconn)。老内核 somaxconn 默认只有 128,所以代码里写 65535 也会被截断到 128。

高并发下队列瞬间打满,Linux 默认可能忽略最后 ACK 并重传 SYN+ACK;客户端一侧可能已经认为连接建立,于是后续数据重传并超时。服务端应用层日志通常毫无异常,因为连接尚未被 accept()tcp_abort_on_overflow=1 会改为更积极地回 RST,但应先解决应用 accept 太慢或队列过小的问题。

排查:ss -lnt 看 Recv-Q 是否顶到 Send-Q,netstat -s | grep listen 看溢出计数。

注意 Nginx 需要显式写 listen 80 backlog=8192;,它不会自动读 somaxconn。

带宽很大但吞吐上不去,可能是什么原因?

如果不丢包,很可能是接收窗口不够——受**带宽时延积(BDP)**限制:

BDP = 带宽 × RTT
1 Gbps × 100ms = 12.5 MB

跨国 1G 链路要跑满,窗口需要 12.5MB 量级,而 tcp_rmem 默认上限 6MB 会成为瓶颈。此时限制吞吐的不是带宽而是窗口,这类链路叫"长肥管道"。

注意:一般情况下不要手动调缓冲区,内核 auto-tuning 比手工设置好。而且代码里显式 setsockopt(SO_RCVBUF)关闭自动调整,反而更慢。