upstream keepalive配错:发布时的502从哪来

上周有个读者在群里问:他的 Nginx 在忙的时候会偶尔冒出几十条 upstream prematurely closed connection while reading response header,但同一时刻的 QPS 并不高,CPU 也才二十几。他一开始怀疑是后端 PHP 崩了,去翻 PHP-FPM 的错误日志,干干净净;又怀疑是网络抖动,抓包看了半天也没丢包。最后把 error.log 按时间排好,发现这些报错全都集中在每天上午十点和晚上八点——正是他发布代码的时间点。

根因是长连接。他在 Nginx 里给 upstream 配了 keepalive 32,但没有配 keepalive_requests,也没意识到一件事:keepalive 连接是有寿命的,Nginx 会在连接用到一定次数后主动关掉它。而他发布代码时会 reload PHP-FPM,FPM master 把老 worker 平滑摘除,那些正被 Nginx 当作「空闲可复用」的连接就瞬间失效了。更巧的是,Nginx 刚从连接池里取出这条已经半死的连接发请求,就撞上了 prematurely closed。

这篇就把 upstream keepalive 这一层容易出错的地方讲清楚:连接怎么被复用的、什么时候被关掉、哪些配置看着对其实不生效,以及出问题时怎么用最少的手段定位。

一、先搞清 upstream keepalive 到底省了什么

不配 keepalive 的时候,Nginx 转发一个请求的完整动作是:跟后端建立 TCP 连接(三次握手)→ 发 HTTP 请求 → 等响应 → 关连接(四次挥手)。在同一个机房、走 loopback 或内网的情况下,这一步的开销大概是 0.1~0.5 毫秒;跨可用区可能就是几毫秒。看着不多,但如果你一秒有 500 个请求,那就是每秒 500 次握手加 500 次挥手,加上内核的 TIME_WAIT 堆积,成本就体现出来了。

配了 keepalive 32 之后,Nginx 的 worker 进程会为这个 upstream 维护一个最多 32 条的空闲连接池。请求来了先从池里拿一条,用完不关,放回池里。省下来的就是握手、挥手,以及每次建连都要做的 TLS(如果 upstream 是 https 的话,这部分省得更多)。

但这里有一个很多人忽略的细节:连接池是「每个 worker 进程各自一份」的,不是全局共享。你有 4 个 worker,keepalive 32 就意味着后端最多可能看到 4×32=128 条空闲连接。所以这个数字不是越大越好——它直接决定了后端(FPM 或者另一台 Nginx、或者 MySQL 前面的代理)要维持多少个空闲 socket。

upstream php_backend {
    server 127.0.0.1:9000;
    keepalive 32;
    keepalive_requests 1000;
    keepalive_timeout 60s;
}

location ~ \.php$ {
    fastcgi_pass php_backend;
    fastcgi_index index.php;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_set_header Connection "";
}

这段配置里有三个参数:keepalive(池大小)、keepalive_requests(单条连接最多服务多少个请求)、keepalive_timeout(空闲多久后关掉)。很多人只配第一个,另外两个用默认值,于是就出现了开头那种「发布时撞上半死连接」的问题。

二、keepalive_requests:默认值比你想的小

在 Nginx 1.15.3 之前,keepalive_requests 的默认值是 100。这个默认值来自 HTTP 早期的经验值,那时候一条连接服务一百个请求就该考虑重新建连了,因为长期连接容易积累状态、也可能掩盖内存泄漏。但从 1.15.3 开始,默认值改成了 1000。

这个数字意味着什么?意味着一条连接服务满 1000 个请求后,Nginx 会主动给它发 Connection: close,让它正常关闭,然后下次请求重新建连。这个动作本身是健康的——它起到了一种「定期换新」的作用,能避免连接长期复用导致的资源累积。但问题是:如果后端正在重启,这条连接是被「非正常」关掉的,Nginx 就会把错误暴露给用户。

所以配置时要注意两件事:

第一,keepalive_requests 不要设成很夸张的数字(比如 100000),否则连接永远不会自然老化,一旦后端有内存泄漏或者句柄泄漏,问题会被拖很久才显形。1000 到 10000 之间是合理区间,绝大多数站用 1000 就够了。

