11135 字
56 分钟
第 3 篇:Nginx 反向代理与后端服务转发

第 3 篇:Nginx 反向代理与后端服务转发——从 proxy_pass 到 WebSocket、SSE 与真实客户端 IP#

专栏:《Nginx 从入口代理到云原生流量治理》
文章序号:第 3 篇
Nginx 实验版本:Nginx Open Source 1.30.3(稳定版)
当前主线版本:1.31.2
后端实验版本:Spring Boot 3.5.15、Java 21
实验环境:Docker Compose、Linux、curl
更新时间:2026-07-06


本文在专栏中的位置#

第一篇解决了“Nginx 为什么经常位于后端服务之前”的架构问题;第二篇建立了配置模型,讲清请求如何命中 serverlocation。从本篇开始,我们进入 Nginx 最常见、也最容易出现隐蔽错误的能力:反向代理

反向代理不是“把请求地址换一下”这么简单。一个请求通过 Nginx 到达 Spring Boot,至少涉及两条独立连接、两套请求头、URI 转换、请求体转发、响应缓冲、超时控制以及错误归属:

客户端连接:Client <------ HTTP/TCP ------> Nginx
上游连接:Nginx <------ HTTP/TCP ------> Spring Boot

这两条连接由 Nginx 分别管理。客户端保持长连接,不代表 Nginx 与后端也在复用同一条连接;客户端使用 HTTPS,不代表 Nginx 到后端一定也是 HTTPS;客户端传入 HostX-Forwarded-For,也不代表这些值应该原样交给应用。

本篇将把反向代理链路完整拆开。下一篇将在此基础上把单个后端扩展为多个实例,继续讲 upstream、负载均衡、故障判断和重试边界。

独立插图 01:反向代理双连接模型
对应文件:01-reverse-proxy-dual-connection.png

反向代理双连接模型


学习目标#

学完本文后,你应当能够:

  1. 解释客户端连接与 upstream 连接为什么是两条独立连接;
  2. 根据 locationproxy_pass 的尾部 URI,准确推导后端最终收到的路径;
  3. 区分原始请求 URI、规范化 URI、查询参数与转发 URI;
  4. 正确设置 HostX-Real-IPX-Forwarded-ForX-Forwarded-Proto
  5. 解释为什么不能直接信任公网客户端提供的转发头;
  6. 区分连接超时、请求发送超时和响应读取超时;
  7. 解释 proxy_read_timeout 为什么不是“整个请求最大执行时间”;
  8. 理解请求缓冲和响应缓冲分别保护哪一侧;
  9. 正确配置大文件上传、流式下载和 SSE;
  10. 区分客户端 Keep-Alive 与 upstream Keep-Alive;
  11. 配置 WebSocket Upgrade 头与长连接超时;
  12. 根据日志判断 400、413、499、502、504 分别可能由谁产生;
  13. 独立搭建一个包含普通代理、URI 改写、SSE、WebSocket 和上传接口的实验环境;
  14. 建立一套从客户端、Nginx 到后端应用的端到端排查路径。

一、反向代理到底代理了什么#

1.1 Nginx 不是透明的“网络转发线”#

HTTP 反向代理工作在应用层。Nginx 会解析客户端 HTTP 请求,然后以 HTTP 客户端的身份向后端重新发起请求。后端并不是在读取客户端原来的 TCP 连接,而是在读取 Nginx 新建或复用的 upstream 连接。

一次代理请求可以抽象为:

1. 客户端与 Nginx 建立 TCP/TLS 连接
2. 客户端向 Nginx 发送 HTTP 请求
3. Nginx 选择 server 与 location
4. Nginx 计算 upstream 地址和转发 URI
5. Nginx 生成一份发往后端的新请求
6. Nginx 新建或复用到后端的连接
7. 后端处理请求并向 Nginx 返回响应
8. Nginx 根据缓冲、过滤、错误页等配置处理响应
9. Nginx 将响应发送给客户端

因此,代理链路里经常同时存在以下差异:

维度客户端到 NginxNginx 到后端
协议HTTPS / HTTP/2 / HTTP/3HTTP/1.1、HTTP/2 或 HTTPS
远端地址客户端或上一层代理Nginx 所在主机或容器
Host用户访问域名默认可能变为 $proxy_host
Connection由客户端协商由 Nginx 重新生成
超时client_*send_timeoutproxy_*_timeout
连接复用客户端 Keep-Aliveupstream Keep-Alive
缓冲客户端请求体缓冲upstream 响应缓冲

1.2 为什么要明确两条连接#

如果没有这个模型,很多排障结论会完全错误:

  • 浏览器与 Nginx 的连接正常,不代表 Nginx 能连接后端端口;
  • 客户端使用 HTTP/2,不代表后端收到 HTTP/2;
  • 客户端连接没有断,不代表 upstream 没有超时;
  • 后端日志中的来源 IP 默认通常是 Nginx,而不是用户;
  • 后端立即输出数据,不代表客户端立即看到,因为 Nginx 可能正在缓冲;
  • 客户端取消请求后,后端可能仍然继续执行,取决于请求阶段与配置。

1.3 最小反向代理配置#

server {
listen 8088;
server_name _;
location /api/ {
proxy_pass http://backend:8080;
}
}

这段配置只解决“能转发”,尚未解决:

  • 后端需要看到哪个 Host
  • 真实客户端 IP 如何传递;
  • 超时如何划分;
  • 是否复用 upstream 连接;
  • 流式响应是否被缓冲;
  • WebSocket 是否能升级;
  • 错误由谁返回;
  • 后端重定向地址是否需要改写。

所以它适合做最小实验,不应直接被称为完整生产配置。


二、proxy_pass 的核心:URI 到底怎么变#

proxy_pass 最容易出错的地方不是 upstream 地址,而是是否携带 URI,以及尾部斜杠如何参与替换

2.1 先区分三个对象#

假设客户端请求:

GET /api/users/42?detail=true HTTP/1.1

需要区分:

  1. 原始请求 URI:/api/users/42?detail=true
  2. 规范化后的路径部分:/api/users/42
  3. 查询参数:detail=true

location 主要匹配规范化路径;proxy_pass 的 URI 替换也基于规范化后的请求 URI。查询参数通常会继续保留,除非重写规则显式生成了新的参数或清除了参数。

独立插图 02:proxy_pass URI 转换对照图
对应文件:02-proxy-pass-uri-transform.png

proxy_pass URI 转换规则

2.2 情况一:proxy_pass 不带 URI#

location /api/ {
proxy_pass http://backend:8080;
}

客户端请求:

/api/users/42?detail=true

后端通常收到:

/api/users/42?detail=true

因为 proxy_pass 只有协议、主机和端口,没有额外 URI,Nginx 不会用新的路径替换 location 命中的前缀。

2.3 情况二:proxy_pass/#

