首页 / 博客 / 本文

Nginx 反向代理与负载均衡深度实践:路径改写、真实 IP、健康检查与灰度发布

🚀 性能优化📅 2026-09-14⏱ 17 分钟

上一篇《Nginx 高性能调优实战》聊的是把 Nginx 当文件服务器用——worker 进程、压缩、静态缓存、限流,目标是把一个静态站的首屏和 QPS 拉满。但真实生产里,Nginx 扮演最多的角色其实是反向代理:站在所有后端前面,负责转发、负载、健康检查、限流、灰度。这一篇就往上一层走,把代理层的坑一次踩完。

一个认知前提。静态服务层和代理层的性能关注点完全不同。静态站关心「磁盘 IO 与缓存命中」,代理层关心三件事:连接复用(省握手)、超时配置(防拖死)、故障隔离(坏节点不要拖垮整条链路)。绝大多数「Nginx 代理慢」的问题,根因都在这三件事上,而不是 worker 数量。

一、proxy_pass 的路径改写:四种组合,四种结果

代理配置里最容易出错、也最难自查的就是 proxy_pass 的 URI 到底会不会被替换。规则其实只有一条:proxy_pass 后面带不带 URI(也就是域名后面那一段路径),决定了 location 匹配到的那部分前缀要不要被换掉。

locationproxy_pass请求 /api/user/1 实际转发到规则
/api/http://127.0.0.1:8080/api/user/1不带 URI → 原样透传,路径不丢
/api/http://127.0.0.1:8080//user/1带 URI(单个 /)→ 用 / 替换掉 /api/
/api/http://127.0.0.1:8080/v2//v2/user/1带 URI → 替换后拼接,前缀被改写
/api(无尾斜杠)http://127.0.0.1:8080/(配置有坑,见下)location 不带尾斜杠时,URI 替换不做,/ 被当作普通 URI 处理

实践上的三条纪律:

# 需要正则匹配 + 路径改写时的正确姿势
location ~ ^/img/(.+)$ {
    rewrite ^/img/(.+)$ /static/$1 break;
    proxy_pass http://127.0.0.1:8080;   # 不带 URI,交给 rewrite 处理
}

二、真实 IP 链路:为什么后端拿到的全是 127.0.0.1

这是代理层第二大高频问题:应用日志里所有客户端 IP 都变成了代理的内网地址,限流、风控、审计全部失效。要彻底讲清楚,得先区分 Nginx 里两个变量的语义:

变量含义代理场景下的取值
$remote_addr与本机建立 TCP 连接的那一方的地址前端代理的地址(如 127.0.0.1),不是真实客户端
$proxy_add_x_forwarded_for原 X-Forwarded-For 头 + $remote_addr 追加形成「客户端, 代理1, 代理2」的链路串

所以正确做法分两步走:转发时把链路头带上,需要时把 $remote_addr 纠正过来。

# 第 1 步:转发时补齐四个头,缺一个应用侧就会有信息缺口
location / {
    proxy_pass http://127.0.0.1:3003;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;   # 应用判断是否 https、生成绝对 URL 要用
}

# 第 2 步:本机前面还有一层可信代理时,把 $remote_addr 纠正为真实客户端
# 只信任明确的代理网段,不能写 0.0.0.0/0,否则伪造头就能冒充任意 IP
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
real_ip_header X-Forwarded-For;
real_ip_recursive on;   # 从右往左找第一个非可信地址
取哪个 IP 才是"真实客户端"?X-Forwarded-For 是一个逗号分隔的列表,最左边是客户端,越往右越接近本机。但这条头是可以被客户端伪造的——如果应用直接取最左边的值做风控,攻击者传一个 X-Forwarded-For: 1.2.3.4 就能绕过。正确的取法是:从右往左,取第一个"不在可信代理列表里"的地址,Nginx 的 real_ip_recursive on 做的就是这件事。应用侧同理,不要盲信最左值。

三、upstream 负载均衡:算法只是开始,连接复用才是性能关键

1. 调度算法怎么选

算法语法适用场景
轮询(默认)upstream a { server ...; }后端规格一致、无状态
加权轮询server ... weight=3;新老机器混部、按规格分流量
最少连接least_conn;请求耗时差异大(长连接、慢接口)
IP 哈希ip_hash;无共享存储的会话粘性(优先级低,尽量做无状态)
一致性哈希hash $request_uri consistent;缓存节点场景:节点增减时只迁移少量 key
最短响应时间least_time header;Nginx Plus / 商业版

