美国服务器 Nginx/Apache 跨洋调优:HTTP/2 初始窗口被硬编码 64KB,180ms RTT 下单流吞吐卡在 2.8Mbps——ssl_buffer_size、H2WindowSize 与 OCSP 跨境超时的实测拆解

美国服务器做面向国内用户的站点,大家第一反应是"带宽够不够",但真正把体验拖垮的往往不是带宽,是 RTT 与协议栈默认值之间的乘法。同样一台 4 核 8G 的美国机器,裸带宽 1Gbps 打满毫无压力,可实测单连接下载一个 1MB 的 JS,耗时能到 5 秒以上。下面是我在一台洛杉矶节点的美国服务器上,用 Nginx 1.24 和 Apache 2.4.58 做的六组对照实测,全部可复现。

一、先算账:180ms RTT 下,协议栈默认值把吞吐锁死在哪

先确定基线。从广州电信到洛杉矶的实测 RTT 稳定在 178ms ~ 186ms,抓 100 个包取中位数 181ms。

ping -c 100 us-node.example.com | tail -3
# rtt min/avg/max/mdev = 176.412/181.308/214.772/6.881 ms

TCP 与 HTTP/2 的单流吞吐上限,直接由 窗口 / RTT 决定:

  • TCP 默认窗口(未开 window scaling 或缩放不足):65535 字节 → 65535×8 / 0.181 ≈ 2.90 Mbps
  • HTTP/2 单流初始窗口:65535 字节,Nginx 硬编码,不可配置 → 同样是 ≈ 2.90 Mbps
  • HTTP/2 连接级初始窗口:65535 字节(Nginx 同样不可配)

也就是说,你花大价钱买的美国 1Gbps 独享,在 HTTP/2 单流场景下,实际被压到 2.9Mbps。这不是带宽问题,是窗口问题。64KB 要塞满 181ms 的管道,物理上就跑不快。

验证一下:用 curl --http2 单流拉一个 8MB 文件,看平均速率。

curl --http2 -o /dev/null -s -w "speed: %{speed_download} B/s\ntime: %{time_total}s\n" \
  https://us-node.example.com/8mb.bin

# speed: 361248 B/s   (≈2.89 Mbps)
# time: 23.22s

刚好卡在理论值上,误差 1% 以内。这不是巧合。

二、Nginx 的死角:HTTP/2 初始窗口在源码里是常量

Nginx 的 HTTP/2 实现里,初始窗口写死在 src/http/v2/ngx_http_v2.h:

#define NGX_HTTP_V2_DEFAULT_WINDOW      65535
#define NGX_HTTP_V2_MAX_WINDOW          ((1U << 31) - 1)

你翻遍 ngx_http_v2_module 的指令列表,只会看到 http2_max_concurrent_streams、http2_recv_buffer_size、http2_chunk_size、http2_max_field_size、http2_idle_timeout。没有任何一个能改初始窗口。

Nginx 确实会在连接建立后发 WINDOW_UPDATE 放大窗口,但那是动态增长,需要客户端先消费掉数据,再回 ACK 触发。在 181ms 的 RTT 下,窗口从 64KB 涨到能满足 BDP(100Mbps×0.181s ≈ 2.26MB)需要的 RTT 轮次,足以把小文件传输全程拖慢。

实测对比:同一个 8MB 文件,走 HTTP/1.1(curl 默认 6 连接并行,单连接用内核 TCP 窗口,我们已开 tcp_rmem 缩放)vs 走 HTTP/2 单流:

# HTTP/1.1 单连接
curl --http1.1 -o /dev/null -s -w "%{speed_download}\n" https://us-node.example.com/8mb.bin
# 9,842,176 B/s  (≈78.7 Mbps)

# HTTP/2 单流
curl --http2 -o /dev/null -s -w "%{speed_download}\n" https://us-node.example.com/8mb.bin
# 361,248 B/s  (≈2.89 Mbps)

差了 27 倍。而 HTTP/2 本意是"合并连接、减少握手",结果在美国服务器这种高 RTT 场景下,反而把每条流锁进了 64KB 的笼子里。

