高级后端 · 网络性能分析

带宽高≠网络有问题, 重传才是 P99 杀手 — 分段计时定方向, TCP 状态定病种, ss/nstat/tcpdump 三件套定罪

TCP 状态矩阵: 状态即病种 ESTAB 主动 close TIME_WAIT 2MSL, 常态产物 多 = 短连接洪流 ESTAB 对端 FIN CLOSE_WAIT 应用没 close! 涨 = 泄漏, 查代码 SYN_SENT 连不出去 下游不可达 / 防火墙 / 网络分区 SYN_RECV 半连接堆积: 洪峰 / backlog 小 / flood curl -w 分段计时: 同样 3 秒, 两种病 案例① total=3.21s: ttfb 占 99% TTFB 3.2s — 慢在服务端 → 查后端 案例② total=2.3s: dns 占 2s DNS 2s — resolver 超时 命令: curl -o /dev/null -s -w 'dns:%{time_namelookup} connect:%{time_connect} tls:%{time_appconnect} ttfb:%{time_starttransfer} total:%{time_total}' URL 五段各答一问: DNS 慢=解析 · connect 慢=链路/backlog TLS 慢=握手开销 · TTFB 慢=服务端处理 · download 慢=带宽 偶发 P99 毛刺的收网链: CPU/DB 都正常时的最后嫌疑人 ① 毛刺现场 P50=20ms, P99=2s CPU / DB / GC 全正常 ② 查重传计数 nstat | grep -i retrans sar -n TCP,ETCP 1 ③ 抓重传现场 tcpretrans-bpfcc 定位到 app→Redis 这条路 ④ 结论与动作 网络路径丢包 证据交给网络组 / 切路径 ss -ant 状态分布速查 (一条命令出诊断) ss -ant | awk '{print $1}' | sort | uniq -c TIME-WAIT 30000 主动侧短连接洪流 → 上连接池/keepalive CLOSE-WAIT 8000 持续涨 对端关了你没关 → fd 泄漏实锤 事故现场: 轻微丢包如何毁掉 P99 正常 RTT 20ms, 丢一个包 → 等 RTO (200ms 起, 指数退避) 1% 丢包 × 1000 QPS = 每秒 10 个请求撞重传 → P50 完全正常, P99 直接 200ms+ 这就是"均值健康、长尾爆炸"在网络层的形态

分段计时定方向

  • • curl -w 拆五段: DNS/连/TLS/TTFB/下载
  • • dns 慢 = resolver; ttfb 慢 = 服务端
  • • 同样 3 秒, 方向完全不同
  • • 线上偶发慢, 先分段再进代码

TCP 状态定病种

  • • TIME_WAIT 多 = 短连接洪流 (治本: 复用)
  • • CLOSE_WAIT 涨 = 应用没 close (泄漏)
  • • SYN_SENT = 下游不可达; SYN_RECV = 半连接堆积
  • • ss -ant | awk | uniq -c 一条命令出诊断

重传 = P99 杀手

  • • 轻微丢包把个别请求拖进 RTO 200ms+
  • • nstat / sar -n TCP,ETCP 看 retrans 趋势
  • • tcpretrans 抓到具体连接
  • • 看带宽更要看 drop 与 retrans

💡 一句话理解

网络排查像高速收费站监控: curl -w 把一段旅程拆成"进匝道(DNS)、过卡口(TCP connect)、验票(TLS)、到闸机(TTFB)、下高速(download)"五张小票, 哪张贵一目了然; ss 的 TCP 状态是停车场出入登记 — CLOSE_WAIT 是"客人走了你没登记出门" (迟早爆满), TIME_WAIT 只是正常出入记录; 而丢包重传像路上偶发的剐蹭 — 大部分车照常到站 (P50 好看), 个别车被拖去定损 (P99 爆炸)。

🧠 必知必会 必考 & 必会