第二,如果你的后端经常 reload(比如每天都发版),那么「非正常关闭」就是常态,需要配合重试机制兜住。这就引出了下一个要点。

三、Connection 头:漏了这一行,keepalive 等于没配

这是最经典的一个坑。很多人配了 keepalive 32,然后用 ss -tn state established 一看,连接数还是每次请求一条、用完就 CLOSE,根本没有复用。原因往往在 fastcgi_params 或者 proxy_params 里:

fastcgi_param  Connection       close;

这一行的意思是「告诉后端:这个请求用完就关连接」。后端(PHP-FPM、另一台 Nginx)收到这个头,就在响应结束后主动 close。Nginx 拿到响应,发现连接已经被对端关了,也就没法放回池里。

正确做法是把发往后端的 Connection 头置空。对 fastcgi 是 fastcgi_set_header Connection "";,对 proxy 是:

proxy_http_version 1.1;
proxy_set_header Connection "";

两个细节:proxy_http_version 必须显式写 1.1,因为 proxy 默认用 1.0,而 1.0 默认就是短连接;Connection "" 不加引号在某些配置习惯下会被当成空值处理不当,建议带引号。这两行缺任何一行,keepalive 都不生效,而且 Nginx 不会报任何错,只是默默每次新建连接。

验证方法很简单:配好之后跑 ss -tn sport = :9000(或者你的 upstream 端口),在有流量的时候应该能看到连接数稳定在几十条,而不是一直在变。如果每次采样都是不同的连接、状态大量处于 TIME-WAIT,那就是没生效。

四、reload 时为什么会撞上半死连接

回到开头那个案例。Nginx 的连接池里存着若干条「对端已经握手完成、当前空闲」的连接。此时后端 PHP-FPM 被 reload,master 进程给老 worker 发信号让它们处理完当前请求后退出,退出时自然关闭自己持有的所有 socket。于是池子里那批连接,对 Nginx 来说还「看起来能用」,实际对端已经 close 了。

下次请求来,Nginx 从池里拿一条,发请求,收到的是 RST 或者 FIN——于是报 upstream prematurely closed connection。

这个问题没法彻底消除(你能做的只是让重启更平滑),但可以大幅降低它的影响:

第一种是配置重试。Nginx 的 proxy_next_upstream 和 fastcgi_next_upstream 默认包含 error 和 timeout,但不包括 prematurely closed 这个类别对应的错误(它属于 upstream 侧的 early EOF,需要通过 http_500 或者别的指令兜)。对 fastcgi,被广泛使用的一行是:

fastcgi_next_upstream error timeout invalid_header http_500 http_502 http_503;
fastcgi_next_upstream_tries 2;

要注意:重试只能用于幂等请求。GET 可以放心重试,POST 重试可能导致重复下单、重复发帖。所以更稳的做法是用 map 按请求方法区分:

map $request_method $retry_flag {
    default   "";
    GET       "error timeout http_502 http_503";
    HEAD      "error timeout http_502 http_503";
}

fastcgi_next_upstream $retry_flag;

这样 POST/PUT/DELETE 就不重试,避免数据被写两遍。

第二种是发布方式本身。不要 systemctl restart php-fpm(会瞬间杀掉所有 worker,池子里全部连接失效),而是用 reload,并且先做一次 upstream 摘流:把 upstream 里这台机器的 server 标记为 down,reload Nginx,等十几秒让现有请求跑完,再真正 reload 后端,最后把 upstream 改回来。这个流程你在单人运维的时候可能觉得繁琐,但如果一天要发好几次版,它值这个时间。

第三种是缩短 keepalive_timeout。默认 60 秒,意味着一条空闲连接可能躺了一分钟才被关掉。如果你把后端重启的时间点挑在连接池比较「新鲜」的时候(比如刚有流量波峰过去),撞上的概率会低一些。不过这只是降低概率,不是解决问题的办法。

五、keepalive 与上游连接上限的联动

