Nginx 上游连接池与健康检查:keepalive 复用、被动探测与超时联动

讲透 Nginx 到上游的连接复用与健康检查机制,覆盖 keepalive 池配置、max_fails 被动探测、主动健康检查以及超时参数联动。

反向代理的性能瓶颈很少在 Nginx 本身,而在它与上游之间那条链路。默认情况下 Nginx 对每个请求都新建一条到上游的 TCP 连接,三次握手加 TLS 握手的开销在高并发下会被放大到不可忽略;而当某个上游实例悄悄挂掉时,如果没有合适的健康检查,流量还会持续打过去,用户看到的是成片的 502。本文围绕这两件事展开:如何让连接复用起来,以及如何让故障实例尽快被摘除。二者最终都要落到超时参数上,所以最后一并讲清超时的联动关系。

1. 上游连接的生命周期

一句话总结: 默认的短连接模式让每个请求都付一次握手成本,keepalive 池把它降为一次复用,代价是要正确设置连接数与协议头。

一次代理请求在上游侧涉及四个动作:从连接池取连接(或新建)、发送请求、读取响应、归还连接(或关闭)。是否复用取决于连接池是否启用、以及上游是否愿意保持连接。

# 默认行为:不启用连接池
upstream backend {
    server 10.0.0.11:8080;
    server 10.0.0.12:8080;
}

在这种配置下,每个请求结束后连接即被关闭。用 ss 可以直观看到 TIME_WAIT 数量随 QPS 线性增长:

# 观察与上游 8080 端口相关的连接状态分布
ss -tan state time-wait '( dport = :8080 or sport = :8080 )' | wc -l
# 短连接模式下这个数字会持续高位,说明握手开销被反复支付

启用 keepalive 后,连接在请求结束后回到池中等待复用,握手成本被摊薄。代价是 Nginx 与上游都必须正确处理长连接,否则会出现「连接被上游提前关闭、Nginx 拿到半开连接」的报错。

2. keepalive 连接池配置

一句话总结: keepalive 必须在 upstream 块中声明、在 location 中设置 HTTP/1.1 与 Connection 头,三者缺一不可。

启用连接池需要三处配合,漏掉任何一处都会静默退化为短连接。

upstream backend {
    server 10.0.0.11:8080;
    server 10.0.0.12:8080;

    # 每个 worker 进程为这个 upstream 保留的空闲连接数
    keepalive 64;
    # 单条连接上最多复用多少次请求,避免长连接上的内存累积
    keepalive_requests 1000;
    # 空闲连接保持多久后关闭
    keepalive_timeout 60s;
}

server {
    location /api/ {
        proxy_pass http://backend;

        # 关键三行:使用 HTTP/1.1 并清空 Connection 头
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
    }
}

proxy_set_header Connection "" 是必须的。 默认情况下 Nginx 会把客户端的 Connection 头透传给上游;客户端通常发 Connection: close,上游收到后就关闭连接,连接池等于没开。清空这个头,上游才会保持连接。

keepalive 的取值需要权衡。它是每个 worker 进程的空闲连接上限,不是总连接数。若 worker_processes 为 8、keepalive 为 64,则全局最多缓存 512 条空闲连接。取值过小会导致复用率低,过大则占用上游的连接数配额。

# 估算合理的 keepalive 值
# 设单实例 QPS 为 2000、上游平均响应时间 20ms
# 并发连接数约为 2000 * 0.02 = 40
# 按每个 worker 承担 250 QPS 计算,keepalive 取 32~64 即可覆盖峰值

若上游是 HTTP/2,连接复用由协议自身完成,keepalive 的语义会有所不同,通常不需要开很大的值。

3. 被动健康检查:max_fails 与 fail_timeout

一句话总结: 被动检查只在真实请求失败时统计次数,达到 max_fails 后在 fail_timeout 内不再选中该实例。

Nginx 社区版没有主动探测,只有被动判定。判定逻辑是:在 fail_timeout 这个时间窗口内,某个 server 累计失败 max_fails 次,就把它标记为不可用,并在接下来的 fail_timeout 内不再分配请求。

upstream backend {
    server 10.0.0.11:8080 max_fails=3 fail_timeout=10s;
    server 10.0.0.12:8080 max_fails=3 fail_timeout=10s;
    server 10.0.0.13:8080 backup;              # 兜底实例
    keepalive 64;
}

两个参数的含义需要精确理解:

  • max_fails:窗口内允许的失败次数,达到即摘除;设为 0 表示不摘除(永远认为可用)
  • fail_timeout:既是统计窗口长度,也是摘除后的冷却时长

也就是说 max_fails=3 fail_timeout=10s 的含义是「10 秒内失败 3 次则摘除 10 秒」。冷却结束后实例会被重新放回,如果仍然失败会再次摘除,形成「探测—摘除」的循环。

哪些情况算「失败」由 proxy_next_upstream 决定:

