4、TCP协议优化(上):三次握手与四次挥手优化、窗口缩放、TFO
TCP 协议,说白了就是网络传输的「交通规则」。你写的每一个 HTTP 请求,背后都跑在 TCP 这条高速公路上。但这条公路的「收费站」——三次握手和四次挥手,往往就是性能瓶颈的源头。
我这些年调优过不少线上系统,发现很多人把精力都花在应用层代码上,却忽略了 TCP 层的「潜规则」。今天咱们就聊聊 TCP 优化的三个核心点:握手挥手、窗口缩放、快速打开。
核心观点:TCP 优化的本质,就是减少不必要的往返、放大单次传输的有效载荷。
4.1 三次握手:你浪费了多少个 RTT?
先看标准的三次握手流程:
客户端 服务端
| |
| —— SYN (seq=x) ——> |
| |
| <—— SYN+ACK (seq=y, ack=x+1) ——|
| |
| —— ACK (seq=x+1, ack=y+1) ——> |
| |
| ===== 连接建立 ===== |
| |
| —— 数据请求 ——> |
| |
看到了吗?在真正发送数据之前,已经浪费了 1.5 个 RTT。如果你的客户端在纽约,服务器在北京,一个 RTT 大约 200ms,那光握手就要 300ms。用户等得黄花菜都凉了。
我记得有一次帮一家电商平台做性能诊断,他们的首页加载时间一直在 3 秒以上。查了半天,发现光是 TCP 连接建立就占了 800ms——因为页面里嵌了 20 多个域名,每个都要重新握手。这就是典型的「握手风暴」。
4.1.1 优化手段:连接复用
最直接的优化就是复用连接。HTTP/1.1 的 Keep-Alive 就是干这个的:
# Nginx 配置示例
keepalive_timeout 65;
keepalive_requests 1000;
设置 keepalive_requests 1000 意味着一个 TCP 连接可以承载 1000 个请求。相比每次请求都新建连接,能省下大量的握手开销。
我的习惯:在网关层统一做连接池管理,把 keepalive 超时时间设为 60-120 秒。太短了复用效果差,太长了会占用服务端内存。
4.1.2 优化手段:TCP Fast Open(TFO)
TFO 是个好东西。它允许客户端在 SYN 包里直接携带数据,省掉一次往返。
# Linux 启用 TFO
echo 3 > /proc/sys/net/ipv4/tcp_fastopen
# 参数说明:
# 0 = 禁用
# 1 = 客户端启用
# 2 = 服务端启用
# 3 = 两端都启用
启用后,握手流程变成这样:
客户端(已有 TFO Cookie) 服务端
| |
| —— SYN + 数据 + Cookie ——> |
| |
| <—— SYN+ACK + 响应数据 ——|
| |
| —— ACK ——> |
| |
嗯,这里要注意:TFO 需要客户端和服务端都支持。我第一次在生产环境启用时,发现有些老版本的 Linux 内核不支持,导致连接异常。所以建议先在灰度环境验证。
我曾经踩过的坑:在 CDN 回源场景启用 TFO,结果源站是 CentOS 6,内核 2.6.32,根本不支持 TFO。回源成功率直接掉了 5%。后来加了内核版本检测,才敢全量放开。
4.2 四次挥手:主动关闭还是被动关闭?
四次挥手比握手更复杂,因为它涉及「半关闭」状态。标准流程:
主动关闭方 被动关闭方
| |
| —— FIN ——> |
| |
| <—— ACK —— |
| |
| <—— FIN —— |
| |
| —— ACK ——> |
| |
| ===== 2MSL 等待 ===== |
| |
这里有个关键点:TIME_WAIT 状态。主动关闭方在发送最后一个 ACK 后,要等待 2 个 MSL(最大报文段生存时间,通常 60 秒)。为什么?
说白了,就是为了防止最后一个 ACK 丢失。如果丢了,被动方会重发 FIN,主动方需要重新 ACK。没有这个等待,新连接可能会收到旧连接的残留数据包。
4.2.1 TIME_WAIT 过多的危害
高并发短连接场景下,服务端会出现大量 TIME_WAIT 状态的连接。每个连接占用一个端口,端口耗尽后新连接就无法建立了。
我记得有一次线上告警:Nginx 报 Cannot assign requested address。一看 netstat,TIME_WAIT 连接数超过 3 万,端口全被占满了。
4.2.2 优化手段:调整 MSL 和端口范围
# 减少 MSL(谨慎操作)
echo 15 > /proc/sys/net/ipv4/tcp_fin_timeout
# 扩大临时端口范围
echo "1024 65535" > /proc/sys/net/ipv4/ip_local_port_range
我的建议:不要轻易把 tcp_fin_timeout 降到 10 秒以下。我曾经为了追求极致性能,降到 5 秒,结果出现数据包乱序,导致应用层报错。后来稳定在 15 秒,既缓解了端口压力,又保证了可靠性。
4.2.3 优化手段:SO_REUSEADDR 和 SO_LINGER
在应用层代码中,可以设置 socket 选项来加速端口回收:
// C 语言示例
int opt = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 设置 SO_LINGER,立即关闭
struct linger l = {1, 0};
setsockopt(sock, SOL_SOCKET, SO_LINGER, &l, sizeof(l));
但注意:SO_LINGER 设置为 0 会导致 TCP 发送 RST 而非 FIN,属于「暴力关闭」。数据可能丢失,只适合在确定不需要可靠传输的场景使用。
4.3 TCP 窗口缩放(Window Scaling)
TCP 的滑动窗口决定了发送方一次能发多少数据而不等确认。早期的 TCP 窗口大小只有 16 位,最大 65535 字节。这在 56k 猫时代够用,但在千兆网络下就是个笑话。
你想想看,如果带宽是 10Gbps,RTT 是 10ms,那带宽延迟积(BDP)是:
BDP = 带宽 × RTT
= 10 Gbps × 0.01 s
= 100 Mb = 12.5 MB
窗口只有 64KB,那链路利用率只有 0.5%。说白了,99.5% 的带宽都在空转。
3.3.1 窗口缩放因子
TCP 窗口缩放选项(RFC 1323)允许窗口大小乘以一个缩放因子,最大可达 1GB。缩放因子在三次握手时协商:
# 查看当前窗口缩放设置
sysctl net.ipv4.tcp_window_scaling
# 启用(默认就是 1)
echo 1 > /proc/sys/net/ipv4/tcp_window_scaling
实际窗口大小计算公式:
实际窗口 = 窗口字段值 × 2^缩放因子
示例:
窗口字段值 = 65535
缩放因子 = 7
实际窗口 = 65535 × 2^7 = 65535 × 128 = 8,388,480 字节 ≈ 8 MB
| 缩放因子 | 最大窗口 | 适用场景 |
|---|---|---|
| 0 | 64 KB | 低延迟、小带宽 |
| 3 | 512 KB | 普通宽带 |
| 7 | 8 MB | 数据中心、高速网络 |
| 14 | 1 GB | 超高速网络(40Gbps+) |
我曾经遇到的坑:某次在云上部署文件传输服务,发现传输速度始终上不去。排查后发现,云主机的默认缩放因子是 0——因为镜像来自一个老旧的虚拟机模板。手动设置 tcp_window_scaling 后,传输速度从 200Mbps 飙升到 8Gbps。
3.3.2 自动调整与手动调优
Linux 内核支持自动调整接收窗口(tcp_rmem)和发送窗口(tcp_wmem):
# 查看当前缓冲区设置
sysctl net.ipv4.tcp_rmem
sysctl net.ipv4.tcp_wmem
# 典型输出(最小值 默认值 最大值)
4096 131072 6291456
4096 16384 4194304
# 针对高 BDP 网络优化
echo "4096 87380 16777216" > /proc/sys/net/ipv4/tcp_rmem
echo "4096 65536 16777216" > /proc/sys/net/ipv4/tcp_wmem
这里有个经验值:默认值设为 BDP 的 1/4,最大值设为 BDP 的 2 倍。比如 BDP 是 8MB,那默认值设为 2MB,最大值设为 16MB。
4.4 知识体系总览
下面这张图总结了本章的核心逻辑:
4.5 本章小结
TCP 优化不是玄学,而是有章可循的工程实践。总结三个要点:
- 连接建立与关闭:用连接复用减少握手次数,用 TFO 在握手阶段捎带数据,用合理的 TIME_WAIT 配置避免端口耗尽
- 窗口缩放:根据 BDP 调整窗口大小,让链路利用率从个位数提升到 90% 以上
- TFO:在兼容性允许的前提下,尽量启用,能省掉 1 个 RTT 的延迟
这些优化手段,我建议你从「最容易见效」的开始:先检查连接复用是否开启,再看窗口缩放因子是否合理,最后考虑 TFO。一步步来,别想一口吃成胖子。