前几天帮一个兄弟团队救火,现象特别典型:线上接口的 P99 延迟从 80ms 一路飙到 1.8s,监控面板上时不时蹦出来几个 502,nginx 日志刷出来一片 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 分布。很多时候你以为的网络问题,抓包一看根本不是那么回事。连接状态查看工具核心就是 ss,ss -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 是 20ms,BDP 就是 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_NODELAY,Java 要在代码里主动设置,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 或 OkHttp,Go 用 http.Transport 并设置 MaxIdleConnsPerHost,Python 用 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 peer | accept 队列满、应用主动 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 延迟这三个指标截图存下来,下次再出问题直接对比,能省不少事。