还有一类问题不那么显眼,但更麻烦:upstream 连接池太大会把后端压垮。

假设你后端是另一台 Nginx,它的 worker_connections 是 1024,worker_processes 是 2,理论上能承接 2048 条连接。你的前端有 8 个 worker,每个 keepalive 64,光空闲连接就可能占掉 512 条。再加上活跃请求的连接,很容易把后端的连接数水位推高,让它开始拒连。

这种情况下日志里会出现 connect() failed (111: Connection refused) 或者 upstream connect error or disconnect/reset before headers。而你在前端看 CPU、内存、QPS 都不高,完全想不到是「空闲连接」把后端占满了。

核算方式很简单:前端 worker 数 × keepalive 值 + 前端的活跃请求峰值 < 后端能接受的最大连接数 ×安全系数(0.7~0.8)。个人站一般不需要 keepalive 超过 32,甚至 16 都够——因为同时并发的请求数本来就有限,池子只是为了让这些并发的请求在连发时不必重复握手。

另外要注意 PHP-FPM 这一侧的 pm.max_children。FPM 的连接处理和进程数是绑定的:一个 worker 进程同一时刻只能处理一个连接。所以如果 Nginx 池里保持了 32 条空闲连接,FPM 那边并不会为此开 32 个进程(连接空闲时没有请求在跑),但一旦这些连接同时发来请求,FPM 就要有足够的 max_children 接住。池子大小和后端并发能力要匹配,不能各配各的。

六、快速定位:四条命令看清连接复用情况

遇到「连接数不对」「报 prematurely closed」这类问题,按下面顺序查,基本五分钟能定性:

第一步,看连接是否真的被复用。在你的 upstream 所在机器上跑:

ss -tn state established sport = :9000 | head -20
ss -s

如果 established 的连接数远小于 QPS、且大量连接处于 TIME-WAIT,说明没有复用。TIME-WAIT 集中出现是「每次新建」的铁证。

第二步,直接看 Nginx 发出去的头。用 curl 打一个临时 location,或者在后端抓包看:

tcpdump -i lo -A -s 0 port 9000 | grep -i connection

如果你看到 Connection: close,就是第三节那个问题。

第三步,看错误日志的时间分布,判断是不是跟发布相关:

grep -c 'prematurely closed' /var/log/nginx/error.log
awk '/prematurely closed/ {print substr($1,1,15)}' /var/log/nginx/error.log | sort | uniq -c | sort -rn | head

如果报错集中在几个时间点,且这些时间点和你发版的时间吻合,那就不用怀疑了。

第四步,核对上游是否真的在做 keepalive。如果你后端也是 Nginx,可以看它的 access.log 里同一个客户端 IP 的端口号是否重复出现——同一个源端口对应多条日志,说明连接被复用了。

七、几个不值得折腾的情况

最后说个反向的判断:不是所有站都该上 upstream keepalive。

如果你的后端和 Nginx 在同一台机器、走 unix socket(fastcgi_pass unix:/run/php/php-fpm.sock),那握手成本本来就是微秒级,keepalive 带来的收益很小,反而增加了连接管理的复杂度。unix socket 上谈 keepalive 意义不大,很多人的配置里连 keepalive 都没写,站点一样跑得很快。

如果你的流量低到每秒几个请求,连接复用带来的收益也就几毫秒,不值得为它引入一套重试和摘流的流程。真正值得优化的场景是:后端在另一台机器(需要跨网络建连)、QPS 上千、或者 upstream 走 https(省掉 TLS 握手收益明显)。

还有一种情况:后端是那种「每个请求都要重连数据库」的老应用,keepalive 只能省掉 HTTP 层的握手,解决不了应用层的连接开销。这时候该优化的是连接池,不是 upstream keepalive。

一句话总结:keepalive 是个便宜但容易配错的优化,收益集中在跨网络场景;配的时候三件套(keepalive、keepalive_requests、Connection 头清空)必须同时到位,否则不但没收益,还会在发布时给你制造一堆看不懂的 502。

Last modification:October 25th, 2026 at 10:25 pm

Leave a Comment