站点跑起来之后,最常见的「小需求」不是加功能,而是让访客能记住一个好记的地址。默认的动态地址形如 /index.php/archives/123/,又长又丑,分享出去没人愿意点。伪静态(URL 重写)就是把这层外观换成 /tech/123.html 这样的形式。听起来是一个正则配置的事,实际上每年都有站长在这上面翻车,而且翻车的方式很统一:改完之后站内链接、旧收录、分页、API 路径全都 404。
这篇讲三件事:伪静态在 Nginx 与 Apache 上分别该怎么写,哪些请求绝对不能被重写吃掉,以及切换之后如何收拾旧地址与验证效果。文中的例子以 Typecho 与 WordPress 这类「单入口 + 前台伪静态」的 PHP 程序为主,换成别的程序思路一样。
一、先分清「重写」和「重定向」
很多人把 301 跳转和内部重写混为一谈。它们的区别只有一句话:重写对浏览器透明,重定向要浏览器再发一次请求。
- 重写(rewrite):服务器内部把
/tech/123.html交给/index.php处理,地址栏不变,客户端只看到一次请求。 - 重定向(301/302):服务器回一个 301,浏览器拿着新地址再请求一次,地址栏变化。
两者的顺序非常关键。Nginx 里 rewrite ... last 会在当前 server 块内重新匹配 location,而 return 301 直接结束处理。如果规则顺序写反,典型症状是「一直在跳转」或者「跳转后 404」。判断方法很简单:curl -I 看响应码。如果是 200 加正确的 HTML,说明是内部重写;如果是 301 并且 Location 指向另一个地址,那就是重定向。
curl -s -o /dev/null -w "code=%{http_code} hops=%{num_redirects} url=%{url_effective}" "https://example.com/tech/123.html"输出 hops=0 才是真正的重写。如果 hops>0 而你本意是内部重写,说明规则被写成了 redirect 或者被别的规则抢先命中。
二、Nginx 的标准写法与三个必查项
以站点根目录 /home/wwwroot/example.com、入口 index.php 为例,最常见的一段配置是:
server {
listen 80;
server_name example.com www.example.com;
root /home/wwwroot/example.com;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php$is_args$args;
}
location ~ \.php($|/) {
fastcgi_pass unix:/dev/shm/php-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}这段配置里 try_files 是全部的关键:先看请求的路径是不是真的存在文件,再看是不是目录,都不存在才交给 index.php 去路由。$is_args$args 保证 ?page=2 这类查询串不会丢。
三个必查项,每一个都对应一类线上事故:
第一,静态资源不能进 index.php。如果站点上有真实的 /uploads/2026/a.jpg,第一段 try_files 会命中它并直出,没问题。但如果文件被误删,请求就会落到 index.php,返回一个 200 的 HTML 页面而不是 404,浏览器拿到一段 HTML 当图片用,结果是破图,同时搜索引擎会把一个「内容错误」的 200 页面收进去。正确做法是对上传目录单独写一段,命中不到就返回 404:
location ^~ /uploads/ {
try_files $uri =404;
expires 30d;
}^~ 的语义是「命中该前缀后不再检查正则 location」,这样上传目录不会被后面的 \.php$ 或 \.(js|css)$ 抢走。
第二,后台与接口不能走前台规则。如果你的程序后台在 /admin,而某条正则过于宽泛地把所有不以文件扩展名结尾的路径都塞给 index.php,后台的 /admin/user/list 会被前台路由接管,表现是「后台白屏或 404」。稳妥的写法是把后台、API、以及程序自身的入口目录用 ^~ 显式排除在前面。
第三,动态请求必须显式禁止缓存。伪静态之后,同一个 URL 对不同访客可能返回不同内容(比如带用户名的头部)。如果中间有 CDN 或 Nginx 缓存层按 URL 缓存,就会出现「A 用户看到 B 用户的信息」。在 location ~ \.php 里补一行 add_header Cache-Control "no-store" always;,把责任声明在源站。
三、用 $uri 判断而不是判扩展名
新手最爱写的一类规则是「不以 .html/.css/.js 结尾的都重写」。这条规则的问题在于,它依赖「所有静态文件都有扩展名」这个假设。实际上有大量请求没有扩展名:/sitemap、/robots、/feed、/.well-known/acme-challenge/xxx,还有 CDN 回源时带的探活路径。它们会被一律塞进 index.php,轻则多一次数据库查询,重则直接破坏 ACME 证书续期——这是真实踩过的坑,证书一直续不上,最后发现是证书验证文件被重写规则吃掉了。
正确的判据是「文件是否存在」,也就是 try_files 的语义,而不是「字符串长什么样」。凡是需要按路径分流的地方,优先想到 try_files 和 location ^~,而不是堆正则。
四、Apache 下的等价配置与两个坑
Apache 用的是 .htaccess 里的 mod_rewrite。Typecho 与 WordPress 默认会生成一个类似这样的文件:
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ /index.php/$1 [L]
</IfModule>逻辑与 try_files 一致:文件存在就不重写,目录存在就不重写。[L] 表示命中后停止后续规则。
坑一:AllowOverride 被关掉。很多面板默认把 AllowOverride None 写进 httpd.conf,.htaccess 里的规则一句都不生效,但页面不报错,只是伪静态完全没用。查证方式是在 .htaccess 里临时写一句 Redirect 301 /test-abc /,如果访问 /test-abc 没有跳转,就是 AllowOverride 的问题。
坑二:PATH_INFO 被吃掉。如果 RewriteRule 写成 /index.php?$1 而不是 /index.php/$1,程序拿不到 PATH_INFO,所有页面都会显示首页。这两行看起来只差一个符号,结果完全不同。
五、分页与带参数地址最容易丢参数
列表页的分页通常形如 /tech/page/2 或 /tech/?page=2。前者靠程序路由解析,后者靠查询串。重写规则如果漏了 $args(Nginx)或没有 [QSA] 标记(Apache),第二次点击分页会回到第一页。这个 bug 的经典特征是:首页正常、第二页显示的还是第一页的内容,而且 URL 看着没问题。
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# Apache 等价
RewriteRule ^(.*)$ /index.php/$1 [L,QSA]验证方法:随便找一个有两页以上的分类,访问第 2 页,然后看正文里第一篇文章的标题是否与第 1 页不同。相同就是参数丢了。
六、切换伪静态:旧地址怎么处理
如果站点此前一直用 /index.php/archives/123/ 这种地址并且已经被收录,直接改伪静态会制造一批 404。正确的顺序是「先兼容,再切换,最后清理」:
第一步,保留旧地址可用。要么在重写规则里同时接受两种前缀,要么对旧前缀做 301 到新地址:
rewrite ^/index\.php/archives/([0-9]+)/?$ /archives/$1.html permanent;
rewrite ^/index\.php/tech/([0-9]+)/?$ /tech/$1.html permanent;第二步,切换站内链接。仅仅改配置不会改数据库里的链接,正文里那些绝对地址、导航里的旧 URL 仍然会指向旧路径。用一个 SQL 或脚本把所有站内旧链接替换掉,再抽查十篇历史文章确认没有残留。
第三步,观察两周再清理。301 需要时间被搜索引擎消化,太早删掉旧规则会让还在排队的抓取拿到 404。判断是否可以清理的指标是:日志里旧路径的请求量降到接近 0,且这些请求的 User-Agent 里不再有搜索引擎爬虫。
第四步,重新生成并提交 sitemap。伪静态后所有 URL 变了,旧 sitemap 全部失效,这是一个很容易漏的动作。
七、验证伪静态是否真的生效
不要只靠浏览器里看着像。四个检查点,一次跑完:
- 首页、分类页、文章页各取一个 URL,
curl -I看是否 200 且hops=0。 - 随便访问一个不存在的路径,应当返回 404 而不是 200 的首页内容。后者说明兜底规则把所有路径都吞了,这是 SEO 上的隐患,会把无限多 URL 变成 200 页面。
- 带参数的地址(
?page=2)确认参数传递正常。 - 静态资源(图片、CSS、JS)的响应头里要有正确的
Content-Type,而不是text/html。后者说明这些请求被交给了index.php。
for u in "/" "/tech/" "/tech/123.html" "/uploads/test.jpg" "/nope-404-test"; do
printf "%-26s " "$u"
curl -s -o /dev/null -w "code=%{http_code} type=%{content_type} " "$u"
echo
done正常结果应该是前四个 200、最后一个是 code=404,并且图片那条的类型是 image/jpeg。如果最后一个是 200 且类型 text/html,说明兜底过于激进;如果图片那条是 text/html,说明静态资源也被交给了 PHP。
八、几个值得提前知道的限制
URL 长度。把文章标题直接塞进 URL 很美,但中文标题转成百分号编码后会膨胀三到九倍,很容易超过某些代理的默认长度限制,导致 414 或者被 CDN 直接拒掉。稳妥做法是用 ID 加短 slug,例如 /tech/123-nginx-rewrite.html,既好看又可控。
大小写与斜杠。/Tech/123.html 与 /tech/123.html 是两个不同的 URL,末尾带不带斜杠也是两个 URL。上线前决定好其中一个,并对另一种形式做 301,否则同一篇内容会有多个可访问地址,权重被摊薄。
伪静态不等于静态。URL 看起来像 HTML 文件,请求仍然由 PHP 处理,仍然要查数据库。它改善的是可读性与分享体验,不是性能。想让页面真正变快,需要的是整页缓存或静态化,这两件事别混为一谈。很多站长以为改了伪静态页面就会变快,然后去调 PHP-FPM 参数,方向从一开始就错了。
九、小结
伪静态这件事,配置本身只有十行,真正的功夫在边界上:哪些路径不能被吞、参数要不要保留、旧地址怎么退场、不存在的地址要老实返回 404。把 try_files 写对、把上传与后台用 ^~ 排除、把 $query_string 带上,剩下的就是切换时的收尾工作。上线前用上面那段循环跑一遍,四个检查点全过,基本不会再出现「改了伪静态全站 404」这种事故。