location /api/ {
proxy_pass http://backend:8080/;
}

客户端请求:

/api/users/42?detail=true

后端收到:

/users/42?detail=true

Nginx 将规范化 URI 中与 location /api/ 匹配的部分替换为 proxy_pass 中的 /

可以理解为:

/api/ + users/42
替换为
/ + users/42
=
/users/42

2.4 情况三:proxy_pass 带其他 URI#

location /api/ {
proxy_pass http://backend:8080/service/;
}

请求:

/api/users/42

后端收到:

/service/users/42

这里不是“把 /service/ 拼到完整原始路径前”,而是用 /service/ 替换已匹配的 /api/

2.5 尾部斜杠不对称时会发生什么#

下面的写法虽然语法可能通过,但通常会得到意料之外的拼接结果:

location /api/ {
proxy_pass http://backend:8080/service;
}

请求 /api/users 可能被映射为:

/serviceusers

原因是 proxy_pass URI 为 /service,后面没有 /,而剩余部分是 users。两部分直接连接。

工程上最稳妥的做法是:

  • 希望保留 /api/proxy_pass http://backend:8080;
  • 希望删除 /api/proxy_pass http://backend:8080/;
  • 希望替换为 /service/proxy_pass http://backend:8080/service/;

不要依靠“看起来应该自动补斜杠”的直觉。

2.6 正则 location 与命名 location#

location 使用正则时,Nginx无法稳定确定“应该替换 URI 的哪一部分”,因此官方要求这类场景中的 proxy_pass 不应携带 URI:

location ~ ^/api/v[0-9]+/ {
proxy_pass http://backend:8080;
}

命名 location 同样应避免携带 URI:

location @backend_fallback {
proxy_pass http://backend:8080;
}

2.7 rewrite ... breakproxy_pass#

location /legacy/ {
rewrite ^/legacy/(.*)$ /api/$1 break;
proxy_pass http://backend:8080;
}

请求 /legacy/users 会先在当前 location 内将 URI 改成 /api/users,然后使用同一个 location 继续代理。此时后端收到修改后的完整 URI。

如果给 proxy_pass 再配置 URI,阅读和推导会变得困难。生产配置应优先保证路径转换只有一个明确来源:要么由 proxy_pass 的 URI 替换完成,要么由 rewrite 完成,不要无必要地叠加。

2.8 变量参与 proxy_pass 后,规则会变化#

set $backend "http://backend:8080";
location /api/ {
proxy_pass $backend/service/;
}

proxy_pass 使用变量时,URI 行为不再等同于静态地址下的前缀替换;指令中计算出的 URI 会按结果直接传递。域名若不对应已定义的 upstream,还需要 resolver 才能在运行期解析。

因此,除非确实需要动态路由,不要为了“看起来灵活”把固定 upstream 改成变量。变量会引入:

  • 运行时 DNS 解析;
  • 不同的 URI 计算语义;
  • proxy_redirect default 等能力限制;
  • 更难预测的缓存和连接复用行为。

2.9 URI 转换速查表#

假设请求路径为 /api/users/42

locationproxy_pass后端路径
/api/http://backend/api/users/42
/api/http://backend//users/42
/api/http://backend/service//service/users/42
/api/http://backend/service/serviceusers/42,通常是错误配置
正则 locationhttp://backend完整规范化 URI
命名 locationhttp://backend当前完整 URI

三、请求头:后端看到的是谁#

3.1 默认行为不是“全部原样透传”#

Nginx 会转发大部分客户端请求头,但默认会重定义部分 hop-by-hop 或代理相关字段。当前官方文档说明,默认代理请求中的 Host 会设置为 $proxy_hostConnection 会被设置为 close;在较新版本中 upstream HTTP 版本默认已经是 1.1,但这并不意味着所有连接管理头都应从客户端原样传递。

如果后端基于域名生成跳转地址、校验租户或构造绝对链接,通常需要显式传递外部域名:

proxy_set_header Host $host;

$host$http_host 不完全相同:

  • $http_host 是客户端原始 Host 字段,可能为空,也可能带端口;
  • $host 会按请求行、Host 字段、匹配到的 server_name 依次计算,通常更适合稳定代理。

3.2 常见转发头#

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_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;

含义如下:

Header用途
Host后端识别外部域名、虚拟主机或租户
X-Real-IP传递一个主要客户端地址
X-Forwarded-For保存经过多层代理的地址链
X-Forwarded-Proto告诉后端外部请求是 HTTP 还是 HTTPS
X-Forwarded-Host保留外部 Host
X-Forwarded-Port保留外部端口

独立插图 03:多层代理 Header 链与信任边界
对应文件:03-forwarded-header-trust-chain.png

转发头信任链

3.3 为什么不能直接信任 X-Forwarded-For#

公网客户端可以自行发送:

X-Forwarded-For: 127.0.0.1

如果应用把这个字段的第一个值直接当成真实 IP,就可能导致:

  • IP 白名单绕过;
  • 审计日志伪造;
  • 限流 Key 被伪造;
  • 地域判断错误;
  • 风控规则失效。

信任转发头的前提是:应用只能从受信任代理网络接收流量,并且边界代理会覆盖或规范化外部提供的字段。

直接暴露公网的边界 Nginx#

如果 Nginx 直接接收互联网流量,最简单安全的策略是覆盖客户端提供的链:

proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;

这样下游只得到由边界 Nginx 确认的直接客户端地址。

Nginx 位于可信云负载均衡器之后#

需要先声明可信代理网段,再使用 Real IP 模块恢复 $remote_addr

set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;

只有来自 set_real_ip_from 指定地址的替换信息才应被信任。ngx_http_realip_module 并非所有源码构建都默认启用,部署前应通过 nginx -V 检查编译参数;官方发行包通常会包含常用模块,但不能无条件假设所有自编译环境一致。

3.4 Spring Boot 如何使用转发头#

Spring Boot 应用在代理后运行时,外部协议、域名和端口可能与容器内信息不同。可配置:

server:
forward-headers-strategy: native

NATIVE 让底层 Web 容器处理常见转发头;FRAMEWORK 使用 Spring Framework 的过滤器或转换器。无论选择哪种方式,都应满足:

  • 应用实例不能被公网绕过代理直接访问;
  • 代理头只由可信入口生成;
  • Tomcat 等容器的受信任代理范围不能被配置成无边界信任。

3.5 proxy_set_header 的继承陷阱#

proxy_set_header 只有在当前层级没有定义任何同类指令时,才整体继承父级配置。

例如:

http {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
server {
location /api/ {
proxy_set_header X-Debug true;
proxy_pass http://backend;
}
}
}

很多人以为该 location 会继承前两个 Header,再追加 X-Debug。实际应把它理解为:当前 location 已经定义了 proxy_set_header,因此父级这一组不再自动完整继承。