curl -w 分段计时
把"慢 3 秒"拆成五段, 每段一个方向: DNS 解析 / TCP 连接 / TLS 握手 / 首字节 / 下载。这是网络排障的第一把刀。
$ curl -o /dev/null -s -w \
#   'dns:%{time_namelookup} ttfb:%{time_starttransfer}\n' URL
# dns:0.005 ttfb:3.200  → 慢在服务端处理
五段各答一问
dns 慢=resolver/ndots; connect 慢=链路/丢包/backlog; tls 慢=握手开销/CPU; ttfb 慢=服务端处理; 下载慢=带宽/拥塞。
# dns 2s     → 查 /etc/resolv.conf 与解析链
# connect 慢 → 查路由/防火墙/对端 backlog
# ttfb 慢    → 别赖网络, 进服务端代码
ss -s 总览
ss -s 给出协议栈摘要: established / timewait 等总量, 10 秒内判断"有没有异常规模"。
$ ss -s
# TCP:  12000 (estab 10000, closed 8, orphaned 0,
#       timewait 30000)   ← timewait 规模一眼可见
TIME_WAIT 本质
主动关闭方的常态产物 (等 2MSL 防旧包串扰), 数量大说明短连接多 — 是症状不是病本身, 治本靠连接复用。
# 3 万 TIME_WAIT + 每秒新建 3000 连接
# → 短连接打下游: 上连接池 / HTTP keepalive
CLOSE_WAIT 泄漏
对端已 FIN, 本端应用迟迟不 close — 每个都在吃一个 fd。持续增长是应用泄漏的铁证, 比TIME_WAIT 危险得多。
# CLOSE_WAIT 500 → 8000 且不回落
# → 必有代码路径没 close (典型: resp.Body 未关)
SYN_SENT / SYN_RECV
SYN_SENT 多=本端在等对端握手 (下游不可达/防火墙); SYN_RECV 多=对端握手到我这里悬着 (半连接队列/backlog)。
# SYN_SENT 500 → 下游黑名单误伤/网段不通
# SYN_RECV 3000 → 连接洪峰 或 listen backlog 太小
重传计数 nstat
TcpRetransSegs 是内核级重传段计数, 看增量趋势而不是瞬时值; 配合 sar -n TCP,ETCP 的 retrans/s。
$ nstat -az TcpRetransSegs
# TcpRetransSegs   183422   ← 配 10s 间隔看增速
丢包→RTO 放大
丢包后 TCP 等 RTO (常 200ms 起步, 指数退避) 才重传。1% 丢包在 1000 QPS 下每秒有 10 个请求撞枪口 — P50 无感, P99 爆炸。
# RTT 20ms + 1 次丢包 → 该请求 ≈ 220ms+
# 重传 2 次 → 620ms;  3 次 → 1s+  ← 毛刺来源
ss -lntp 与 backlog
看监听与队列: Recv-Q/Send-Q 在 LISTEN 态是当前 accept 队列长度 vs 上限 — 接近上限就是握手处理不过来。
$ ss -lntp
# LISTEN 0 4096 0.0.0.0:8080  users:(("api",pid=1234))
# Recv-Q 逼近 Send-Q → accept 跟不上, 连接被拒
网卡层 drops/errors
sar -n DEV / ip -s link 看 RX/TX 的 drop 与 error: 持续增长说明带宽打满、缓冲不足或网卡异常, 与"带宽够不够"是两回事。
$ ip -s link show eth0
# RX:  packets 120G  errors 0  dropped 8432  ← 在涨就要查
keepalive 与复用
短连接每次付握手+TLS+慢启动三笔税。HTTP keepalive + 连接池把税摊到几百个请求上, TIME_WAIT 也随之消失。
# Go: Transport 复用, 池参数给足
# MaxIdleConns: 200, MaxIdleConnsPerHost: 100
# IdleConnTimeout: 90s  ← 与 LB 空闲超时对齐
DNS resolver
应用偶发卡几秒的经典元凶: resolv.conf 的 timeout/attempts 放大 (默认 5s×2), ndots:5 让每次查询先试搜索域。
# /etc/resolv.conf: options timeout:1 attempts:2 ndots:2
$ dig +stats api.internal.svc
# Query time: 2000 msec  ← 复现现场
tcpdump 测谎仪
最后的现场录像: 抓握手/重传/RST/DNS。到这步基本是"拿证据找网络组对质"。
$ tcpdump -i eth0 -nn 'tcp[tcpflags] & (tcp-rst|tcp-syn) != 0'
# 抓握手与 RST;  加 -w file.pcap 留证回放

🏭 生产实战 real world

场景 1 · 偶发"请求卡 3 秒": curl 分段锁定 DNS

用户反馈偶发慢, 服务端埋点显示应用只花了 50ms — 请求在别处失踪。分段计时定位。

# 在问题机器上连跑 20 次分段计时
$ for i in $(seq 20); do curl -o /dev/null -s -w \
    'dns:%{time_namelookup} total:%{time_total}\n' https://api.internal.svc; done
# 18 次 dns:0.004 total:0.08
# 2 次  dns:2.003 total:2.11   ← DNS 偶发超时 1s×2 attempts

# 修复: /etc/resolv.conf → options timeout:1 attempts:1 rotate
# 并上本地缓存 (nscd / Node dns cache / Go 自带 resolve 缓存策略)