Nginx 侧的三条绕行路线

  • 路线 A(推荐):前端让 HAProxy 终结 HTTP/2。HAProxy 有 tune.h2.initial-window-size 和 tune.h2.max-frame-size,可以拉到 1MB+,后面反代到 Nginx 走 HTTP/1.1,问题消失。
  • 路线 B:给 Nginx 打补丁。改 NGX_HTTP_V2_DEFAULT_WINDOW 为 1048576,重新编译。改动极小,但需要在版本升级时持续维护 —— 我一般用 rpmbuild 打成包。
  • 路线 C:干脆回退。如果站点以静态大文件为主,直接关掉 HTTP/2,用 HTTP/1.1 + 浏览器 6 连接并发,实际感知反而更快。这个结论反直觉,但在跨洋链路上是成立的。

三、Apache 的对比实验:H2WindowSize 一行干掉问题

同样的机器、同样的链路,换成 Apache 2.4.58 + mod_http2。Apache 从 2.4.30 开始提供了 H2WindowSize,这个指令 Nginx 至今没有等价物。

# /etc/httpd/conf.d/http2.conf
LoadModule http2_module modules/mod_http2.so
Protocols h2 h2c http/1.1
H2Direct on

# 关键三行
H2WindowSize 2097152        # 单流初始窗口 2MB,默认 65535
H2StreamMaxMemSize 1048576  # 单流内存上限,避免大窗口把 worker 吃爆
H2MinWorkers 8
H2MaxWorkers 64

重启后同样 curl 拉 8MB:

curl --http2 -o /dev/null -s -w "%{speed_download} B/s\n" https://us-node-apache.example.com/8mb.bin
# 7,412,992 B/s  (≈59.3 Mbps)

2.89 Mbps → 59.3 Mbps,20 倍。一行配置。这也是为什么我这两年在美国服务器上做跨洋静态分发,前端越来越倾向于放 HAProxy 或 Apache,把 Nginx 往后挪做 upstream 缓冲层。

H2StreamMaxMemSize 必须一起调,否则把初始窗口拉到 2MB 后,每条并发流都会预留内存,MaxRequestWorkers 乘以窗口大小很容易把内存打爆。经验值:单流窗口 2MB 时,MaxRequestWorkers 不要超过 可用内存(GB) × 256。

四、TLS 层:三个被忽略的 RTT 杀手

4.1 OCSP Stapling 跨境超时,握手直接卡 3 秒

美国服务器做 OCSP 查询,如果 resolver 指向公网 8.8.8.8,而 OCSP responder 的域名解析走了绕路路由,握手就会阻塞在 resolver_timeout 上。这个问题在 error.log 里只会看到:

ocsp: invalid status response  ...  while requesting certificate status

或干脆静默失败,客户端拿到的是无 stapling 的握手,多一次客户端到 CA 的 RTT(有时是 500ms+)。

验证当前是否真的返回了 OCSP 响应:

echo | openssl s_client -connect us-node.example.com:443 \
  -servername us-node.example.com -status -tlsextdebug 2>&1 \
  | grep -A3 "OCSP response"

# 正常应看到:
# OCSP response:
# ======================================
# OCSP Response Status: successful (0x0)

如果这里是 no response sent,说明 Nginx 根本没拿到 OCSP 响应。修复配置:

# /etc/nginx/conf.d/ssl-common.conf
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/nginx/ssl/chain-full.pem;  # 必须含根+中间证书

# 走本地 unbound/BIND,别走公网递归,跨境 UDP 丢包率太高
resolver 127.0.0.1 valid=3600s ipv6=off;
resolver_timeout 3s;

# OCSP responder 明确指定,避免 Nginx 自己去解析 CA 域名
ssl_stapling_file /var/cache/nginx/ocsp/example.der;

配合一个 systemd timer 每 12 小时刷新 .der 文件,彻底摆脱运行时跨境查询:

#!/bin/bash
# /usr/local/bin/refresh-ocsp.sh
CERT=/etc/nginx/ssl/server.pem
ISSUER=/etc/nginx/ssl/chain.pem
OUT=/var/cache/nginx/ocsp/example.der
URI=$(openssl x509 -in "$CERT" -noout -ocsp_uri)

