带宽高≠网络有问题, 重传才是 P99 杀手 — 分段计时定方向, TCP 状态定病种, ss/nstat/tcpdump 三件套定罪
curl -w 拆五段: DNS/连/TLS/TTFB/下载ss -ant | awk | uniq -c 一条命令出诊断nstat / sar -n TCP,ETCP 看 retrans 趋势tcpretrans 抓到具体连接网络排查像高速收费站监控: curl -w 把一段旅程拆成"进匝道(DNS)、过卡口(TCP connect)、验票(TLS)、到闸机(TTFB)、下高速(download)"五张小票, 哪张贵一目了然; ss 的 TCP 状态是停车场出入登记 — CLOSE_WAIT 是"客人走了你没登记出门" (迟早爆满), TIME_WAIT 只是正常出入记录; 而丢包重传像路上偶发的剐蹭 — 大部分车照常到站 (P50 好看), 个别车被拖去定损 (P99 爆炸)。
$ curl -o /dev/null -s -w \ # 'dns:%{time_namelookup} ttfb:%{time_starttransfer}\n' URL # dns:0.005 ttfb:3.200 → 慢在服务端处理
# dns 2s → 查 /etc/resolv.conf 与解析链 # connect 慢 → 查路由/防火墙/对端 backlog # ttfb 慢 → 别赖网络, 进服务端代码
ss -s 给出协议栈摘要: established / timewait 等总量, 10 秒内判断"有没有异常规模"。 $ ss -s # TCP: 12000 (estab 10000, closed 8, orphaned 0, # timewait 30000) ← timewait 规模一眼可见
# 3 万 TIME_WAIT + 每秒新建 3000 连接 # → 短连接打下游: 上连接池 / HTTP keepalive
# CLOSE_WAIT 500 → 8000 且不回落 # → 必有代码路径没 close (典型: resp.Body 未关)
# SYN_SENT 500 → 下游黑名单误伤/网段不通 # SYN_RECV 3000 → 连接洪峰 或 listen backlog 太小
TcpRetransSegs 是内核级重传段计数, 看增量趋势而不是瞬时值; 配合 sar -n TCP,ETCP 的 retrans/s。 $ nstat -az TcpRetransSegs # TcpRetransSegs 183422 ← 配 10s 间隔看增速
# RTT 20ms + 1 次丢包 → 该请求 ≈ 220ms+ # 重传 2 次 → 620ms; 3 次 → 1s+ ← 毛刺来源
$ ss -lntp # LISTEN 0 4096 0.0.0.0:8080 users:(("api",pid=1234)) # Recv-Q 逼近 Send-Q → accept 跟不上, 连接被拒
sar -n DEV / ip -s link 看 RX/TX 的 drop 与 error: 持续增长说明带宽打满、缓冲不足或网卡异常, 与"带宽够不够"是两回事。 $ ip -s link show eth0 # RX: packets 120G errors 0 dropped 8432 ← 在涨就要查
# Go: Transport 复用, 池参数给足 # MaxIdleConns: 200, MaxIdleConnsPerHost: 100 # IdleConnTimeout: 90s ← 与 LB 空闲超时对齐
# /etc/resolv.conf: options timeout:1 attempts:2 ndots:2 $ dig +stats api.internal.svc # Query time: 2000 msec ← 复现现场
$ tcpdump -i eth0 -nn 'tcp[tcpflags] & (tcp-rst|tcp-syn) != 0' # 抓握手与 RST; 加 -w file.pcap 留证回放
用户反馈偶发慢, 服务端埋点显示应用只花了 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。
每天 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" 从此绝迹。
压测 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 税。
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 字段做降级确认。
把 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 分钟内报警"。
切流后部分请求卡 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 大量堆积 = 连不出去, 与服务端负载无关, 别去扩容。
带宽没满但偶发重传 — 看网卡计数器, 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)。
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 的空闲回收, 否则复用到死连接。
跨团队扯皮终结术: 抓包文件一发, 谁的责任一目了然。
# 只抓目标端口, -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, 证据链才完整。
容器里访问外域偶发慢 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 秒彻底消失。
# 错: sysctl net.ipv4.tcp_max_tw_buckets=5000 # 对: client 全局复用 + 池参数 → TIME_WAIT 自然归零
# 错: "TIME_WAIT/CLOSE_WAIT 都正常波动" # 对: CLOSE-WAIT 持续涨 → grep resp.Body.Close()
# 错: handler 里 redis.NewClient(...) 每次调用 # 对: 包级 var 全局单例 + PoolSize
# 错: socket.settimeout(3) 只管读写 # 对: connect 显式超时 + 整体 deadline
curl -w %{time_namelookup} 进巡检。 # 错: 只监控接口 RT # 对: blackbox_exporter 记录 dns 时间 + 告警
# 错: "带宽才 80%, 网络没问题" # 对: ip -s link 看 dropped 是否增长
ethtool -S 与 ip -s link 进巡检。 # 错: 只查 TCP 层 # 对: rx_no_buffer 增长 → 调大 ring buffer
sar 1 / nstat interval)。 # 错: nstat 拍一个数 "18 万, 挺多?" # 对: sar -n TCP,ETCP 1 → retrans/s 从 0→38
# 错: "TCP keepalive 会兜底" (2 小时后才会!) # 对: conn.SetReadDeadline(now+5s) + 应用层心跳
listen backlog + somaxconn 调大并监控 Recv-Q。 # 错: listen(fd, 10) # 对: backlog 4096 + ss -lnt 监控 Recv-Q/Send-Q
# 错: 每请求新 TLS 连接 (tls 握手 30ms×N) # 对: 长连接 + session ticket 复用
TCP_NODELAY。 # 错: Redis 客户端没关 Nagle → 偶发 40ms # 对: setsockopt(fd, TCP_NODELAY, 1)
ss -mi 是否 buffer 受限, 有证据再调。 # 错: rmem_max 拉到 1G "为了性能" # 对: ss -mi 确认 socket buffer 被限流再调
# 错: gw 30s / svc 30s / db 30s (层层全等) # 对: gw 2s → svc 剩余 deadline → db 更小
# 错: func f() { c := &http.Client{} ... } # 对: var c = &http.Client{Transport: sharedTP}
# 错: curl 默认双栈但 v6 不通 → 卡 1-2s # 对: curl --happy-eyeballs-timeout 200 或修 v6 路由
ping -M do -s 1472 探测, 统一 MTU。 # 错: "ping 通就是网络没问题" # 对: ping -M do -s 1472 目标 → 验证路径 MTU
nf_conntrack_count 与 max。 # 错: 偶发连不上查应用 # 对: dmesg: "nf_conntrack: table full" → 扩表/减短连接
# 错: SYN_RECV 3000 → "被打了!" → 开清洗 # 对: QPS 同步冲高 → 是洪峰, 扩 accept 能力
# 错: LB 空闲 60s, client IdleConnTimeout 300s # 对: client 90s < LB 空闲回收阈值