第 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 为什么经常位于后端服务之前”的架构问题;第二篇建立了配置模型,讲清请求如何命中 server 和 location。从本篇开始,我们进入 Nginx 最常见、也最容易出现隐蔽错误的能力:反向代理。
反向代理不是“把请求地址换一下”这么简单。一个请求通过 Nginx 到达 Spring Boot,至少涉及两条独立连接、两套请求头、URI 转换、请求体转发、响应缓冲、超时控制以及错误归属:
客户端连接:Client <------ HTTP/TCP ------> Nginx上游连接:Nginx <------ HTTP/TCP ------> Spring Boot这两条连接由 Nginx 分别管理。客户端保持长连接,不代表 Nginx 与后端也在复用同一条连接;客户端使用 HTTPS,不代表 Nginx 到后端一定也是 HTTPS;客户端传入 Host 和 X-Forwarded-For,也不代表这些值应该原样交给应用。
本篇将把反向代理链路完整拆开。下一篇将在此基础上把单个后端扩展为多个实例,继续讲 upstream、负载均衡、故障判断和重试边界。
独立插图 01:反向代理双连接模型
对应文件:01-reverse-proxy-dual-connection.png

学习目标
学完本文后,你应当能够:
- 解释客户端连接与 upstream 连接为什么是两条独立连接;
- 根据
location与proxy_pass的尾部 URI,准确推导后端最终收到的路径; - 区分原始请求 URI、规范化 URI、查询参数与转发 URI;
- 正确设置
Host、X-Real-IP、X-Forwarded-For、X-Forwarded-Proto; - 解释为什么不能直接信任公网客户端提供的转发头;
- 区分连接超时、请求发送超时和响应读取超时;
- 解释
proxy_read_timeout为什么不是“整个请求最大执行时间”; - 理解请求缓冲和响应缓冲分别保护哪一侧;
- 正确配置大文件上传、流式下载和 SSE;
- 区分客户端 Keep-Alive 与 upstream Keep-Alive;
- 配置 WebSocket Upgrade 头与长连接超时;
- 根据日志判断 400、413、499、502、504 分别可能由谁产生;
- 独立搭建一个包含普通代理、URI 改写、SSE、WebSocket 和上传接口的实验环境;
- 建立一套从客户端、Nginx 到后端应用的端到端排查路径。
一、反向代理到底代理了什么
1.1 Nginx 不是透明的“网络转发线”
HTTP 反向代理工作在应用层。Nginx 会解析客户端 HTTP 请求,然后以 HTTP 客户端的身份向后端重新发起请求。后端并不是在读取客户端原来的 TCP 连接,而是在读取 Nginx 新建或复用的 upstream 连接。
一次代理请求可以抽象为:
1. 客户端与 Nginx 建立 TCP/TLS 连接2. 客户端向 Nginx 发送 HTTP 请求3. Nginx 选择 server 与 location4. Nginx 计算 upstream 地址和转发 URI5. Nginx 生成一份发往后端的新请求6. Nginx 新建或复用到后端的连接7. 后端处理请求并向 Nginx 返回响应8. Nginx 根据缓冲、过滤、错误页等配置处理响应9. Nginx 将响应发送给客户端因此,代理链路里经常同时存在以下差异:
| 维度 | 客户端到 Nginx | Nginx 到后端 |
|---|---|---|
| 协议 | HTTPS / HTTP/2 / HTTP/3 | HTTP/1.1、HTTP/2 或 HTTPS |
| 远端地址 | 客户端或上一层代理 | Nginx 所在主机或容器 |
| Host | 用户访问域名 | 默认可能变为 $proxy_host |
| Connection | 由客户端协商 | 由 Nginx 重新生成 |
| 超时 | client_*、send_timeout | proxy_*_timeout |
| 连接复用 | 客户端 Keep-Alive | upstream 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需要区分:
- 原始请求 URI:
/api/users/42?detail=true; - 规范化后的路径部分:
/api/users/42; - 查询参数:
detail=true。
location 主要匹配规范化路径;proxy_pass 的 URI 替换也基于规范化后的请求 URI。查询参数通常会继续保留,除非重写规则显式生成了新的参数或清除了参数。
独立插图 02:
proxy_passURI 转换对照图
对应文件:02-proxy-pass-uri-transform.png

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=trueNginx 将规范化 URI 中与 location /api/ 匹配的部分替换为 proxy_pass 中的 /。
可以理解为:
/api/ + users/42替换为/ + users/42=/users/422.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 ... break 与 proxy_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:
| location | proxy_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,通常是错误配置 |
| 正则 location | http://backend | 完整规范化 URI |
| 命名 location | http://backend | 当前完整 URI |
三、请求头:后端看到的是谁
3.1 默认行为不是“全部原样透传”
Nginx 会转发大部分客户端请求头,但默认会重定义部分 hop-by-hop 或代理相关字段。当前官方文档说明,默认代理请求中的 Host 会设置为 $proxy_host,Connection 会被设置为 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: nativeNATIVE 让底层 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

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: websocketConnection: UpgradeUpgrade 和 Connection 属于 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

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: noCache-Control: no-cacheContent-Type: text/event-streamNginx 官方代理模块允许通过 X-Accel-Buffering 控制响应缓冲,除非使用 proxy_ignore_headers 禁用了该行为。
独立插图 08:SSE 流式传输与心跳模型
对应文件:08-sse-streaming-heartbeat.png