生产环境通常把公共头写到一个 snippet 中,再在每个需要定制的 location 显式 include:

include /etc/nginx/snippets/proxy-headers.conf;
proxy_set_header X-Debug true;

四、超时不是一个参数,而是一条链#

4.1 代理请求的超时阶段#

请求从客户端到后端至少经过:

读取客户端请求头
→ 读取客户端请求体
→ 连接 upstream
→ 向 upstream 发送请求
→ 等待 upstream 返回数据
→ 向客户端发送响应

不同阶段对应不同超时。只修改 proxy_read_timeout,无法解决所有慢请求问题。

独立插图 04:Nginx 代理超时边界图
对应文件:04-proxy-timeout-boundaries.png

代理超时边界

4.2 proxy_connect_timeout#

proxy_connect_timeout 3s;

它限制 Nginx 与后端建立连接的时间,主要覆盖 TCP 握手以及必要的上游 TLS 建连阶段。它不包含后端业务执行时间。

常见触发原因:

  • 后端 IP 或端口不可达;
  • 网络策略或防火墙丢包;
  • 容器网络异常;
  • 后端连接队列耗尽;
  • 上游 DNS 解析到了不可达地址。

内网服务通常不需要 60 秒才确认“连接失败”。合理的连接超时通常应明显小于业务读取超时,但具体值必须依据网络和跨地域情况测量。

4.3 proxy_send_timeout#

proxy_send_timeout 30s;

它限制向 upstream 连续两次写操作之间的等待时间,不是完整请求体上传的总时间。

在以下场景可能触发:

  • 后端读取请求体非常慢;
  • 后端线程或事件循环阻塞,迟迟不从 Socket 读取;
  • 大文件上传时 upstream 出现背压;
  • 网络持续不可写。

4.4 proxy_read_timeout#

proxy_read_timeout 60s;

它限制从 upstream 连续两次读取操作之间的空闲时间,而不是整个响应允许持续的总时长。

例如,后端每 30 秒发送一个心跳,整个 SSE 连接持续 2 小时,也不一定触发 60 秒读取超时;如果后端连续 60 秒完全没有发送任何数据,则连接可能被关闭。

这是为什么:

  • 长轮询需要按最长静默时间设置;
  • WebSocket 需要 Ping/Pong 或应用心跳;
  • SSE 应定期发送注释心跳;
  • 不能简单把超时设置成一天来掩盖后端卡死。

4.5 客户端侧超时#

代理配置还常与以下指令共同决定请求能否完成:

  • client_header_timeout:读取客户端请求头;
  • client_body_timeout:读取客户端请求体;
  • send_timeout:向客户端连续发送数据之间的超时;
  • keepalive_timeout:客户端空闲持久连接的保留时间;
  • client_max_body_size:虽然不是时间,但会在进入业务前拒绝过大请求体。

4.6 超时设计原则#

生产超时应按业务类型分层:

接口类型连接超时读取超时说明
普通查询 API较短与应用 SLA 对齐超时后应尽快释放资源
报表导出较短可适当延长更推荐异步任务而不是无限等待
文件上传较短结合请求体读取与发送超时关注客户端慢传与后端背压
SSE较短大于心跳间隔需要定期事件或注释心跳
WebSocket较短大于 Ping/Pong 周期依赖应用层保活

一个常见错误是:应用自身 3 秒就会超时失败,但 Nginx 设置 300 秒;或者 Nginx 30 秒关闭连接,应用却继续执行 5 分钟。网关、代理、应用客户端、数据库查询应形成一致的超时预算。


五、缓冲:Nginx 为什么不一定立即转发#

5.1 请求缓冲#

默认情况下,proxy_request_buffering on。Nginx 会先读取客户端请求体,再把它发送给 upstream。

优点:

  • 慢客户端不会长时间占用后端连接;
  • 后端收到请求后可以较快读取完整请求体;
  • 在请求体尚未发送给 upstream 前,Nginx 仍有更多故障切换空间;
  • 可以把过慢的公网连接压力挡在入口层。

代价:

  • 大请求体可能写入临时文件;
  • 增加入口磁盘 I/O;
  • 后端开始处理的时间可能延后;
  • 真正的端到端流式上传无法实现。

关闭请求缓冲:

proxy_request_buffering off;

此时请求体会边读边发往后端。一旦请求体已经开始发送,通常不能安全地把同一请求切换到另一个 upstream。

5.2 响应缓冲#

默认 proxy_buffering on。Nginx 尽快从后端读取响应,先放入内存缓冲区,必要时写临时文件,然后按照客户端速度发送。

这能够隔离“快后端、慢客户端”:后端可以较早释放连接,而 Nginx 负责慢慢把数据发给用户。

关闭响应缓冲:

proxy_buffering off;

数据会尽量在收到后同步传给客户端,适用于:

  • SSE;
  • token 流式输出;
  • 部分实时日志;
  • 需要降低首字节后续分片延迟的流式接口。

但关闭缓冲意味着慢客户端更长时间占用 upstream 连接,所以不能把它作为全局默认。

独立插图 05:请求缓冲与响应缓冲对照图
对应文件:05-request-response-buffering.png

请求与响应缓冲机制

5.3 大文件上传不一定要关闭缓冲#

对普通文件上传,保留请求缓冲通常能更好地保护应用。只有在以下场景才更适合关闭:

  • 后端能够真正流式消费;
  • 文件不需要在入口完整落盘;
  • 已评估后端连接长时间占用;
  • 不依赖请求重试;
  • 对磁盘 I/O 与延迟的权衡明确。

还要同时检查:

client_max_body_size 100m;
client_body_timeout 60s;

413 Request Entity Too Large 往往在 Nginx 层就返回,后端没有任何日志。

5.4 临时文件不是“配置错了”#

当响应或请求体超过内存缓冲,Nginx 可能使用临时文件。这是设计的一部分,不必看到磁盘文件就立即关闭缓冲。

正确做法是评估:

  • 临时目录容量;
  • 容器是否有可写路径;
  • 文件系统性能;
  • 单个请求大小;
  • 高并发下总磁盘吞吐;
  • 是否应把大文件改为对象存储直传。

六、Keep-Alive:两端各有一套连接池#

6.1 客户端 Keep-Alive#

客户端 Keep-Alive 是浏览器或 API Client 与 Nginx 之间复用 TCP 连接。它由客户端协议、keepalive_timeout、最大请求数等控制。

6.2 upstream Keep-Alive#

upstream Keep-Alive 是 Nginx Worker 与后端服务之间复用连接。它与客户端连接一一对应的想法是错误的:

  • 多个不同客户端请求可能先后复用同一条 upstream 连接;
  • 一个客户端的多个请求可能被分配到不同 upstream 连接;
  • WebSocket 建立后会长期绑定一条代理隧道;
  • 每个 Worker 有自己的连接缓存和并发状态。