还有一个常被忽略的语义:max_fails 与 fail_timeout 是一组参数。max_fails=3 fail_timeout=10s 表示「10 秒内失败 3 次,就在接下来 10 秒内把这个节点摘掉」——注意后半段的 10 秒既是计数窗口、也是熔断时长。

2. keepalive:代理层性能的最大杠杆

默认情况下,Nginx 每转发一个请求就要和后端重新建立一次 TCP 连接(三次握手 + 慢启动)。在高并发下,这会同时吃掉两边的端口资源和 CPU。开启 upstream 长连接池的收益通常远大于任何 worker 调参:

upstream backend {
    server 127.0.0.1:3003 max_fails=3 fail_timeout=10s;
    server 127.0.0.1:3004 max_fails=3 fail_timeout=10s backup;
    least_conn;

    keepalive 64;              # 每个 worker 保留的空闲长连接数
    keepalive_requests 1000;   # 单条长连接最多复用多少次后关闭
    keepalive_timeout 60s;     # 空闲多久后关闭
}

location / {
    proxy_pass http://backend;
    # 这两行是 keepalive 生效的【必要前提】,缺了长连接池就形同虚设
    proxy_http_version 1.1;
    proxy_set_header Connection "";
}
为什么必须写 Connection ""?HTTP/1.0 时代默认短连接,Nginx 默认会把 Connection: close 传给后端,后端收到就关连接,长连接池自然用不上。把 Connection 显式置空 + 声明 HTTP/1.1,才是「让后端保持连接」的开关。这三行是代理层性价比最高的一段配置,值得背下来。

四、健康检查:被动是兜底,主动才不抖动

Nginx 开源版只有被动健康检查——它不会主动去探活,而是等真实请求失败后,再根据 max_fails 把节点标记为不可用。这意味着:节点挂掉后的头几个请求,一定会被转发到坏节点上。用户看到的就是「偶发 502」。

被动(开源版自带)主动(Plus / check 模块)
触发方式真实请求失败累积到阈值后台定时探测健康端点
发现延迟取决于流量,低峰期可能很久固定间隔,与流量无关
副作用坏节点会先吃几个真实请求几乎无用户可见抖动

开源版下的实用折中:把 proxy_next_upstream 打开,让请求在失败后自动重试到下一个健康节点,把「偶发 502」降级成「偶发的延迟增加」。

location / {
    proxy_pass http://backend;
    # 遇到连接错误/超时/502/503/504 时,换下一个节点重试
    proxy_next_upstream error timeout http_502 http_503 http_504;
    proxy_next_upstream_tries 2;      # 最多换 2 个节点,防雪崩式重试
    proxy_next_upstream_timeout 5s;
}
注意幂等性。proxy_next_upstream 默认会重试非幂等请求(比如 POST),这会导致「后端已经处理成功,但客户端又发了一遍」的重复写。上线前务必确认业务幂等,或者把重试条件收紧为 error timeout 并排除 non_idempotent。

五、超时与缓冲三兄弟:三个超时管的不是同一件事

指令管的是什么典型值
proxy_connect_timeout与后端建立 TCP 连接的耗时3–5s
proxy_send_timeout把请求体发给后端两次写操作之间的间隔10–60s
proxy_read_timeout等待后端返回响应两次读操作之间的间隔30–60s;慢接口/导出任务需单独放大

最容易踩的是 proxy_read_timeout:它不是「整个请求的处理总时长」,而是「两次成功读取之间的空闲间隔」。所以一个持续输出数据的流式接口(SSE、大文件下载、日志推送)只要不断有字节流动,就不会超时;反而一个 10 秒内一声不吭去查大表的接口,即使它最终只要 12 秒也会被判定超时。

缓冲相关的两个开关,决定了「后端是不是被客户端网速绑架」:

# 关闭响应缓冲:后端不必等客户端慢速接收,适合流式输出
proxy_buffering off;

# 或者在应用侧显式关闭:Nginx 看到这个响应头就不缓冲该响应
proxy_set_header X-Accel-Buffering no;   # 常用于 SSE / 分块下载

# 请求体缓冲:打开则先完整落盘再转发(后端更稳),
# 关闭则边收边转(大文件上传更省磁盘,但后端要能扛慢客户端)
proxy_request_buffering on;
client_max_body_size 50m;
一个高频困惑:为什么下载大文件时进度条不动?因为 proxy_buffering 默认开启,Nginx 会先把后端的响应攒到缓冲区(默认几 MB)再吐给客户端。文件越大、首字节越慢。对下载类、导出类接口,按 location 单独关掉 proxy_buffering 即可。