8.3 心跳设计
如果 proxy_read_timeout 75s,应用可以每 20~30 秒发送一次注释心跳:
: heartbeat\n\n心跳的价值包括:
- 防止代理因长时间无数据关闭连接;
- 让客户端更快发现网络断开;
- 验证链路仍然可写;
- 避免把读取超时设置成无限大。
8.4 SSE 与 WebSocket 的区别
| 维度 | SSE | WebSocket |
|---|---|---|
| 方向 | 主要是服务端到客户端 | 双向 |
| 协议 | 普通 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 upstreamupstream prematurely closed connection while reading response header from upstream9.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 实验目标
本实验验证:
- 客户端直连后端与通过 Nginx 访问的 Header 差异;
- 三种
proxy_passURI 写法; - 普通 API 超时;
- 请求与响应缓冲;
- SSE 实时输出;
- WebSocket Upgrade;
- 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.html10.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: bridgedepends_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 buildWORKDIR /workspaceCOPY pom.xml .COPY src ./srcRUN mvn -q -DskipTests package
FROM eclipse-temurin:21-jre-alpineWORKDIR /appCOPY --from=build /workspace/target/proxy-lab-0.0.1-SNAPSHOT.jar app.jarEXPOSE 8080ENTRYPOINT ["java", "-jar", "/app/app.jar"]10.6 application.yml
server: port: 8080 forward-headers-strategy: native
spring: application: name: proxy-lab10.7 启动类
package com.example.proxylab;
import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplicationpublic 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;
@RestControllerpublic 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@EnableWebSocketpublic 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 启动与检查
docker compose builddocker compose up -d
docker compose exec nginx nginx -tdocker compose exec nginx nginx -T10.15 验证普通代理与 Header
直连:
curl -s http://localhost:18080/api/headers通过 Nginx:
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 转换
curl -s http://localhost:8088/keep-prefix/api/hellocurl -s http://localhost:8088/strip/hellocurl -s http://localhost:8088/replace/hello需要根据响应中的 requestUri 验证:
- 第一条保留完整前缀;
- 第二条去掉
/strip/; - 第三条替换为
/service/。
10.17 验证超时
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 验证请求体大小
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
curl -N http://localhost:8088/sse/sse-N 关闭 curl 自身输出缓冲。预期每秒看到一个事件,而不是最后一次性出现。
10.20 验证 WebSocket
可以使用 websocat:
websocat ws://localhost:8088/ws/echo输入:
hello预期:
echo: hello10.21 查看日志
docker compose logs -f nginxdocker compose logs -f backend重点关注:
$status:Nginx 最终返回状态;$upstream_status:后端或多次尝试状态;$request_time:Nginx 处理总时长;$upstream_connect_time:连接后端耗时;$upstream_header_time:收到响应头耗时;$upstream_response_time:upstream 整体响应耗时。
10.22 停止实验
docker compose down -v十一、配置逐项解释
11.1 proxy_pass
| 项目 | 内容 |
|---|---|
| 上下文 | location,也可出现在部分条件上下文中 |
| 作用 | 指定代理协议、地址和可选 URI |
| 默认行为 | 未配置时该 location 不会自动成为 HTTP 反向代理 |
| 核心风险 | URI 替换、变量解析、正则 location 限制 |
| 验证方式 | 后端回显 requestUri、使用 access log |
11.2 proxy_set_header
| 项目 | 内容 |
|---|---|
| 上下文 | http、server、location |
| 作用 | 重定义或追加发往 upstream 的请求头 |
| 默认行为 | Host 与 Connection 会被重新定义 |
| 核心风险 | 信任伪造转发头;同层定义后丢失父级整组继承 |
| 验证方式 | 后端回显 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_timeupstream_addrupstream_statusupstream_connect_timeupstream_header_timeupstream_response_timerequest_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
curl -v http://example.test/api/hellocurl --resolve example.test:8088:127.0.0.1 http://example.test:8088/api/hello检查:
- DNS 是否正确;
- 端口是否监听;
- 是否命中正确
server; - Nginx access log 是否出现请求;
- 上一层是否提前返回错误。
14.2 location 与 URI
nginx -T检查:
- 实际加载的是哪份配置;
location是否匹配预期;proxy_pass是否携带 URI;- 是否发生 rewrite 或内部跳转;
- 后端回显的路径是什么。
14.3 Nginx 到 upstream
在 Nginx 容器或主机内执行:
getent hosts backendcurl -v http://backend:8080/api/helloss -ntp检查:
- 名称解析;
- 端口连通性;
- HTTP/HTTPS 是否匹配;
- 后端是否监听容器正确地址;
- 网络策略和防火墙。
14.4 Header 与真实 IP
让后端回显:
remoteAddrHostX-Real-IPX-Forwarded-ForX-Forwarded-Proto确认代理边界与应用信任模型一致。
14.5 时间字段
| 字段 | 解释 |
|---|---|
$request_time | Nginx 从读取请求到日志记录的总时长 |
$upstream_connect_time | 建立 upstream 连接耗时 |
$upstream_header_time | 从连接到收到响应头的时间 |
$upstream_response_time | upstream 响应耗时 |
判断示例:
- connect time 高:网络或连接队列;
- header time 高:应用首字节慢;
- upstream 很短而 request time 很长:客户端读取慢或 Nginx 后处理;
- upstream 字段为空:请求可能未进入代理阶段。
14.6 状态码归属
同时查看:
$status$upstream_statuserror_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 配置决策表
| 需求 | 推荐做法 | 不推荐做法 |
|---|---|---|
| 保留原 URI | proxy_pass http://backend; | 依赖模糊 rewrite |
| 去掉 location 前缀 | proxy_pass http://backend/; | 手工拼接多个变量 |
| 真实 IP | 明确信任代理并规范化 | 直接信任客户端 XFF |
| 普通 API | 保持响应缓冲 | 全局 proxy_buffering off |
| SSE | 独立 location、关闭响应缓冲、心跳 | 把读取超时设成无限 |
| WebSocket | Upgrade 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 指令拆成了完整请求链路:
- 客户端到 Nginx 与 Nginx 到后端是两条独立连接;
proxy_pass是否携带 URI 决定 location 前缀是否被替换;Host与转发头必须显式设计,真实 IP 的本质是信任边界;- 连接、发送、读取超时分别对应不同阶段;
- 请求缓冲保护后端免受慢客户端影响,响应缓冲保护后端免受慢下载影响;
- upstream Keep-Alive 与客户端 Keep-Alive 是两套独立机制;
- WebSocket 需要协议升级 Header 与心跳;
- SSE 需要关闭响应缓冲并设计静默心跳;
- 502、504、499、413 必须结合
$upstream_*字段与 error log 判断归属。
下一篇将把单后端扩展为多个 Spring Boot 实例,重点讲 upstream 负载均衡、权重、被动故障判断、主动健康检查能力边界、连接复用和非幂等请求重试风险。
参考资料
- NGINX Download:稳定版与主线版,访问日期 2026-07-06
https://nginx.org/en/download.html - NGINX
ngx_http_proxy_module官方文档,访问日期 2026-07-06
https://nginx.org/en/docs/http/ngx_http_proxy_module.html - NGINX WebSocket Proxying 官方文档,访问日期 2026-07-06
https://nginx.org/en/docs/http/websocket.html - NGINX
ngx_http_upstream_module官方文档,访问日期 2026-07-06
https://nginx.org/en/docs/http/ngx_http_upstream_module.html - NGINX
ngx_http_realip_module官方文档,访问日期 2026-07-06
https://nginx.org/en/docs/http/ngx_http_realip_module.html - F5 NGINX Reverse Proxy Admin Guide,访问日期 2026-07-06
https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/ - Spring Boot:Running Behind a Front-end Proxy Server,访问日期 2026-07-06
https://docs.spring.io/spring-boot/how-to/webserver.html - Spring Boot System Requirements,访问日期 2026-07-06
https://docs.spring.io/spring-boot/system-requirements.html
文章验收清单
内容完整性
- 说明反向代理的架构问题与两条连接;
- 详细推导
proxy_passURI 转换; - 解释转发头和真实 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 图片在单独的图片包中交付,文件名与文中标记一一对应。