独立插图 06:客户端 Keep-Alive 与 upstream Keep-Alive
对应文件:06-client-vs-upstream-keepalive.png

客户端与上游 Keep-Alive 区别

6.3 当前版本的重要变化#

从 Nginx 1.29.7 开始,upstream HTTP 默认使用 HTTP/1.1,upstream keepalive 缓存也默认启用,每个 Worker 默认保留 32 条空闲连接。因此,在本文使用的 1.30.3 中,不再必须像旧教程那样为了基础复用显式写:

proxy_http_version 1.1;
proxy_set_header Connection "";

但是在需要兼容旧版本、强调配置意图或 WebSocket 升级时,仍可以显式设置。阅读网络教程时必须先判断其对应版本。

可显式配置:

upstream backend_pool {
server backend:8080;
keepalive 32;
keepalive_requests 1000;
keepalive_timeout 60s;
}

需要注意:

  • keepalive 32 是每个 Worker 缓存的最大空闲连接数;
  • 它不是 upstream 总连接数上限;
  • 并发活跃连接仍可能远大于 32;
  • 值过大可能挤占后端连接容量;
  • keepalive_requests 让连接周期性关闭,以释放每连接内存;
  • 长连接与大量 Worker 会放大总连接数。

6.4 连接复用与后端连接池#

后端通常还有自己的数据库连接池、HTTP Client 连接池和线程池。Nginx upstream 连接复用只能减少 TCP 建连开销,不能提高后端业务线程、数据库连接或 CPU 的上限。

调优时应同时观察:

Nginx Worker 数
× 每 Worker 活跃 upstream 连接
× 应用实例数
× 应用线程与数据库连接池

不要单独把 keepalive 值调大,然后期待吞吐线性提升。


七、WebSocket:从 HTTP 请求切换为双向隧道#

7.1 为什么普通代理配置不够#

WebSocket 首先通过 HTTP/1.1 发起握手,请求包含:

Upgrade: websocket
Connection: Upgrade

UpgradeConnection 属于 hop-by-hop Header,代理不会简单地把它们自动传递到下一跳,因此需要显式配置。

7.2 推荐配置#

map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
location /ws/ {
proxy_pass http://backend_pool;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 65s;
}
}

在 Nginx 1.30.3 中 proxy_http_version 默认已是 1.1,但显式写出有助于说明 WebSocket 的协议要求,并兼容旧版本配置迁移。

独立插图 07:WebSocket Upgrade 时序图
对应文件:07-websocket-upgrade-sequence.png

WebSocket Upgrade 时序

7.3 101 之后发生什么#

后端返回 101 Switching Protocols 后,Nginx 在客户端连接与 upstream 连接之间建立隧道。此后不再是普通“一次请求一次响应”的短交互,而是双向数据帧持续传输。

需要关注:

  • proxy_read_timeout 是两次读取之间的静默时间;
  • 后端应发送 Ping/Pong 或业务心跳;
  • Nginx reload 时旧 Worker 会等待已有连接结束,长期 WebSocket 会延长旧 Worker 存活;
  • 负载均衡后,每个连接建立时只选择一次实例;
  • 扩缩容不会把已建立的 WebSocket 自动迁移到新实例;
  • 应用发布前应设计优雅断连和客户端重连。

八、SSE 与流式响应#

8.1 SSE 为什么“后端打印了,浏览器却没收到”#

SSE 使用普通 HTTP 长连接,服务端持续发送:

data: message-1\n\n

如果 Nginx 保持响应缓冲,它可能等待积累更多数据后再发送,导致客户端不能实时看到事件。

8.2 Nginx 配置#

