为什么你的API总是慢成蜗牛?可能不是代码问题,是TCP在搞你
上周帮一个朋友看接口性能问题,他的Go服务RT(响应时间)中位数800ms,P99直接飙到2秒。代码review了一圈,没什么问题,数据库慢查询也没有,Redis也在线。最后怎么解决的?改了三行TCP配置,RT降到50ms。
这不是玄学,这是TCP协议的基础知识没到位。今天我们来聊聊那些后端工程师天天在用、却很少深入理解的TCP细节。
一、连接复用:你以为reuse了就真的复用了?
所有人都在说HTTP连接要复用,Keep-Alive是基操。但我见过太多项目,嘴上说着连接池,实际上每次请求都在创建新连接。
来看一个经典的误解:
# 伪代码,展示一个常见的假复用
client = HTTPClient(keep_alive=True) # 以为开了keep-alive就万事大吉
for url in urls:
response = client.get(url) # 实际上每个URL可能都是新连接
问题出在哪?大多数HTTP客户端库的连接复用是按host:port维度的。如果你请求的是同一个域名的不同路径,复用没问题。但如果你的服务有多个后端实例,负载均衡器后面跑着多台机器——恭喜你,每次请求可能是不同的TCP连接。
更隐蔽的问题是HTTP/1.1的pipeline并没有被广泛实现。你以为可以像流水线一样发多个请求,实际上大多数客户端是串行等待响应的。要真正并发?你需要HTTP/2的多路复用或者每个请求独立连接(然后用连接池)。
二、Nagle算法:那个让你RT暴涨200ms的小恶魔
TCP Nagle算法是为了减少小包数量设计的,原理很简单:凑够一批数据再发送,别一点一点往外吐。这对ssh这种交互式会话是好事,但对你的API是噩梦。
想象这个场景:
# 你返回了一个200字节的JSON响应
# 由于Nagle算法,这200字节可能不会立即发送
# 客户端在等,RT就这么上去了
# 禁用Nagle(setsockopt TCP_NODELAY)
import socket
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
我知道你在想什么:200字节等一下怎么了?但问题是,如果你的服务QPS是10000,每个请求多等20ms,就是200秒的等待时间浪费。放大到集群规模,这就是灾难。
一个无意识的测试:禁用NODELAY后,某些场景下P99延迟直接降40%。这不是我编的,是真实测出来的。
三、TIME_WAIT陷阱:那个你从未注意过的端口耗尽
当TCP连接关闭时,主动关闭的一方会进入TIME_WAIT状态,持续2MSL(通常60秒)。这意味着这个端口在60秒内无法重用。
如果你每秒建立10000个短连接,2分钟后你的可用端口就耗尽了。新连接建不了,只能等待或者失败。
# 查看当前连接状态
netstat -an | grep TIME_WAIT | wc -l
# 经典解决思路:
# 1. 客户端使用连接池,不要每次都新建连接
# 2. 服务端合理设置tcp_tw_reuse(允许重用TIME_WAIT状态的端口)
# 3. 如果用Go,检查你的http.Transport是否正确配置了
Go的http.Transport默认配置其实还行,但很多人会手动配置然后配出问题。比如把MaxIdleConns设成0——恭喜你,连接池完全失效。
四、TCP缓冲区:被遗忘的性能洼地
每个TCP连接都有发送缓冲区和接收缓冲区,默认大小通常很小(几KB到几十KB)。当你传大请求体或者下载大文件时,这个缓冲区会频繁触发流控。
# Linux下查看和调整
# 查看当前缓冲区大小
cat /proc/sys/net/ipv4/tcp_rmem # 接收缓冲区分3个值:min, default, max
cat /proc/sys/net/ipv4/tcp_wmem # 发送缓冲区分3个值
# 调整示例(需要root)
sysctl -w net.ipv4.tcp_rmem="4096 87380 6291456"
sysctl -w net.ipv4.tcp_wmem="4096 16384 4194304"
一个实际案例:有个服务上传图片,文件稍大就超时。追查发现是接收缓冲区太小,滑动窗口卡住了。调大缓冲区,完事。
但要注意,不是越大越好。内存是有限的,每个连接都在消耗资源。Nginx的worker_connections默认是1024,如果每个连接缓冲区4KB,光连接就占4MB内存,看起来不多。但如果你把缓冲区设成1MB,1024个连接就是1GB——分分钟OOM。
五、连接池的正确姿势:大多数人都在瞎配
说到连接池,我见过最离谱的配置是这样的:maxIdle=100, maxOpen=100,然后问为什么性能上不去。
来,解释一下这两个参数:
- maxIdle:最大空闲连接数,连接池里最多躺多少个空闲连接
- maxOpen:最大打开的连接数,包括使用中的和空闲的
你maxIdle=100, maxOpen=100意味着什么?连接池永远不会有超过100个连接,所有连接都在idle和open之间摇摆。当并发上来,连接不够用,频繁创建销毁,性能就崩了。
正确的配置思路:
# 根据你的QPS和平均响应时间推算
# 公式:需要的连接数 ≈ QPS * 平均响应时间(秒)
# 假设QPS=1000,平均RT=50ms
# 需要的连接数 ≈ 1000 * 0.05 = 50
# 所以你可以这样配
maxOpen = 100 # 留点余量
maxIdle = 80 # 接近实际需要的数量,避免频繁创建销毁
idleTimeout = 30s # 空闲连接30秒后关闭,别让连接僵死
六、实战排查:我的三板斧
遇到TCP相关的性能问题,我是这样排查的:
第一板斧:ss而不是netstat
# 查看连接状态统计
ss -s
# 输出类似:
# Total: 256 (kernel 512)
# TCP: 124 (estab 12, closed 98, orphaned 0, synrecv 0, timewait 98/10)
# 查看具体连接,特别关注TIME_WAIT数量
ss -tan state time-wait | wc -l
第二板斧:抓包看RTT
# 抓取SYN和 handshake包,看实际RTT
tcpdump -i eth0 'tcp[tcpflags] and tcp-syn != 0' -c 100
# 或者用更现代的工具
ss -ti dst 10.0.0.1 # 看到这个IP的连接详细信息,包括rtt、cwnd
第三板斧:内核参数巡检
# 检查关键TCP参数
sysctl net.ipv4.tcp_tw_reuse # 应该是1
sysctl net.ipv4.tcp_nodelay # 这个不是sysctl,是setsockopt
sysctl net.core.somaxconn # 监听队列长度,太小会丢连接
sysctl net.ipv4.tcp_max_syn_backlog
结语
TCP是个老协议,1981年的RFC 793。但即使是老协议,大多数工程师对它的理解也停留在三次握手四次挥手的八股文水平。等到线上出问题了,才发现自己连ss命令都不会用。
所以我的建议是:别光顾着追框架、学新语言。基础知识学扎实了,你能解决别人解决不了的问题。
当然,如果你读到这里发现卧槽我好像踩过这些坑,那这篇文章就值了。