Nginx 伪静态六类冷门写法:location 优先级、try_files 成环、alias 拼接与 $uri 编码差异的 404 归因

Nginx 的配置语法很薄,薄到给人一种「读两小时文档就懂了」的错觉。但真正写伪静态规则、写反代规则的人都会遇到一种情况:规则明明写了,访问却 404;或者配置看起来完全一样,换一个路径就失效。

我做了几年站,见过太多人是靠「试」来写 Nginx 配置的——加一个 location,刷新,404,再换成 alias,能用了,但不知道为什么能用。这篇文章想解决的就是这件事:把几类最容易踩、又最难自我诊断的写法讲清楚。

一、先把症状看清:404 到底是谁返回的

在讲具体配置之前,必须先建立一个观念:Nginx 的 404 有很多来源,不同来源的处理方式完全不同。

第一种,Nginx 自己找不到文件。这是最常见的,error.log 里会有一行类似:

open() "/var/www/html/zz1984/index.php/archive/2026" failed (2: No such file or directory)

注意路径里出现了 index.php/...,说明 Nginx 在把它当静态文件找,也就是说 fastcgi 那段没生效。

第二种,PHP-FPM 返回的 404。这种在 error.log 里看不到 open() 失败,但 access.log 的状态码是 404,且 upstream 的时间非零。典型原因是网站程序自己返回了 404,比如 Typecho 找不到对应文章、WordPress 的 permalink 规则冲突。

第三种,上游应用(Node、Python)返回的 404。这时候 Nginx 只是个透明代理。

第四种,CDN 或者上游代理返回的 404,Nginx 根本没参与。

判断方法很直接:看 error.log 有没有 open() 记录、看 access.log 的 upstream 字段、最后在服务器上 curl 本机 127.0.0.1 带 Host 头绕开 CDN:

curl -I -H "Host: www.example.com" "http://127.0.0.1/index.php/123.html"

如果本机正常、公网 404,问题在 CDN 或者中间的反代;如果本机也 404,再往下查配置。

二、冷门写法一:正则 location 的优先级与「看起来匹配了」的错觉

Nginx 的 location 匹配顺序是一个被反复讲、但依然大量误用的知识点。完整顺序是:

1. = 精确匹配,命中即返回;
2. 所有前缀匹配里挑出最长的一个,记下来;
3. 如果最长前缀带有 ^~,直接用,不再看正则;
4. 否则按配置文件中出现的顺序依次测试正则 location,第一个命中的胜出;
5. 如果所有正则都不命中,用第 2 步记下的最长前缀。

坑在第 4 步:正则的顺序是配置文件里的书写顺序,不是长度顺序。下面这段配置里,/static/ 的请求会命中第一个正则而不是第二个,因为 \.(js|css)$ 出现在前面:

location ~ \.(js|css)$ {
    expires 30d;
}
location ~ ^/static/ {
    add_header Cache-Control "public";
}

很多人写伪静态时把一堆正则堆在一起,以为 Nginx 会挑最精确的。它不会。想控制顺序只有两个办法:把更具体的正则写在前面,或者用 ^~ 把前缀匹配锁死。

还有一个更隐蔽的问题:^~ 只对前缀生效,写在正则 location 上没有任何意义(Nginx 会忽略)。见过有人写 location ~ ^~ /admin,以为这能让它优先,实际是把它彻底变成一个普通正则。

另外要提防正则的性能问题。location ~ .*\.(php|php5)$ 这种写法在大流量下没有任何优势,反而因为回溯可能比固定后缀慢。能用前缀匹配就用前缀,正则只给确实需要的地方。

三、冷门写法二:try_files 的 last 参数与嵌套 location 的死循环

伪静态的核心几乎都是 try_files:先找文件,找不到交给 index.php。标准写法:

location / {
    try_files $uri $uri/ /index.php?$query_string;
}

这里有个细节:最后那个参数如果是 URI,Nginx 会发起一次内部重定向,重新走一遍 location 匹配;如果写成 =404 或 @named,则是直接返回或者跳到命名 location。很多人不知道这一点,于是写出了死循环:

location / {
    try_files $uri /index.php;
}
location ~ \.php$ {
    try_files $uri =404;      # 这里如果也指回 /index.php 就死循环了
    fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}

上面这段是对的。但如果第二段写成 try_files $uri /index.php,请求 /index.php 时又找不到文件(比如路径算错了),就会重新进 location /,再被重定向到 /index.php,无限循环,Nginx 最终报 rewrite or internal redirection cycle,访问结果是 500 而不是 404。

看到 500 加这个报错,第一反应就该是 try_files 的闭环。