不修时用户侧是"随机 2 秒", 修后 P99 从 2.1s 回到 90ms。

场景 2 · CLOSE_WAIT 涨到 8000: 一个没 close 的 response body

每天 OOM 没发生, fd 先爆了。状态分布 + 代码审查收网。

$ ss -ant | awk '{print $1}' | sort | uniq -c
#   100 ESTAB
#  8000 CLOSE-WAIT              ← 对端关了, 我没关

// Go 审查发现: 只读码不看 body, 连接无法复用/关闭
if resp.StatusCode != http.StatusOK {
    return fmt.Errorf("upstream %d", resp.StatusCode)  // 错: 没 close
}
// 对:
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
    io.Copy(io.Discard, resp.Body)  // 读掉 body 才会复用连接
    return fmt.Errorf("upstream %d", resp.StatusCode)
}

上线后 CLOSE_WAIT 归零, "too many open files" 从此绝迹。

场景 3 · TIME_WAIT 3 万: 短连接打 Redis 的池化改造

压测 QPS 上不去, CPU/内存都闲 — 每条命令新建一个 TCP 连接。

$ ss -ant | grep -c TIME-WAIT
# 31000    ← 全是发往 6379 的短连接

// 错: 每请求新建
rdb := redis.NewClient(&redis.Options{Addr: "redis:6379"})  // 在 handler 里!

// 对: 全局单例 + 池参数
var rdb = redis.NewClient(&redis.Options{
    Addr: "redis:6379", PoolSize: 200, MinIdleConns: 20,
})
// 效果: TIME_WAIT 3.1万→200, P99 40ms→3ms

经验: 一切"每请求 new client"都是 TIME_WAIT 工厂 + P99 税。

场景 4 · retrans 三连抓毛刺: sar → nstat → tcpretrans

P99 周期性毛刺, 应用/DB 全洗清嫌疑。按收网链走一遍, 直接抓到路径。

$ sar -n TCP,ETCP 1
# 14:00  active/s  passive/s  retrans/s  38  ← 平时 0
$ nstat -az TcpRetransSegs TcpExtTCPSynRetrans
# 两个计数都在涨 → 丢包实锤

$ tcpretrans-bpfcc 10
# TIME  PID   IP  LADDR:LPORT → RADDR:RPORT  TYPE
# 14:00 1234  4   10.0.1.5:52300 → 10.0.9.2:6379  LOSS  ← Redis 路径
# 结论: 到 Redis 机房链路丢包 → 换交换路径后毛刺消失

没有 eBPF 工具时用 ss -i 看 retrans 字段做降级确认。

场景 5 · 状态分布巡检脚本: 每分钟一份"停车场登记"

把 ss 状态分布做成时序指标, 泄漏与洪流在趋势图上无处可藏。

#!/bin/bash
# tcp_state_exporter.sh — 丢给 node_exporter textfile 采集
ss -ant | awk 'NR>1 {s[$1]++} END {
  for (k in s)
    printf "tcp_state_total{state=\"%s\"} %d\n", k, s[k]
}' > /var/lib/node_exporter/tcp_states.prom

# 告警规则:
#   CLOSE-WAIT 5 分钟环比增长 > 200 → page
#   TIME-WAIT 突破 5 万           → 审连接复用

CLOSE_WAIT 类泄漏从"OOM 才发现"变成"5 分钟内报警"。

场景 6 · SYN_SENT 堆积 500: 防火墙误伤新网段

切流后部分请求卡 30 秒超时 — 连接根本没建立出去。

$ ss -ant state syn-recv | head; ss -ant | grep SYN-SENT | head -3
# SYN-SENT 10.0.1.5:41022 → 10.0.20.8:8443  ← 悬着

$ tcpdump -i eth0 -nn host 10.0.20.8 and port 8443
# 只有出站 SYN, 没有回 SYN-ACK → 中间被吞

# 定案: 新网段不在防火墙白名单
# 修复: 开通 10.0.1.0/24 → 10.0.20.8:8443 策略
# 教训: 网络变更清单里要有"双向策略检查"

SYN_SENT 大量堆积 = 连不出去, 与服务端负载无关, 别去扩容。

场景 7 · 网卡持续丢包: sar -n DEV 与 ethtool 双眼

带宽没满但偶发重传 — 看网卡计数器, drop 是独立的病。

$ sar -n DEV 1
# IFACE  rxpck/s  txpck/s  rxkB/s  %ifutil
# eth0   180000   150000   210000   68     ← 带宽没满

