Nginx location 优先级:匹配顺序完全解析
nginx 并不会从上到下读你的 location 块,然后停在第一个匹配上。绝大多数「我的 location 块不生效」的报障,根子都在这一个误解上。对前缀 location(prefix location)来说,块写在文件的哪一行完全不影响结果,nginx 会把所有前缀都比一遍,留下最长的那个。
nginx 实际执行的是一段固定的四步流程:
- 精确匹配。 如果某个
location = /path与 URI 完全相等,nginx 直接用它并结束搜索,既不比前缀,也不跑正则。 - 最长前缀。 所有「URI 以之开头」的前缀 location(
location /path和location ^~ /path)都参与比较,最长的那个被记住,但还不会被使用。 ^~短路。 如果记住的这个前缀带^~,nginx 跳过整个正则阶段,直接用这个块。- 正则,按文件顺序。 否则
~和~*的 location 按它们在配置文件中出现的顺序逐个尝试,第一个命中的胜出。如果一个都不命中,才使用第二步记住的那个前缀。
其中两条规则方向相反:前缀比长度,不看顺序;正则比顺序,不看长度。从上往下读配置,永远读不出这个矛盾。自己那份配置到底怎么走,粘进免费的 nginx location 匹配优先级测试器 一试便知,它会重放这段流程,并标出每个落选的块是在哪一步被淘汰的。工具完全在浏览器里跑,粘进去的生产配置不会离开页面。本文写的每条匹配规则都是拿运行中的 nginx 1.27.5 实测核对过的,不是从二手文章里抄的。
一张表看懂 nginx location 优先级
参与 URI 匹配的修饰符有五个,另外还有一个从不参与。
| 修饰符 | 写法 | 匹配方式 | 是否终止正则阶段 | 典型用途 |
|---|---|---|---|---|
= | location = /path | 完全相等 | 是 | /、/favicon.ico 这类热点路径 |
^~ | location ^~ /path | 以之开头 | 是,前提是它就是最长前缀 | 绝不能落到正则上的目录 |
~ | location ~ regex | PCRE,区分大小写 | 否 | 需要区分大小写的扩展名路由 |
~* | location ~* regex | PCRE,不区分大小写 | 否 | 不需要区分大小写的扩展名路由 |
| (无) | location /path | 以之开头 | 否 | 按路径做常规路由 |
@ | location @name | 永远不参与 URI 匹配 | — | error_page 与 try_files 的跳转目标 |
一句话记忆:精确匹配赢过一切,正则赢过前缀,前缀之间比长度。唯一的例外是 ^~,而它的适用范围比看上去窄得多。
这张表顶多算个粗排序。^~ 在表里排在 ~ 上面,可现实中 ^~ 块经常输给正则,因为这个修饰符只会在「已经靠长度胜出的那个前缀」身上被检查。
四步选择算法
学 nginx location 匹配顺序,最快的办法是看一份把所有规则一次性触发的配置。下面这份来自 nginx 官方文档,值得记牢:
server {
location = / { return 200 "A\n"; }
location / { return 200 "B\n"; }
location /documents/ { return 200 "C\n"; }
location ^~ /images/ { return 200 "D\n"; }
location ~* \.(gif|jpg|jpeg)$ { return 200 "E\n"; }
}
五个请求,五个不同的答案:
| 请求 | 胜出者 | 原因 |
|---|---|---|
/ | A | 精确匹配,搜索立即结束。 |
/index.html | B | 没有正则命中,于是使用记住的前缀。 |
/documents/document.html | C | 比 / 更长的前缀。 |
/images/1.gif | D | ^~ 赢下前缀阶段,正则根本没跑。 |
/documents/1.jpg | E | 正则赢过了一个更长、但没带 ^~ 的前缀。 |
对比最后两行。/images/ 和 /documents/ 都是前缀,都能匹配,也都是各自请求的最长匹配。一个请求交给了前缀块,另一个交给了正则,区别只在那个 ^~。
第二步是大家最容易跳过的:nginx 不「使用」最长前缀,只是把它记住。这个块只是候选,正则阶段仍然可能把请求抢走。只有第 1、3、4 步会结束搜索。
为什么「最长前缀」比的是字符数,不是路径层级
前缀比较就是普通的字符串比较。它不知道 / 用来分隔路径段,也不会停在分隔边界上。看这份配置:
server {
location /static { }
location /static/ { }
}
请求 /staticfoo 会交给 location /static 处理。URI 以这七个字符开头,匹配成立;/static/ 则完全匹配不上,因为那个位置上没有斜杠。请求 /static/x 的走向相反,落到更长的 /static/ 上。
结果就是 location /static 顺带把 /static-backup、/staticfiles 以及任何以同样字母开头的路径都收编了。如果你想表达的是一个目录,就把结尾的斜杠写上,再给不带斜杠的路径补一条精确 location:
server {
location /static/ { root /var/www; }
location = /static { return 301 /static/; }
}
多数教程用的都是干净整齐的路径示例,永远暴露不出这个问题,所以它才总能顺利通过 code review。nginx location 匹配优先级测试器 会列出所有命中的块以及各自匹配到的字符数,让「吞掉兄弟路径」的前缀直接显形。
^~ 到底是什么意思(以及不是什么意思)
关于 nginx location ^~ 修饰符,最常见的描述是「它让这个块的优先级高于正则」。这个说法离正确只差一点点,而正因为只差一点点才危险。
^~ 实际做的事情是:在前缀比较阶段什么都不做。它不会让匹配变长,不会把这个块抬到其他前缀之上,也不会改变哪个前缀被记住。它是在之后才被检查的,而且只检查那个已经靠长度胜出的前缀。如果胜出者带 ^~,正则阶段被跳过;如果不带,正则阶段照常运行。
也就是说,一个更长的普通前缀就能悄无声息地把它废掉:
server {
location ^~ /a/ { }
location /a/b/ { }
location ~ \.php$ { }
}
请求 /a/b/x.php 由 ~ \.php$ 处理。/a/b/ 是最长的匹配前缀,nginx 记住的是它;它是普通修饰符,允许进入正则阶段;正则先命中,把请求拿走。那个 ^~ 块还老老实实待在文件里,看上去还在保护什么,实际对这个请求毫无影响。
把 URI 换成 /a/x.php,同一份配置的行为就完全不同了:这时 ^~ /a/ 是最长匹配,正则阶段被跳过,^~ 块胜出。同一个文件,两个长得差不多的请求,结论正好相反。
这不是纸上谈兵。^~ 最经典的用途,就是把一个可写目录挡在解释器之外:
server {
location ^~ /uploads/ { }
location ~ \.php$ { fastcgi_pass unix:/run/php-fpm.sock; }
}
去掉 ^~,请求 /uploads/evil.php 就会直奔 PHP-FPM。一长串「上传变 RCE」的漏洞报告都是这个形状,而安全与不安全之间只差两个字符。前面那个「^~ 被废掉」的场景放在这里就不只是理论问题:加一条更长的普通前缀,比如 location /uploads/thumbs/,就把它下面的一切重新敞开了,而做这件事的 diff 看起来人畜无害。
作用范围也要留意。^~ 只压制同一层级声明的正则,压不住嵌套在它自己内部的正则;嵌套 location 上的 ^~ 也保护不了自己免受 server 层正则的影响。在 nginx location 匹配优先级测试器 里把这个修饰符开开关关,看胜出者怎么变,它会把每个被跳过的正则单独标出来,不会让它们从表里凭空消失。
正则 location:顺序压倒精确度
nginx location 正则用 ~ 表示区分大小写,~* 表示不区分大小写。相比之下,前缀匹配在 Linux 上永远区分大小写。(在 macOS 这类不区分大小写的文件系统上,nginx 比较前缀时不区分大小写,并且会强制所有正则 location 按 ~* 的方式行事。如果你在 Mac 上开发、部署到 Linux,这个差异能把一条坏规则一路藏到线上。)
最坑人的是这条规则:正则按照它们在配置文件中出现的顺序求值,第一个命中就结束搜索。精确度、长度、锚定都不影响这个顺序。
server {
location ~ ^/a { }
location ~ ^/a/b/c$ { }
}
请求 /a/b/c 被 ~ ^/a 拿走。第二个块能完全精确地匹配这个 URI,写得也精细得多,却对任何请求都永远不会执行。这是一段死配置,而 nginx -t 会一声不吭地接受它。
习惯上应该把正则从最精确排到最宽泛,并且把列表压短。靠上的宽泛模式会让它下面的一切变得不可达;而一个只调整了行顺序的 diff 读起来毫无威胁,所以这类回归往往不在做功能时出现,而在做整理时出现。
锚定同样值得当心。location ~ /admin 两头都没锚,它会在 URI 的任意位置搜索,/public/admin/x 照样命中。想表达「从开头算起」就写 ~ ^/admin。只锚定结尾(比如 ~ \.php$)则是扩展名路由里正常且正确的写法。
有几个 PCRE 细节,用 JavaScript 的直觉去套会翻车:
- nginx 编译 location 模式时用的是 PCRE,不开 UTF 模式也不开多行模式,因此模式作用在字节上,
^只锚定 URI 的开头。 - PCRE 的
$也能匹配结尾换行符之前的位置。以%0A结尾的 URI 依然满足\.php$,这是绕过「按文件扩展名设置的规则」的一种已知手法。 - PCRE 里有不少 JavaScript 没有对应写法的构造:原子组
(?>…)、占有量词a*+、内联修饰符如(?i)、POSIX 字符类如[[:alpha:]],以及\A、\z、\K、\Q…\E这类转义。 - 含
{或}的模式必须加引号。location ~ ^/a{2}$会加载失败并报unknown directive "2}$",因为花括号把 token 截断了。要写成location ~ "^/a{2}$"。
捕获组的行为和你期望的一致,块内可以直接用 $1 及之后的编号:
upstream backend {
server 127.0.0.1:8080;
}
server {
location ~ ^/user/(\d+)/profile$ {
proxy_pass http://backend/profiles/$1;
}
}
如果你要调的是模式本身而不是它在文件里的位置,先到 正则表达式测试 里试;Regex 正则表达式速查 把语法讲得更细。
所有人都跳过的一步:URI 规范化
在任何 location 被考虑之前,nginx 会先改写请求目标。你的模式比对的是规范化之后的路径,不是网络上到达的那串原始字节。几乎没有教程提这件事,可它决定了相当一部分「我的 location 不匹配」的案例。
规范化做四件事:切掉查询字符串、对路径做百分号解码、解析 . 与 .. 段、合并重复的斜杠。
| 请求目标 | 规范化后的 $uri | 说明 |
|---|---|---|
//a//x | /a/x | 重复斜杠被合并 |
/a/../b/x | /b/x | .. 在匹配前就被解析 |
/a/b%2F..%2Fzz | /a/zz | %2F 解码成真正的分隔符并参与解析 |
/a/%2e%2e/b/x | /b/x | %2E 解码出的点同样参与解析 |
/a%20b/x | /a b/x | %20 变成真正的空格 |
/a+b/x | /a+b/x | 在路径里 + 不是空格 |
/a?x=/b | /a | 查询字符串先被切掉 |
/a%3Fx=1 | /a?x=1 | %3F 保持字面量,查询字符串为空 |
有三个解码后的字符属于例外,它们会被原样写出,不再重新解释:%25、%23 和 %3F。所以 /a%3Fx=1 最后的结果是:路径里带着一个问号,$args 却是空的。
还有两类目标根本到不了 location 选择这一步。爬到根目录之上的 .. 段,以及 %00 这类非法转义,都会在匹配开始前就以 400 被拒。
这里的安全含义得单独说。如果你拿 location 块当访问控制边界,那么你写下的路径比对的是解析之后的路径:
server {
location /a/ { }
location /b/ { }
}
请求 /a/b%2F..%2Fzz 并不会老实待在 /a/b/ 下面。它规范化成 /a/zz,由 location /a/ 处理。按原始目标而不是按 $uri 来推理,在这里会得出错误答案,而「错误答案」出现在访问控制场景里是有专门名字的。在你指望 location /admin 保护什么之前,先确认规范化后的路径究竟长什么样:nginx location 匹配优先级测试器 会把原始目标、规范化后的 $uri 和被切掉的查询字符串分成三行分别展示;如果你只是想单独琢磨编码本身,URL 编码解码工具 就够用了。
还有一个后果得说明白:查询字符串从不参与匹配。 location /search?q= 匹配不了请求 /search?q=1,因为选择阶段看到的永远只是 /search。要按参数分支,请在块内读 $arg_name。
五份「和你想的不一样」的配置
^~ 块输给更长的普通前缀
症状: 一个 ^~ 目录看着受了保护,但目录内的请求还是被正则接走了。
原因: ^~ 只在已经靠长度胜出的那个前缀上被检查。被记住的是更长的普通前缀,而它不压制任何东西。
修法: 给更长的那个前缀也加上 ^~,或者干脆删掉它。
# Broken: /a/b/x.php goes to the regex
location ^~ /a/ { }
location /a/b/ { }
location ~ \.php$ { }
# Fixed: /a/b/x.php goes to ^~ /a/b/
location ^~ /a/ { }
location ^~ /a/b/ { }
location ~ \.php$ { }
前缀不带尾斜杠,把兄弟路径一起收了
症状: 一个本该只服务某个目录的块,也接管了仅仅是开头字母相同的路径。
原因: 前缀匹配比的是字符,不是路径段,所以 location /app 也会匹配 /application。
修法: 把尾部斜杠写上;如果不带斜杠的路径也需要处理,再加一条 location = /app。
精确的正则被放在宽泛的正则下面
症状: 一条精细的规则永远不触发,而且哪里都不报错。 原因: 正则按文件顺序尝试,第一个命中即结束,所以宽泛模式下面的一切都不可达。 修法: 把精确的模式移到宽泛的上面,或者用锚定把宽泛的那条收紧。
把正则写在 ^~ 后面
症状: 一条 deny 规则加载得干干净净,却什么都拦不住。
原因: ^~ 接的是字面前缀,不是模式。nginx 从不抱怨,这个块只是永远匹配不到任何 URI。
修法: 改用正则修饰符。
# Broken: matches nothing, loads without error
location ^~ "\.php$" { deny all; }
# Fixed
location ~ \.php$ { deny all; }
以为查询字符串会参与匹配
症状: 一个带 ? 的 location 永远不匹配。
原因: 查询字符串在规范化阶段就被切掉了,location 选择只针对路径运行。
修法: 按路径匹配,在块内检查 $arg_name。
location /search {
if ($arg_q = "") { return 400; }
}
调试:查出到底哪个块赢了
debug 日志给出的是权威答案,搭起来的代价也最大。 它需要一个带 debug 支持编译出来的二进制,所以先检查:
nginx -V 2>&1 | grep -o with-debug
然后打开它,并 grep 出指明所选块的那一行:
error_log /var/log/nginx/debug.log debug;
grep "using configuration" /var/log/nginx/debug.log
响应头探针更快,也不需要 debug 编译。 给每个候选打上标签,再把响应头读回来:
location ^~ /uploads/ {
add_header X-Debug-Location "uploads-caret" always;
return 204;
}
location ~ \.php$ {
add_header X-Debug-Location "php-regex" always;
return 204;
}
curl -sI --path-as-is 'http://localhost/uploads/evil.php' | grep -i x-debug-location
--path-as-is 很关键:不加它,curl 会热心地替你把 .. 解析掉,于是你测的其实是另一个 URI。如果你要拼的命令更复杂,cURL 命令生成器 能帮你把参数写好,curl 命令速查表 覆盖其余部分。当探针返回的是重定向或 404 而不是你的自定义响应头时,HTTP 状态码速查表 通常能告诉你是哪个模块产生的。
nginx -T 会打印完整合并后的配置,包括所有 include 进来的文件。六个文件拼到一起之后,你的正则实际是什么顺序,就靠它来确认,而那个顺序很少等于你正在编辑的那个文件里的顺序。
nginx -T | grep -n "location"
在动服务器之前,先在 nginx location 匹配优先级测试器 里把问题复现出来。对着一份还没部署的配置迭代,比走一轮 reload 快得多,而且决策表会直接写明每个块是在哪一步被淘汰的,不用你自己推。
嵌套 location、try_files,以及它们改变不了的事
嵌套 location 在下一层跑同一套算法。一旦某个前缀 location 胜出,nginx 就进入它的子块并在那里重复搜索,这意味着嵌套的正则会先于父层的正则被尝试:
server {
location ~ \.php$ { }
location /a/ {
location ~ \.php$ { return 200 "nested\n"; }
}
}
/a/x.php 由嵌套块处理。嵌套还有一个不那么显眼的效果:它可能让一个全局更长的前缀变得不可达,因为每一层只会进入该层的胜出者。如果 location /a/bb/ 嵌套在 location /a/ 里面,而外层还有一个兄弟 location /a/b,那么请求 /a/bb/x 会走 /a/b。外层比较先发生,/a/b 赢下了它。而且进入子块后没找到匹配也不会回溯,请求仍然留在父块里。
try_files 和 rewrite 不属于选择阶段。它们在已经胜出的那个块内部执行,无法反过来改变胜出结果。如果请求压根到不了包含 try_files 的那个块,这条指令就毫无意义,而常见的元凶是一条 ~ \.php$ 正则在前缀块还没轮到时就把请求接走了。例外只有一个:内部重定向(rewrite … last,或 error_page 跳转)会重启匹配,改写后的 URI 会从头再走一遍 location 列表。
请求一个不带尾斜杠的目录时收到的 301,也不是匹配失败。产生它的机制有两套。如果一个名字以 / 结尾的 location 上带了 proxy_pass 或其他 *_pass 指令,那么对同一路径不带斜杠的请求会在选择阶段就被回以 301,发生在任何正则求值之前,并且查询字符串会被保留:
server {
location /user/ { proxy_pass http://backend/; }
}
# GET /user?x=1 -> 301 to /user/?x=1
加一条 location = /user 就能抑制这个重定向。另一套机制来自静态文件模块:当路径在磁盘上解析到一个真实目录时,它会自己发出 301,这取决于你的文件系统而不是你的配置。
常见问题
nginx location 的五个修饰符分别是什么?
nginx location 的五个修饰符是:= 表示精确匹配,^~ 表示跳过正则阶段的前缀,不写修饰符表示普通前缀,~ 表示区分大小写的正则,~* 表示不区分大小写的正则。还有第六种形式 location @name,它永远不参与 URI 匹配,只作为 error_page 和 try_files 的跳转目标存在。
用 = 做精确匹配能让 nginx 更快吗?
精确匹配的 location 会立即结束搜索,跳过前缀扫描和所有正则求值。这份节省是真实的,但小到感觉不出来。对每秒被打上千次的端点,比如健康检查或 /favicon.ico,值得写一条。给普通页面堆一大摞 = 块,配置复杂度上的代价会超过收益。
修饰符可以不带空格吗,比如 ~*^/api?
可以。location ~*^/api/ 和 location ~* ^/api/ 完全等价,因为 nginx 会从名字前面剥掉修饰符,并且优先匹配更长的修饰符,所以 ~* 会先于 ~ 被识别。但空格还是留着。粘在一起的修饰符看起来像模式的一部分,评审时人眼容易读错。
location 块里的 root 和 alias 有什么区别?
root 是把整个 URI 拼到目录后面,alias 是用它替换掉已匹配的前缀。用 location /static/ { root /var/www; } 时,请求 /static/x.css 会去找 /var/www/static/x.css;换成 alias /var/www/assets/; 则去找 /var/www/assets/x.css。用 alias 时,location 和路径要么都带尾斜杠,要么都不带。
一个请求能匹配多个 location 块吗?
可以有多个块匹配,但只有一个会处理请求。nginx 会比较所有前缀 location,必要时还会比较所有正则 location,然后把请求交给唯一的胜出者。落选块里的指令不会被继承,任何需要处处生效的配置,都得放在 server 或 http 层,或者在每个块里重复一遍。
nginx -t 能告诉我哪个 location 会匹配吗?
不能。nginx -t 检查的是语法和配置有效性,它从不模拟请求,因此对匹配顺序不给任何信息。想知道某个 URI 归谁管,可以读 debug 日志、加一个临时响应头,或者把配置粘到 nginx location 匹配优先级测试器 里,看每个块胜出或落选的原因。