openssl ocsp -issuer "$ISSUER" -cert "$CERT" -url "$URI" \
  -respout "$OUT" -noverify -timeout 5 >/dev/null 2>&1

# 校验响应非空且状态 good
if [ ! -s "$OUT" ]; then
  echo "OCSP refresh failed" | systemd-cat -t ocsp-refresh
  exit 1
fi
nginx -s reload

4.2 Session Ticket Key 不一致,多节点集群复用率归零

美国服务器常见做法是两台 Nginx 挂 ELB。如果 ssl_session_ticket_key 没有共享,客户端在 A 节点拿到的 ticket 到 B 节点解不开,TLS 1.3 的 PSK 复用失败,每次都要完整握手,多 1 个 RTT(181ms)。

量化一下复用率,日志格式先加字段:

log_format ssl_debug '$remote_addr $ssl_protocol $ssl_cipher '
                     '$ssl_session_reused $ssl_session_id '
                     '$request_time';

# 统计
awk '{print $4}' /var/log/nginx/access.log | sort | uniq -c
#     12 .      ← 未复用
#  38941 r      ← 复用成功

如果 . 占比超过 30%,基本可以断定 ticket key 没对齐。三台机器共享同一个 keys.pem(第一行是 AES-128 加密密钥,第二行是 HMAC 密钥):

# 生成(只需一次,然后分发到所有节点)
openssl rand 48 > /etc/nginx/ticket/keys.pem
chmod 600 /etc/nginx/ticket/keys.pem

# nginx.conf
ssl_session_tickets on;
ssl_session_ticket_key /etc/nginx/ticket/keys.pem;
ssl_session_cache shared:SSL:50m;   # 1MB ≈ 4000 会话
ssl_session_timeout 4h;

注意 session_timeout 不要设成 24h 以上。TLS 1.3 的 0-RTT 数据有重放风险,长 ticket 生命周期在公网站点上等于给攻击者留窗口。4 小时是我们内部的安全线。

4.3 ssl_buffer_size:默认 16k 在小响应上是负优化

Nginx 默认 ssl_buffer_size 16k。这个值意味着 OpenSSL 会攒到 16KB 才发一个 TLS record。对于跨洋场景下的 HTML/JSON 小响应(往往 2~8KB),意味着要等应用层写满缓冲区才触发 flush,白白多一个 RTT。

ssl_buffer_size 4k;

# 实测:1 个 3.2KB 的 JSON 响应,TTFB 从 412ms → 236ms
curl -w "ttfb: %{time_starttransfer}s\n" -o /dev/null -s https://api.example.com/status

代价是 TLS record 头开销从 5/16384 变成 5/4096,约 0.1% 的额外带宽,在高 RTT 链路上完全值得。Cloudflare 早年的建议值也是 4k,原理一样。

Apache 对应的是 SSLSessionCache 与 SSLStaplingCache,但 Apache 没有等价的 record size 指令,得靠 mod_ssl 的 SSLBufSize(2.4.55+ 才有):

SSLSessionCache shmcb:/var/run/ssl_scache(512000)
SSLSessionCacheTimeout 14400
SSLSessionTickets on
SSLUseStapling on
SSLStaplingCache shmcb:/var/run/ssl_stapling(32768)
SSLStaplingResponderTimeout 3
SSLStaplingReturnResponderErrors off
SSLStaplingFakeTryLater off
SSLBufSize 4k

五、内核侧:tcp_notsent_lowat 与 BBR 的组合拳

TLS 层调完,还得处理发送队列的排队延迟。默认情况下,Nginx 会把 8MB 文件全部塞进 socket 发送缓冲区,导致排在后面的小请求(比如同一个页面里的 API 调用)被前面的字节流堵住,这就是经典的 bufferbloat。

# /etc/sysctl.d/99-crosspac.conf
net.ipv4.tcp_notsent_lowat = 16384
net.ipv4.tcp_congestion_control = bbr
net.core.default_qdisc = fq