$ ip -s link show eth0
# RX errors 0  dropped 8432  ← 在涨!
$ ethtool -S eth0 | grep -iE "drop|miss|fifo"
# rx_no_buffer: 8410   ← 环形缓冲太小
# 修复: ethtool -G eth0 rx 4096 + 开多队列 RSS

看网络健康是三眼: 带宽 (%ifutil)、丢包 (drop)、重传 (retrans)。

场景 8 · HTTP client 复用: Transport 全局化与池参数

Go 服务每请求 new http.Client, fd 与 TIME_WAIT 双爆; 正解是一行改动 + 池参数。

// 错: handler 里每次新建, 连接不复用
func call(url string) {
    resp, _ := http.Get(url)   // 默认 client, 且每次 new 更糟
    defer resp.Body.Close()
}

// 对: 全局 client + 显式 Transport 池
var client = &http.Client{
    Timeout: 2 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns: 200, MaxIdleConnsPerHost: 100,
        IdleConnTimeout: 90 * time.Second,
    },
}

90s 空闲超时必须小于 LB 的空闲回收, 否则复用到死连接。

场景 9 · tcpdump 留证: 把"网络有问题"变成证据

跨团队扯皮终结术: 抓包文件一发, 谁的责任一目了然。

# 只抓目标端口, -s 128 减小体积, 30 秒足够
$ timeout 30 tcpdump -i eth0 -nn -s 128 \
    'host 10.0.9.2 and port 6379' -w /tmp/redis.pcap

$ tcpdump -nn -r /tmp/redis.pcap 'tcp[tcpflags] & tcp-rst != 0' | head
# 发现 RST 由中间设备发出 (TTL 不符) → 链路设备在杀连接
# wireshark 打开: 看 Seq/Lost/Retrans 统计一屏读完

抓包口径: 限定 host/port + 短时长 + 留原始 pcap, 证据链才完整。

场景 10 · K8s 里 DNS 偶发 5 秒: ndots 放大与超时收缩

容器里访问外域偶发慢 5 秒, 典型 ndots:5 + 搜索域全试一遍 + 默认超时放大。

$ cat /etc/resolv.conf
# search svc.cluster.local cluster.local
# options ndots:5 timeout:5 attempts:2  ← 最坏 5s×2×N 域

# 外域名 api.example.com 触发 5 次搜索域查询后才查根
# 修复 (K8s dnsConfig):
dnsConfig:
  options:
  - name: ndots, value: "2"
  - name: timeout, value: "1"
  - name: attempts, value: "2"