location /sse/ {
proxy_pass http://backend_pool;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 75s;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}

后端也可以返回:

X-Accel-Buffering: no
Cache-Control: no-cache
Content-Type: text/event-stream

Nginx 官方代理模块允许通过 X-Accel-Buffering 控制响应缓冲,除非使用 proxy_ignore_headers 禁用了该行为。

独立插图 08:SSE 流式传输与心跳模型
对应文件:08-sse-streaming-heartbeat.png

SSE 流式心跳链路

8.3 心跳设计#

如果 proxy_read_timeout 75s,应用可以每 20~30 秒发送一次注释心跳:

: heartbeat\n\n

心跳的价值包括:

  • 防止代理因长时间无数据关闭连接;
  • 让客户端更快发现网络断开;
  • 验证链路仍然可写;
  • 避免把读取超时设置成无限大。

8.4 SSE 与 WebSocket 的区别#

维度SSEWebSocket
方向主要是服务端到客户端双向
协议普通 HTTP 流HTTP 握手后升级
浏览器重连EventSource 原生支持应用自行实现
数据格式文本事件文本或二进制帧
Nginx 重点关闭响应缓冲、心跳Upgrade Header、心跳
常见场景AI token、通知、进度聊天、协作、实时控制

九、错误由谁生成:502、504、499 不要混为一谈#

9.1 502 Bad Gateway#

502 通常表示 Nginx 没有从 upstream 得到一个可用的 HTTP 响应,常见原因:

  • 连接被拒绝;
  • 后端进程未监听;
  • 容器服务名解析失败;
  • 后端提前关闭连接;
  • 后端返回无效响应头;
  • HTTP 请求被发到了 HTTPS 端口,或反之;
  • upstream TLS 校验或 SNI 配置错误。

典型 error log:

connect() failed (111: Connection refused) while connecting to upstream
upstream prematurely closed connection while reading response header from upstream

9.2 504 Gateway Timeout#

504 常见于:

  • 连接 upstream 超时;
  • 等待后端响应数据超时;
  • 后端一直没有发送响应头;
  • 网络黑洞导致连接或读取迟迟没有结果。

但必须结合 error log 判断是 connect timeout 还是 read timeout。

9.3 499 Client Closed Request#

499 是 Nginx 常用的非标准日志状态,表示客户端在 Nginx 返回响应前断开连接。可能原因:

  • 浏览器取消请求;
  • 上一层负载均衡器超时更短;
  • 客户端 SDK 主动超时;
  • 用户刷新页面;
  • 移动网络中断。

看到 499 不应直接归责于 Nginx。需要比较:

客户端超时 < 上层代理超时 < Nginx 超时 < 应用超时

如果上层 30 秒断开,而 Nginx 与应用都设置 60 秒,Nginx 会记录大量 499,应用可能仍继续计算。

9.4 413 Request Entity Too Large#

请求体超过 client_max_body_size 时,Nginx 可能直接返回 413,后端不会收到请求。排查上传接口必须先看入口层大小限制。

9.5 proxy_intercept_errors#

默认情况下,后端状态码通常会透传给客户端。启用:

proxy_intercept_errors on;
error_page 500 502 503 504 /50x.html;

Nginx 会对符合条件的响应执行内部错误页处理。使用时必须考虑:

  • API 客户端是否需要后端原始 JSON 错误;
  • 统一 HTML 错误页是否会破坏 API 协议;
  • 错误响应头是否需要保留;
  • 监控中如何区分后端生成与 Nginx 生成的状态码。

独立插图 09:代理错误归属与状态码路径
对应文件:09-proxy-error-ownership.png

代理错误归属判断


十、最小可运行实验#

10.1 实验目标#

本实验验证:

  1. 客户端直连后端与通过 Nginx 访问的 Header 差异;
  2. 三种 proxy_pass URI 写法;
  3. 普通 API 超时;
  4. 请求与响应缓冲;
  5. SSE 实时输出;
  6. WebSocket Upgrade;
  7. 413、502、504 与自定义错误页。

10.2 目录结构#

nginx-reverse-proxy-lab/
├── docker-compose.yml
├── backend/
│ ├── Dockerfile
│ ├── pom.xml
│ └── src/main/
│ ├── java/com/example/proxylab/
│ │ ├── ProxyLabApplication.java
│ │ ├── ApiController.java
│ │ └── WebSocketConfig.java
│ └── resources/
│ └── application.yml
└── nginx/
├── nginx.conf
├── conf.d/
│ └── proxy-lab.conf
├── snippets/
│ └── proxy-headers.conf
└── html/
└── 50x.html

10.3 docker-compose.yml#

services:
backend:
build:
context: ./backend
container_name: proxy-lab-backend
expose:
- "8080"
ports:
- "18080:8080"
networks:
- proxy-lab
nginx:
image: nginx:1.30.3-alpine
container_name: proxy-lab-nginx
depends_on:
- backend
ports:
- "8088:8088"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/snippets:/etc/nginx/snippets:ro
- ./nginx/html:/usr/share/nginx/html:ro
networks:
- proxy-lab
networks:
proxy-lab:
driver: bridge

depends_on 只控制容器启动顺序,不保证 Spring Boot 已完成就绪。生产环境应使用健康检查、重试或编排平台的 readiness 机制。

10.4 后端 pom.xml#

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.15</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>proxy-lab</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

10.5 后端 Dockerfile#

FROM maven:3.9.11-eclipse-temurin-21-alpine AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN mvn -q -DskipTests package
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /workspace/target/proxy-lab-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

10.6 application.yml#

server:
port: 8080
forward-headers-strategy: native
spring:
application:
name: proxy-lab

10.7 启动类#

package com.example.proxylab;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ProxyLabApplication {
public static void main(String[] args) {
SpringApplication.run(ProxyLabApplication.class, args);
}
}

10.8 API Controller#

package com.example.proxylab;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@RestController
public class ApiController {
@GetMapping({"/api/hello", "/hello", "/service/hello", "/keep-prefix/api/hello"})
public Map<String, Object> hello(HttpServletRequest request) {
return Map.of(
"message", "hello from Spring Boot",
"requestUri", request.getRequestURI(),
"query", String.valueOf(request.getQueryString()),
"time", Instant.now().toString()
);
}
@GetMapping("/api/headers")
public Map<String, Object> headers(HttpServletRequest request) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("remoteAddr", request.getRemoteAddr());
result.put("scheme", request.getScheme());
result.put("serverName", request.getServerName());
result.put("serverPort", request.getServerPort());
result.put("host", request.getHeader("Host"));
result.put("xRealIp", request.getHeader("X-Real-IP"));
result.put("xForwardedFor", request.getHeader("X-Forwarded-For"));
result.put("xForwardedProto", request.getHeader("X-Forwarded-Proto"));
return result;
}
@GetMapping("/api/slow")
public Map<String, Object> slow(@RequestParam(defaultValue = "3") long seconds)
throws InterruptedException {
Thread.sleep(seconds * 1000);
return Map.of("sleptSeconds", seconds);
}
@PostMapping(value = "/api/upload", consumes = MediaType.ALL_VALUE)
public Map<String, Object> upload(@RequestBody byte[] body) {
return Map.of("receivedBytes", body.length);
}
@GetMapping("/api/error")
public void error() {
throw new IllegalStateException("intentional error");
}
@GetMapping(value = "/api/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter sse() {
SseEmitter emitter = new SseEmitter(0L);
CompletableFuture.runAsync(() -> {
try {
for (int i = 1; i <= 5; i++) {
emitter.send(SseEmitter.event()
.name("message")
.data("event-" + i));
Thread.sleep(1000);
}
emitter.complete();
} catch (Exception ex) {
emitter.completeWithError(ex);
}
});
return emitter;
}
}

10.9 WebSocket 配置#

package com.example.proxylab;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.*;
import org.springframework.web.socket.config.annotation.*;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(new TextWebSocketHandler() {
@Override
protected void handleTextMessage(
WebSocketSession session,
TextMessage message) throws Exception {
session.sendMessage(new TextMessage("echo: " + message.getPayload()));
}
}, "/ws/echo").setAllowedOriginPatterns("*");
}
}

setAllowedOriginPatterns("*") 仅用于本地实验。生产 WebSocket 应限制 Origin,并结合认证鉴权。

10.10 nginx.conf#

