2023 年我维护一个内部 API 服务用 Nginx 做反向代理前面挂着多个上游应用上线一年多一直很稳直到某次产品要做个大功能上线灰度需要按用户 ID 把 10% 的流量切到新版本服务我心里很笃定 Nginx 配置嘛简单加几个 upstream 加个 split_clients 切流量就行可等真把这套上线一串问题冒了出来第一种最先把我打懵新版本上线后旧版本依然有零星 504 客户端报"upstream timed out"我以为是后端代码问题去查后端日志一切正常请求根本没到后端第二种最难缠切流量改完 nginx -s reload 看监控部分用户的 session 突然乱了刚才是 A 用户的请求路由到了 B 用户的会话第三种最离谱我配置了 proxy_pass + proxy_set_header Host $host 域名又改了一次 SSL 证书后端拿到的 Host 头部居然是错的导致后端的 cookie 域名都设错第四种最莫名其妙某天凌晨流量突然飙高 Nginx 日志里冒出大量 499 状态码我从来没见过这个状态码翻文档才知道是 Nginx 特有的"客户端主动断开"我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为 Nginx 配置无非就是 location proxy_pass 几行配置好就行可这个认知是错的Nginx 的每一个 directive 背后都有一连串"默认值会出什么事"的隐含语义本文从头梳理 proxy_pass 的尾斜杠玄机超时与 keepalive 的多层配置 upstream 负载均衡的隐藏坑 504 502 499 状态码的真实含义灰度发布与 reload 的安全做法以及一些把 Nginx 反代做扎实要避开的工程坑
问题背景
Nginx 是后端最常见的反向代理,几乎每个有点规模的服务都会用。但用得"对"和用得"能跑"差得很远。很多团队的 Nginx 配置是早期某个工程师抄一份网上的模板调通了,后面就一直用,从没真正读过每一行 directive 背后的语义。这种"借来的配置"在小流量时能用,流量一上来就开始冒怪事。常见的几类:
- 路径丢失或重复:proxy_pass 的尾斜杠一字之差,后端拿到的 URI 不同,接口直接 404 或路径双倍。
- 超时层层不一致:客户端、Nginx、上游各自的超时配置不协调,出现"上游已经处理完,Nginx 已经超时返回"的诡异 502。
- 会话/状态错乱:反代没做会话保持或 hash 策略,同一用户在不同上游间跳,session 失效。
- 状态码迷雾:504 / 502 / 499 / 408 / 444 含义各异,不分清就只能瞎猜。
- reload 不"零停机":大量长连接 + reload 不当,会让正在进行的请求被切断。
一、proxy_pass 的尾斜杠玄机:URL 路径是怎么传给上游的
这一个坑是新手到老手都会偶尔栽进去的,因为它的逻辑反直觉。Nginx 的 proxy_pass 根据"是否带 URI"分成两种行为,这两种行为在请求路径转发上有本质区别。
proxy_pass 行为对照表(关键:URL 中有没有路径)
location /api/ {
proxy_pass http://backend; # 不带 URI 形式
}
请求: GET /api/users?id=1
传递: GET /api/users?id=1 (原样转发,路径不变)
location /api/ {
proxy_pass http://backend/; # 带 URI 形式(连斜杠都算)
}
请求: GET /api/users?id=1
传递: GET /users?id=1 (location 前缀被替换为 /)
location /api/ {
proxy_pass http://backend/v2/; # 带具体路径
}
请求: GET /api/users?id=1
传递: GET /v2/users?id=1 (location 前缀被替换为 /v2/)
location ~ ^/api/(.*)$ {
proxy_pass http://backend/$1; # 正则 location + 带 URI
}
请求: GET /api/users?id=1
传递: GET /users?id=1 (用捕获组明确指定)
这套规则的核心可以记成一句话:proxy_pass 后面带 URI(哪怕只是个 / )就替换 location 匹配前缀,不带 URI 就保持原路径。这个差别在调试阶段最容易藏起来,因为"看起来都能通"——首页能打开,接口能调通,可某个深层路径请求过来就 404,后端日志显示拿到的 URI 不对。
另一个相关的坑是 location 匹配模式。Nginx 的 location 有前缀匹配、精确匹配、正则匹配三种,优先级是 = (精确) > ^~ (前缀且不再走正则) > 正则 (按出现顺序) > 普通前缀。同一个 server 块里写多个 location 的话,匹配顺序非常关键,新手常常配出"为什么我那个新 location 没生效"的疑问,九成是被上面某个高优先级 location 截胡了。
下面是一个生产里推荐的、能避开尾斜杠歧义的写法骨架:
server {
listen 443 ssl http2;
server_name api.example.com;
# 静态资源:精确匹配优先
location = /favicon.ico {
access_log off;
root /var/www/static;
expires 30d;
}
# 健康检查:不走任何代理
location = /healthz {
access_log off;
return 200 "ok\n";
}
# 业务 API:正则 location + 显式路径,避开尾斜杠歧义
location ~ ^/api/v1/(.*)$ {
proxy_pass http://backend_v1/$1$is_args$args;
include proxy_common.conf;
}
location ~ ^/api/v2/(.*)$ {
proxy_pass http://backend_v2/$1$is_args$args;
include proxy_common.conf;
}
# 兜底:让未匹配的 /api/* 返回明确错误,而不是 404
location /api/ {
return 404 '{"error":"unknown_api_version"}';
default_type application/json;
}
}
认知翻转:proxy_pass 的尾斜杠不是"格式喜好",是改变行为的关键参数。生产 Nginx 配置里强烈建议每个 proxy_pass 都用"正则 location + 捕获组"的方式显式拼路径,而不是依赖"location 前缀截取规则"。代价是配置稍微长一点,收益是任何人看一眼配置都能立刻知道"后端会拿到什么 URI",不需要在脑子里反推 Nginx 的尾斜杠算法。
二、超时与 keepalive:每一层都要"算到一起"
反向代理场景里超时是一个多层叠加的问题:客户端→Nginx→上游,每一层都有自己的超时,而且默认值往往跟你想象的不一样。一旦某层的超时短于其上游层,就会出现"上游还在跑,代理已经超时"的 502。一组关键超时参数和它们的默认值:
# 客户端 <-> Nginx 这一段
client_body_timeout 60s; # 读 body 单次的超时
client_header_timeout 60s; # 读 header 单次的超时
keepalive_timeout 75s; # 客户端长连接空闲超时
send_timeout 60s; # Nginx 向客户端发响应单次超时
client_max_body_size 1m; # 这其实不是超时,是大小,但常常一起忘配
# Nginx <-> 上游这一段
proxy_connect_timeout 60s; # 连接上游的超时
proxy_send_timeout 60s; # 把请求 body 发给上游的单次超时
proxy_read_timeout 60s; # 读上游响应的单次超时(默认值!)
proxy_next_upstream_timeout 0; # 失败时切换下一个 upstream 的总超时
# upstream 内部连接复用
upstream backend {
server 10.0.0.1:8080 max_fails=3 fail_timeout=30s;
keepalive 64; # 到上游的长连接池大小,默认是不复用
keepalive_timeout 60s; # 上游长连接空闲超时
keepalive_requests 1000; # 单个长连接最大请求数
}
真实生产中超时配置的几个常见错误:第一,后端某个接口需要 90 秒(比如生成大报表),你没改 proxy_read_timeout 还是默认 60 秒,后端正常返回但 Nginx 已经超时给客户端 504;第二,客户端是手机网络,上传大文件时 client_body_timeout 太短,客户端总是上传到一半被切断;第三,upstream 没配 keepalive 块,每次请求都建立新的 TCP 连接,QPS 高时把上游端口耗尽出现 connection refused;第四,client_max_body_size 默认 1MB,用户上传大文件时直接 413,前端给的错误提示常常莫名其妙。
超时配置的一个工程心法是:"客户端超时 ≥ Nginx 超时 + 缓冲,Nginx 超时 ≥ 上游处理时间 + 缓冲"。如果你的上游某个接口最长可能跑 60 秒,proxy_read_timeout 至少要 70 秒,客户端那一边的请求超时(比如 axios timeout)至少要 80 秒。否则的话,客户端先超时主动断开,留下 Nginx 还在等上游,而 Nginx 日志里看到的是 499——这就是开头我提到的那个状态码。
认知翻转:超时不是"设大一点都没事"的参数。设得太大,上游一个慢请求会卡住 Nginx 的 worker connection,堆积起来 Nginx 自己就先扛不住;设得太小,正常请求会被误杀。正确做法是按业务接口拆分超时——给报表接口单独配长超时,给秒级 API 配短超时,在 location 块里分别 override 默认值,而不是在 server 块里用一个值覆盖所有接口。一刀切的超时配置永远是错的。
三、负载均衡策略:轮询、最少连接、IP/UID Hash 的取舍
upstream 默认的策略是 round-robin(轮询),所有上游平均分配。这个策略简单可靠,但忽略了上游的负载差异——某台上游正在跑慢请求,新请求继续按轮询过去,会越积越多。Nginx 提供了几种策略,各有取舍:
upstream 负载均衡策略对照
round-robin (默认):
机制: 依次分配,可配 weight
优点: 简单可靠,无状态
缺点: 不考虑实际负载,慢请求堆积
适用: 上游同质 + 请求处理时间均匀
least_conn:
机制: 选当前活跃连接数最少的上游
优点: 自适应负载,慢请求不会堆积
缺点: 短连接场景效果不明显
适用: 上游处理时间差异大,长连接为主
ip_hash:
机制: 按客户端 IP 哈希,固定到某台上游
优点: 同一 IP 总落到同一上游(原生会话保持)
缺点: NAT/代理后大量用户共享一个 IP 会失衡
某台上游下线时,落它的客户都会被重哈希
适用: 老系统强依赖 session 黏性
hash $arg_uid consistent:
机制: 按指定 key 一致性哈希
优点: key 灵活(用户 ID、订单 ID 都行),consistent
让节点变动时只少量 key 重映射
缺点: key 必须能从请求里取到
适用: 缓存上游、分片上游、需要会话黏性
random two least_conn:
机制: 随机选两台,从两台里选连接数少的
优点: 比 least_conn 更分散,适合集群大的场景
缺点: 不如纯 least_conn 准
适用: 上游数量 > 50 的大集群
选策略最常见的错误是:无脑用 ip_hash 当"会话保持"。NAT 时代很多用户共享一个出口 IP,某个公司里几千号人都从同一个 IP 出来,ip_hash 会把所有请求都打到同一台后端,负载严重不均。正确做法是用 hash + 你能拿到的用户标识(比如 cookie 里的 uid、URL 里的 token),并加上 consistent 参数让节点增减时尽量少 key 被重映射。
下面是一个生产推荐的 upstream 配置,带健康判断:
upstream backend_v1 {
# 按用户 ID 做一致性哈希,实现会话黏性 + 节点变动影响小
hash $cookie_uid consistent;
# 上游列表 + 失败检测
server 10.0.1.10:8080 weight=10 max_fails=3 fail_timeout=20s;
server 10.0.1.11:8080 weight=10 max_fails=3 fail_timeout=20s;
server 10.0.1.12:8080 weight=5 max_fails=3 fail_timeout=20s backup;
# 上游长连接池
keepalive 64;
keepalive_timeout 60s;
keepalive_requests 1000;
}
这段配置的几个细节:max_fails + fail_timeout 是被动健康检查,Nginx 会在 fail_timeout 内累计 max_fails 次失败后把该节点标记为不可用,之后 fail_timeout 过完再尝试恢复;backup 是只在所有非 backup 节点不可用时才启用的节点,适合做容量缓冲;keepalive 64 是给"到上游"的长连接池,QPS 高的场景下不配这个会让你建大量短连接,把上游的 TIME_WAIT 累爆。
主动健康检查(active health check)是商业版 Nginx Plus 才有的功能,开源版只有被动检查。生产里替代方案有两种:用一个外部健康检查服务(consul、kong)做主动检查再动态更新 upstream;或者部署 nginx_upstream_check_module 等第三方模块。如果上游是 Kubernetes 里的 Pod,直接走 Service + endpoints 会更稳——把"健康判断"这件事交给 K8s 而不是 Nginx。
认知翻转:upstream 的负载策略本质上是在"分布均匀"和"会话粘性"之间做权衡。绝对的均匀会破坏粘性,绝对的粘性会让某些上游过载。选哪种取决于你的上游是不是真的需要粘性——无状态服务就用 least_conn 或 round-robin,有状态/有缓存服务就用 hash consistent。把这件事想清楚比记 directive 名字重要得多。
四、502 504 499 408 状态码:每一个都在告诉你不同的事
反代下出现的 5xx/4xx 状态码每一个都有特定含义,搞清它们能让你少走一大半的弯路:
[mermaid]
flowchart TD
A[出现错误状态码] --> B{状态码是几}
B -->|502 Bad Gateway| C[Nginx 联系不到上游或上游返回非法响应]
B -->|504 Gateway Timeout| D[Nginx 等上游超过 proxy_read_timeout]
B -->|499| E[客户端在 Nginx 拿到响应前自己断开]
B -->|408 Request Timeout| F[Nginx 等客户端 header/body 超过 client_*_timeout]
B -->|413 Payload Too Large| G[请求体超过 client_max_body_size]
B -->|444| H[Nginx 主动关连接 不返回任何响应]
C --> I[检查上游是否存活/端口/防火墙/上游进程返回是否合法 HTTP]
D --> J[加大 proxy_read_timeout 或排查上游慢]
E --> K[多半是上游太慢 + 客户端超时短 也可能用户主动取消]
F --> L[加大 client_*_timeout 或 检查客户端是否在慢速发送]
G --> M[按业务调整 client_max_body_size]
H --> N[一般是配置里主动 return 444 防滥用扫描]
几个特别容易误判的:
502 和 504 经常被混淆,实际上区别明确。502 是"我连不上上游"或"上游响应不是合法 HTTP"——比如上游进程挂了、端口监听不存在、上游返回了一段乱码。504 是"我连上了,上游处理时间超过我设的 read 超时"——上游本身在跑只是太慢。这两者的解决方向完全不同:502 多半要查上游进程或网络,504 多半要查上游慢的根因或调大超时。
499 是 Nginx 特有的扩展状态码(标准 HTTP 没有),表示"客户端在 Nginx 还没返回响应时就关掉了连接"。最常见原因是上游慢 + 客户端超时短:用户的浏览器/APP 配的请求超时是 10 秒,Nginx 调用上游耗了 15 秒,客户端在 10 秒时就 close 了 socket,Nginx 这边等到 15 秒拿到响应想发回去发现 socket 已关——记一条 499。499 高的服务一般要查"上游慢"和"客户端超时是否合理"两件事。
408 是 Nginx 主动告诉客户端"你发的太慢了,我等不了了",发生在客户端连接上了但 header 或 body 一直没发完。常见于慢速攻击(slowloris)、客户端网络极差、或者前端不小心写了"分块发送 body 但有大间隔"的代码。
444 是一个特殊的 Nginx 自定义状态码,表示"我主动关闭连接不返回任何响应"。生产里常常用在 return 444; 这种规则上,用于扫描器、攻击 IP、不允许的 Host 头——比起返回 403/404,直接 444 让对方看不到任何东西,反侦察效果更好。一段最小的反扫描配置:
# 任何匹配不到正确 Host 的请求直接 444
server {
listen 80 default_server;
listen 443 ssl http2 default_server;
ssl_certificate /etc/nginx/ssl/default.crt;
ssl_certificate_key /etc/nginx/ssl/default.key;
return 444;
}
# 已知扫描路径直接 444,别让扫描器拿到任何反馈
map $request_uri $is_scanner {
default 0;
~*/\.env$ 1;
~*/wp-login\.php 1;
~*/phpmyadmin 1;
~*/\.git/ 1;
~*/\.aws/credentials 1;
}
server {
listen 443 ssl http2;
server_name api.example.com;
if ($is_scanner) { return 444; }
# 正常业务 location ...
}
认知翻转:状态码不是给客户端看的礼貌话,是给运维诊断用的精确信号。把每一种状态码的含义和它指向的根因记清楚,出问题时你能直接跳到正确的排查方向,而不是先盲查上游、再盲查 Nginx、再盲查网络。一个成熟运维看到 504 直接去查"上游有没有慢请求",看到 499 直接去查"客户端是不是超时短",看到 502 直接去看"上游进程在不在"。这种条件反射能省下大量排查时间。
五、灰度发布与 reload:配置变更的零停机做法
反向代理是流量入口,所以配置变更必须做到"对正在进行的请求零影响"。Nginx 的 reload 机制本身是为零停机设计的——它会拉起新 worker 处理新连接,老 worker 处理完手头连接后退出,这一切看起来很美好,但实际有几个坑。
第一个坑是 reload 不会"立刻"切换。新配置只对 reload 之后的新连接生效,老连接还会按老配置继续跑到结束。如果你有大量长连接(WebSocket、SSE、gRPC),reload 后老 worker 可能挂着不退出半小时甚至更久,期间 Nginx 进程数会翻倍,内存占用翻倍。监控里会看到 worker 数量周期性波动,这是正常现象但要意识到。
第二个坑是 reload 不会校验完整的运行时正确性。nginx -t 只做语法检查,你写了一个 proxy_pass 到 http://backend_typo,语法是对的,nginx -t 会过,reload 也成功,只是请求来了才发现 upstream 不存在直接 502。正确做法是 reload 之前先在测试环境跑全套接口验证,或者用 nginx -T 把整个配置 dump 出来人工 review 一遍。
第三个坑是 reload 频繁会消耗资源。Nginx 每次 reload 会 fork 新 worker,如果你的配置脚本(比如某些自动化平台)是每分钟拉一次配置 reload 一次,就会出现 worker 进程数失控。生产推荐做法是有变化才 reload,无变化跳过。
灰度发布的标准 Nginx 实现是 split_clients,按 cookie 或 IP 或自定义 key 把流量按比例分到不同 upstream:
split_clients "${cookie_uid}" $gray_target {
10% backend_v2; # 10% 流量到新版本
* backend_v1; # 其余到老版本
}
server {
location /api/ {
proxy_pass http://$gray_target;
include proxy_common.conf;
}
}
用 cookie_uid 做 hash 的好处是"同一用户始终落同一版本",不会出现刷新一下就跳来跳去的奇怪体验。要扩大灰度比例时,改 10% 为 30%、50%、100%,nginx -s reload 即可,被切到 v2 的用户群是稳定增加的,不会有人来回乱跳。
灰度过程中另一个常见的坑是 cookie/header/路径在新老版本上行为不一致。比如老版本 backend_v1 接受 ?lang=zh,新版本 backend_v2 改成了 Accept-Language 头,这种情况下灰度过去的用户会突然语言变了。正确做法是新版本上线前先做接口兼容性测试,确保对外接口语义完全一致,内部实现再换。
认知翻转:灰度发布不是 Nginx 配置层面的事,是端到端的事。Nginx 只是把流量按比例分出去,真正决定灰度能不能成功的是:新老版本对外接口必须语义一致、监控指标必须能区分 v1 v2(打 tag 到 access_log)、回滚必须可瞬时执行(改一行配置 reload 就回滚)、灰度比例必须可灰度(从 1% 开始而不是直接 50%)。少任何一项,灰度都可能从"风险可控"变成"线上事故"。Nginx 给你的只是工具,不是策略。
六、工程坑:那些"配置看起来没问题但就是出事"的细节
除了上面五节,真正的生产 Nginx 还会遇到一堆"教程不教但你一定撞到"的细节。挑几个常见的列一下:
第一,proxy_set_header Host 必须显式设。默认值是 proxy_pass 后面写的 host(比如 upstream 名),你以为是请求里的 Host,实际不是。后端拿到的 Host 会跟用户访问的不一样,导致 cookie 域名、redirect URL 等都出错。生产推荐 proxy_set_header Host $http_host(或 $host,根据是否要带端口选择)。
第二,proxy_set_header X-Real-IP / X-Forwarded-For 要设,但要记得后端拿真实 IP 时不能直接信 X-Forwarded-For,要校验客户端是不是经过你信任的代理链,否则伪造 IP 太容易。
第三,gzip 别开太狠。gzip_comp_level 6 已经是性价比最高的,9 几乎不会更小但 CPU 涨一倍。gzip 也要排除已经压缩过的内容(图片、视频),gzip_types 别加 image/* video/*。
第四,access_log 在高 QPS 时会变成瓶颈,因为写盘是同步的。开 buffer + flush 参数可以让日志先攒在内存里再批量落盘,QPS 高时这一改能让 worker CPU 降一截。
第五,worker_connections 默认 512 太小,生产至少配到 4096 或 8192。worker_processes 配 auto 让它跟 CPU 核数对齐,不要手动写一个固定值上线后机器换了规格才发现没用上多核。
第六,SSL 配置千万别用网上抄的老模板,TLS 协议要禁掉 1.0/1.1,只留 1.2/1.3;cipher 用 Mozilla 推荐的现代列表;ssl_session_cache 一定要开,否则每个新连接都做完整 TLS 握手会让 CPU 飙升。
第七,proxy_buffering 默认开,意思是 Nginx 会先把上游响应缓存到本地再发给客户端。这对常规接口没问题,但对流式响应(SSE、流式 LLM 输出)就是灾难——用户要等响应全部返回才看到第一个字。这种场景必须 proxy_buffering off,并配合 X-Accel-Buffering: no header:
# 给流式接口单独一个 location,关掉所有缓冲
location /api/stream/ {
proxy_pass http://backend_stream/;
proxy_http_version 1.1;
proxy_set_header Connection "";
# 关键:三个 off 一起来
proxy_buffering off;
proxy_request_buffering off;
proxy_cache off;
# 通知中间所有代理都别缓冲
add_header X-Accel-Buffering no;
# SSE 长连接需要长超时
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# gzip 也要关,否则会等到一定大小才 flush
gzip off;
}
第八,limit_req 和 limit_conn 是 Nginx 提供的原生限流,值得用上。限流粒度可以按 IP、按 URL、按 cookie,精细一点能挡住相当一部分恶意流量,比让流量穿透到上游再被后端拒掉成本低得多。
第九,location 里 try_files 的写法常常被滥用。try_files 是为静态资源设计的,反代场景里几乎用不到;新手常用 try_files $uri @backend 模式,这跟直接 proxy_pass 在语义上有微妙差异,容易出 bug。
第十,Nginx 的 error_log 默认 level 是 error,生产排查问题时往往不够。临时把某个 server 块的 error_log 切到 warn 或 info 能看到更多内容,排查完记得改回去,info 级别长期开会让日志炸盘。
认知翻转:Nginx 配置的复杂度被严重低估,它表面上是一堆 directive,实际上每个 directive 都连着一段"在某种边界条件下会出什么事"的隐藏语义。一个真正稳的 Nginx 配置不是"复制粘贴 + 让它跑起来",而是"每一行都有人能解释为什么这么写,每个默认值都有人决定过保留还是改"。新手写的配置能扛住小流量,老手写的配置能扛住大流量,专家写的配置能在压力下还能让你看清楚问题在哪——后两者的核心区别就在"是否真的读过每个 directive 的文档"。
关键概念速查
| 概念 | 含义 | 常见误区 | 正确做法 |
|---|---|---|---|
| proxy_pass 尾斜杠 | 决定路径是否被替换 | 随手加/不加 | 用正则 location + 捕获组显式拼路径 |
| proxy_read_timeout | 读上游响应单次超时 | 不改默认 60s | 按 location 拆分,慢接口单独配长超时 |
| keepalive(upstream) | 到上游的长连接池 | 不配,每请求都新建连接 | 必配,大小按 QPS 调,通常 64-256 |
| ip_hash | 按 IP 哈希分配上游 | 当通用会话保持 | 用 hash $cookie_uid consistent 更准 |
| 状态码 502 | 连不上或上游返回非法 | 跟 504 混 | 查上游进程/端口/防火墙 |
| 状态码 504 | 上游处理超时 | 跟 502 混 | 查上游慢请求或调大 proxy_read_timeout |
| 状态码 499 | 客户端先断开 | 不认识 | 查上游是否慢 + 客户端超时是否短 |
| split_clients | 按 key 比例分流 | 用 $remote_addr 不稳定 | 用 $cookie_uid 保证用户黏在版本上 |
| reload | 热加载配置 | 当瞬时切换 | 新连接才生效,长连接老配置继续跑 |
| proxy_buffering | 缓存上游响应再发客户端 | 流式场景下不关 | SSE/流式 LLM 必须 off |
避坑清单
- 不要依赖 proxy_pass 尾斜杠的隐式行为,用"正则 location + 捕获组"显式拼路径,任何人一眼能看懂后端拿到的 URI。
- 不要用一个 proxy_read_timeout 覆盖所有接口,按业务在 location 块里 override,慢接口长超时短接口短超时。
- 不要忘了配 upstream 的 keepalive,QPS 高时不配会把上游的 TIME_WAIT 累爆,出现 connection refused。
- 不要无脑用 ip_hash 当会话保持,NAT 环境下会严重失衡,改用 hash $cookie_uid consistent。
- 不要把 502 / 504 / 499 / 408 当一回事处理,每个状态码指向不同根因,排查方向完全不同。
- 不要直接 reload 上线大改,先 nginx -t 校验语法、nginx -T dump 配置人工 review、灰度环境验证全套接口。
- 不要让灰度发布只靠 Nginx 配置,新老版本必须接口语义一致、监控可区分、回滚可瞬时,缺一不可。
- 不要忘了显式 proxy_set_header Host,否则后端拿到的 Host 是 upstream 名,cookie/redirect 全错。
- 不要在流式响应场景下保留 proxy_buffering 默认开,SSE/流式 LLM 输出必须 off,否则用户体验是断崖式的。
- 不要照搬网上老 SSL 模板,TLS 必须 1.2+、cipher 用现代列表、ssl_session_cache 必须开,否则 CPU 直接飙升。
总结
Nginx 反向代理是后端世界里"看起来最简单、实际最深的"那一类技术。简单是因为它的语法极其紧凑,几行配置就能让流量跑起来;深是因为它的每一个 directive 都有默认值,而每个默认值都是"满足某种历史场景"的妥协,搁到你的业务里未必合适。一份生产 Nginx 配置的质量,不取决于它有多少行,取决于"每一行写或者没写"背后是否都有人想过为什么。
另一层被低估的是 Nginx 的"上下文叠加"。每条请求经过 Nginx 时会被多个上下文影响:server 块的全局设置、location 块的局部覆盖、upstream 的负载策略、map / split_clients 的动态变量、变量传递时的转义规则。新手往往只在意"我写了什么",老手会同时想"我没写的部分默认是什么,我写的部分会不会被外层覆盖"。这种叠加思维是写好 Nginx 配置的核心技能,它比记住每个 directive 名字重要得多。
打个不太严谨的比方,Nginx 配置有点像古法中医的方剂:剂量少一钱多一钱效果完全不同,药材组合每一种都互相影响,看起来是几味简单药材,实际上是一套精密平衡。新手会照着方子抓药,药效能凑合;老手会根据病情调剂量、加减味,药效精准;专家会知道每一味药的禁忌、配伍、归经,出问题能立刻调整。Nginx 配置也一样:照搬模板能跑起来,真正出问题时要靠"每个 directive 都熟"才能找到根因。
所以做 Nginx 反向代理,本地起一个 demo 通几个接口永远暴露不了真正的问题。它暴露不了大流量下 upstream 长连接耗尽,暴露不了慢接口让 worker connection 堆积,暴露不了灰度切流量时一个 cookie 没设对导致用户跳来跳去,暴露不了 reload 后老 worker 永远不退出,暴露不了 ip_hash 在 NAT 环境下严重失衡,更暴露不了 SSL 配置不当让 CPU 在流量峰值直接 100%。真正的检验在生产环境,在大促压测的午夜、在一次上游故障的清晨、在一次灰度发布的下午。把上面六节里的功夫提前做扎实,等那些时刻到来时,你会感谢自己当初没图省事。如果你正在用 Nginx 做反向代理,不妨找一段空闲时间把你的配置按这套标准盘一遍,你大概率会找到至少三处可以变得更扎实的地方——这是收益极高、风险极低的投资,因为反向代理是流量入口,它出事波及范围比任何一个上游应用都大。
—— 别看了 · 2026