先还原一下这次优化的真实场景。我接手的是一个内部 BFF 网关,职责很单纯:接收移动端的 HTTP 请求,拼装后端数据,再返回 JSON。平时 QPS 在 2000 左右,CPU 30%,看起来一切正常。某次活动流量上来后,QPS 爬到 9000,最先崩的不是业务逻辑,而是网络 IO——客户端开始报连接超时、502、connection reset,机器上到处是 TIME_WAIT。第一反应是加机器,但加的机器过了一会儿又开始报同样的错。其实就是把同一个问题复制到了更多节点上。
从那时候开始,我花了几天时间把整个链路从 TCP 到 HTTP 一层层拆开,先后调整了连接生命周期、TCP 队列、应用层 IO 模型、HTTP 连接复用策略和响应体大小,最后单机吞吐量翻了近三倍,P99 延时从 400ms 级降到了 100ms 以内。这篇文章不是教科书式解释,是我把这些配置、命令、判断方法完整整理出来的一次实操复盘。合适给后端开发、运维、SRE,或者任何正在被“网络慢、连接多、服务假死”折磨的人当参考。
1. 优化前先明确边界:问题出在 TCP 还是 HTTP
1.1 现场监控里的几个反常信号
我先说当时看到的几个核心指标,因为优化网络 IO 最忌讳的是不先看现象就改内核参数。那台机器 CPU 只有 28% 到 35%,内存没满,磁盘也没压力,但系统的软中断占用明显偏高,负载在高峰期接近 12。用 ss -s 看连接统计,TIME_WAIT 大概有 2 万多个,同时 ESTABLISHED 也到了 1 万多,业务日志里大量上游读取超时。业务同学的第一反应是“线程池满了”,但线程数其实离上限还远,大部分线程阻塞在 socket 读上,所以真正的问题是连接处理和请求分发在 TCP/HTTP 两层都没有跑对。
遇到这种情况,我的经验是先分清两个方向。如果 CPU 已经打满、内存频繁 GC、业务代码有明显耗 CPU 逻辑,那可能先做代码级优化更值。但如果 CPU 明明有空闲、线程又在 wait、网络相关指标异常,那就应该优先怀疑网络 IO 路径。网络 IO 性能优化往往不需要一次性改多少个地方,找到一个堵点先打通,效果会非常明显。
1.2 TCP 层和 HTTP 层分别负责什么
TCP 层的职责可以通俗理解为“保证数据可靠地按顺序到达”。连接能不能建立、连接断了会不会重传、数据包会不会丢、滑动窗口给多少,这些都在 TCP 层处理。HTTP 层则是应用语义:一次 GET 代表什么、请求头和响应头怎么解析、body 用什么格式、连接能不能复用、客户端要不要重试。
所以排查问题时,我会把错误样本分类。连接失败、连接被重置、握手超时、大量重传,这些大概率要看 TCP 层或者与 TCP 直接相关的进程模型。如果连接明明建立了,但请求响应不对、返回慢、状态码不对,就要看 HTTP 层以及应用代码。拿当时最典型的 502 来说,那是个七层网关返回的,但导致它的根因往往在 TCP 层:比如后端连接的 accept 队列溢出,HTTP 请求还没被应用读到,连接就被内核断开。只看 HTTP 状态码会被带偏,需要往 TCP 层去找。
1.3 先定目标再动手
我给自己定的优化目标不是“哪哪都要调”,而是三条:连接建立阶段的错误率降下去、请求的长尾延时降下来、单机能扛住的 QPS 提上来。指标主要看 P99、accept 队列溢出数、TIME_WAIT 数量、网络重传率这些相对直接的指标。正因为目标清楚,后面改动带来的收益才能横向比较。否则今天改个 backlog,明天调个超时,数据完全对不上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. TCP 层优化:连接生命周期与队列是重点
2.1 三次握手不是免费午餐
TCP 建立连接需要三次握手。在优化之前,我看到客户端每次请求都新建一个 TCP 连接,这其实是最影响网络 IO 开销的行为之一。假设一个接口的业务处理只花 1ms,但 RTT 是 20ms,那么每次请求如果新建连接,握手阶段就先吃掉 20ms 左右,整体响应时间要么接近 21ms,要么并行并发时因为握手排队变得更夸张。如果还需要 TLS,那还要额外加一个 RTT,代价更明显。
同样的道理体现在连接关闭上。短连接请求结束后会进入四次挥手,主动关闭的一方还要进入 TIME_WAIT 状态,默认要停留 60 秒。一个高并发网关如果经过的都是短连接,每秒几千个请求就会同时制造几千个 TIME_WAIT 连接,虽然它们本身不占太多内存,但会让 ss 看着触目惊心,而且大量连接新建/关闭会显著增加内核软中断处理量和进程上下文切换。
2.2 让连接活得更久:keep-alive 和连接池
这个阶段的第一个优化动作就是让客户端和服务端之间的连接尽量复用。HTTP/1.1 默认开启 keep-alive,但在真实链路里,很多框架、网关、代码库会在某些条件下主动把连接关掉,尤其是每发一次请求就 new 一个 HttpClient 或者每写一段代码就手动 close socket,这样 keep-alive 就形同虚设。
以 Java 为例,我习惯把连接池显式配置出来,至少保证每个路由有固定数量的连接复用,而不是请求来了随手建一个。以 Go 为例,http.Transport 的关键参数大致是这样:
go复制transport := &http.Transport{
MaxIdleConns: 200,
MaxIdleConnsPerHost:
