- Published on
HTTP Keep-Alive 与连接复用 — 制造间歇性 502 的超时竞态条件
- Authors

- Name
- Youngju Kim
- @fjvbn20031
引言 — 后端好好的,502 却混了进来
流量涨上去之后,开始收到这样的报告。
- 负载均衡器的访问日志里混进了大约占总量 0.05% 的 502。
- 同一时刻的后端应用日志里,压根就没有那条请求。
- CPU 和内存都很宽裕,健康检查一次都没失败过。
- 复现不出来。跑压测反倒一切正常。
这时常见的应对是加后端,或者调健康检查的阈值。两者都没用。因为这个症状的原因不是容量,而是时序。
当服务器要关闭空闲连接的瞬间,与客户端要复用这条连接的瞬间重叠时,请求和 FIN 会互相错过。服务器对到达已关闭连接的数据回以 RST,中间的代理再把它翻译成 502。这个竞态条件在 HTTP/1.1 的结构下无法彻底消除,把超时顺序对齐、让概率趋近于零是唯一的应对。
本文讲清这个顺序为什么必须如此,以及围绕连接复用的其余判断该怎么做。
打开一条连接的代价
先用数字看看为什么复用重要。打开一条新的 HTTPS 连接所需的往返是这样的。
DNS 查询 0~1 RTT (已缓存则为 0)
TCP 握手 1 RTT (SYN, SYN-ACK, ACK)
TLS 1.3 1 RTT
TLS 1.2 2 RTT
--------
合计 以 TLS 1.3 为准最少 2 RTT
同一区域内 RTT 若为 1 毫秒,那就是 2 毫秒,可以忽略。可是从首尔到美国东部 RTT 大约是 180 毫秒,光建立连接就花掉 360 毫秒。即使服务器处理只要 20 毫秒,用户感受到的也是 380 毫秒。
从这一点开始,连接复用带来的效果压倒性地大于应用层优化。因为在复用的连接上,这 2 个 RTT 会整个变成 0。
实际测一下就很清楚。
cat > /tmp/curl-format.txt <<'EOF'
time_namelookup: %{time_namelookup}s
time_connect: %{time_connect}s
time_appconnect: %{time_appconnect}s
time_pretransfer: %{time_pretransfer}s
time_starttransfer: %{time_starttransfer}s
----------
time_total: %{time_total}s
num_connects: %{num_connects}
EOF
curl -s -o /dev/null -w "@/tmp/curl-format.txt" \
https://api.example.com/v1/orders \
https://api.example.com/v1/orders
time_namelookup: 0.004312s
time_connect: 0.184901s
time_appconnect: 0.371244s
time_pretransfer: 0.371402s
time_starttransfer: 0.393118s
----------
time_total: 0.394552s
num_connects: 1
time_namelookup: 0.000021s
time_connect: 0.000029s
time_appconnect: 0.000031s
time_pretransfer: 0.000094s
time_starttransfer: 0.022704s
----------
time_total: 0.023918s
num_connects: 0
第一次请求 394 毫秒,第二次 24 毫秒。服务器处理时间两次都是 22 毫秒,一模一样。相差的 370 毫秒全都是建立连接的代价。
HTTP/1.1、HTTP/2、HTTP/3 各自消除了什么
把每个版本解决了什么区分清楚,选型就容易了。
HTTP/1.1 把持久连接变成了默认。收到一个响应就关连接的做法被换成继续用下去。只不过在同一条连接上,请求和响应只能按顺序一个一个来回。流水线虽然写在规范里,却因为中间设备的兼容性问题实际上被废弃了。于是并发只能靠连接数来获得。浏览器对每个域名开 6 条连接的惯例就是这么来的。这就是 HTTP 层的队头阻塞。
HTTP/2 在一条 TCP 连接上多路复用多个流。同时发 100 个请求,连接也只有一条。HTTP 层的队头阻塞消失了。首部压缩和服务器优先级也是在这里引入的。
这里有个要纠正的误解。"HTTP/2 只有一条连接所以总是更快"这种说法只在有条件时成立。TCP 保证顺序,所以一旦丢失一个段,之后到达的所有数据都会被困在内核缓冲区里。多路复用的 100 条流会一起停住。如果是 HTTP/1.1 的 6 条连接,丢包只会影响其中一条。也就是说,在丢包率高的移动网络上 HTTP/2 反而可能更慢。因为 TCP 层的队头阻塞还在。
HTTP/3 之所以搬到 QUIC 之上,原因正在于此。QUIC 在 UDP 之上按流做独立的丢包恢复。某条流的包丢了,其他流照样继续。此外 TLS 被整合进传输层,首次连接是 1 RTT,重连可以做到 0 RTT。而且用连接 ID 来标识连接,所以从 Wi-Fi 切到 LTE 连接也能保持。
从实务角度看结论是这样。用户与边缘之间,HTTP/2 或 HTTP/3 更有利。而数据中心内部的代理与后端之间丢包率几乎为零,所以用 HTTP/1.1 的 keepalive 就足够,而且连接数更好调控、运维更简单。实际上很多架构就是边缘 HTTP/2、后端 HTTP/1.1 keepalive。
协议这样确认。
curl -sI --http2 https://api.example.com/v1/health -o /dev/null -w '%{http_version}\n'
curl -sI --http3 https://api.example.com/v1/health -o /dev/null -w '%{http_version}\n'
2
3
超时错位就会产生的竞态条件
现在是核心部分。一条请求经过的路径上至少有三个空闲超时。
[客户端连接池] ---- 连接A ---- [负载均衡器/代理] ---- 连接B ---- [后端]
idle timeout idle timeout keepalive_timeout
按时间顺序看连接 B 上发生了什么。假设后端的 keepalive 超时是 5 秒,负载均衡器的空闲超时是 60 秒。
- 请求处理完毕,连接 B 进入空闲状态。
- 过了 5 秒,后端发出 FIN。
- 这个 FIN 到达负载均衡器需要半个往返时间。
- 偏偏在这段时间里来了新请求,负载均衡器把请求写到了仍在空闲列表里的连接 B 上。
- 请求到达后端。后端那边套接字已经关闭,于是回以 RST。
- 负载均衡器判定后端没有响应就断开了连接,于是返回 502。
后端应用日志里什么都没留下的原因就在这里。请求根本没有到达过应用代码。
发生概率虽低但不为零。而每秒几千请求时,低概率也会变成每天几百次。这正是它只在流量上涨时才显眼的原因。
原则只有一条。关闭连接的决定,永远应该由发起请求的一方来做。发起请求的一方关闭就没有竞争,因为那是它自己决定不再使用的连接。反过来,接收方关闭就必定产生竞争。
因此超时必须从外向内越来越长。客户端连接池的空闲超时最短,负载均衡器比它长,后端最长。
AWS ALB 的默认空闲超时是 60 秒,所以后端要设得比它宽裕地长。
# nginx 作为后端时 — 比 ALB 的 60 秒更长
keepalive_timeout 75s;
keepalive_requests 10000;
# Node.js 后端
# server.keepAliveTimeout = 75000;
# server.headersTimeout = 80000; # 必须大于 keepAliveTimeout
# Go 后端
# srv := &http.Server{ IdleTimeout: 75 * time.Second }
Node.js 默认 keepAliveTimeout 是 5 秒这件事,尤其经常闯祸。把 Node 服务器原样放到 ALB 或 nginx 后面,上面的剧本就会原封不动地重演。把 headersTimeout 设得比 keepAliveTimeout 大,也要一并记住。
nginx 处在代理侧时,很容易漏掉开启上游 keepalive 的配置。三行全都在,才会真的复用。
upstream backend {
server 10.0.3.11:8080;
keepalive 64; # 每个 worker 维持的空闲连接数
keepalive_timeout 60s;
keepalive_requests 10000;
}
server {
location / {
proxy_pass http://backend;
proxy_http_version 1.1; # 没有这行就会以 1.0 发出,keepalive 失效
proxy_set_header Connection ""; # 必须去掉默认的 close 首部
}
}
proxy_http_version 1.1 和空的 Connection 首部只要缺一个,上游连接就会在每个请求上重新打开。"配置写了却没效果"的报障,大多就是这两行。确认要在后端数。
# 在后端数一下来自代理的 ESTABLISHED 连接数
ss -tan state established '( sport = :8080 )' | wc -l
watch -n1 "ss -tan state established '( sport = :8080 )' | wc -l"
如果这个值随请求量不停起伏,就是没有复用;如果稳定维持在某个水平,就是复用生效了。
除了从概率上减少竞态之外,客户端一侧的重试也要一并备好。Go 的 net/http 在复用连接上、还没收到任何响应字节就失败时,会自动把幂等请求再试一次。Java 的 Apache HttpClient 通过 validateAfterInactivity 在借出之前检查连接状态。如果客户端没有这类保险,那么至少针对 GET、PUT 这类幂等请求加上显式重试会更好。
用 curl -w 拆分区间定位瓶颈
报告延迟时,"慢"这个说法毫无用处。哪个区间慢,直接决定了归属团队和处置方式。curl 的计时变量能把这件事做得很准确。
| 变量 | 测量的区间 | 变大时该怀疑什么 |
|---|---|---|
| time_namelookup | 从开始到 DNS 解析完成 | 解析器延迟、search 域过多、缓存未生效 |
| time_connect | 从 DNS 完成到 TCP 握手完成 | 物理距离、路径拥塞、SYN 重传 |
| time_appconnect | 从 TCP 完成到 TLS 握手完成 | 使用了 TLS 1.2、证书链过长、OCSP 查询 |
| time_pretransfer | 到即将发出请求为止 | 代理协商、客户端侧准备延迟 |
| time_starttransfer | 从请求发出到第一个响应字节 | 服务器处理时间、后端排队、数据库延迟 |
| time_total | 全部 | 响应体传输量、带宽、接收侧处理速度 |
读法就是做减法。
curl -s -o /dev/null -w "@/tmp/curl-format.txt" https://api.example.com/v1/orders
time_namelookup: 0.004312s
time_connect: 0.031104s
time_appconnect: 0.098742s
time_pretransfer: 0.098901s
time_starttransfer: 0.341285s
time_total: 0.343990s
- DNS: 4.3 毫秒,正常。
- TCP 握手: 31.1 减 4.3 得 26.8 毫秒,也就是 RTT 约 27 毫秒。
- TLS: 98.7 减 31.1 得 67.6 毫秒。约为 RTT 的 2.5 倍,所以很可能是 TLS 1.2。如果是 TLS 1.3,应该在 27 毫秒附近。
- 服务器处理: 341.3 减 98.9 得 242.4 毫秒,占总量的 70%。
- 响应体传输: 344.0 减 341.3 得 2.7 毫秒。
结论很明确。这条请求的瓶颈不是网络,而是服务器处理时间。不过升到 TLS 1.3 还能再省 40 毫秒,复用连接则能整整去掉 95 毫秒。
想多测几次看分布,就这样用。
for i in $(seq 1 20); do
curl -s -o /dev/null -w '%{time_connect} %{time_appconnect} %{time_starttransfer} %{time_total}\n' \
https://api.example.com/v1/orders
done | sort -k4 -n | tail -5
0.028112 0.094221 0.298104 0.301002
0.029004 0.096118 0.312880 0.315442
0.031104 0.098742 0.341285 0.343990
0.030221 0.097009 0.512033 0.515118
0.029880 0.095442 1.204118 1.207002
如果前两列很稳定而只有第三列在跳,那就是服务器一侧的长尾延迟而不是网络。反过来如果 time_connect 在跳,那才轮到看网络。
连接池大小的测算
连接池大小不是靠感觉定的值,要用利特尔法则来算。
需要同时在途的请求数,等于吞吐量乘以响应时间。
并发请求数 = 每秒请求数 × 平均响应时间
例) 一个实例每秒 500 请求,平均响应 80 毫秒
500 × 0.08 = 40
考虑长尾延迟留出 1.5 倍余量
建议连接池大小 = 60
还要一并看两个上限。
第一,连接总数。如果应用实例有 40 台、每个池是 60,后端最多要承接 2400 条连接。后端的文件描述符上限和最大连接配置必须扛得住。数据库尤其危险。Postgres 的 max_connections 是 200,而应用池的总和是 2400,那么部署刚完成就会因为连接暴涨而出故障。
第二,维持空闲连接的成本。池开得大,大部分连接就一直空闲,而这些连接正是前面那个竞态条件的候选者。没有理由开得超出需要。
主要运行时的默认值也需要知道。Go 的 http.DefaultTransport 里 MaxIdleConns 是 100,但每主机空闲连接数 MaxIdleConnsPerHost 只有 2。不把这个值调大,无论对一个后端发多少请求,都只会保留 2 条空闲连接,其余每次都要新开。这是微服务里最常被漏掉的默认值。
# Go 里必须调整的值
# t := http.DefaultTransport.(*http.Transport).Clone()
# t.MaxIdleConns = 200
# t.MaxIdleConnsPerHost = 100
# t.IdleConnTimeout = 90 * time.Second
实际的复用率这样确认。
# 客户端主机上通往特定后端的连接状态分布
ss -tan dst 10.0.3.11 | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn
58 ESTAB
3 TIME-WAIT
ESTAB 稳定维持、TIME-WAIT 很少,说明复用得不错。反过来的形态,说明每个请求都在新开连接。
TIME_WAIT 什么时候是问题,什么时候不是
看到 ss -tan | grep TIME-WAIT | wc -l 打出几万,很多人会吓一跳。大多数情况这都不是问题。要准确分清什么时候才是问题。
TIME_WAIT 只会出现在先关闭连接的一方,也就是执行主动关闭的一方。在 Linux 上固定为 60 秒,无法用 sysctl 调整。目的有两个:防止迟到的旧段混入同一个四元组的新连接,以及在最后一个 ACK 丢失时能够重传。
先看不构成问题的情况。
如果是服务器在响应后关闭连接的架构,TIME_WAIT 会堆在服务器一侧。但这些条目的本地端口全都是 443,只有远端地址和端口不同。四元组不会重叠,所以不会发生端口耗尽。剩下的成本只有内存,每条大约几百字节。就算 3 万条也不过几十兆字节,可以忽略。
构成问题的情况则不同,那是大量发起出站连接的一方。代理、API 网关、批处理作业都属于这一类。此时目标 IP 和端口是固定的,能区分连接的只有本地端口。
sysctl net.ipv4.ip_local_port_range
net.ipv4.ip_local_port_range = 32768 60999
可用端口有 28232 个,每个端口被占住 60 秒。也就是说,对一个目标每秒约 470 条新建连接是理论上限。超过它就会出现这样的错误。
dial tcp 10.0.3.11:8080: connect: cannot assign requested address
这个 EADDRNOTAVAIL 才是端口耗尽的真正信号。要看的不是 TIME_WAIT 的数量本身,而是这个错误有没有出现。
# 按目标统计 TIME-WAIT,看看是哪里在耗尽
ss -tan state time-wait | awk 'NR>1 {print $4}' | cut -d: -f1 | sort | uniq -c | sort -rn | head
26104 10.0.3.11
412 10.0.3.12
88 169.254.169.254
这里要把流传甚广的错误处方理一理。
net.ipv4.tcp_tw_recycle 不能用。它曾有一个严重问题:NAT 后面的多个客户端发送不同的时间戳时,连接会被随机拒绝,因此在 Linux 4.12 里被彻底移除了。至今仍有很多博客推荐这个参数。
net.ipv4.tcp_tw_reuse 是有条件有效的。它只对出站连接生效,而且要求两端都开启时间戳选项。对堆在服务器一侧的 TIME_WAIT 毫无作用。
# 只在出站量大的主机上才有意义
sudo sysctl -w net.ipv4.tcp_tw_reuse=1
sudo sysctl -w net.ipv4.ip_local_port_range="10240 65535"
把端口范围放宽,上限能提到约 920。但这些全都是缓解症状。
真正的解法是复用连接,从根本上减少新建连接。应该先问一句:为什么会需要每秒 470 条新连接?很可能是 keepalive 没开、没有连接池,或者每个请求都在新建客户端对象。Python requests 里直接调用 requests.get() 的代码就是典型。创建一个 Session 对象复用起来,这个问题就整个消失了。
结语 — 把顺序对齐,然后复用
可以概括成三句话。
如果出现间歇性 502 而后端日志是空的,就怀疑连接复用竞态,并去确认超时顺序。发起请求一方的空闲超时最短,接收方最长。把后端 keepalive 设得比负载均衡器空闲超时宽裕地长,就是这条原则的具体落地。
报告延迟时一定要拆分区间。只要有 curl 的 namelookup、connect、appconnect、starttransfer 这四个点,是 DNS 问题、距离问题、TLS 配置问题还是服务器处理问题,立刻就能分辨。没有这四个数字的性能讨论,大多只是猜测。
还有,在被 TIME_WAIT 的数量吓到之前,先确认 cannot assign requested address 是否真的发生。与其去动 sysctl,不如复用连接,后者永远更好。打开连接的代价在远距离通信里甚至会超过响应时间的一半,而消除这份代价的效果,比大多数应用层优化都要大。