另一个坑是 $uri/ 这个带斜杠的项。它的作用是判断「这是一个目录」,如果存在就内部重定向到该目录的 index 文件。在纯伪静态站点上这一项是必要的(否则像 /archive/ 这样的目录路径会漏),但它也会带来一个问题:如果目录存在但没有 index 文件,且 autoindex off,就会返回 403。排查「目录访问 403」时先看这里。

还有一个 try_files 与 root 的位置关系问题:try_files 里的路径是相对 root 的,root 写在 location 外和写在 location 内效果一样,但如果用 alias,相对路径的拼接规则会变——这就是下一节要说的。

四、冷门写法三:alias 与 root 下划线陷阱

root 和 alias 的区别大家都知道一句话:root 是把 URI 拼到目录后面,alias 是替换掉 location 匹配的部分。但真正让人抓狂的是结尾斜杠。

看这个例子,要把 /data/files/ 映射到磁盘目录:

location /data/files/ {
    alias /www/files;      # 少了结尾斜杠
}

访问 /data/files/a.txt,Nginx 会去找 /www/filesa.txt——注意中间没有斜杠,路径直接粘在一起了。这是 alias 最经典的坑,报错是 404,error.log 里的路径会清楚地暴露这个拼接错误。

正确写法是 location 和 alias 要么都带斜杠,要么都不带,保持一致:

location /data/files/ {
    alias /www/files/;
}

另一个坑是正则 location 里的 alias。如果 location 用了正则并且带捕获组,alias 里应该用捕获组变量:

location ~ ^/thumb/(\d+)/(.+)$ {
    alias /www/thumbs/$1/$2;
}

但在正则 location 里,如果 alias 里完全不使用任何变量,Nginx 在部分版本上会报配置错误,或者行为不符合预期。稳妥的做法就是显式使用捕获组。

最后说一个和伪静态强相关的组合坑:location / { try_files ... } 与 alias 一起用时,try_files 里的 $uri 是完整的请求 URI,而 alias 会截掉前缀,两者叠加后的路径经常不是你以为的那个。这种场景我建议直接改用 root + 正确的目录结构,别硬用 alias。

五、冷门写法四:$uri、$request_uri 与 URL 编码差异

写伪静态时几乎每个人都会用到 $uri 和 $request_uri,但知道它们区别的人不多:

$request_uri 是客户端发来的原始 URI,带查询串、且保持原始编码;$uri 是 Nginx 内部规范化处理后的路径,不含查询串,且已经被解码(%E4%B8%AD 变成「中」)。

这个差异在中文站点上会直接导致 404。假设有一篇文章的路径是 /tag/中文/,客户端请求时是 URL 编码过的 /tag/%E4%B8%AD%E6%96%87/。这时候:

如果用 try_files $uri,Nginx 会用解码后的中文路径去拼磁盘路径,此时还要看文件系统编码是否一致,通常能对上;
如果自己拼 rewrite 规则,用了 $request_uri 去做字符串匹配,那匹配的是编码后的形态,规则里写中文永远匹配不上。

更麻烦的是双重编码。+ 在查询串里代表空格,但在路径里就是加号本身。有些 CMS 生成的链接里带 +,Nginx 默认不做路径里的 + 转空格,PHP 侧却可能收到不同的值,于是出现「链接能打开但内容不对」。

实用建议:伪静态规则里尽量只处理 ASCII 路径,把中文、空格这类非 ASCII 全部交给应用层。如果必须处理,rewrite 用 $request_uri 匹配,try_files 用 $uri,并且用 curl 带上真实编码测试,别在浏览器地址栏里测(浏览器会替你编码,掩盖问题)。

六、冷门写法五:if 的「为什么 if 是邪恶的」到底指什么

Nginx 社区有句名言「if is evil」。这句话被引用得太多,以至于很多人的理解变成了「if 完全不能用」,这是误读。原文的意思更精确:if 在 location 上下文中会创建一个隐式嵌套 location,导致其内部的部分指令行为不可预期。

最典型的是:在 if 块里写 proxy_pass 或 rewrite,实际行为可能是「if 条件为真时外的配置全部失效」或者「重写规则重复执行」。下面这个常见写法就是错的:

if ($http_user_agent ~* bot) {
    rewrite ^ /blocked.html last;
}

如果这段写在 location / 里面,last 会触发重新匹配,可能导致新的循环。正确做法是用 map 定义一个变量,然后在 location 级别用 if 加上 return(return 在 if 里是安全的):

map $http_user_agent $is_bot {
    default 0;
    "~*bot" 1;
}
server {
    if ($is_bot) { return 403; }
}

在 server 级别的 if 里用 return 是官方认可的安全用法。而在 location 里用 if 加 rewrite、proxy_pass、try_files 则容易出问题。

伪静态场景下更常见的错误是用 if 判断文件是否存在:

if (!-e $request_filename) {
    rewrite ^/(.*)$ /index.php?$1 last;
}

这是 WordPress 官方文档里的老写法(现在已改为 try_files),能跑,但每次请求都要做一次 stat 系统调用,而且 if 嵌套带来的指令失效问题依然存在。能用 try_files 表达的逻辑,就不要用 if 加 rewrite。

七、冷门写法六:error_page 与命名 location 的跳转方式

把 404 交给程序处理(比如 SPA 路由、或者需要记录 404 日志的站点),有两种写法:

# 写法 A:内部重定向到 URI
error_page 404 /index.php?$uri;

# 写法 B:内部跳转到命名 location
error_page 404 @app;
location @app {
    fastcgi_pass unix:/run/php/php8.2-fpm.sock;
    fastcgi_param SCRIPT_FILENAME /var/www/html/index.php;
    include fastcgi_params;
}

写法 A 的问题在于:error_page 后面的 URI 如果带查询串,Nginx 会有额外的拼接行为,容易和原有的 $query_string 冲突,导致程序收到的参数不对。写法 B 更干净,但要注意命名 location 里不会自动继承 SCRIPT_FILENAME 等 fastcgi 参数,必须手动写全,否则 PHP-FPM 报 Primary script unknown。

还有一个高频问题:error_page 加了 =200 会把状态码伪装成 200。做 SEO 的时候这是个经典错误——站点的 404 页面全部返回 200,搜索引擎会把大量不存在的页面当成正常页面收录,最终引发「软 404」问题。如果你确实用 error_page 处理 404,务必保留 404 状态码,除非你明确知道自己在做什么。

八、排查方法论:从 access.log 与 error.log 反推

讲了这么多写法,最后说一套我自己用的排查流程,因为实际工作中你遇到的往往不是一个「标准坑」,而是一个你没见过的组合。

第一步:确认请求是否到了 Nginx。用 curl -I 看响应头有没有 Server: nginx,有没有你自己的 add_header。如果被 CDN 拦了,先在服务器上 curl 127.0.0.1。

第二步:看 access.log 的完整一行。要确认三件事:状态码、$request_uri、upstream_status。如果你还没在 log_format 里加 upstream_status,现在就该加,它决定了问题在 Nginx 还是在上游:

log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                '$status $body_bytes_sent "$http_referer" "$http_user_agent" '
                'rt=$request_time urt=$upstream_response_time us=$upstream_status '
                'host=$host uri=$uri';

第三步:看 error.log。open() failed 的路径就是 Nginx 实际去找的位置,和你以为的位置一对比,九成问题当场解开。rewrite or internal redirection cycle 则说明 try_files 或 rewrite 成环。

第四步:用 nginx -T 打印合并后的完整配置。注意是大写 T,它会输出所有 include 进来的文件内容。很多人排查半天,最后发现是 conf.d 里另一个文件写了同样的 location,被后面的覆盖了——单个文件看起来完全正常,只有看合并结果才能发现。

第五步:确认 reload 真的生效了。nginx -t && systemctl reload nginx,然后看 master 进程的启动时间是否变化(ps -o lstart -p $(cat /run/nginx.pid))。有些托管面板把 nginx 包在容器里,systemctl reload 打在宿主上是无效的。

九、一份可用的伪静态配置模板

最后给一份我自己在用的、覆盖了 Typecho 类博客的模板,注释里标了每一步的理由:

server {
    listen 80;
    server_name www.example.com;
    root /var/www/example;
    index index.php index.html;

    # 静态资源先走这里,避免落到 PHP 分支
    location ^~ /usr/ { expires 30d; access_log off; }
    location ^~ /admin/ { expires 7d; access_log off; }

    # 主入口:先找真实文件,再找目录,最后交给 index.php
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    # PHP 只认真实存在的 .php 文件,防止任意文件执行
    location ~ \.php$ {
        try_files $uri =404;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        fastcgi_read_timeout 60s;
    }

    # 拒绝访问隐藏文件,但放行 ACME 验证
    location ~ /\.(?!well-known) { deny all; }

    # 404 交给程序处理时保留状态码
    error_page 404 /index.php;
}

这份模板里有三个细节值得再说一次:try_files $uri =404 防止了路径穿越导致的任意 PHP 执行;location ~ /\.(?!well-known) 是唯一带负向断言的地方,用来放行 Let's Encrypt 的验证路径;error_page 404 后面不加 =200。

写 Nginx 配置最有效的方法不是背文档,而是养成两个习惯:改完先 nginx -t,出问题先看 error.log 里的实际路径。这两个动作能解决绝大多数「规则明明写了却 404」的问题,剩下的才需要回到 location 优先级、alias 拼接这些细节里去。

Last modification:October 19th, 2026 at 12:23 pm

Leave a Comment