
前几天帮一个兄弟团队救火现象特别典型线上接口的 P99 延迟从 80ms 一路飙到 1.8s监控面板上时不时蹦出来几个 502nginx 日志刷出来一片 upstream timed out可后端服务的 CPU 才用了不到 40%。一开始大家都怀疑是代码问题我顺手看了眼 TCP 连接监控发现 ESTABLISHED 连接数比平时高了 5 倍TIME_WAIT 密密麻麻铺了一屏。这时候基本可以定性了不是某个环节坏了而是整条网络IO链路从 TCP 到 HTTP 都藏着问题。这篇文章就是当时排查和优化的完整复盘从 TCP 连接管理、内核参数到 HTTP 连接复用、超时重试再到最后几个高频报错的排查套路一次讲透。不管你是做后端服务、维护网关还是刚接触网络性能优化都能直接参考。1. 先定方向网络IO性能问题的分析框架1.1 一条请求链路三个层面的问题网络IO性能优化很多人一上来就调内核参数或者把超时时间改小一点其实这是误区。一个请求从客户端发起到服务端响应中间至少经过三个层面每一层的问题表现不一样优化手段也完全不同。第一层是传输层也就是 TCP 层。它管的是连接怎么建立、怎么维持、怎么断开。常见问题包括三次握手开销太大、TIME_WAIT 堆积、连接数被打满、握手超时等。这一层的问题通常表现为“连接建立慢”“连接被重置”“端口不够用”。第二层是协议语义层也就是 HTTP 层。它管的是请求怎么封装、怎么复用、怎么保证可靠性。常见问题包括连接不复用、队头阻塞、超时设置不合理、无脑重试导致雪崩等。这一层的问题表现为“单个请求慢”“错误率上升”“后端被重试流量打死”。第三层是应用与架构层管的是线程模型、连接池大小、负载均衡策略。常见问题包括线程池耗尽、连接池太小导致排队、流量倾斜导致某个节点被打爆。这三层是耦合的。TCP 的握手开销只有在 HTTP 层不做连接复用时才会被放大而 HTTP 层的重试风暴又会反过来导致 TCP 连接数爆增。所以做优化的时候不能只看一个点必须把整条链路串起来看。这个思路跟 TCP/IP 四层模型是能对上的链路层看物理网卡和 MTU网络层看路由和 IP 分片传输层看 TCP 连接和端口应用层看 HTTP 协议和应用逻辑。性能问题可能出现在任何一层排查时要像漏斗一样逐层收敛。1.2 优化之前先回答三个问题在实际动手之前我会先回答三个问题防止优化方向跑偏。第一目前的瓶颈到底是什么是新建连接太多还是单连接吞吐太低还是错误率太高这三个问题的解法完全不同连接多就做复用和调大容量吞吐低就看窗口和缓冲区错误率高则要看超时和上游可靠性。没有数据支撑的优化都是自我安慰所以在调整任何参数之前先把监控指标拉出来看。第二优化目标是什么是降低 P99 延迟还是提升极限 QPS还是降低错误率目标不同参数取舍也不同。比如为了极限 QPS可以适当放弃单请求的公平性加大并发但如果是延迟敏感型服务反而要限制突发并发防止拥塞。第三改动风险有多大网络参数调优有个特点很多参数是全局生效的改一个值可能影响所有连接。比如把 tcp_max_syn_backlog 调大能缓解握手丢弃但如果同时没有做连接队列长度监控反而掩盖了后端处理能力不足的真相。所以每次改动要小步快跑改一个、压一次、验证一次。这三个问题想清楚之后再往下走就有章法了。我见过太多人拿着网上现成的配置一顿改结果不知道哪个参数起了作用也不知道哪个参数误伤了业务最后只能回滚重来。1.3 手边要常备的三样工具做网络IO优化工具不需要很多但三样东西必须顺手。压测工具推荐 wrk轻量且功能足够。压测的时候不要只看平均值要看 P99 和 P999还得关注错误率。平均值是最骗人的指标一个接口 99% 的请求都很快只要 1% 的请求卡了 5 秒平均值也会被拉高不少但用户感受到的体感往往由 P99 决定。抓包工具是 tcpdump 加 Wireshark服务器上用 tcpdump 抓包保存为 pcap拉到本地用 Wireshark 分析握手耗时、重传率和 RTT 分布。很多时候你以为的网络问题抓包一看根本不是那么回事。连接状态查看工具核心就是 ssss -s看整体连接统计ss -lnt看监听队列ss -ant看当前连接状态分布ss -tnp能查到具体进程。这里多说一句ss 比 netstat 快得多尤其是在连接数上万的时候netstat 可能会卡半天ss 基本秒出。所以新项目我都建议直接用 ss别再用 netstat 了。2. TCP层优化是地基连接管理直接决定上限2.1 一次三次握手的固定成本比你想象的高TCP 是面向连接的协议客户端和服务端建立连接要经历三次握手断开连接要经历四次挥手。很多人觉得这不过是毫秒级的事但在高并发场景下这个固定成本会被无限放大。举个具体的例子。假设客户端和服务端之间的网络 RTT 是 40ms那么短连接场景下一次请求需要先花 40ms 建连再花 40ms 等响应一次请求的纯网络开销就是 80ms 起步。长连接场景下第一次请求也是 80ms但后续请求省掉了建连的 40ms每个请求只需 40ms。如果服务的 QPS 是 10000短连接意味着每秒要新建 10000 个连接这 10000 次握手不仅消耗 CPU还会大量占用本地端口和文件描述符。当连接建立速度跟不上请求速度时就会出现握手超时、连接被拒、本地端口枯竭等一系列连锁问题。所以 TCP 层优化的第一个核心思想就是能复用就不要新建。HTTP 层的 Keep-Alive 和连接池本质上都是在为 TCP 层省钱这个关系后面会专门讲。2.2 长连接的取舍以及绕不开的心跳长连接能省掉握手开销但引出了新问题连接怎么保活断了怎么感知常见方案有三种。第一种是应用层心跳。客户端定期发送心跳消息比如每 30 秒一次服务端在一定时间内没收到心跳就判定连接失效并回收资源。很多即时通讯和游戏长连接都是这么做的。心跳间隔必须小于服务端空闲超时时间否则会被误杀。第二种是利用 TCP 的 KeepAlive 机制这是内核提供的保活能力通过 net.ipv4.tcp_keepalive_time、net.ipv4.tcp_keepalive_intvl、net.ipv4.tcp_keepalive_probes 三个参数控制。默认值太久了做高并发服务时我会调短比如把 tcp_keepalive_time 调到 600 秒intvl 调到 10 秒probes 调到 3 次这样僵尸连接大概在 630 秒内就能被清理掉避免大量死连接白白占着内存和句柄。第三种是网关层的空闲超时比如 nginx 的 keepalive_timeout 控制客户端连接的空闲时间超过就主动关闭。这个值要跟心跳间隔配合如果心跳 30 秒一次keepalive_timeout 建议至少给到 65 秒留足余量。这里有个坑要提醒不要把 TCP KeepAlive 当成心跳来用。TCP KeepAlive 探测的是连接是否还活着不关心业务是否正常而且默认探测间隔太长根本不能满足业务级健康检查的要求。正经的业务保活还是要靠应用层心跳。2.3 内核参数优化清单可以直接抄作业以下是我在实际项目中验证过的一组内核参数适用于大多数高并发 TCP 服务端场景。注意这是经验值不同业务要微调。net.ipv4.tcp_tw_reuse 这个参数允许内核将处于 TIME_WAIT 状态的连接用于新的连接。它主要在主动发起连接的一方生效能显著缓解短连接场景下的 TIME_WAIT 堆积。注意老内核还有个 tcp_tw_recycle 参数强烈不建议开启它对 NAT 场景有严重副作用而且从内核 4.12 起已经被移除了。net.ipv4.tcp_fin_timeout 控制主动关闭方在 FIN_WAIT_2 状态等待的时间默认 60 秒。如果对端迟迟不关闭连接这个值越大占用资源越久调成 30 秒对绝大多数场景够用。net.core.somaxconn 和 net.ipv4.tcp_max_syn_backlog 决定 accept 队列和 SYN 队列的上限并发高时如果队列太小连接会被直接丢弃表现出来就是客户端连接超时或 RST。调大之后别忘了应用层也要配合Java 里 ServerSocket 的 backlog 参数、nginx 的 listen 指令 backlog 参数都要同步调大否则内核队列再大也白搭。net.ipv4.ip_local_port_range 控制客户端发起连接时的本地端口范围默认一般是 32768 到 60999在大量短连接场景下很容易端口不够用。调大这个范围能缓解但治标不治本真正解法还是连接复用。net.ipv4.tcp_slow_start_after_idle 设为 0 很关键。TCP 在连接空闲一段时间后会重置拥塞窗口导致下一个请求重新慢启动对时延敏感型服务很不友好。把这个参数设为 0可以让长连接的拥塞窗口保持住。net.core.rmem_max 和 net.core.wmem_max 用来加大接收和发送缓冲区上限对高带宽时延积网络尤其重要。这里有个计算可以记住BDP 带宽 × RTT / 8如果带宽是 1Gbps、RTT 是 20msBDP 就是 2.5MB窗口必须大于这个值才能跑满带宽。修改方式不多说写入 /etc/sysctl.conf 然后 sysctl -p 生效改完之后用 sysctl 命令验证一下即可。2.4 容易被忽略的两个小参数一个是 TCP_NODELAY。默认 TCP 启用 Nagle 算法会把小包攒起来一起发以减少网络报文数但在交互式请求场景下这个攒包会增加延迟。典型的坑是 Nagle 算法和延迟确认配合时产生 40ms 左右的额外延迟客户端发小包被 Nagle 扣住服务端回 ACK 被延迟确认扣住两边互相等。解决办法就是在 socket 上设置 TCP_NODELAY禁用 Nagle 算法。Go 语言默认对 TCP 连接设了 TCP_NODELAYJava 要在代码里主动设置Netty 中是一行 childOption 的事。另一个是 SO_REUSEADDR。服务器重启时如果端口还有 TIME_WAIT 状态的连接残留直接监听会报 Address already in use。设置 SO_REUSEADDR 之后允许在 TIME_WAIT 状态下绑定端口这是所有生产级服务端必须设置的选项。Java 的 ServerSocket 默认不开启需要显式调用 setReuseAddress(true)nginx、Redis 这些 C 语言实现的服务基本都主动开启了。3. HTTP层优化复用、并行、超时一个都不能少3.1 Keep-Alive 是 HTTP 层性价比最高的优化TCP 层讲完来到 HTTP 层。HTTP/1.1 默认启用 Keep-Alive同一个 TCP 连接上可以发送多个请求和响应这是 HTTP 层最基础也是最有效的优化。但在实际部署中很多团队根本没有吃透 Keep-Alive 的链路。一个请求从客户端到服务端中间可能要经过接入网关比如 nginx。如果只配置了客户端到 nginx 的 Keep-Alive而 nginx 到后端服务的连接没有做复用那么 TCP 连接依然是每请求新建。因为 nginx 默认到上游是短连接每次转发都会新建 TCP。正确做法是在 nginx 的 upstream 配置块里启用 keepalive 连接池upstream backend { server 192.168.1.10:8080; server 192.168.1.11:8080; keepalive 64; } server { listen 80; location /api/ { proxy_http_version 1.1; proxy_set_header Connection ; proxy_pass http://backend; } }关键点有两个。一是 proxy_http_version 必须设为 1.1否则默认用 1.0 发请求到上游根本没法复用连接。二是 proxy_set_header Connection 要清掉 Connection 头否则 nginx 转发时会把客户端的 Connection 头传给上游导致连接复用失效。keepalive 64 表示每个 worker 进程维护到上游的空闲连接缓存池大小。像 gunicorn、uwsgi、Tomcat 这类后端如果用 nginx 接入这个参数提升非常明显。我见过一个 Django 服务加上 upstream keepalive 之后 QPS 直接翻了一倍后端负载反而降了因为省掉了大量握手开销。3.2 HTTP/2 多路复用解决队头阻塞的进阶玩法HTTP/1.1 的 Keep-Alive 解决了连接复用问题但还有个老大难问题队头阻塞。同一个连接上的请求必须按顺序返回如果前面一个请求响应慢后面所有请求都只能排队等。浏览器对同一域名一般开 6 个连接来缓解但后端服务内部不可能为每个请求都开 6 个连接。HTTP/2 通过多路复用解决了这个问题。它在单个 TCP 连接上划分出多个流请求和响应可以交错传输每个流有自己的流 ID 和优先级互不阻塞。把服务升级到 HTTP/2 的收益在延迟敏感场景下非常明显。对外 HTTPS 服务只要在 nginx 里开启 http2 就完成了大部分工作浏览器会自动协商。但要注意两点。第一HTTP/2 大部分实现要求 TLS所以要先把证书配置好。第二HTTP/2 的头部压缩依赖 TLS 的 ALPN 协商老客户端可能不支持要保留 HTTP/1.1 降级能力。内网服务之间如果也想用 HTTP/2可以考虑明文模式但很多框架支持不完善不建议在生产中折腾。我的经验是对外网关开 HTTP/2对内服务做好连接池就足够覆盖绝大多数场景了。3.3 超时、重试、并发三组参数的搭配艺术HTTP 层优化不只是协议特性参数设计同样重要而且最容易踩坑。超时参数要分级设置。连接超时主要取决于网络 RTT 和建连压力一般建议 1 到 3 秒不要超过 5 秒。连接建不起来基本就是网络或对端有问题再等也是浪费时间。读取超时要根据业务接口的真实耗时来定比如业务 P99 是 500ms那读取超时设置 3 秒是合理的如果业务本身要跑 10 秒的长任务读取超时就得放宽到 15 秒以上不能一刀切。写入超时通常比读取超时短一些因为把请求发出去一般很快如果写超时都发生了大概率是对方接收窗口满了或者连接已经断了。重试策略要克制。网络抖动导致的失败可以重试但必须满足两个条件一是这个请求是幂等的比如 GET、PUT 这类操作不建议对非幂等的 POST 做盲目重试二是要有合理的重试次数和退避策略一般最多重试一次用指数退避避免重试风暴。我见过真实事故一个小流量接口超时调用方做了 3 次无脑重试直接把下游打到宕机然后更多请求超时、更多重试最后整个链路雪崩。连接池大小要测算而不是拍脑袋。一个经验公式连接池大小约等于目标 QPS 乘以平均响应时间。假如 QPS 是 2000单请求平均耗时 300ms那么连接池大约需要 600 个连接。设置太大会浪费资源太小会排队实践中可以留 30% 余量再结合压测微调。3.4 把 TCP 和 HTTP 串起来一个完整的优化链路到这里TCP 层和 HTTP 层的优化点已经分别讲完但真实场景中它们是一个整体。我以一个典型的“客户端到 nginx 再到后端服务”链路为例把完整优化串联起来。第一步客户端通过连接池访问网关。连接池的作用是复用 TCP 连接避免每次请求都握手。Java 用 Apache HttpClient 或 OkHttpGo 用 http.Transport 并设置 MaxIdleConnsPerHostPython 用 requests.Session这些都是自带连接池的实现。第二步网关到后端这条链路重点做 upstream keepalive 连接池把 proxy_http_version 设成 1.1清掉 Connection 头同时把 keepalive_timeout 和 keepalive_requests 调到一个合理值。第三步后端服务本身要能够扛住连接。调大 accept 队列开启 SO_REUSEADDR设置 TCP_NODELAY再配合前面说的内核参数。第四步压测验证。先用 wrk 压网关再用 wrk 直连后端对比两条链路的 P99 差距基本就能定位瓶颈在哪一层。这个链路优化完之后最直观的变化是新建连接数大幅下降、TIME_WAIT 明显减少、P99 延迟显著下降。4. 排查实录五个高频网络IO故障的处理过程4.1 502 Bad Gateway到底是谁的锅502 是最常见的网关报错但它只是表象只说明网关没有从上游拿到有效响应。排查要按顺序来。第一看网关日志。nginx 开启了 upstream 重试策略时如果上游超时或返回错误日志里的 upstream_status 字段会告诉我们具体状态码。常见的组合是 504 表示上游超时000 表示连接失败502 表示上游返回错误。第二查上游服务的健康状态。连接数、CPU、内存、线程池使用率都要看。我遇到过一次典型的 502 事故后端线程池全部阻塞在慢 SQL 上新请求进入就排队然后 nginx 等不到响应就返回 502。这种问题根因在应用层调网关参数没用。第三检查连接是否被 RST。用 tcpdump 抓包如果看到连接刚建立就被重置检查上游的连接队列是不是满了。ss -lnt命令要看监听队列的 Send-Q 字段它代表当前队列上限ss -lnt State Recv-Q Send-Q Local Address:Port LISTEN 0 128 0.0.0.0:8080Send-Q 是 128说明队列上限是 128。当队列满的时候新连接会被内核丢弃。如果 Recv-Q 长期接近 Send-Q说明应用层处理不过来要么应用有问题要么 somaxconn 和 backlog 配置没匹配上。4.2 bind: only one usage of each socket address这个报错的意思很直白端口已经被占用了。常见于服务重启旧的进程还没完全退出新进程绑定同一个端口失败。排查分两步。第一步用ss -lntp | grep 端口看端口被谁占用。如果是旧进程还活着等它退出或者直接处理掉。第二步如果进程已经没了但端口还处在 TIME_WAIT那就是内核还没回收这个连接此时需要设置 SO_REUSEADDR 才能立即绑定。Go 的服务默认会设置Java 要手动开启。另外还有一种隐蔽情况IPv6 和 IPv4 双栈绑定冲突。nginx 默认监听某个端口时会同时监听 IPv6 和 IPv4如果配置里多次监听同一个端口就会报这个错。检查一下配置是否有重复的 listen 指令即可。4.3 curl: (35) TCP connection reset by peer这个报错说明 TCP 连接已经建立但服务端主动发了 RST 断开连接。也就是说建连成功却在发送或接收过程中被重置。常见原因有三种。第一种是服务端 accept 队列满。内核处理不过来新连接时会丢 SYN 或者直接发 RST此时 ss -lnt 里会看到 Recv-Q 一直很大。第二种是应用层主动关闭。比如 nginx 配置了客户端超时时间客户端超过时限才发送完整请求头连接会被直接关闭客户端就可能收到 RST。这类问题在慢客户端场景很常见需要合理放宽超时参数。第三种是中间网络设备干预。比如防火墙、负载均衡器出于安全策略或空闲超时对长时间空闲的 TCP 连接发 RST。解决思路是应用层心跳保活或者调整两端的 keepalive 参数让连接在中间设备超时之前就被探测到。排查命令还是 tcpdump抓包看到 RST 的源地址和端口后先区分是服务端内核发的还是应用层发的。如果 RST 报文的 TTL 和正常包不一致大概率是中间设备。这个问题排查起来很折腾但思路对了就不难。4.4 拉取资源报 dial tcp i/o timeout问题出在哪一层Docker 或 conda 拉取远程资源时经常报 dial tcp i/o timeout这类错误本质上就是 TCP 建连超时。排查顺序如下。第一步先确认网络通不通。用 curl 访问目标地址看能不能建连如果 curl 也卡住说明网络层到目标地址的路由存在问题。第二步检查 DNS。有时候域名解析超时也会表现为建连超时因为解析出来的地址不对或者 DNS 响应太慢。用 dig 验证解析结果确保解析到的是可访问的地址。第三步检查 MTU 和路径上的传输限制。如果大包被丢弃而小包正常典型的现象就是建连可以但数据量一大就超时。可以在客户端查看当前 MTU尝试调小验证。这个坑在云环境尤其常见很多云厂商的隧道网络 MTU 比标准值小大包直接就被丢了。第四步换可靠的源。Docker 配置镜像加速器或者内网仓库conda 配置国内镜像站这些问题大多能缓解。排查的时候记得看错误信息里的关键字段比如 dial tcp 后面的 IP 地址和端口这个地址指向哪里基本就能判断问题的边界。4.5 云服务器 TCP 连接数爆表怎么定位看到 TCP 连接数过高先别急着下结论。用 ss -s 看连接状态分布重点区分两种情况。ESTABLISHED 连接数过高一般是连接池管理不当或者业务并发太高。排查对象是后端服务的线程池、数据库连接池、缓存连接池配置看是不是池子开得过大、连接回收不及时。如果连接数一直在涨且没有回落很可能是连接泄漏比如请求处理完后没有正常关闭。TIME_WAIT 连接数过高这是短连接场景的典型现象说明大量连接被快速建立和关闭。除了调大端口范围和开启 tcp_tw_reuse更根本的解法还是连接复用。还有个常见问题是连接数到上限。Linux 下 ulimit -n 默认是 1024高并发服务根本不够用。调大文件描述符上限后还要检查 systemd 服务配置里的 LimitNOFILE因为 systemd 会覆盖 ulimit 的值。我见过多次“ulimit 改了没用”的案例就是忘了改服务单元文件。4.6 高频故障排查速查表最后把上面这几个问题整理成一张速查表方便日常翻阅。现象常见原因首要排查命令参考解法502 Bad Gateway上游超时、连接失败、队列满nginx 日志 upstream_status、ss -lnt检查上游健康状态、调大连接队列bind: Address already in use端口被进程或 TIME_WAIT 占用ss -lntp设置 SO_REUSEADDR、确认无重复监听TCP connection reset by peeraccept 队列满、应用主动 RST、中间设备干预tcpdump 抓 RST、ss -lnt调大 somaxconn、合理超时、心跳保活dial tcp i/o timeout网络不通、DNS 解析慢、MTU 问题curl -v、dig、ip link show换镜像源、调 MTU、回退内网仓库TCP 连接数过高连接池过大、连接泄漏、TIME_WAIT 堆积ss -s、ss -ant连接复用、排查泄漏、调大文件描述符上限5. 最后再分享一点体会网络IO优化这个事最核心的一点其实就是先建基准再定位瓶颈最后小步调整。不要一上来就照抄别人的内核参数因为你不知道他那边是短连接还是长连接、是延迟敏感还是带宽敏感。我自己一开始做性能优化时也吃过这个亏照着网上一份高性能内核配置抄了一遍结果延迟反而更高了后来才发现人家是为了下载大流量场景调的缓冲区跟我的场景根本不匹配。另外网络协议栈是层层配合的TCP 层的优化必须和 HTTP 层的连接管理配套单改一层往往效果有限。而且很多时候你以为的 TCP 问题其实是 HTTP 层没有做复用导致的你以为的 HTTP 问题其实是内核参数不合理导致的。多抓包、多看监控、多压测让数据说话比啥都管用。最后再分享一个小技巧每次排查完问题之后把监控面板里 TCP 连接数、TIME_WAIT 数量、P99 延迟这三个指标截图存下来下次再出问题直接对比能省不少事。