location /api/ {
    proxy_pass http://backend;
    # 哪些情况算失败并尝试下一个上游
    proxy_next_upstream error timeout http_502 http_503 http_504;
    # 尝试次数与总超时预算
    proxy_next_upstream_tries 3;
    proxy_next_upstream_timeout 5s;
}

默认值只包含 error 与 timeout,不含 5xx。这意味着上游返回 502 时 Nginx 不会重试,也不会把它计入失败次数。若希望 5xx 也参与摘除,必须显式加上。但要谨慎:把 http_500 加进去可能导致业务错误被误判为实例故障。

被动检查的固有缺陷是「必须有人踩坑才知道实例挂了」。低流量时段,一个实例可能几分钟都收不到请求,自然也不会被摘除;而用户恰好被路由到它,就会失败。这就是主动健康检查存在的理由。

4. 主动健康检查

一句话总结: 社区版需要借助 OpenResty 或 nginx-plus 生态实现主动探测,核心是定时打健康端点并把结果写入共享内存。

社区版 Nginx 没有内置主动检查,主流做法有两种:使用 OpenResty 配合 lua-resty-upstream-healthcheck,或使用带 ngx_http_upstream_check_module 的第三方编译版本。下面以 OpenResty 方案为例。

# 使用 lua-resty-upstream-healthcheck 做主动探测
lua_shared_dict healthcheck 1m;
lua_socket_log_errors off;

upstream backend {
    server 10.0.0.11:8080;
    server 10.0.0.12:8080;
    server 10.0.0.13:8080;
    keepalive 64;
}

init_worker_by_lua_block {
    local hc = require "resty.upstream.healthcheck"
    local ok, err = hc.spawn_checker{
        shm = "healthcheck",
        upstream = "backend",
        type = "http",
        http_req = "GET /healthz HTTP/1.0\r\nHost: backend\r\n\r\n",
        interval = 2000,          -- 每 2 秒探测一次
        timeout = 1000,           -- 单次探测 1 秒超时
        fall = 3,                 -- 连续 3 次失败标记为 down
        rise = 2,                 -- 连续 2 次成功恢复为 up
        valid_statuses = {200, 204},
        concurrency = 2,
    }
    if not ok then
        ngx.log(ngx.ERR, "failed to spawn health checker: ", err)
    end
}

server {
    # 暴露健康状态供监控采集
    location = /healthcheck_status {
        access_log off;
        content_by_lua_block {
            local hc = require "resty.upstream.healthcheck"
            ngx.say(hc.status_page())
        }
    }
}

健康端点本身的设计也有讲究。不要用 / 或业务首页做探测,因为它可能依赖下游服务,导致级联误判。应当提供一个只反映「本实例能否处理请求」的轻量端点,返回 200 即可。

# 应用侧提供一个不依赖下游的存活端点
# Go 示例(伪代码语义):
#   GET /healthz -> 200 "ok"       仅表示进程存活
#   GET /readyz  -> 200 或 503     表示依赖是否就绪
# 探测用 /healthz,负载均衡摘除用 /readyz

主动与被动检查应当同时启用:主动检查负责在低流量时也能发现故障,被动检查负责捕获那些健康端点正常但业务路径异常的情况。

5. 超时参数联动

一句话总结: 上游超时必须形成「连接 < 发送 < 读取 < 客户端」的递进关系,否则重试与熔断都会失效。

代理相关的超时有四个,它们的语义和取值必须协同:

参数含义建议取值
proxy_connect_timeout与上游建连超时1s ~ 3s
proxy_send_timeout向上游发送请求超时与读取超时接近
proxy_read_timeout等待上游响应超时略大于上游 P99
proxy_next_upstream_timeout重试总预算小于客户端超时
location /api/ {
    proxy_pass http://backend;

    proxy_connect_timeout 2s;
    proxy_send_timeout    10s;
    proxy_read_timeout    10s;

    # 重试必须在总预算内完成,否则用户等到的还是失败
    proxy_next_upstream error timeout http_502 http_503 http_504;
    proxy_next_upstream_tries 2;
    proxy_next_upstream_timeout 12s;

    # 与上游建连失败时快速返回,不要拖满客户端超时
    proxy_connect_timeout 2s;
}

递进关系的意义在于:内层超时先于外层触发,才有机会重试或降级。若 proxy_read_timeout 设成 60 秒,而客户端 30 秒就断开,那么这 60 秒的等待毫无价值,还占着 worker 连接。合理的做法是让读取超时略大于上游的 P99 延迟,同时显著小于客户端的容忍上限。

keepalive_timeout 也需要与上游的 Keep-Alive: timeout= 响应头配合。若上游声明 5 秒后关闭空闲连接,而 Nginx 设成 60 秒,那么第 6 秒复用时就会拿到已被关闭的连接。

