上个月我接手一个Java服务,线上接口时不时超时,部署的机器CPU经常飙到80%以上,查看堆栈全是Tomcat线程在等待。排查到最后一拍大腿——这台服务直接以8080端口对外,连一层Nginx都没有。做Java这么多年,我见过太多同样的情况:代理转发这件事,配置Nginx不是可选项,而是基本功。这篇文章我就把自己实战中用到的Nginx代理转发配置、踩过的坑、排查思路完整梳理一遍,从最小配置到集群负载,从前后端分离到高频故障,一次性讲透。
1. Java服务裸奔的代价:为什么代理转发是基本功
1.1 线程模型差异:Java应用被连接拖垮的过程
先讲个很多Java开发者忽略的基础问题:Tomcat这类Servlet容器和高性能代理服务器处理连接的模型完全不同。Tomcat默认采用线程池模型,一次HTTP连接在业务处理期间基本占用一个线程。当并发连接数超过线程池上限,请求要么排队等待,要么直接拒绝。更麻烦的是,如果某个接口响应很慢,线程池会被占满,后续所有请求都进不来,表现为服务"假死"。
我自己经历过一次典型的故障:一个内部系统没有接Nginx,前端页面直接把请求打到Tomcat,同时还有一堆爬虫在扫端口。高峰期连接数冲到两三千,Tomcat线程池瞬间打满,CPU上下文切换飙高,所有页面加载都变成转圈。当时第一反应是加机器,后来加了Nginx才发现,静态资源和恶意请求根本不该进Java应用。
Nginx基于事件驱动和epoll模型,单进程就能维护数万连接,处理静态资源更是强项。它就像一个非常高效的前台接待员:一个前台同时能应对几百个访客,按顺序分发到后面干活的工位。Java的线程池模型则是"每个客人配一个专职服务员",遇到大型活动就直接人手不够。所以把Nginx挡在前面,本质上是把连接管理和分发这些"轻活"从Java应用里剥离出去,让JVM专心执行业务逻辑。
注意:这里不是说Tomcat可以无脑调大线程池。线程越多,上下文切换开销越大,JVM内存占用也越高。正确的做法是让Nginx做第一层分流,合理控制后端Java的并发水位。
1.2 Nginx在Java架构里到底替你挡了什么
结合我日常维护的Spring Boot项目,Nginx代理转发带来的收益至少有这几块:
- 静态资源剥离:图片、CSS、JS这些文件由Nginx直接返回,不占Java线程。Spring Boot打出来的jar包里虽然能放静态资源,但性能和Nginx完全不在一个量级。
- 统一入口和端口收敛:所有域名、证书、路由都在Nginx统一收口,Java服务监听内网端口,不直接暴露公网,减少被扫描攻击的面。
- 多实例调度基础:后面接多个Java服务实例时,Nginx负载均衡承担了流量分发角色,Java应用自身保持无状态,扩容缩容都不影响入口。
- 请求头增强与日志透视:Nginx能统一注入Host、真实IP等请求头,Java后端拿到客户端真实来源,日志和排障都轻松一大截。
- 安全过滤和限流:非法UA、恶意IP、突发流量都可以在Nginx这一层先挡住,不用业务代码去实现。
这些能力组合起来,Nginx在Java架构里就是"总闸+分流器+保安"三重角色。哪怕你的服务只有一台机器,我也建议至少用Nginx把端口收敛一层,后续加机器、上HTTPS、做灰度都非常方便。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一份可用配置:location、proxy_pass与请求头传递
2.1 一个能跑起来的最小配置
不管多复杂的架构,最核心的就一段反向代理配置。假设Java服务跑在127.0.0.1:8080,我们想通过Nginx的80端口对外提供服务,最小配置长这样:
nginx复制server {
listen 80;
server_name yourdomain.com;
location / {
proxy_pass http://127.0.0.1:8080;
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。Nginx转发给Java服务时,默认情况下Host头是127.0.0.1:8080而不是你访问的域名。如果Spring Boot应用里用到了request.getServerName()、生成链接地址或者做域名白名单,就会拿到错误的值。把$host传进去,后端才能正确识别域名。
X-Real-IP和X-Forwarded-For更容易被漏掉。没有这两行,Java后端获取到的客户端IP全是127.0.0.1,日志、风控、审计全废了。$proxy_add_x_forwarded_for会把原始请求头里的X-Forwarded-For追加当前客户端IP,支持多级代理场景。
配置完成后先nginx -t检查语法,再nginx -s reload热加载。不用重启进程是Nginx非常好用的特点,改配置基本零中断。
2.2 proxy_pass结尾斜杠:绝大多数人踩过的坑
proxy_pass后面到底带不带斜杠,这个细节我至少看了十几次同事栽跟头。它的规则其实不复杂:proxy_pass后面是否带URI,决定location匹配部分会不会被替换。
第一种,不带URI,只写主机地址:
nginx复制location /api/ {
proxy_pass http://127.0.0.1:8080;
}
此时请求/api/user/list会原样转发成/api/user/list,location匹配到的部分不会被动的。
第二种,带URI,比如末尾加个斜杠:
nginx复制location /api/ {
proxy_pass http://127.0.0.1:8080/;
}
此时请求/api/user/list会被替换成/user/list,也就是location匹配到的/api/部分被替换成了/。
第三种,proxy_pass带具体路径:
nginx复制location /api/ {
proxy_pass http://127.0.0.1:8080/new/;
}
请求/api/user/list会变成/new/user/list。
这个行为用一句话记:location前缀匹配的那一段,被proxy_pass里写的URI替换。理解之后,你就能根据后端接口的实际路径来决定要不要带斜杠。很多Spring Boot接口路径恰好是/api开头,想让Nginx原样转发,就千万别在proxy_pass后面多加那个/。
注意:如果location用的是正则形式,比如
location ~ ^/api/,proxy_pass里不能带URI,否则Nginx启动会直接报错。这是Nginx出于正则捕获组的限制做的强制约束。
2.3 请求头与真实IP:Java后端拿到的信息从哪来
proxy_set_header除了Host和真实IP,还有两个字段在高频场景里很关键。
第一个是X-Forwarded-Proto。Java服务如果通过request.isSecure()判断是否HTTPS,或者Spring Security里配置了HTTPS重定向,就需要这个头告诉后端用户原本用的是哪个协议。否则Nginx用443接入HTTPS,转发给Java却是HTTP,后端会以为请求来自非安全通道,导致重定向死循环。
第二个是X-Forwarded-Prefix。如果服务部署在网关子路径下,比如/app前缀,Spring Boot可以通过这个头感知到前缀,正确生成带前缀的URL。不是所有项目都用得到,但一旦用了,能省掉很多上下文路径的麻烦。
Java后端也要配合。Spring Boot 2.x可以在application.yml里设置:
yaml复制server:
forward-headers-strategy: framework
或者在Tomcat层配置RemoteIpValve,让后端信任Nginx传入的转发头。这一步不做,Nginx那边头传得再好,后端也可能忽略。前后端联调时我经常遇到"Nginx配置没问题但后端IP不对"的案例,十有八九是后端的forward策略没打开。
3. 单机到集群:upstream负载均衡与Java多实例部署
3.1 什么情况下需要upstream
当Java服务从单机部署变成多实例集群,Nginx就不能再写死某个IP+端口了。upstream就是用来定义一组后端服务器,让Nginx在它们之间分发请求。
我常用的一个场景是:同一套Spring Boot应用,在一台机器上起了8080和8081两个实例,做简单负载分担。server块里的proxy_pass不再直接指向IP,而是指向upstream名字:
nginx复制upstream backend_java {
server 127.0.0.1:8080 weight=2;
server 127.0.0.1:8081 weight=1;
keepalive 32;
}
server {
listen 80;
server_name yourdomain.com;
location / {
proxy_pass http://backend_java;
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_http_version 1.1和proxy_set_header Connection "":Nginx到后端的连接默认用HTTP/1.0,每次请求都会重新建立TCP连接。设置成HTTP/1.1并清空Connection头,配合upstream里的keepalive,才能复用与Java服务之间的TCP长连接,减少大量TIME_WAIT。
3.2 负载均衡策略选型
Nginx upstream的负载策略我整理成一张表,方便你对照场景选:
| 策略 | 默认 | 适用场景 | 注意点 |
|---|---|---|---|
| 轮询 | 是 | 后端实例配置基本一致,无状态接口 | 默认平均分配,不考虑后端即时负载 |
| weight权重 | 否 | 机器性能不均,新老实例混部 | 权重高的实例分到更多请求 |
| ip_hash | 否 | 需要粘性会话,且不便引入Redis共享Session | 后端增减实例后,同IP可能重新分配 |
| least_conn | 否 | 接口耗时长,各实例连接数可能失衡 | 将请求发给当前活跃连接最少的后端 |
| fair / url_hash | 第三方模块 | 按响应时间或URL哈希分流 | 需要编译Nginx时额外加上模块 |
Java应用在分布式场景下一般建议做成无状态,Session统一放Redis,这样直接用默认轮询或least_conn就行。如果历史项目用了本地Session又不想改,ip_hash是最省事的过渡方案。注意ip_hash在Nginx reload或后端实例变化后,可能打散原有映射,不能完全依赖它。
3.3 健康检查与连接复用
默认情况下upstream里配的server如果连续请求失败,Nginx会按max_fails和fail_timeout把该实例标记为不可用,然后暂停转发一段时间。我用得比较多的参数是:
nginx复制upstream backend_java {
server 127.0.0.1:8080 max_fails=3 fail_timeout=15s;
server 127.0.0.1:8081 max_fails=3 fail_timeout=15s;
}
含义是15秒内失败3次,就认为该实例挂了,接下来15秒不再转发给它。这个属于被动健康检查,成本低,但发现故障有延迟。如果希望主动探测Java服务健康状态,需要给Nginx编译nginx_upstream_check_module模块,或者借助额外的健康检查方案。我一般建议先用好被动检查,大部分场景够用。
keepalive参数我之前提过,再强调一次:只配置keepalive 32不生效,必须配合proxy_http_version 1.1和空Connection头。这个组合能把Nginx到后端Java的TCP连接保持复用,特别适合短平快接口,能明显降低时延和TCP握手开销。
4. 前后端分离项目:静态资源与API转发的路径规划
4.1 典型目录与location划分
现在主流的Java项目基本都是前后端分离:前端Vue或React打包出来的dist目录由Nginx托管,后端Spring Boot接口通过/api路径转发。我日常用的配置骨架长这样:
nginx复制server {
listen 80;
server_name yourdomain.com;
root /opt/frontend/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8080;
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;
}
}
核心思路是精确路由:/开头的请求去找前端静态文件,/api/开头的请求转发给Java后端。两个location各自负责一套逻辑,互不干扰。很多刚接触的人会把所有请求都代理到Java,静态文件也从Java返回,导致前端部署一个版本就要重启一次Java服务,这是完全可以避免的。
4.2 try_files与前端路由history模式
前端用Vue Router或React Router的history模式时,路由路径是/user/list这种,看起来像后端路径,但刷新页面时浏览器会真实请求这个地址。如果Nginx只有proxy_pass转发给Java,Java端没有匹配这个路径的接口,就会返回404。
解决方式就是上面配置里的try_files $uri $uri/ /index.html。它的逻辑是:先找是否存在对应的真实文件,如果不存在就找目录,目录也不存在则回退到index.html,让前端框架自己去解析路由。这一个指令把前端history模式的所有刷新问题都处理干净了。
不过要注意,/api/的location优先级高于这个通配location,接口请求不会落到try_files分支。Nginx location匹配时,带前缀的/api/比/更精确,所以两个location的职责可以明确分开。实际排错时,如果前端页面能打开但接口都404,先确认是不是location顺序写反了,把/写在了/api/前面且用了proxy_pass,导致所有请求都进了错误分支。
4.3 跨域问题的两种处理方式
前后端分离后,前端在localhost:8081开发,后端API在https://api.example.com,跨域是跑不掉的。生产环境我通常建议用Nginx把前端和后端放在同一个域名下,比如前端访问https://example.com,API走https://example.com/api,这样天然同源,根本不存在跨域。
但有些项目前端域名和API域名确实没法合并,此时可以在Nginx层统一处理CORS头:
nginx复制location /api/ {
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With";
add_header Access-Control-Allow-Credentials true;
if ($request_method = OPTIONS) {
return 204;
}
proxy_pass http://127.0.0.1:8080;
}
这里要注意Access-Control-Allow-Credentials true时,Access-Control-Allow-Origin不能写*,必须回显请求的Origin,否则浏览器会阻止携带Cookie的跨域请求。OPTIONS预检请求直接返回204能有效减少一次后端逻辑处理。我更推荐的方式是后端Spring Boot用CorsFilter统一管理,Nginx层处理容易在异常响应时漏加CORS头。
5. 高发故障排查:502、504、413、WebSocket断连
5.1 502/504:Java进程假死还是上游超时
502和504是Nginx代理Java服务时最常遇到的两个错误。502 Bad Gateway表示Nginx无法从后端拿到合法响应,504 Gateway Timeout表示后端在规定时间内没处理完。
我的排查链路固定是这个顺序:
- 看Nginx错误日志:
tail -f /var/log/nginx/error.log,确认日志里报的是connect() failed还是upstream timed out。 - 检查Java端口是否存活:
ss -lntp | grep 8080,如果端口没了,大概率是进程崩溃或OOM被系统杀掉。 - 看Java应用日志和GC日志:热词里那个
java: outofmemoryerror: insufficient memory的场景我遇到过,JVM申请堆内存失败,进程表现为假死或直接退出,Nginx侧自然全部502。 - 确认是超时还是连接失败,再把Nginx的超时参数调出来。
针对超时,我用的几个参数:
nginx复制proxy_connect_timeout 60s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout是两次读操作之间的间隔超时,不是整个请求的总超时。如果Java接口本身要跑几十秒批量任务,可以适当调大,但更好的做法是把长任务改成异步接口,不要让HTTP请求一直挂着。我见过一个报表导出功能,后端跑5分钟才返回,Nginx默认60秒直接504,前端用户等得心累,后来改成任务提交+结果轮询,问题才彻底解决。
5.2 413请求体过大:上传功能的经典报错
Java应用做文件上传时,Nginx默认允许的请求体最大只有1MB。上传文件稍微大一点,Nginx直接返回413 Request Entity Too Large,Java端连请求都收不到。
遇到这种问题,在server块或location块里加一行:
nginx复制client_max_body_size 50m;
注意这个值要考虑Java端本身的限制。Spring Boot默认单文件最大1MB,多文件最大10MB,需要同时调整:
yaml复制spring:
servlet:
multipart:
max-file-size: 50MB
max-request-size: 50MB
如果Nginx调大了但Java端没调,请求能到达后端,后端同样会拒绝。我排查这种问题最快的办法是直接看Java日志里有没有MaxUploadSizeExceededException,根据异常来源判断卡在哪一层。
5.3 WebSocket长连接断连与Upgrade头
Java后端用Spring Boot做WebSocket推送时,Nginx代理的配置和普通HTTP不一样。WebSocket协议升级依赖HTTP的Upgrade头,而Nginx默认转发时会把这个头去掉,导致连接建立失败或者一推数就断开。
我的完整配置如下:
nginx复制location /ws/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
Upgrade和Connection "upgrade"这两行是WebSocket能建立的关键。proxy_read_timeout设成1小时,是因为WebSocket是长连接,如果还保持默认60秒,服务端或客户端只要一分钟内没有心跳,Nginx就会主动断开。设置长超时后,心跳机制负责维持连接,Nginx就不会乱掐线了。
我还踩过一个坑:Nginx到Java走的proxy_http_version没设置成1.1,虽然WebSocket能连上,但连接复用异常,长时间运行后出现大量CLOSE_WAIT。所以WebSocket的location里,proxy_http_version 1.1必须带上。
5.4 代理后获取不到真实IP
这个前面提过,但排查逻辑值得单独说。如果Java后端日志里全部是127.0.0.1,先检查Nginx有没有设置X-Real-IP和X-Forwarded-For。设置了还不生效,再检查Spring Boot是否设置了forward-headers-strategy: framework。框架没有开启时,内嵌Tomcat不信任来自代理的转发头,会继续使用连接来源IP。
调试时可以先用curl验证Nginx转发后的效果:
bash复制curl -H "Host: yourdomain.com" http://127.0.0.1 -I
然后看Java端日志里记录的remoteAddr。只要能拿到公网IP,链路就通了。这个数据对风控、限流、审计都很重要,别等上线后才发现。
6. 给Java服务加一层保护:限流、压缩、证书
6.1 限流与访问控制
虽然Java应用本身可以做接口限流,但Nginx在入口层限流成本更低,也不影响业务代码。我常用的限流配置是limit_req_zone加limit_req:
nginx复制limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
server {
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
proxy_pass http://127.0.0.1:8080;
}
}
这里rate=10r/s表示平均每秒处理10个请求,burst=20表示允许突发20个请求排队,nodelay表示突发请求不排队直接处理,但会触发限流标记。对于Java接口防刷、防爬虫,这个组合已经能挡掉大部分异常流量。
黑白名单用allow和deny:
nginx复制location /admin/ {
allow 192.168.1.0/24;
deny all;
proxy_pass http://127.0.0.1:8080;
}
管理后台只允许内网访问,这个配置简单粗暴且有效。再配合fail2ban之类的工具,对一些恶意UA做拦截,Java服务的压力能小很多。
6.2 Gzip压缩与代理缓冲
Java接口返回的JSON体量大的时候,开启Gzip能显著减小传输体积。我常用的配置:
nginx复制gzip on;
gzip_min_length 1k;
gzip_comp_level 5;
gzip_types text/plain text/css application/json application/javascript application/xml;
注意Nginx的gzip指令默认就在http上下文里生效,但gzip_types需要明确指定,否则只压缩HTML文本。图片、视频这类本身已压缩的文件不要开Gzip,浪费CPU且体积几乎没有变化。
代理缓冲方面,proxy_buffering on是默认开启的。它让Nginx先缓存后端响应,再以合适速度发给客户端,避免Java处理慢导致客户端等待过久。如果做的是SSE推送或大文件流式下载,则需要关掉缓冲,让数据边到边发:
nginx复制proxy_buffering off;
6.3 HTTPS配置简述
现在新项目基本全部HTTPS化。Nginx配置HTTPS比Java应用直接配证书要简单得多,证书也方便统一续期。我用的模板:
nginx复制server {
listen 443 ssl http2;
server_name yourdomain.com;
ssl_certificate /etc/nginx/ssl/yourdomain.pem;
ssl_certificate_key /etc/nginx/ssl/yourdomain.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name yourdomain.com;
return 301 https://$host$request_uri;
}
HTTP强制跳转HTTPS用301。这里X-Forwarded-Proto必须设置,因为后端需要知道原始请求是HTTPS,否则Spring Security可能会把重定向地址写成http://,导致跳转循环。另外,HTTPS证书要提前检查有效期,我习惯在crontab里加一个证书到期提醒脚本,避免证书过期导致大面积故障。
写到这里,我想起实际操作中一个非常受用的习惯:每次改完Nginx配置,第一时间nginx -t检查,然后reload,再用curl加-H模拟实测转发结果。这样能保证每次变更都可回退、可验证。Nginx代理转发配置Java服务这件事,难点不在于某个指令记不住,而在于整个链路的每一环——从连接、转发头、超时到后端策略——都要提前想清楚,而不是等线上故障来教你。希望这篇梳理能帮你少走一些我当年走过的弯路。
