Nginx-05 location 匹配与 rewrite
location 匹配规则和 rewrite 是 Nginx 配置中最容易出错的地方,也是面试必考。这一篇把规则讲透,并用可复现的实验验证。
1. location 的五种形式
location = /exact { } # 精确匹配
location ^~ /prefix { } # 前缀匹配,命中后不再尝试正则
location ~ \.php$ { } # 正则匹配,区分大小写
location ~* \.(jpg|png)$ { } # 正则匹配,不区分大小写
location /normal { } # 普通前缀匹配
location @name { } # 命名 location,只能内部跳转,不参与匹配
2. 匹配优先级(核心)
Nginx 的匹配算法不是「从上到下第一个匹配的赢」,而是有明确的优先级。完整流程:
① 先看所有「=」精确匹配
→ 命中 → 立即使用,结束匹配 ★
② 再看所有前缀匹配(包括 ^~ 和普通前缀),
记录下「匹配长度最长」的那一个
→ 如果这个最长匹配带 ^~ → 立即使用,跳过正则 ★
③ 按配置文件中的书写顺序,依次尝试所有正则
→ 第一个匹配成功的 → 立即使用,结束匹配 ★
(注意:正则之间是「顺序优先」,不是「最长优先」)
④ 所有正则都不匹配 → 使用第 ② 步记录的最长前缀匹配 ★
浓缩成一句话:精确 > 带 ^~ 的最长前缀 > 正则(按书写顺序)> 最长前缀。
两个最容易记错的点:
- 前缀匹配之间比的是「长度」,正则之间比的是「顺序」。
- 普通前缀匹配的结果会被暂存,正则全不匹配时才启用——所以普通前缀写在正则前面还是后面都无所谓。
2.1 对照实验
server {
listen 8080;
default_type text/plain;
location = / {
return 200 "A: exact /\n";
}
location / {
return 200 "B: prefix /\n";
}
location /docs/ {
return 200 "C: prefix /docs/\n";
}
location ^~ /images/ {
return 200 "D: ^~ /images/\n";
}
location ~* \.(gif|jpg|png)$ {
return 200 "E: regex image\n";
}
location ~ ^/docs/.*\.html$ {
return 200 "F: regex docs html\n";
}
}
验证结果:
| 请求 | 命中 | 原因 |
|---|---|---|
/ |
A | 精确匹配最高优先级 |
/index.html |
B | 只有 / 前缀匹配,正则都不中 |
/docs/a.txt |
C | 最长前缀 /docs/,正则不中 |
/docs/a.html |
F | 正则命中,优先于最长前缀 /docs/ |
/images/a.png |
D | ^~ 命中,直接跳过正则 E |
/other/a.png |
E | 正则命中 |
/docs/a.png |
E | 正则 E 写在 F 前面,先匹配到 E 就结束 |
for u in / /index.html /docs/a.txt /docs/a.html /images/a.png /other/a.png /docs/a.png; do
printf "%-20s -> " "$u"; curl -s "http://127.0.0.1:8080$u"
done
最后一行 /docs/a.png 命中 E 而不是 F,就是「正则按书写顺序」的直接体现。把 E 和 F 调换位置,结果就变了。
2.2 生产中最常见的一个坑
# ❌ 静态资源没被 /api/ 拦住,但 API 请求里带 .js 的会被静态规则截胡
location /api/ {
proxy_pass http://backend;
}
location ~* \.(js|css|png)$ {
root /var/www/static;
expires 30d;
}
请求 /api/config.js 会命中正则规则,去 /var/www/static/api/config.js 找文件,结果 404。因为正则优先于普通前缀匹配。
修复方法是给 /api/ 加 ^~:
# ✅
location ^~ /api/ {
proxy_pass http://backend;
}
location ~* \.(js|css|png)$ {
root /var/www/static;
expires 30d;
}
规律:只要一个前缀 location 里配了 proxy_pass,并且同时存在按扩展名匹配的正则 location,就应该给这个前缀加 ^~。
2.3 匹配用的是什么
匹配用的是 $uri,即:
- 已 URL 解码:
/a%20b匹配的是/a b - 已规范化:
/a/./b、/a//b、/a/c/../b都会被归一成/a/b - 不含查询串:
/a?x=1匹配的是/a
这点很重要——想靠 location ~ /\.\. 防路径穿越是没用的,Nginx 早就把 .. 归一化掉了。
3. proxy_pass 的斜杠玄学
proxy_pass 的 URL 末尾有没有斜杠,行为完全不同。这是最高频的踩坑点。
# 情况一:proxy_pass 不带路径(只有 host:port)
# → 完整的原始 URI 直接拼到后面
location /api/ {
proxy_pass http://backend;
}
# /api/user/1 → http://backend/api/user/1
# 情况二:proxy_pass 带路径且以 / 结尾
# → location 匹配的部分被「替换」成 proxy_pass 的路径
location /api/ {
proxy_pass http://backend/;
}
# /api/user/1 → http://backend/user/1 ← /api/ 被去掉了
# 情况三:proxy_pass 带路径且不以 / 结尾
# → location 匹配的部分被替换成这个路径(直接字符串拼接)
location /api/ {
proxy_pass http://backend/v1;
}
# /api/user/1 → http://backend/v1user/1 ← ⚠️ 注意粘在一起了!
# 情况四:都带 /
location /api/ {
proxy_pass http://backend/v1/;
}
# /api/user/1 → http://backend/v1/user/1 ← ✅ 这才是想要的
记忆口诀:proxy_pass 后面有 URI(哪怕只是一个 /)就做「替换」,没有 URI 就做「透传」。
对照表:
| location | proxy_pass | 请求 /api/user/1 |
转发到 |
|---|---|---|---|
/api/ |
http://b |
/api/user/1 |
|
/api/ |
http://b/ |
/user/1 |
|
/api/ |
http://b/v1 |
/v1user/1 ⚠️ |
|
/api/ |
http://b/v1/ |
/v1/user/1 |
|
/api |
http://b/ |
/user/1(前面多个 /?不,是 /user/1) |
3.1 两个特殊情况
正则 location 里,proxy_pass 不能带 URI:
# ❌ 报错:proxy_pass cannot have URI part in location given by regular expression
location ~ ^/api/(.*)$ {
proxy_pass http://backend/v1/;
}
# ✅ 用捕获组自己拼
location ~ ^/api/(.*)$ {
proxy_pass http://backend/v1/$1$is_args$args;
}
# ✅ 或者先 rewrite 再 proxy_pass
location ~ ^/api/ {
rewrite ^/api/(.*)$ /v1/$1 break;
proxy_pass http://backend;
}
proxy_pass 里含变量时,URI 不会自动传递:
# ❌ 请求 /api/user 会转发到 http://backend/ (URI 丢了)
set $target "backend";
proxy_pass http://$target;
# ✅ 必须显式写
proxy_pass http://$target$request_uri;
含变量的 proxy_pass 还有个副作用(也是常用的技巧):它会在运行时用 resolver 解析域名,而不是启动时解析一次。见第 03 篇。
4. rewrite 指令
rewrite 正则 替换内容 [flag];
4.1 四个 flag
| flag | 行为 |
|---|---|
| (无) | 改写 $uri,继续执行后面的 rewrite 指令 |
last |
改写 $uri,停止后续 rewrite,跳回 FIND_CONFIG 重新匹配 location |
break |
改写 $uri,停止后续 rewrite,不重新匹配 location,继续在当前 location 走后面的阶段 |
redirect |
返回 302 临时重定向给客户端 |
permanent |
返回 301 永久重定向给客户端 |
前两个是内部行为(客户端不知情,URL 不变),后两个是真的让浏览器重新发一次请求。
4.2 last vs break
这是最经典的面试题。看具体例子:
server {
location /a/ {
rewrite ^/a/(.*)$ /b/$1 last; # 用 last
return 200 "from location /a/\n"; # ← 永远执行不到
}
location /b/ {
return 200 "from location /b/\n";
}
}
# 请求 /a/x → "from location /b/"
# last 触发内部重定向,重新匹配到了 /b/
server {
location /a/ {
rewrite ^/a/(.*)$ /b/$1 break; # 用 break
return 200 "from location /a/\n";
}
location /b/ {
return 200 "from location /b/\n";
}
}
# 请求 /a/x → "from location /a/"
# break 不重新匹配 location,继续在 /a/ 里往下走
实用判断标准:
- 想让改写后的 URI 走另一个 location 的配置 →
last - 想只改 URI、继续用当前 location 的配置(尤其是配合
proxy_pass)→break
配合 proxy_pass 时基本都用 break:
location /api/ {
rewrite ^/api/(.*)$ /$1 break; # 去掉 /api/ 前缀
proxy_pass http://backend; # 用 break 才能保住这里的 proxy_pass
}
如果这里写 last,改写后的 /xxx 会重新匹配 location,可能匹配到别的地方去。
补充:
break指令(不带 rewrite,单独用)是另一回事,它的作用是「停止当前的 rewrite 阶段处理」,效果和rewrite ... break的第二部分一样。
4.3 死循环防护
内部重定向最多循环 10 次:
# ❌ 死循环
location / {
rewrite ^(.*)$ /index.php$1 last;
}
# error.log: rewrite or internal redirection cycle while processing "/index.php/index.php..."
# 客户端收到 500
改成 break 或者加条件排除:
location / {
if (!-e $request_filename) {
rewrite ^(.*)$ /index.php$1 break;
}
}
4.4 rewrite 的正则与捕获
# $1 $2... 是捕获组
rewrite ^/user/(\d+)/profile$ /profile.php?uid=$1 last;
# 替换串以 http:// https:// $scheme 开头时,自动变成 302(无需写 redirect)
rewrite ^/old$ https://new.example.com/;
# 替换串里带 ? 会丢弃原有查询参数;末尾加 ? 明确丢弃
rewrite ^/a$ /b?x=1?; # 原查询参数被丢弃
rewrite ^/a$ /b?x=1; # 原查询参数会被追加(因为默认追加原 args)
# 用 (?i) 做不区分大小写
rewrite (?i)^/ABC$ /abc last;
关于查询参数的规则:如果替换串里不含 ?,原始的查询串会自动追加;如果含 ?,则不追加,除非再在末尾加一个 ?。
4.5 return —— 优先用它
能用 return 就不要用 rewrite。return 更快(不用跑正则)、更清晰。
return 状态码 [文本或 URL];
return 状态码;
return URL; # 只能是 http:// https:// 开头,等价于 302
# HTTP 跳 HTTPS —— 标准写法
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
# 而不是这样(慢,且容易写错)
server {
listen 80;
rewrite ^(.*)$ https://$host$1 permanent; # ❌ $1 不含查询串
}
# 返回自定义内容
location = /health {
access_log off;
return 200 "ok\n";
}
# 返回 JSON
location = /api/status {
default_type application/json;
return 200 '{"status":"ok","host":"$hostname"}';
}
# 直接断开连接(Nginx 私有码)
location ~ /\.(git|svn|env) {
return 444;
}
注意 return 301 https://$host$request_uri; 里为什么用 $request_uri 而不是 $uri:$request_uri 包含查询串,$uri 不包含。用 $uri 会导致跳转后丢参数。
5. if 的陷阱
Nginx 官方文档里有一篇专门的 If is Evil。
5.1 if 只在 REWRITE 阶段执行
if 是 rewrite 模块提供的,所以它在 REWRITE 阶段(第 4 阶段)执行。这导致它和 CONTENT 阶段的指令组合时行为诡异。
if 里只有两类指令能可靠工作:return 和 rewrite。其他的都可能出问题。
5.2 典型的 if 事故
# ❌ 事故一:if 里的 add_header 会导致 location 配置被替换
location /api/ {
add_header X-A "1";
if ($arg_debug) {
add_header X-B "2"; # 进入 if 后,X-A 消失了
}
proxy_pass http://backend;
}
原因:if 在内部实现上会创建一个匿名的嵌套 location,进入 if 就等于进入了这个新 location。而 add_header 是数组式继承——新 location 写了一条,父级的全丢。
# ❌ 事故二:两个 if 里的 proxy_pass,后面的会覆盖前面的
location / {
if ($arg_a) { proxy_pass http://a; }
if ($arg_b) { proxy_pass http://b; }
# 行为完全不可预测
}
# ❌ 事故三:if 里没有 content handler,导致 404 或未定义行为
location /files/ {
if ($request_method = POST) {
return 405;
}
# 如果条件不成立,隐式的 location 里没有 root,可能读不到文件
root /data;
}
5.3 正确的替代方案
用 map 代替 if:
# ❌
location / {
if ($http_user_agent ~* "mobile") {
proxy_pass http://mobile_backend;
}
proxy_pass http://desktop_backend;
}
# ✅
map $http_user_agent $backend_pool {
default "desktop_backend";
~*mobile|android "mobile_backend";
}
location / {
proxy_pass http://$backend_pool;
}
用 try_files 代替文件存在性判断:
# ❌ 官方明确不推荐
if (!-f $request_filename) {
rewrite ^ /index.html last;
}
# ✅
try_files $uri $uri/ /index.html;
用 limit_except 代替方法判断:
# ❌
if ($request_method = POST) { return 405; }
# ✅
location /files/ {
limit_except GET HEAD {
deny all;
}
root /data;
}
用独立 location 代替 if:
# ❌
location / {
if ($uri ~ ^/admin) { ... }
}
# ✅
location /admin { ... }
location / { ... }
5.4 if 的条件语法
如果实在要用(配合 return/rewrite 是安全的):
if ($var) # 变量非空且不为 "0" 则为真
if ($var = "value") # 字符串相等
if ($var != "value") # 不相等
if ($var ~ "regex") # 正则匹配,区分大小写
if ($var ~* "regex") # 正则匹配,不区分大小写
if ($var !~ "regex") # 正则不匹配
if (-f /path/file) # 文件存在
if (!-f /path/file) # 文件不存在
if (-d /path/dir) # 目录存在
if (-e /path) # 文件或目录存在
if (-x /path/file) # 文件可执行
Nginx 的 if 不支持 &&、||、else if。多条件只能用 map 拼变量:
# 想表达「是 POST 且带 token」
map "$request_method:$http_x_token" $need_check {
default 0;
"~^POST:.+$" 1;
}
if ($need_check) { ... }
6. error_page
server {
# 基本用法:内部重定向到指定 URI
error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
# 改变响应码(= 后面跟新状态码)
error_page 404 =200 /empty.gif; # 404 时返回 200 和一张空图
error_page 403 =404 /404.html; # 隐藏 403,对外表现为 404
# 用 = 不跟状态码:使用被重定向到的那个 URI 的真实状态码
error_page 404 = /fallback;
# 跳转到外部地址(会变成 302)
error_page 404 http://cdn.example.com/404.html;
# 交给命名 location
error_page 502 504 = @fallback;
location = /404.html {
root /usr/share/nginx/html;
internal; # 不允许客户端直接访问
}
location @fallback {
proxy_pass http://backup_server;
}
}
6.1 拦截上游的错误页
默认情况下,上游返回的 4xx/5xx 会原样透传给客户端,error_page 不生效。想让 Nginx 接管,要加 proxy_intercept_errors:
location /api/ {
proxy_pass http://backend;
proxy_intercept_errors on; # 让 error_page 能处理上游的错误码
error_page 500 502 503 504 /50x.html;
}
对应的还有 fastcgi_intercept_errors、uwsgi_intercept_errors。
6.2 API 服务应该关掉 error_page
对纯 API 服务,返回 HTML 错误页是灾难(客户端要解析 JSON 却收到 HTML)。应该统一返回 JSON:
location /api/ {
proxy_pass http://backend;
error_page 502 503 504 = @api_error;
}
location @api_error {
default_type application/json;
return 502 '{"code":502,"message":"upstream unavailable","request_id":"$request_id"}';
}
7. 一个完整的实战配置
综合上面所有知识点,一个典型的「前后端分离 + 静态资源」站点:
server {
listen 443 ssl;
http2 on;
server_name example.com;
root /var/www/dist;
include snippets/ssl-common.conf;
include snippets/security-headers.conf;
# 1. 健康检查:精确匹配,最快,不记日志
location = /health {
access_log off;
return 200 "ok\n";
}
# 2. API 反代:用 ^~ 避免被下面的静态正则截胡
location ^~ /api/ {
include snippets/proxy-headers.conf;
proxy_pass http://backend/; # 注意结尾的 /,去掉 /api/ 前缀
proxy_intercept_errors on;
error_page 502 503 504 = @api_error;
}
# 3. WebSocket
location ^~ /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s; # 长连接要放宽
}
# 4. 带 hash 的构建产物:强缓存
location ~* \.[0-9a-f]{8,}\.(js|css|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# 5. 其他静态资源
location ~* \.(png|jpe?g|gif|svg|ico|webp)$ {
expires 30d;
add_header Cache-Control "public";
access_log off;
}
# 6. 屏蔽敏感路径
location ~ /\.(git|svn|env|htaccess) {
return 444;
}
# 7. SPA 兜底:所有前端路由都回 index.html
location / {
try_files $uri $uri/ /index.html;
# index.html 本身不能缓存,否则发版后用户拿不到新版
location = /index.html {
add_header Cache-Control "no-cache, must-revalidate";
}
}
location @api_error {
default_type application/json;
return 502 '{"code":502,"message":"service unavailable"}';
}
}
注意第 7 条里的嵌套 location:location 可以嵌套在 location 里,内层的匹配优先级更高。这是给 SPA 的 index.html 单独设缓存策略的干净写法。
8. 调试技巧
用 return 把变量打出来,比看日志快得多:
location = /debug {
default_type text/plain;
return 200 "uri=$uri
request_uri=$request_uri
args=$args
host=$host
http_host=$http_host
remote_addr=$remote_addr
scheme=$scheme
document_root=$document_root
request_filename=$request_filename
";
}
curl "http://127.0.0.1/debug?a=1&b=2"
在日志里加上命中的 location(用 set 变量标记):
log_format debug '$remote_addr "$request" $status loc=$matched_loc';
location ^~ /api/ {
set $matched_loc "api";
proxy_pass http://backend;
}
location / {
set $matched_loc "root";
try_files $uri /index.html;
}
error_log debug 直接看匹配过程(需要 --with-debug):
[debug] test location: "/"
[debug] test location: "api/"
[debug] test location: "health"
[debug] using configuration "^~ /api/"
9. 面试题
Q:location 的匹配优先级是什么?
= 精确匹配 > ^~ 前缀匹配(取最长的) > 正则匹配(~/~*,按配置文件书写顺序,第一个匹配的赢) > 普通前缀匹配(取最长的)。注意:前缀匹配之间比长度,正则之间比顺序;普通前缀的匹配结果会先暂存,只有所有正则都不匹配时才启用。
Q:^~ 的作用是什么?
它是前缀匹配修饰符,含义是「如果这个前缀是最长匹配,就直接使用它,不再尝试任何正则 location」。典型用途是保护 proxy_pass 的 location 不被按扩展名匹配的正则截胡,比如 /api/config.js 不应该被 location ~* \.js$ 抢走。
Q:proxy_pass http://b; 和 proxy_pass http://b/; 有什么区别?
前者不带 URI,原始请求 URI 完整透传(/api/x → /api/x);后者带 URI(一个 / 也算),会把 location 匹配的部分替换掉(location /api/ 下 /api/x → /x)。规律是「有 URI 就替换,没 URI 就透传」。
Q:rewrite ... last 和 rewrite ... break 的区别?
last 停止执行后续 rewrite,并触发内部重定向,跳回 FIND_CONFIG 阶段重新匹配 location(最多 10 次);break 停止执行后续 rewrite,但不重新匹配 location,继续在当前 location 的配置下走后面的阶段。配合 proxy_pass 改写路径时必须用 break,否则会跳到别的 location。
Q:为什么官方说 “If is Evil”?
if 在实现上会创建一个匿名的嵌套 location,进入 if 相当于切换了配置上下文。这导致:(1) add_header/proxy_set_header 这类数组式指令的父级配置全部丢失;(2) 多个 if 里写 proxy_pass 行为不可预测;(3) if 里缺少 content handler 时可能 404。只有 return 和 rewrite 在 if 里是安全的。替代方案:map、try_files、limit_except、拆成独立 location。
Q:Nginx 的 if 支持 && 吗?
不支持,也不支持 || 和 else if。多条件判断的标准做法是用 map 把多个变量拼成一个 key 再映射,或者用嵌套 if(但要小心上下文问题)。
Q:return 301 https://$host$request_uri 里为什么不用 $uri?
$uri 不包含查询串,用它会导致跳转后丢失 ?a=1 这样的参数。$request_uri 是客户端原始请求的完整 URI,包含查询串。
Q:上游返回 502,我配的 error_page 502 /50x.html 为什么不生效?
默认情况下上游的错误响应会原样透传,需要显式打开 proxy_intercept_errors on; 才会交给 error_page 处理。
上一篇:Nginx-04 请求处理流程与 11 个阶段 | 下一篇:Nginx-06 反向代理与负载均衡
xingliuhua