user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format proxy_lab
'$remote_addr - $host "$request" status=$status '
'request_time=$request_time upstream_addr=$upstream_addr '
'upstream_status=$upstream_status connect=$upstream_connect_time '
'header=$upstream_header_time response=$upstream_response_time';
access_log /var/log/nginx/access.log proxy_lab;
sendfile on;
keepalive_timeout 65s;
include /etc/nginx/conf.d/*.conf;
}

10.11 公共 Header snippet#

文件:nginx/snippets/proxy-headers.conf

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;

实验环境中的 Nginx 直接面对客户端,所以覆盖 X-Forwarded-For,避免 curl 自带的伪造字段进入后端。

10.12 完整代理配置#

文件:nginx/conf.d/proxy-lab.conf

map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream backend_pool {
server backend:8080;
keepalive 32;
keepalive_requests 1000;
keepalive_timeout 60s;
}
server {
listen 8088;
server_name _;
client_max_body_size 2m;
# 1. 保留 /api/ 前缀
location /keep-prefix/ {
proxy_pass http://backend_pool;
include /etc/nginx/snippets/proxy-headers.conf;
}
# 2. 删除 /strip/ 前缀
location /strip/ {
proxy_pass http://backend_pool/;
include /etc/nginx/snippets/proxy-headers.conf;
}
# 3. 将 /replace/ 替换为 /service/
location /replace/ {
proxy_pass http://backend_pool/service/;
include /etc/nginx/snippets/proxy-headers.conf;
}
# 4. 普通 API
location /api/ {
proxy_pass http://backend_pool;
include /etc/nginx/snippets/proxy-headers.conf;
proxy_connect_timeout 3s;
proxy_send_timeout 30s;
proxy_read_timeout 10s;
}
# 5. SSE
location /sse/ {
rewrite ^/sse/(.*)$ /api/$1 break;
proxy_pass http://backend_pool;
include /etc/nginx/snippets/proxy-headers.conf;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 75s;
}
# 6. WebSocket
location /ws/ {
proxy_pass http://backend_pool;
include /etc/nginx/snippets/proxy-headers.conf;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 65s;
}
proxy_intercept_errors on;
error_page 502 503 504 /50x.html;
location = /50x.html {
internal;
root /usr/share/nginx/html;
}
}

10.13 错误页#

<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>Upstream unavailable</title></head>
<body><h1>后端服务暂时不可用</h1></body>
</html>

10.14 启动与检查#

Terminal window
docker compose build
docker compose up -d
docker compose exec nginx nginx -t
docker compose exec nginx nginx -T

10.15 验证普通代理与 Header#

直连:

Terminal window
curl -s http://localhost:18080/api/headers

通过 Nginx:

Terminal window
curl -s \
-H 'Host: api.example.test' \
-H 'X-Forwarded-For: 127.0.0.1' \
http://localhost:8088/api/headers

预期:后端看到 Host=api.example.test,而 X-Forwarded-For 被入口覆盖为实际连接地址,不会直接采用伪造的 127.0.0.1

10.16 验证 URI 转换#

Terminal window
curl -s http://localhost:8088/keep-prefix/api/hello
curl -s http://localhost:8088/strip/hello
curl -s http://localhost:8088/replace/hello

需要根据响应中的 requestUri 验证:

  • 第一条保留完整前缀;
  • 第二条去掉 /strip/
  • 第三条替换为 /service/

10.17 验证超时#

Terminal window
curl -v 'http://localhost:8088/api/slow?seconds=3'
curl -v 'http://localhost:8088/api/slow?seconds=12'

第二条超过 proxy_read_timeout 10s,应观察 Nginx 返回的网关超时以及 error log。

10.18 验证请求体大小#

Terminal window
head -c 1048576 </dev/zero | \
curl -s -X POST --data-binary @- http://localhost:8088/api/upload
head -c 3145728 </dev/zero | \
curl -v -X POST --data-binary @- http://localhost:8088/api/upload

第二次请求超过 2 MiB,预期由 Nginx 返回 413。

10.19 验证 SSE#

Terminal window
curl -N http://localhost:8088/sse/sse

-N 关闭 curl 自身输出缓冲。预期每秒看到一个事件,而不是最后一次性出现。

10.20 验证 WebSocket#

可以使用 websocat

Terminal window
websocat ws://localhost:8088/ws/echo

输入:

hello

预期:

echo: hello

10.21 查看日志#

Terminal window
docker compose logs -f nginx
docker compose logs -f backend

重点关注:

  • $status:Nginx 最终返回状态;
  • $upstream_status:后端或多次尝试状态;
  • $request_time:Nginx 处理总时长;
  • $upstream_connect_time:连接后端耗时;
  • $upstream_header_time:收到响应头耗时;
  • $upstream_response_time:upstream 整体响应耗时。

10.22 停止实验#

Terminal window
docker compose down -v

十一、配置逐项解释#

11.1 proxy_pass#

项目内容
上下文location,也可出现在部分条件上下文中
作用指定代理协议、地址和可选 URI
默认行为未配置时该 location 不会自动成为 HTTP 反向代理
核心风险URI 替换、变量解析、正则 location 限制
验证方式后端回显 requestUri、使用 access log

11.2 proxy_set_header#

项目内容
上下文httpserverlocation
作用重定义或追加发往 upstream 的请求头
默认行为HostConnection 会被重新定义
核心风险信任伪造转发头;同层定义后丢失父级整组继承
验证方式后端回显 Header

11.3 proxy_connect_timeout#

只控制建立 upstream 连接。连接快速成功后,业务执行多久与它无关。

11.4 proxy_send_timeout#

控制向 upstream 连续两次写之间的时间,不是整个上传总时长。

11.5 proxy_read_timeout#

控制从 upstream 连续两次读之间的时间,不是整个响应总时长。SSE 和 WebSocket 的心跳设计必须与它配合。

11.6 proxy_request_buffering#

开启时先在 Nginx 侧读取请求体;关闭时边读边向后端发送。关闭后,一旦开始发送请求体,重试能力会显著受限。

11.7 proxy_buffering#

开启时 Nginx 尽快读取 upstream 响应并缓冲;关闭时更接近实时透传。不要为所有 API 全局关闭。

11.8 proxy_intercept_errors#

开启后,符合 error_page 规则的 upstream 错误响应会由 Nginx 进行内部处理。API 与页面站点应分别评估。


十二、从开发配置升级到生产配置#

12.1 第一阶段:能代理#

location /api/ {
proxy_pass http://backend:8080;
}

只用于验证网络和路径。

12.2 第二阶段:补齐 Header#

location /api/ {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}

必须先明确 Nginx 是否为真正边界,以及上一层是否可信。

12.3 第三阶段:按接口设置超时#

proxy_connect_timeout 3s;
proxy_send_timeout 30s;
proxy_read_timeout 15s;

不同接口不应盲目共享一个极大超时。

12.4 第四阶段:结构化日志#

至少记录:

request_time
upstream_addr
upstream_status
upstream_connect_time
upstream_header_time
upstream_response_time
request_id

第七篇会系统展开日志和 Trace ID。

12.5 第五阶段:连接复用#

在确认后端连接容量后,配置或核验 upstream keepalive。当前新版本已有默认行为,但仍要结合 Worker 数和应用连接限制评估。

12.6 第六阶段:为流式接口单独建 location#

不要在全局关闭缓冲。将 SSE、WebSocket、上传、下载分别拆分配置,明确其超时和缓冲策略。

12.7 第七阶段:发布与回滚#

配置变更应遵循:

生成配置
→ nginx -t
→ nginx -T 留档
→ reload
→ 冒烟验证
→ 观察 4xx/5xx 与 upstream 指标
→ 异常时恢复旧配置并 reload

十三、常见错误与反例#

错误 1:误判 proxy_pass 尾部斜杠#

现象#

后端返回 404,应用日志显示路径比预期多了或少了前缀。

错误配置#

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

但后端实际 Controller 是 /api/**

根因#

/api/ 被替换为 /

修复#

proxy_pass http://backend;

验证#

让后端回显 requestUri,不要只看浏览器地址栏。

错误 2:把 proxy_read_timeout 当总请求时长#

现象#

一个持续输出 10 分钟的流式响应没有超时,误以为配置未生效。

根因#

它计算的是两次读取之间的静默间隔。

修复#

需要总时长限制时,应在应用层、API Gateway 或任务模型中实现明确 deadline。

错误 3:直接使用客户端 X-Forwarded-For#

现象#

日志出现 127.0.0.1、内网 IP 或任意伪造地址。

根因#

边界代理追加了不可信的客户端 Header,应用又读取了链首。

修复#

公网边界覆盖该字段;多层代理只信任明确网段并配置 Real IP。

错误 4:为所有 location 全局关闭缓冲#

现象#

后端连接数明显上升,慢客户端导致应用连接长期占用。

根因#

响应无法在 Nginx 侧快速吸收,upstream 与客户端速度直接耦合。

修复#

仅为 SSE、流式 token 等必要接口关闭。

错误 5:WebSocket 只配置 proxy_pass#

现象#

握手返回 400 或普通 HTTP 响应,没有 101。

根因#

Upgrade 与 Connection 没有显式传到 upstream。

修复#

使用 map 和两条 proxy_set_header

错误 6:上传失败只检查 Spring Boot#

现象#

后端没有日志,客户端收到 413。

根因#

请求在 Nginx 的 client_max_body_size 阶段被拒绝。

修复#

同步检查 CDN、云 LB、Ingress、Nginx 和应用各层大小限制。

错误 7:看到 502 就增加读取超时#

现象#

改大 proxy_read_timeout 后仍然 502。

根因#

502 可能是连接拒绝、协议错误或后端提前关闭,并非读取超时。

修复#

先读 error log,再检查端口、协议、DNS 和后端日志。

错误 8:把 keepalive 数当总连接上限#

现象#

配置 keepalive 32,但后端看到远多于 32 条连接。

根因#

该值限制每 Worker 缓存的空闲连接,不限制活跃连接总数。

修复#

结合 Worker 数、max_conns、应用线程池和监控评估。

错误 9:在正则 location 中携带 proxy_pass URI#

现象#

路径行为无法按前缀替换直觉推导,或配置被判定不允许。

根因#

正则 location 无法确定稳定替换片段。

修复#

使用不带 URI 的 proxy_pass,必要时先明确 rewrite。


十四、故障排查流程#

独立插图 10:反向代理端到端故障排查流程
对应文件:10-reverse-proxy-troubleshooting.png

反向代理故障排查流程

按照以下顺序排查:

14.1 客户端到 Nginx#

Terminal window
curl -v http://example.test/api/hello
curl --resolve example.test:8088:127.0.0.1 http://example.test:8088/api/hello

检查:

  • DNS 是否正确;
  • 端口是否监听;
  • 是否命中正确 server
  • Nginx access log 是否出现请求;
  • 上一层是否提前返回错误。

14.2 location 与 URI#

Terminal window
nginx -T

检查:

  • 实际加载的是哪份配置;
  • location 是否匹配预期;
  • proxy_pass 是否携带 URI;
  • 是否发生 rewrite 或内部跳转;
  • 后端回显的路径是什么。

14.3 Nginx 到 upstream#

在 Nginx 容器或主机内执行:

Terminal window
getent hosts backend
curl -v http://backend:8080/api/hello
ss -ntp

检查:

  • 名称解析;
  • 端口连通性;
  • HTTP/HTTPS 是否匹配;
  • 后端是否监听容器正确地址;
  • 网络策略和防火墙。

14.4 Header 与真实 IP#

让后端回显:

remoteAddr
Host
X-Real-IP
X-Forwarded-For
X-Forwarded-Proto

确认代理边界与应用信任模型一致。

14.5 时间字段#

字段解释
$request_timeNginx 从读取请求到日志记录的总时长
$upstream_connect_time建立 upstream 连接耗时
$upstream_header_time从连接到收到响应头的时间
$upstream_response_timeupstream 响应耗时

判断示例:

  • connect time 高:网络或连接队列;
  • header time 高:应用首字节慢;
  • upstream 很短而 request time 很长:客户端读取慢或 Nginx 后处理;
  • upstream 字段为空:请求可能未进入代理阶段。

14.6 状态码归属#

同时查看:

$status
$upstream_status
error_log
应用日志

$status=504$upstream_status=504$upstream_status 为空代表的链路不同。多 upstream 尝试时字段还可能出现逗号分隔的多个值,下一篇会继续展开。


十五、生产实践与能力边界#

15.1 哪些职责适合放在 Nginx#

适合:

  • 域名和路径入口;
  • TLS 终止;
  • 通用 Header 规范化;
  • 连接、超时和大小保护;
  • 静态代理规则;
  • 缓冲与基础流式代理;
  • WebSocket/SSE 转发;
  • 基础错误页和访问日志。

15.2 哪些职责不应强行塞进 Nginx#

不适合把复杂业务逻辑全部写成 Nginx 配置,例如:

  • 复杂权限模型;
  • 交易幂等;
  • 多步骤业务编排;
  • 动态用户画像路由;
  • 需要数据库查询的决策;
  • 复杂响应体业务改写。

这类能力更适合应用、API Gateway 或专门的策略服务。

15.3 Nginx 前后还可能有哪些层#

Client
→ CDN/WAF
→ Cloud Load Balancer
→ Nginx
→ API Gateway
→ Spring Boot

每多一层代理,就多一套:

  • 超时;
  • 请求体限制;
  • Header 信任;
  • 日志;
  • 重试;
  • TLS;
  • 状态码。

生产排障必须画出真实链路,不能只看 Nginx 单层。

15.4 配置决策表#

需求推荐做法不推荐做法
保留原 URIproxy_pass http://backend;依赖模糊 rewrite
去掉 location 前缀proxy_pass http://backend/;手工拼接多个变量
真实 IP明确信任代理并规范化直接信任客户端 XFF
普通 API保持响应缓冲全局 proxy_buffering off
SSE独立 location、关闭响应缓冲、心跳把读取超时设成无限
WebSocketUpgrade Header + 心跳只配置 proxy_pass
大文件上传先评估缓冲与对象存储直传无条件关闭请求缓冲
超时按 SLA 分层预算所有值统一设置 300s
错误页页面与 API 分开设计把所有错误改为 HTML 200

十六、面试与复习问题#

16.1 核心问题#

1. Nginx 反向代理为什么是两条连接?#

因为 Nginx 终止客户端 HTTP 连接,并作为新的 HTTP 客户端向 upstream 发起请求。两端协议、Header、超时和连接复用分别管理。

2. proxy_pass http://backend;proxy_pass http://backend/; 有什么区别?#

前者不携带 URI,通常保留完整请求 URI;后者携带 /,会用 / 替换与前缀 location 匹配的部分。

3. proxy_read_timeout 是整个请求最大时长吗?#

不是,它是连续两次从 upstream 读取数据之间的最大静默时间。

4. 为什么 WebSocket 需要显式传递 Upgrade?#

因为 Upgrade 和 Connection 是 hop-by-hop Header,不会自动原样传递到下一跳。

5. 为什么后端默认看到 Nginx IP?#

因为后端 TCP 连接的直接对端是 Nginx。真实用户地址需要通过可信 Header 或 PROXY Protocol 传递。

6. $proxy_add_x_forwarded_for 一定安全吗?#

不一定。它会在已有字段后追加 $remote_addr;如果已有字段来自不可信公网客户端,就可能保留伪造值。边界代理应覆盖或先规范化。

7. 请求缓冲的主要价值是什么?#

隔离慢客户端与后端,避免后端连接长时间等待公网请求体,同时为部分故障切换提供空间。

8. 响应缓冲的主要价值是什么?#

让 Nginx 尽快读取后端响应并释放 upstream,使慢客户端主要占用 Nginx 资源而不是应用连接。

9. keepalive 32 是否限制 upstream 总连接数为 32?#

否,它主要限制每 Worker 保存的空闲 keepalive 连接缓存数量,不限制活跃连接总数。

10. 502 与 504 的核心区别是什么?#

502 更偏向没有得到有效 upstream 响应,如连接拒绝、协议错误、提前关闭;504 表示等待 upstream 的连接或响应超过超时。最终仍应以 error log 为准。

16.2 场景题#

场景 1:后端 Controller 是 /api/users,Nginx 配置 location /api/ { proxy_pass http://backend/; },为什么 404?#

Nginx 删除了 /api/ 前缀,后端收到 /users。应去掉 proxy_pass 尾部 /,或者调整后端路径。

场景 2:SSE 五秒后一次性显示五条消息,怎么排查?#

检查 Nginx proxy_buffering、后端是否返回 X-Accel-Buffering: no、客户端是否自身缓冲、压缩和其他上层代理是否缓冲。

场景 3:大量 499,但后端平均耗时 45 秒,客户端 SDK 超时 30 秒,问题在哪?#

客户端先放弃,Nginx 记录 499。需要统一超时预算或将任务改为异步,而不是仅增加 Nginx 超时。

场景 4:应用根据 request.getScheme() 生成 HTTP 回调地址,但用户访问的是 HTTPS,如何处理?#

入口传递 X-Forwarded-Proto $scheme,应用启用可信转发头处理,并确保应用只能从可信代理访问。

场景 5:配置 keepalive 64 后后端连接数仍然很高,为什么?#

该值不是并发连接上限;多 Worker、活跃请求和长连接都会增加连接数。应结合 Worker 数、后端容量和 max_conns 分析。

16.3 配置排错题#

题 1#

location ~ ^/api/ {
proxy_pass http://backend/service/;
}

问题:正则 location 中不应依赖带 URI 的 proxy_pass 做前缀替换。应使用不带 URI 的地址,并用明确 rewrite 或改为前缀 location。

题 2#

location /ws/ {
proxy_pass http://backend;
proxy_set_header Upgrade $http_upgrade;
}

问题:缺少 Connection Upgrade 处理。应使用 map 生成 $connection_upgrade 并显式设置。

题 3#

http {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
location /api/ {
proxy_set_header X-App demo;
proxy_pass http://backend;
}
}

问题:示例层级本身还缺少 server,且 location 定义自己的 proxy_set_header 后,不应假设父级整组继续继承。应 include 公共 Header snippet。


十七、本文总结#

本文把反向代理从一条 proxy_pass 指令拆成了完整请求链路:

  1. 客户端到 Nginx 与 Nginx 到后端是两条独立连接;
  2. proxy_pass 是否携带 URI 决定 location 前缀是否被替换;
  3. Host 与转发头必须显式设计,真实 IP 的本质是信任边界;
  4. 连接、发送、读取超时分别对应不同阶段;
  5. 请求缓冲保护后端免受慢客户端影响,响应缓冲保护后端免受慢下载影响;
  6. upstream Keep-Alive 与客户端 Keep-Alive 是两套独立机制;
  7. WebSocket 需要协议升级 Header 与心跳;
  8. SSE 需要关闭响应缓冲并设计静默心跳;
  9. 502、504、499、413 必须结合 $upstream_* 字段与 error log 判断归属。

下一篇将把单后端扩展为多个 Spring Boot 实例,重点讲 upstream 负载均衡、权重、被动故障判断、主动健康检查能力边界、连接复用和非幂等请求重试风险。


参考资料#

  1. NGINX Download:稳定版与主线版,访问日期 2026-07-06
    https://nginx.org/en/download.html
  2. NGINX ngx_http_proxy_module 官方文档,访问日期 2026-07-06
    https://nginx.org/en/docs/http/ngx_http_proxy_module.html
  3. NGINX WebSocket Proxying 官方文档,访问日期 2026-07-06
    https://nginx.org/en/docs/http/websocket.html
  4. NGINX ngx_http_upstream_module 官方文档,访问日期 2026-07-06
    https://nginx.org/en/docs/http/ngx_http_upstream_module.html
  5. NGINX ngx_http_realip_module 官方文档,访问日期 2026-07-06
    https://nginx.org/en/docs/http/ngx_http_realip_module.html
  6. F5 NGINX Reverse Proxy Admin Guide,访问日期 2026-07-06
    https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/
  7. Spring Boot:Running Behind a Front-end Proxy Server,访问日期 2026-07-06
    https://docs.spring.io/spring-boot/how-to/webserver.html
  8. Spring Boot System Requirements,访问日期 2026-07-06
    https://docs.spring.io/spring-boot/system-requirements.html

文章验收清单#

内容完整性#

  • 说明反向代理的架构问题与两条连接;
  • 详细推导 proxy_pass URI 转换;
  • 解释转发头和真实 IP 信任边界;
  • 区分连接、发送、读取超时;
  • 覆盖请求与响应缓冲;
  • 覆盖 upstream Keep-Alive;
  • 覆盖 WebSocket 与 SSE;
  • 提供完整 Docker Compose + Spring Boot 实验;
  • 提供生产演进与排错流程;
  • 提供面试题、场景题与配置排错题。

技术准确性#

  • 以 Nginx 1.30.3 为主要实验版本;
  • 标注 1.29.7 后 upstream HTTP/Keep-Alive 默认行为变化;
  • 未把 proxy_read_timeout 写成总时长;
  • 未直接信任客户端 X-Forwarded-For
  • 未把 Keep-Alive 缓存数写成总连接上限;
  • 未对所有接口全局关闭缓冲;
  • WebSocket 配置包含 Upgrade 与 Connection;
  • 正则 location 未使用错误的 URI 替换模型。

图片交付说明#

本文不嵌入图片文件,仅保留 10 个独立插图位置。PNG 图片在单独的图片包中交付,文件名与文中标记一一对应。

第 3 篇:Nginx 反向代理与后端服务转发
https://jupiter-ws.cn/posts/backend/nginx/03_nginx_reverse_proxy_backend_forwarding/
作者
Jupiter
发布于
2026-07-06
许可协议
CC BY-NC-SA 4.0