六、限流、熔断与灰度:把风险关在入口

1. limit_req:漏桶算法,理解 burst 与 nodelay

# 定义两个共享内存区(10m 大约能存 16 万个 IP 的状态)
limit_req_zone  $binary_remote_addr zone=general:10m rate=20r/s;
limit_req_zone  $binary_remote_addr zone=convert:10m rate=1r/s;   # 重接口单独限
limit_conn_zone $binary_remote_addr zone=perip:10m;

server {
    limit_req  zone=general burst=300 nodelay;
    limit_conn perip 20;          # 单 IP 并发连接上限,防慢速攻击

    location = /jobs {
        limit_req zone=convert burst=5 nodelay;
        # ...
    }
}
参数语义
rate=20r/s令牌补充速率,漏桶的平均放行速度
burst=300允许瞬时排队/溢出的请求数(桶容量)
nodelayburst 内的请求立即放行而不是按速率排队

「burst + nodelay」和「只有 burst」的区别很关键:只有 burst 时,超出速率的请求会被排队延迟处理,用户感知是「变慢」;加了 nodelay,桶里的请求立刻放行,只在桶满后才返回 503——用户感知是「正常或明确被拒」,不会体验到诡异的排队延迟。对静态站和 API,一般推荐 burst + nodelay。

2. 灰度发布:权重、分流与 A/B

# 方式一:按权重切流(最简单,适合新版本小流量验证)
upstream app {
    server 127.0.0.1:3003 weight=9;   # 老版本 90%
    server 127.0.0.1:3005 weight=1;   # 新版本 10%
}

# 方式二:按用户维度稳定分流(同一用户在灰度期内始终命中同一版本)
split_clients "${remote_addr}${http_user_agent}" $app_pool {
    10%     newver;
    *       oldver;
}
upstream newver { server 127.0.0.1:3005; }
upstream oldver { server 127.0.0.1:3003; }

location / {
    proxy_pass http://$app_pool;
}
为什么用 split_clients 而不是纯权重?权重按请求切分,同一个用户的每次请求可能落到不同版本,会话状态、前端缓存、AB 指标全乱。split_clients 对指定变量做哈希,同一变量值恒定映射到同一池,因此具备「用户级粘性」。这是灰度发布里最实用的一条技巧。

七、一套可直接抄的生产模板

upstream backend {
    least_conn;
    server 127.0.0.1:3003 max_fails=3 fail_timeout=10s;
    server 127.0.0.1:3004 max_fails=3 fail_timeout=10s backup;
    keepalive 64;
    keepalive_requests 1000;
}

location / {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Connection        "";
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    proxy_connect_timeout 5s;
    proxy_send_timeout    60s;
    proxy_read_timeout    60s;
    proxy_next_upstream   error timeout http_502 http_503 http_504;
    proxy_next_upstream_tries 2;
}

# 长任务/流式接口单独放开超时并关闭缓冲
location /stream/ {
    proxy_pass http://backend;
    proxy_read_timeout 300s;
    proxy_buffering off;
    proxy_set_header X-Accel-Buffering no;
}

排障清单:五个问题五个动作

现象先查什么常见根因
后端拿到的 IP 是 127.0.0.1X-Forwarded-For 是否转发漏了 proxy_set_header
路径变成了 /user/1 而不是 /api/user/1proxy_pass 是否带 URI尾斜杠组合写错
QPS 上不去、后端连接数暴涨ss -s 的 TIME_WAIT 数量upstream keepalive 没生效
偶发 502,重启后又好了error.log 里的 upstream 关键字只有被动检查,坏节点未及时摘除
大文件下载首字节极慢该 location 的 proxy_buffering响应被缓冲攒块
总结。代理层的本质是「把三个不变量维持住」:连接不重建(keepalive + HTTP/1.1)、信息不丢失(四个转发头 + 可信 real_ip)、故障不外溢(健康检查 + next_upstream + 限流)。配置写对只是及格,把超时和缓冲按接口语义分开调,才算真正把反向代理用明白。下一篇我们往协议栈再下一层,聊 TLS 握手与证书链排障。
#Nginx#反向代理#负载均衡#性能优化#灰度发布
广告