1. Location 是你最容易写错,又最影响线上的一行配置
大概每一个和线上环境打过交道的人,都见过被 location 配置坑到加班的情况。Nginx 的 location 指令看似只是一个“路径匹配”,但它牵扯到 root、alias、proxy_pass、try_files 这一整套资源分发逻辑,任何一个符号写错,轻则接口 404,重则整个站点不可用。这篇文章我想把 Nginx location 配置从头到尾梳理一遍,不吹概念,直接讲清楚匹配顺序、常见场景和一堆我实际踩过的坑。
1.1 从一次静态资源 404 开始
上个月帮一个团队排查上线事故,现象很典型:主站首页可以打开,CSS 和 JS 全部 403/404,控制台刷了一屏红色。我登上服务器打开 Nginx 配置,看到这样一段:
location /static/ { root /data/www/project/dist; }不细看会觉得没问题,可实际上root会把完整的 URI 拼到目录后面——请求/static/css/app.css,Nginx 找的是/data/www/project/dist/static/css/app.css。如果构建产物里根本没有static这一层,那就只能 404。改成alias或者调整目录结构,问题立刻消失。
这个案例不是个例。location 配置的难度在于它不是一个简单的“URL 等于某个值就执行某段逻辑”,而是一套有优先级、有继承、有上下文关联的路由决策机制。你要先理解 location 怎么匹配,再理解匹配之后 root、alias、proxy_pass 这些指令到底拿什么路径去工作,否则就是“配置能启动,一上线就炸”。
1.2 location 匹配的是“归一化之后的 URI”,不是浏览器地址栏原文
很多人忽略了一个事实:Nginx 在对请求做 location 匹配之前,会先对 URI 做一次归一化处理。浏览器地址栏里的//api//user、/a/../b、%2f这类写法,到了 location 匹配阶段已经不是原样了。
具体来说,Nginx 会:
- 剥离 query string,所以
location匹配时不需要考虑?后面的参数; - 合并连续的多个
/,比如/foo//bar会归一化为/foo/bar; - 解析
.和..,比如/foo/../bar会归一化为/bar; - 对
%XX形式的 URL 编码做解码,个别特殊场景下还涉及编码展开的细节。
这意味着在配置文件里写location /foo/../bar基本没意义,因为请求进来后 URI 早就变成/bar了。你要是真想让某个路径规则生效,应该直接写归一化后的形式。
这个特性和$uri变量也有关。location 匹配用的是归一化后的$uri,而$request_uri才是浏览器传来的原始 URI。做跳转、记录日志、生成签名链接时,这俩变量经常把人绕晕。比如:
location /old/ { return 301 /new/$request_uri; }如果按$request_uri拼,会保留原始双斜杠或编码,有些场景下这不是你想要的。我一般在重写规则里优先使用$uri,只有明确要保留原始地址时才用$request_uri。
1.3 什么时候该把配置写进 location
location 不是万能的条件容器。很多人会把变量判断、环境判断、甚至复杂 if 逻辑都塞进 location,结果踩中 Nginx 的“if 陷阱”。官方文档里那句话我一直记得:if在 location 里,只有使用return和rewrite时才是稳妥的,其他用法都可能产生不可预期的行为。
一个合理的判断标准是:这个配置是否需要根据 URI 不同而不同?如果是,放进 location;如果是根据 header、host、客户端 IP 之类的条件,优先考虑在 server 层用map或单独 server 块做判断,别硬塞到 location 里堆 if。后面在第五章我会专门讲工程化整理。
2. 五种匹配修饰符的优先级,我用一条判定链给你讲透
2.1 修饰符一览:无前缀、=、^~、~、~*
location 支持五种写法,每种语义完全不同。我先放一张对照表,接下来再逐个解释。
| 写法 | 修饰符 | 作用 | 示例 |
|---|---|---|---|
location /api/ | 无 | 普通前缀匹配,先记下最长前缀,后续还可能被正则覆盖 | location /api/ { ... } |
location = /api | = | 精确匹配,URI 必须完全相等,命中立即结束 | location = /api { ... } |
location ^~ /static/ | ^~ | 前缀匹配,命中后不再检查正则 | location ^~ /static/ { ... } |
location ~ \.php$ | ~ | 正则匹配,区分大小写,按配置顺序检查 | location ~ \.php$ { ... } |
location ~* \.jpg$ | ~* | 正则匹配,忽略大小写,按配置顺序检查 | location ~* \.jpg$ { ... } |
location @fallback | @ | 命名 location,只能被内部指令跳转,不能处理外部请求 | location @fallback { ... } |
初学者最容易混淆的是普通前缀匹配和正则匹配。普通前缀只看“以某字符串开头”,它不会管这个字符串后面是什么;正则匹配则是完整正则表达式匹配 URI。两者共存时,不是简单谁写前面谁生效,Nginx 有一套独立的裁决顺序。
2.2 Nginx 的真正匹配顺序:先最长前缀,再按顺序扫正则
我按 Nginx 源码行为和官方文档把匹配顺序拆成了这样一条判定链,记熟就不会踩坑:
- 先查
=精确匹配。如果有,并且 URI 完全相等,立刻命中,结束匹配; - 没有精确匹配时,把所有普通前缀和
^~前缀都拿出来,做最长前缀匹配,记住匹配结果; - 如果第 2 步选出的最长前缀带
^~,直接采用,跳过后续正则检查; - 如果第 2 步选出的最长前缀是普通前缀,则按配置文件里正则出现的顺序依次检查
~和~*,第一个匹配到哪个正则就采用哪个; - 如果没有正则匹配,或者没有写正则,就使用第 2 步的最长前缀结果。
这里有个非常反直觉的点:正则的优先级不是“写在 location 前面就高”,而是“前缀先选出最长,再由正则覆盖”。正则之间才是按顺序执行的,不是按写的位置自动获得优先级。
我也见过网上有人总结成“无修饰符<~<^~<=”,这种说法不够准确。准确的说法是:
=最高,精确匹配独立于前缀链路;^~只是避免了正则覆盖,并不天然高于其他前缀匹配;- 普通前缀之间由最长匹配决定,和配置文件顺序无关;
- 正则之间由配置文件顺序决定,和正则长短、精确度无关。
2.3 用具体请求走一遍判定流程
直接看一段实际配置,然后带几个请求走一遍:
server { listen 80; server_name example.com; location / { try_files $uri $uri/ /index.html; } location = /favicon.ico { access_log off; log_not_found off; } location ^~ /static/ { alias /data/www/static/; expires 30d; } location ~* \.(js|css|png|jpg|jpeg|gif|svg|webp)$ { expires 7d; add_header Cache-Control "public"; } location /api/ { proxy_pass http://backend; } }请求/static/js/main.js:
- 没有
=精确匹配命中; - 前缀匹配候选有
/和/static/,最长前缀是/static/; /static/带^~,直接命中,后面的静态资源正则不会执行。
请求/user/profile:
- 没有
=精确匹配; - 最长前缀是
/; - 按顺序扫正则,
~* \.(js|css|...)$不匹配; - 结果落到
location /,执行try_files。
请求/api/login:
- 没有
=; - 最长前缀是
/api/; - 正则不匹配(虽然
/api/这个 URI 不以 js、css 等结尾); - 结果落到
/api/,走反向代理。
请求/logo.png:
- 没有
=; - 最长前缀是
/; - 正则
~* \.(png)$匹配,所以正则 location 覆盖了普通前缀,命中缓存规则。
如果这时候你想要“让/api/下的图片也走代理而不是走静态缓存”,答案很简单:把/api/也改成^~,比如location ^~ /api/ { proxy_pass http://backend; },这样最长前缀命中后跳过正则。很多人搞不清这个原因,就会在正则里写大量排除条件,搞得配置又长又难维护。
2.4 尾部斜杠带来的路径边界问题
斜杠是 location 配置里的隐形杀手,差一个斜杠,匹配范围完全不同。
location /api匹配:/api、/api/、/api/v1、/apic。它只做前四个字符的匹配,/apic也会命中,这个范围往往比你预期的大;location /api/匹配:/api/、/api/v1,但不匹配/api;location = /api/只匹配/api/,/api不匹配;location = /api只匹配/api,/api/不匹配。
所以设计接口路由时,我通常默认写location /api/,加尾斜杠,避免把/apixxx这类路径误匹配进 API。静态资源也是同理,location /static/比location /static更精确。你需要“从某个前缀开始的所有请求”时用尾斜杠,需要“精确只命中一个 URI”时用=。
如果非要匹配“路径边界”,正则是个选择:
location ~ ^/api(/|$) { proxy_pass http://backend; }^/api(/|$)表示/api后面必须跟着/或者是字符串末尾,这样/apic就不会命中。但能用前缀表达清楚的场景,我还是优先前缀,少用正则,性能更好。
3. 静态站点、SPA 与反向代理:三个高频场景的 location 实战写法
3.1 纯静态站点:root、try_files 和缓存头的搭配
纯静态站点是所有场景里最简单的,但也最容易把 root 和别名搞混。我先给一个推荐配置,再解释为什么这么写:
server { listen 80; server_name www.example.com; root /data/www/example; index index.html; location = /favicon.ico { access_log off; log_not_found off; } location = /robots.txt { access_log off; log_not_found off; } location / { try_files $uri $uri/ =404; } location ^~ /assets/ { expires 30d; add_header Cache-Control "public, immutable"; access_log off; } }root写在 server 层后,location 内部没有特殊情况时就不需要重复写,Nginx 会继承。try_files $uri $uri/ =404的意思是:先看看有没有对应文件,再看有没有对应目录,都没有就返回 404。这里用=404而不是跳转到自定义错误页,是为了避免静态文件缺失时还跑一堆额外逻辑。
location ^~ /assets/的^~在这里很重要。它确保 assets 下的图片、字体、样式请求不会掉进正则 location,直接命中并添加缓存头。我在给静态站点做优化时,特别喜欢用^~ /assets/这种写法,语义非常简单:这类资源不做正则判断,直接按静态文件处理。
3.2 SPA History 路由:try_files 回退 index.html
单页应用(SPA)是 location 配置里另一个高频场景。前端路由是 History 模式时,浏览器地址栏的/user/123在服务器上没有对应文件,如果 Nginx 找不到文件就返回 404,那用户一刷新页面就没了。
正确的做法是让未命中的路径回退到index.html,由前端路由接管:
location / { root /data/www/spa; index index.html; try_files $uri $uri/ /index.html; }这里的第三个参数/index.html是内部跳转地址,请求/user/123找不到文件时,Nginx 会内部重写到/index.html,然后返回前端入口。注意这个路径是相对于 root 的,不是完整 URI。
这里有几件事要同时处理好:
- API 请求必须从 SPA 回退中分离出来。你至少要有
location /api/ { proxy_pass http://backend; }这样的配置,否则/api/user这类请求找不到文件时也会被回退到index.html,前端拿到一坨 HTML,然后报 JSON 解析失败; - 静态资源也应该和 SPA 回退分离,否则像
logo.png这种文件存在但路径拼错时,不会返回 404,反而会返回index.html,排查起来特别痛苦。我习惯把构建产物里的 assets 单独拎出来:location ^~ /assets/ { root /data/www/spa; expires 30d; add_header Cache-Control "public, immutable"; } - 如果你用 Vite 或 Webpack 构建,产物里带有 hash 的文件很适合长期缓存,但
index.html本身不能缓存太久,否则版本更新后用户还是旧页面。一般我会在location = /index.html里设置no-cache。
3.3 反向代理:proxy_pass 的斜杠语义与常见误区
反向代理里的 location 是坑最多的。尤其是proxy_pass后面到底加不加斜杠,很多工作三五年的人也会弄错。
先记一条规则:proxy_pass的 URL 分为带 URI 和不带 URI 两种情况。
不带 URI 时,请求 URI 原样传给后端:
location /api/ { proxy_pass http://backend; }请求/api/login,后端收到的还是/api/login。
带 URI 时,Nginx 会用proxy_pass里的路径替换掉 location 匹配到的那一段,然后拼接剩余部分:
location /api/ { proxy_pass http://backend/; }请求/api/login,后端收到的是/login。因为/api/被换成了/。
再看一个更细的例子:
location /api/ { proxy_pass http://backend/v2/; }请求/api/user,后端收到/v2/user。同理,请求/api/user/profile,后端收到/v2/user/profile。
如果 location 不带尾斜杠,比如:
location /api { proxy_pass http://backend/api; }请求/api/user,匹配的部分是/api,剩余是/user,拼接后是/api/user;请求/apidoc,匹配/api,剩余doc,拼接后变成/apidoc,后端收到/apidoc——这绝对不是你想要的结果。
我的个人习惯是:后端接口本身就是完整路径时,proxy_pass http://backend;和location /api/配合,原样转发,最不容易出错。只有当后端路径和前端路径不一样,比如前端叫/api/,后端叫/v1/,或者需要去掉版本前缀时,才用带 URI 的写法。改完一定要用 curl 打一下真实请求,确认后端收到的 path 符合预期。
WebSocket 场景下也有同样的斜杠问题。举例:
location /ws/ { proxy_pass http://ws_backend/; }前端连接/ws/chat,后端收到的路径变成了/chat,如果后端 WebSocket 路由注册在/ws/chat,自然连不上。这时候把proxy_pass改成不带 URI 的形式就正常了:
location /ws/ { proxy_pass http://ws_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }3.4 命名 location:@ 的用途与限制
命名 location 是容易被忽略的一种类型,它不参与外部请求匹配,只能被try_files、error_page、rewrite这类内部指令跳转。优点是语义明确,不会暴露成独立 URL。
典型用法是统一错误页:
location / { try_files $uri $uri/ @fallback; } location @fallback { return 404; }或者配合error_page:
location @maintenance { return 503; } location / { try_files $uri $uri/ @maintenance; }命名 location 的局限在于它不接受外部访问,你没法直接在浏览器输入example.com/@fallback看到它。内部跳转时它会占一次内部 rewrite,不过 Nginx 对内部跳转的效率还是很高的,不用太担心性能。
4. 线上排查实录:404、502 和缓存失效背后的 location 问题
4.1 root 与 alias 混用导致的路径拼接事故
先从最常见的 404 说起。root和alias的区别就是一个做加法,一个做替换。
root的拼接规则是:root路径 + 完整 URI。
location /static/ { root /data/www; }请求/static/css/app.css,实际查找路径是/data/www/static/css/app.css。
alias的拼接规则是:alias路径 + location 匹配后剩余的部分。
location /static/ { alias /data/www/public/static/; }请求/static/css/app.css,实际查找路径是/data/www/public/static/css/app.css。
很多人以为location /static/ { root /data/www; }会去找/data/www/static/,这个理解没错,前提是项目目录结构里确实存在/static/这一层。但如果项目构建产物没有这层目录,那就直接 404。
我在实际项目里见过两种常见修法:
- 用
alias把/static/映射到实际的静态资源目录; - 调整目录结构,让文件放在
root能拼出来的位置上。
哪种更好取决于你的部署方式。如果是打包工具产物,我通常建议直接用alias,因为构建出的目录往往不是按 URL 前缀组织的。比如项目构建输出在dist/assets,但线上 URL 想保持/static/,你当然可以用:
location /static/ { alias /data/www/project/dist/assets/; }只要把 URL 前缀和物理路径解耦,后面改目录结构就不会牵一发动全身。
4.2 SPA 刷新 404:try_files 没写好
第二种高频 404 是 SPA 刷新导致。现象是:从首页点进去一切正常,一刷新/list/123就 404。
原因就是配置里只写了 root 和 index,没有 try_files 回退:
location / { root /data/www/spa; }请求/list/123时,Nginx 去找/data/www/spa/list/123,当然找不到。这时候要么返回 404,要么如果开了index,可能会去尝试目录下的index.html又失败。
修复方案:
location / { root /data/www/spa; index index.html; try_files $uri $uri/ /index.html; }try_files的执行逻辑是:第一个参数找不到就试第二个,第二个找不到就把请求内部重写到第三个参数。/index.html是内部重写,不是浏览器重定向,所以浏览器地址栏保持不变。
但这里有个反过来的坑:如果某个静态资源真的丢了,try_files也会把它重写到/index.html,前端拿到的是一份 HTML,但响应码是 200。排查这类问题时你会看到“资源加载失败”或者“Unexpected token<”,很容易让人误判为代码问题。所以我推荐给静态资源单独加一个 location:
location ^~ /assets/ { root /data/www/spa; try_files $uri =404; }这样构建资源缺失会直接 404,不会混进 SPA 回退。
4.3 proxy_pass 路径被“吃掉”的问题
第三种常见故障是反向代理后接口路径不对,表现为后端日志里的请求 path 少了一段或多了前缀。
例如:
location /api/ { proxy_pass http://backend/; }前端请求/api/user,后端收到的是/user。如果后端接口本来定义的是/api/user,那就会 404。
还有更隐蔽的:
location /api { proxy_pass http://backend/api; }前端请求/apidoc,经 location 匹配后剩余部分是doc,拼上/api后变成/apidoc。如果你的本意是希望所有/api开头请求都落到后端/api前缀下,结果/apidoc也会被代理进去。
我的排查方法很简单:打开后端访问日志,看真实的$request_uri是什么。如果发现路径变了,先检查 proxy_pass 的 URL 是否带 URI。不带 URI 就是原样转发,带 URI 就会替换匹配段。
如果你和我一样经常在多个微服务之间做转发,推荐用 upstream 把后端地址管理起来:
upstream api_upstream { server 10.0.0.2:8080; keepalive 32; } server { location /api/ { proxy_pass http://api_upstream; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }后面机器扩容、端口调整,只需要改 upstream,location 里的转发逻辑完全不用动。
4.4 location 与 add_header 的继承“陷阱”
这个坑比较隐晦,一般要等到安全扫描或者对比响应头的时候才会发现。
Nginx 的add_header继承规则是:如果当前配置块(比如 location)里定义了任何add_header,那么外层配置块里的add_header全部失效;如果当前块没定义,才继承外层。
举个例子:
server { add_header X-Frame-Options "SAMEORIGIN"; location /api/ { add_header Cache-Control "no-store"; } }这段配置里,访问/api/时,响应头只有Cache-Control,没有X-Frame-Options。安全扫描一跑,直接爆一个“缺少 X-Frame-Options”问题。
解决方法是把需要并存的 header 都写在同一层:
server { location /api/ { add_header Cache-Control "no-store"; add_header X-Frame-Options "SAMEORIGIN"; } }如果你用新版 Nginx,可以确认一下add_header的inherit参数是否可用,但我的经验是别依赖这个特性,因为线上不同服务器版本不一定统一。最稳妥的做法:要么别在子块写 add_header,要么把该有的头都写全。
4.5 内嵌 location 与 if 的隐藏风险
Nginx 支持 location 嵌套,但这个功能大部分人用不好,我也不推荐日常使用。原因在于 location 嵌套并不是“路径拼接”,而是“父 location 命中后,再一次重新匹配子 location”。
比如:
location /user/ { location /posts/ { proxy_pass http://posts_backend; } }你以为是/user/123/posts/匹配,实际上请求/posts/today也会命中内嵌 location,因为父 location 只是把/user/当作普通前缀,一旦转进来,内部又做了一次 location 匹配,和/user/这段路径已经完全无关。这种配置很容易让人产生路径拼接的错觉,出问题后极难定位。
location 里的if更危险。官方文档“If is Evil”不是段子,if在 location 里经常引发段错误或者多段继承问题。尤其是这样的写法:
location / { if ($request_filename ~* \.html$) { expires 7d; } }它会让你在语义上产生“满足条件就应用配置”的错觉,但 Nginx 的配置是声明式的,不是命令式 if 逻辑。我的建议是:能用try_files、map、server拆分解决的,绝对不用 location + if。
5. 工程化建议:location 配置的规范化与性能优化
5.1 先列目录,再写 location
我看到太多配置文件把 location 写得很随意,正则和前缀混在一起,顺序也没有层次。时间一长,没人敢动那台服务器的配置,因为一动就不知道会踩到哪个正则。
我自己的习惯是先列一份“路由目录”,按业务逻辑分类,再整理 location:
- 精确匹配:favicon、healthz、robots.txt;
- 静态资源:assets、static、uploads;
- API 反向代理:/api/、/admin/;
- SPA 回退:/;
- 错误页:@fallback、@maintenance。
于是配置自然长成这样:
server { listen 80; server_name example.com; root /data/www/example; index index.html; location = /favicon.ico { access_log off; log_not_found off; } location = /healthz { access_log off; return 200 "ok"; } location ^~ /assets/ { expires 30d; add_header Cache-Control "public, immutable"; access_log off; } location /api/ { include proxy_params; proxy_pass http://api_upstream; } location / { try_files $uri $uri/ /index.html; } }这样谁接手都能一眼看懂:精确匹配放在最上面,静态资源单独拦截,API 单独转发,剩下的交给前端路由。每条规则之间不会互相干扰。
5.2 正则能少用就少用
正则匹配在高并发下确实有性能损耗,虽然 Nginx 内部对正则做了缓存,但每个新请求第一次匹配时还是要走 PCRE。如果你在国外或国内高流量站点待过,应该见过pcre_jit on;开启后正则性能翻倍的效果。但就算有 JIT,复杂的正则也远不如前缀匹配快。
我给自己定了一条规矩:能写前缀匹配的就不写正则,必须写正则时把它们集中放在一个区域,按业务顺序排列,并且用注释注明匹配意图。
比如静态资源和带版本号的路由,能用^~就用^~:
location ^~ /static/ { alias /data/www/static/; expires 30d; } location ^~ /api/ { proxy_pass http://backend; }如果你编译的 Nginx 支持,开启 JIT:
pcre_jit on;但注意这是全局配置,放在http块里。有的老版本编译参数没带 PCRE JIT,直接写也不会生效,可以通过nginx -V查编译参数。
5.3 用 nginx -T 和 curl 做配置核验
改完 location,别急着 reload。我每次都会先跑两遍:
nginx -t nginx -T | grep -n "location"nginx -t只做语法检查,不会告诉你匹配逻辑对不对。要确认实际效果,还要用 curl 打一组代表性 URL:
curl -I http://127.0.0.1/static/css/app.css curl -I http://127.0.0.1/api/user curl -I http://127.0.0.1/user/profile-I发的是 HEAD 请求,Nginx 默认 fastcgi 等模块也可能不支持 HEAD,但静态文件、location 匹配、响应头基本都能验证到。观察三样东西:HTTP 状态码、Content-Type、Cache-Control。状态码不对说明匹配链有问题,Content-Type 不对说明 root/alias 拼错,Cache-Control 不对说明 add_header 覆盖或缓存配置没生效。
如果还是查不出问题,在测试站点临时开 debug 日志:
error_log /var/log/nginx/debug.log debug;开启后请求一次,日志里会出现 location 匹配过程的详细记录。注意线上别长期开着 debug,流量大的话日志量很吓人。
5.4 遇到复杂路由,先拆服务,别硬塞在 location 里
最后一个建议可能不太像技术,但很实用。当你的 location 开始需要大量if、连续rewrite、变量判断、多个 proxy_pass 分支时,不要继续在 Nginx 配置文件里堆逻辑了。Nginx 的强项是静态处理和反向代理,不是业务逻辑引擎。
我看到过的“终结级”复杂配置长这样:一个 location 里写了三个 if,每个 if 里又嵌 rewrite,rewrite 的目标还是另一个 location,另一个 location 里又根据 host 环境变量选不同的 upstream。这种配置维护成本极高,任何一个改动都可能牵动整个链路。
更好的做法是:
- 业务检测、鉴权逻辑放到后端服务或网关层;
- 需要条件路由时用
map在 server 层预计算变量,而不是在 location 里堆 if; - 确实需要脚本化处理,可以考虑 OpenResty 这类带 Lua 能力的版本,用代码管理逻辑,用 Nginx 管理流量。
我自己现在遇到复杂路由需求,第一反应是先问一句“这段逻辑能不能拆成一个单独服务”?如果能,就别为难 Nginx 配置文件。毕竟 location 最理想的状态是一眼能看懂它负责什么,而不是变成另一个需要 debug 的系统。