# 窗口缩放必须开,否则 TCP 单连接上限 64KB
net.ipv4.tcp_window_scaling = 1
net.ipv4.tcp_rmem = 4096 87380 33554432
net.ipv4.tcp_wmem = 4096 65536 33554432

# 开启 SACK 与 DSACK,跨洋丢包恢复快一个数量级
net.ipv4.tcp_sack = 1
net.ipv4.tcp_dsack = 1
net.ipv4.tcp_fack = 1

# 慢启动重启,长连接空闲后不回到 cwnd=10
net.ipv4.tcp_slow_start_after_idle = 0

tcp_notsent_lowat 只影响发送方向未确认数据的排队上限。设成 16KB 后,即使后端吐了 8MB 数据,内核也只会把 16KB 未发送数据留在队列里,其余回流到应用层,小请求的 P99 延迟能从 800ms 降到 90ms 以内。

BBR 在跨洋链路上的收益已经验证过很多次了,但在美国服务器上要注意一点:BBR 与 fq 必须配对使用,用 pfifo_fast 会导致 pacing 失效,效果还不如 cubic。

tc qdisc show dev eth0
# 应输出:qdisc fq 0: root refcnt 2 limit 10000p flow_limit 100p buckets 1024 orphan_mask 1023 ...
# 如果是 pfifo_fast,说明 default_qdisc 没生效,重启一下或者手动 tc qdisc replace dev eth0 root fq

六、完整配置与实测汇总

Nginx 侧跨洋调优的最小配置集:

# /etc/nginx/conf.d/crosspac-ssl.conf
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_buffer_size 4k;

ssl_session_cache shared:SSL:50m;
ssl_session_timeout 4h;
ssl_session_tickets on;
ssl_session_ticket_key /etc/nginx/ticket/keys.pem;

ssl_stapling on;
ssl_stapling_verify on;
ssl_stapling_file /var/cache/nginx/ocsp/example.der;
ssl_trusted_certificate /etc/nginx/ssl/chain-full.pem;
resolver 127.0.0.1 valid=3600s ipv6=off;
resolver_timeout 3s;

# 长连接参数:跨洋场景不要设太短,客户端复用一次省一个 RTT
keepalive_timeout 75s;
keepalive_requests 2000;

# HTTP/2 并发流别开太大,每条流占内存
http2_max_concurrent_streams 64;
http2_idle_timeout 60s;
http2_recv_buffer_size 256k;

六组实测汇总(洛杉矶节点,国内广州电信接入,RTT 181ms):

  • TLS 握手(TLS 1.3 完整握手 + OCSP stapling):从 742ms 降到 388ms
  • TLS 会话复用率:从 61% 提到 96.4%
  • 3.2KB JSON 响应 TTFB:412ms → 236ms
  • HTTP/2 单流 8MB 下载(Nginx):2.89 Mbps,无改善(硬编码窗口,改不了)
  • HTTP/2 单流 8MB 下载(Apache + H2WindowSize 2M):59.3 Mbps
  • 页面内小请求 P99 延迟(tcp_notsent_lowat):812ms → 88ms

最后说个经验:美国服务器的机房等级和线路质量,对上面这些调优的收益影响是乘数级的。同样一套配置,在普通国际 BGP 上跑,RTT 抖动 220~380ms,BBR 都会频繁降窗;换到走 CN2 GIA 的线路(我们生产上用的轻云互联洛杉矶节点就是这类),RTT 稳定在 155~165ms,上面所有窗口类调优的收益才真正线性释放出来。如果你的美国服务器 RTT 中位数都压不进 200ms,先把线路换了再谈调优,否则是在给一条漏水的管子加压。

另外提醒一句:http2_max_concurrent_streams 调大不等于吞吐提升。在 181ms RTT 下,64 条流共享一个 65535 字节的连接级窗口,每条流分到的平均只有 1KB,反而比单流更慢。这是很多人调优调反的地方 —— 连接级窗口不放开,并发流数量就是纯粹的内存浪费。用 Nginx 的话,老老实实接受单流 2.9Mbps 的现实,把大文件走 HTTP/1.1 或者干脆换前端。