href 拼接导致 404 的六类编码事故:中文 slug、双重 urlencode 与 Nginx $uri 解码差异

做中文内容站的人都知道 href 拼接是个高频操作:从数据库读出的 slug、从 URL 里取的查询参数、从配置里读的静态资源前缀。这些字符串最终都会被拼进链接。麻烦在于,中文站的 URL 里常常同时存在中文、空格、斜杠、问号和已编码的百分号,一旦拼接方式不一致,就会出现"链接看着对、点开 404"或者"同一个页面产生两个入口、收录分裂"的怪现象。

这篇文章把 href 拼接的边界条件拆开讲:浏览器怎么解析、服务端怎么解析、两者在什么情况下不一致,以及 PHP 站里最常见的几类 404 该怎么归因和修复。

一、先建立一个模型:URL 不只是字符串

很多人把 URL 当普通字符串处理,$href = $base . '/' . $slug . '.html',看起来天衣无缝。但 URL 在进入网络请求之前,会被浏览器做一遍标准化(normalization),服务端拿到之后再解析一遍。两遍处理都可能改写你的字符串,所以拼接时的"原文"和最终请求的"实际值"并不是一回事。

浏览器标准化至少包含这几种动作:

  • 把 \ 当作 /(在特殊协议下)
  • 把路径里的 . 和 .. 段折叠掉
  • 把连续的空格去掉、把制表符和换行删掉
  • 对非 ASCII 字符按 UTF-8 进行百分号编码
  • 把 %2F 之类在路径段的保留字符,多数情况下不再解码
  • 把 # 之后的内容视为片段,不发往服务端

关键在于,这些动作在拼接之后才发生。你在拼接时认为在传一个参数,浏览器可能把它当成了路径分隔符。

二、六类典型的拼接事故

事故一:slug 里带斜杠,路径多了一层

内容站的 slug 如果允许从标题自动生成,中文标题里出现 / 是常事(比如「Nginx/MySQL 调优」)。如果生成 slug 时没把斜杠替换掉,最终 href 就是 /nginx/mysql-调优.html,浏览器把它当成两级目录,请求落到一个不存在的路径。

修复要在数据层做,不要在展示层做。slug 生成函数里明确规定白名单字符集:

function slugify($s) {
    $s = trim($s);
    $s = preg_replace('/[\s\x{3000}]+/u', '-', $s);      // 各类空格
    $s = preg_replace('#[\\\\/|:*?"<>]+#u', '-', $s);   // URL 保留字符
    $s = preg_replace('/-+/', '-', $s);
    return trim($s, '-');
}

已经入库的脏 slug 不要直接改(会破坏已有收录),而应该在输出时检测并用 301 跳到规范地址,同时给这一批页面加上 canonical。

事故二:中文没有编码,或被编码了两次

直接在 HTML 里写中文路径,浏览器会自动按 UTF-8 编码,通常能用。但如果这段 href 是先经过 urlencode 再经过模板转义的,就会变成 %25E4%25B8%25AD(百分号被编码了一次),服务端解码后得到字面量 %E4%B8%AD,文件名对不上。

判断方法:抓一个坏链,看实际请求的路径是什么。用 curl 加 -v 能看到请求行里的原始路径:

curl -sv -o /dev/null "https://www.example.com/%25E4%25B8%25AD.html" 2>&1 | grep '^> GET'

如果请求行里出现 %25,基本就是双重编码。修法是:数据库里只存原始中文或只存编码后的单一形式,输出时只编码一次,模板转义和 URL 编码不要叠加。

事故三:查询参数里拼了未编码的值

?tag=Nginx & MySQL 这样拼出来的链接,& 会被当成参数分隔符,服务端只收到 tag=Nginx ,后面的内容变成了一个名叫 MySQL 的空参数。

正确做法是用 http_build_query,不要手工拼:

$url = '/search?' . http_build_query([
    'tag' => $tag,
    'page' => $page,
], '', '&', PHP_QUERY_RFC3986);

PHP_QUERY_RFC3986 会把空格编成 %20 而不是 +,对中文站更稳——某些老旧服务端或 CDN 会把 + 当字面加号处理,导致搜索词里的空格搜不到东西。

事故四:base 前缀重复或缺失

站点从根目录搬到子目录,或者临时加了 CDN 前缀,就容易出现 /blog/blog/post.html 或者 https://cdn.example.com//img/x.jpg 这种双斜杠。双斜杠在多数服务器上不会 404(服务器会把空段忽略),但会让缓存键分裂、让相对路径解析出错。

约定一条规则:base 不带结尾斜杠,路径以斜杠开头,并且用一个函数统一处理:

function join_url(string $base, string $path): string {
    return rtrim($base, '/') . '/' . ltrim($path, '/');
}

项目里所有 href/src 拼接都走这个函数,不要各处手写点号。

事故五:相对路径的基准被 <base> 或路径层级改变

<base href="/"> 一旦加上,页面内所有相对链接的基准就变成了根目录,原本写 ./img/a.png 的地方全部失效。Typecho、WordPress 的某些主题会加 base 标签,迁移主题时最容易撞上。

排查方式:在浏览器里打开控制台,执行:

[...document.querySelectorAll('a[href],img[src]')]
  .map(e => [e.href || e.src])
  .filter(([u]) => !u.startsWith(location.origin))
  .slice(0, 20)

如果原本应该是站内的链接跑到了别的域名下,就是 base 的问题。彻底一点的方案是不用 base 标签,改用根相对路径 /img/a.png。

事故六:链接里有不可见字符

从 Excel、Word 复制过来的标题,经常带着零宽空格(U+200B)、不换行空格(U+00A0)或者全角空格(U+3000)。这些字符肉眼看不见,但会被编码进 URL,导致页面 404,而你在日志里怎么看都正常。

批量清洗时用正则把不可见字符全删掉:

$s = preg_replace('/[\x{200B}-\x{200F}\x{202A}-\x{202E}\x{2060}\x{FEFF}\x{00A0}\x{3000}]/u', '', $s);

这三个范围分别是零宽空格族、方向控制符族、BOM,加不换行空格和全角空格。入库前统一跑一遍,能省掉大量莫名其妙的 404。

三、Nginx 侧为什么会返回 404

前端拼对了,还要看 Nginx 怎么解析。最常见的规则是:

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

这里有个容易误解的地方:$uri 是已经解码的路径,而 $request_uri 是未解码的原始路径。当路径里包含 %2F 时,$uri 会把它解成 /,然后 Nginx 把路径切成多一段;而文件系统上的文件名里是真的有个字面 %2F 或者根本没有。这就导致"浏览器请求了一个地址,Nginx 去找了另一个文件"。

排查时对比两者最直接:

add_header X-Debug-URI "$uri" always;
add_header X-Debug-Request-URI "$request_uri" always;

(只在测试环境加,生产会泄露路径信息。)在响应头里看到两者不一致,就是编码层面的问题了。

另一个高频原因是没有处理大小写。Linux 文件系统区分大小写,Post.html 和 post.html 是两个文件。Windows 上开发时没发现,上线就 404。内容站的 permalink 应该统一小写,在生成层强制 strtolower。

四、排查 404 的可复现流程

不要凭感觉改。按下面四步走,每一步都有可验证的输出:

第一步,拿真实坏链。从 GSC 的「网页」报告里导出 404,或者从访问日志里筛:

awk '$9==404 {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -30

第二步,看请求行原文。对每个坏链执行 curl,抓请求行和响应头,确认是编码问题还是路径问题。

第三步,定位生成该链的地方。在模板和代码里搜 href 的拼法。用 grep 找所有 href= 出现的位置,重点看那些直接点号拼接的。

grep -rn "href=" /var/www/html/theme/ | grep -v 'http_build_query' | head -40

第四步,修复后回归验证。用一个脚本把目标链接批量请求一遍,只输出非 200 的:

while read -r u; do
  code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "$u")
  [ "$code" != "200" ] && echo "$code $u"
done < urls.txt

五、收录层面的连带影响

href 拼接问题不只是用户体验问题,它直接影响收录。同一篇文章如果存在两个可访问地址(一个带参数、一个不带),搜索引擎会认为是重复内容,权重被稀释。这时候需要三个动作同时做:

  • 页面里所有内链统一指向规范地址,不给搜索引擎发现旧地址的机会;
  • 旧地址返回 301 到规范地址,而不是 200 或 404;
  • 规范页面加 rel="canonical",指向自己。

判断站内是否存在多入口,可以用最简单的办法:搜站点地图里的 URL 数量,和 Nginx 日志里被访问的不同路径数量对比。如果后者远大于前者,说明有大量非规范入口在被访问。

# 一天内被访问的不同路径数
awk '{print $7}' /var/log/nginx/access.log | sort -u | wc -l
# 站点地图里的 URL 数
curl -s https://www.example.com/sitemap.xml | grep -o '<loc>' | wc -l

两者比例超过 3:1 就值得查一查了,尤其是在站点还没做站内搜索的情况下。

六、一套可落地的编码规范

把这些经验固化成几条团队约定,能避免 90% 的重复踩坑:

  1. 数据库里存储的 slug 只允许 [a-z0-9-],中文标题用拼音或语义英文,写入前强制过滤。
  2. URL 拼接统一走 join_url() 和 http_build_query(),禁止点号手拼。
  3. 查询参数一律 RFC3986 编码,空格用 %20。
  4. 路径统一小写,结尾是否带斜杠全站二选一,另一形式 301。
  5. 不用 <base> 标签,资源路径用根相对。
  6. 入库前跑一次不可见字符清洗。
  7. 上线前用脚本回归全部内链,非 200 即阻断发布。

七、小结

URL 拼接看起来是最没有技术含量的部分,但它同时踩在浏览器标准化、Nginx 解析、文件系统语义和搜索引擎收录四条线上。中文站因为字符集的原因,踩坑概率比英文站高得多。

一旦出现"链接看着对但打不开",最快的路径不是猜,而是把请求行原文打出来,对比 $uri 和 $request_uri,再回到代码里找是哪一处拼接没有走统一函数。把这个流程走顺了,这类问题一般十分钟内能定位。

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

Leave a Comment