Skip to content
Published on

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

分享
Authors

引言 — 后端好好的,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 秒。

  1. 请求处理完毕,连接 B 进入空闲状态。
  2. 过了 5 秒,后端发出 FIN。
  3. 这个 FIN 到达负载均衡器需要半个往返时间。
  4. 偏偏在这段时间里来了新请求,负载均衡器把请求写到了仍在空闲列表里的连接 B 上。
  5. 请求到达后端。后端那边套接字已经关闭,于是回以 RST。
  6. 负载均衡器判定后端没有响应就断开了连接,于是返回 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.DefaultTransportMaxIdleConns 是 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,不如复用连接,后者永远更好。打开连接的代价在远距离通信里甚至会超过响应时间的一半,而消除这份代价的效果,比大多数应用层优化都要大。