配合 NodeLocal DNSCache, 偶发慢 5 秒彻底消失。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · TIME_WAIT 多就调内核参数 — tw_reuse 只是止痛药, 短连接根因没动. 原因: 网上老方方便照抄. 正解: 连接池 + keepalive 治本。
# 错: sysctl net.ipv4.tcp_max_tw_buckets=5000
# 对: client 全局复用 + 池参数 → TIME_WAIT 自然归零
坑 2 · CLOSE_WAIT 与 TIME_WAIT 混为一谈 — 前者是泄漏, 后者是常态, 处理方式完全不同. 原因: 都叫"连接多". 正解: CLOSE_WAIT 涨 = 查代码路径。
# 错: "TIME_WAIT/CLOSE_WAIT 都正常波动"
# 对: CLOSE-WAIT 持续涨 → grep resp.Body.Close()
坑 3 · 每请求新建连接/客户端 — 付握手+TLS+慢启动三重税. 原因: 图省事. 正解: 全局单例 + 连接池。
# 错: handler 里 redis.NewClient(...) 每次调用
# 对: 包级 var 全局单例 + PoolSize
坑 4 · 不设 connect 超时 — SYN 默认重试可卡 2 分钟, 线程全挂住. 原因: 只设了 read timeout. 正解: connect/read/total 分层超时。
# 错: socket.settimeout(3) 只管读写
# 对: connect 显式超时 + 整体 deadline
坑 5 · DNS 不监控 — 偶发慢几秒全赖"网络玄学". 原因: 解析链没埋点. 正解: curl -w %{time_namelookup} 进巡检。
# 错: 只监控接口 RT
# 对: blackbox_exporter 记录 dns 时间 + 告警
坑 6 · 只看带宽判网络健康 — 8G/10G 带宽 + 持续 drop = 有问题; 带宽高+零丢包=健康. 原因: 单指标. 正解: 带宽+drop+retrans 三眼同看。
# 错: "带宽才 80%, 网络没问题"
# 对: ip -s link 看 dropped 是否增长
坑 7 · 忽略网卡 errors/drops — 缓冲不足的丢包伪装成"偶发重传". 原因: 不看链路层计数. 正解: ethtool -S 与 ip -s link 进巡检。
# 错: 只查 TCP 层
# 对: rx_no_buffer 增长 → 调大 ring buffer
坑 8 · 重传只看瞬时值 — 一次 nstat 数字无意义, 趋势才是证据. 原因: 计数器语义不懂. 正解: 定间隔看增量 (sar 1 / nstat interval)。
# 错: nstat 拍一个数 "18 万, 挺多?"
# 对: sar -n TCP,ETCP 1 → retrans/s 从 0→38
坑 9 · 读超时缺失依赖内核 keepalive — 默认 7200s 才探测, 对端拔线时应用读 hang 半天. 原因: 误解 keepalive 层级. 正解: 应用层 read deadline + 空闲心跳。
# 错: "TCP keepalive 会兜底"  (2 小时后才会!)
# 对: conn.SetReadDeadline(now+5s) + 应用层心跳
坑 10 · listen backlog 太小 — 突发连接握手排队溢出, 客户端莫名超时. 原因: 默认值小. 正解: listen backlog + somaxconn 调大并监控 Recv-Q。
# 错: listen(fd, 10)
# 对: backlog 4096 + ss -lnt 监控 Recv-Q/Send-Q
坑 11 · 每次全量 TLS 握手 — RTT×2+证书运算全付, 跨机房 P99 惨不忍睹. 原因: 没开会话复用. 正解: session resumption / TLS1.3 PSK / 连接池。
# 错: 每请求新 TLS 连接 (tls 握手 30ms×N)
# 对: 长连接 + session ticket 复用
坑 12 · Nagle 撞 delayed ACK — 小包写+等回执的经典 40ms 毛刺. 原因: 两个优化互相打架. 正解: 交互式小包协议设 TCP_NODELAY。
# 错: Redis 客户端没关 Nagle → 偶发 40ms
# 对: setsockopt(fd, TCP_NODELAY, 1)
坑 13 · 玄学调 buffer — 乱调 rmem/wmem 可能挤占内存反而变差. 原因: 没基准. 正解: 先看 ss -mi 是否 buffer 受限, 有证据再调。
# 错: rmem_max 拉到 1G "为了性能"
# 对: ss -mi 确认 socket buffer 被限流再调
坑 14 · 超时链上下游不一致 — 上游 30s 下游 5s, 上游干等 25 秒还以为会成功. 原因: 各自为政. 正解: deadline 从入口传递, 层层递减。
# 错: gw 30s / svc 30s / db 30s (层层全等)
# 对: gw 2s → svc 剩余 deadline → db 更小
坑 15 · Transport 在函数里 new — 每个实例独立连接池, 复用失效还漏 fd. 原因: 误以为 client 轻量. 正解: client+Transport 包级单例。
# 错: func f() { c := &http.Client{} ... }
# 对: var c = &http.Client{Transport: sharedTP}
坑 16 · IPv6 回退拖慢首连 — AAAA 超时后才走 A 记录, 双栈残缺环境偶发慢秒级. 原因: happy eyeballs 未配置. 正解: 客户端开 happy-eyeballs / 统一协议族。
# 错: curl 默认双栈但 v6 不通 → 卡 1-2s
# 对: curl --happy-eyeballs-timeout 200 或修 v6 路由
坑 17 · MTU 不匹配大包丢 — VPN/隧道环境小包通大包丢, 表现为"下载卡住". 原因: 分片被丢. 正解: ping -M do -s 1472 探测, 统一 MTU。
# 错: "ping 通就是网络没问题"
# 对: ping -M do -s 1472 目标 → 验证路径 MTU
坑 18 · conntrack 表满 — 大量短连接把 nf_conntrack 撑爆, 新连接静默丢弃. 原因: 不看内核日志. 正解: 监控 nf_conntrack_count 与 max。
# 错: 偶发连不上查应用
# 对: dmesg: "nf_conntrack: table full" → 扩表/减短连接
坑 19 · SYN_RECV 高直接定性攻击 — 洪峰、backlog 小、rebind 都会推高半连接. 原因: 条件反射. 正解: 结合 QPS 曲线与 backlog 配置判断。
# 错: SYN_RECV 3000 → "被打了!" → 开清洗
# 对: QPS 同步冲高 → 是洪峰, 扩 accept 能力
坑 20 · 空闲连接被 LB 静默回收 — 复用到半死连接, 首请求必失败重连. 原因: 池空闲超时大于 LB 空闲超时. 正解: client idle timeout < LB idle timeout。
# 错: LB 空闲 60s, client IdleConnTimeout 300s
# 对: client 90s < LB 空闲回收阈值