upstream backend {
    server 10.0.0.11:8080;
    # 略小于上游声明的空闲超时,避免复用到半开连接
    keepalive_timeout 55s;
    keepalive 64;
}

Nginx 对复用到已关闭连接的情况有容错:会检测到并重试一次。但这次重试对用户是可感知的延迟,能避免就避免。

6. 负载均衡算法与慢启动

一句话总结: 算法决定流量如何分配,慢启动决定新实例上线时如何被温和地放量,二者共同影响故障恢复的表现。

默认算法是轮询(round-robin),此外还有几种常用选择:

upstream backend_rr {
    server 10.0.0.11:8080;
    server 10.0.0.12:8080;
}

upstream backend_least_conn {
    least_conn;                       # 连接数最少优先,适合长请求
    server 10.0.0.11:8080;
    server 10.0.0.12:8080;
}

upstream backend_hash {
    hash $request_uri consistent;     # 一致性哈希,适合缓存亲和
    server 10.0.0.11:8080;
    server 10.0.0.12:8080;
}

upstream backend_weight {
    server 10.0.0.11:8080 weight=5;   # 按权重分配
    server 10.0.0.12:8080 weight=1;
}

慢启动(slow_start)是 NGINX Plus 特性,社区版不可用。它的作用是让刚恢复的实例在指定时间内从低权重线性升到全权重,避免冷实例被瞬间打满。社区版的替代方案是配合动态 upstream(如 ngx_http_dynamic_upstream 或 OpenResty 的 balancer)自行实现。

一致性哈希有个常见误区:它只保证同一 key 落到同一实例,不保证负载均匀。若 key 分布倾斜(例如少数热门 URL 占了大部分流量),个别实例会被压垮。此时应改用 least_conn 或在哈希前对 key 做散列。

7. 观测与排错

一句话总结: 上游问题要通过连接状态、错误日志与 upstream 相关变量三条线交叉定位,单看指标容易误判。

先把上游状态暴露成可采集的指标。社区版可以用 stub_status 加日志分析,或借助 nginx-prometheus-exporter 读取日志与状态。

# 记录上游相关变量,便于事后分析
log_format upstream_detail '$remote_addr "$request" $status '
                          'upstream=$upstream_addr '
                          'status=$upstream_status '
                          'time=$upstream_response_time '
                          'connect=$upstream_connect_time '
                          'retries=$upstream_connect_time';

access_log /var/log/nginx/access.log upstream_detail;

$upstream_status 出现多个值(如 502, 200)说明发生了重试;$upstream_addr 的多个值则记录了重试过程中访问过的实例。

# 统计各上游实例的失败比例
awk '{ for (i=1;i<=NF;i++) if ($i ~ /^upstream=/) print $i }' \
    /var/log/nginx/access.log | sort | uniq -c | sort -rn | head

# 找出重试比例最高的接口
grep -c ',' /var/log/nginx/access.log

常见的排错路径:

症状一:上游连接数持续增长但吞吐不升。 多半是 keepalive 没生效,检查 proxy_http_version 与 Connection 头两行是否都写了。

症状二:间歇性 502,日志中有 upstream prematurely closed connection。 上游主动关闭了长连接,缩短 keepalive_timeout 使其小于上游的空闲超时。

症状三:故障实例长时间未被摘除。 检查 proxy_next_upstream 是否包含了你认为应当计入的失败类型;默认不含 5xx。

症状四:重试把故障放大。 上游过载时重试会雪上加霜。proxy_next_upstream_tries 建议不超过 3,并配合熔断。

# 用上游实例的健康端点手工验证,确认是实例问题还是链路问题
curl -sS -o /dev/null -w "code=%{http_code} connect=%{time_connect} total=%{time_total}\n" \
     http://10.0.0.11:8080/healthz

8. 总结

环节要点
连接复用keepalive 声明、HTTP/1.1、清空 Connection 头,三处缺一不可
池容量keepalive 是每 worker 空闲上限,按并发与 worker 数估算
被动检查max_fails 与 fail_timeout 互为窗口与冷却,默认不统计 5xx
主动检查社区版靠 OpenResty 实现,健康端点必须不依赖下游
超时联动连接小于读取小于客户端容忍,重试预算必须小于客户端超时
负载算法长请求用 least_conn,缓存亲和用一致性哈希但要防倾斜
观测upstream_addr 与 upstream_status 多值即代表重试,据此定位

上游治理的目标是「让好的实例多干活、坏的实例快速出局、复用的连接不出错」。做到这三点,代理层在高并发下就能保持稳定。下一篇我们换个角度,看如何在变更前用流量镜像把新版本放到真实流量下验证,从而把风险前置。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nginx」更多文章

  1. Nginx 在服务网格中的角色:边车代理、mTLS 与 Envoy 取舍
  2. Nginx 大文件上传与请求体处理:缓冲、临时文件与断点续传
  3. Nginx 证书自动化与 ACME:certbot、DNS-01 通配符与自动续期