部署后重定向不生效:6 个原因 + curl 排查

vercel.json 或 _redirects 里的 301 部署后变 404 或还显示旧页面。用 curl -I 在 Vercel / Netlify / Cloudflare Pages 上逐一定位:文件位置、规则顺序、文件遮蔽、CDN 缓存、trailing slash、rewrites 与 redirects 混用。

你在 vercel.json_redirectsnetlify.toml 里写了一条 /old-path/new-path 的重定向,部署完去浏览器里访问,结果不是 404 就是继续显示旧页面。这是 web 部署里最常见的”配置看起来对、但就是没跑”问题。

**最快的排查:**跑一句 curl -I -L "https://yourdomain.com/old-path?cb=$(date +%s)",看第一行状态码。出现 308/307/301/302 并带 location: 头,说明规则生效了;只返回 200 说明根本没匹配上(文件位置或规则顺序的问题);看到 x-vercel-cache: HITcf-cache-status: HIT 这类缓存头,说明你看到的是旧的缓存响应。下文把每一种 curl 结果对应到具体原因和修复。

先纠正一个几乎人人都会踩的坑,尤其是调 Vercel 的时候:Vercel 在 permanent: true 时返回的是 308 Permanent Redirect(不是 301),permanent: false 时返回 307 Temporary Redirect(不是 302)(Vercel 官方文档,截至 2026 年 6 月)。而 Netlify 和 Cloudflare Pages 的 _redirects 默认是 301。所以如果你 curl 一条 Vercel 重定向,本以为会看到 “301 Moved Permanently”,结果是 “308”——这是对的、是正常工作状态,不是 bug。

常见原因

按命中率从高到低排列。

1. 规则文件不在 build 产物里

规则文件必须落在平台真正对外服务的那个目录里:

  • vercel.json 必须在仓库根目录(不能放 src/、也不能放子目录)。
  • Netlify 的 _redirects 要放在发布目录里。Astro / Vite 及大多数静态框架,放进 public/,构建后会被复制到 dist/_redirectsnetlify.toml 则放仓库根目录。
  • Cloudflare Pages 要求 _redirects 出现在你在面板里设置的构建输出目录(常见是 dist/public/)。

如果你把文件塞在了 src/app/,构建器不会复制过去,重定向就静默地什么都不做。

如何判断:本地跑 npm run build 后看 ls dist/(或 ls .vercel/output/),确认 _redirects 或 Vercel 的 config.json 真的在那里。Vercel 还可以打开一次部署 → Source 标签,在产物里直接搜文件名。

2. 前面的 catch-all 抢先匹配

重定向引擎都是自上而下、第一个匹配胜出。如果你的 vercel.json 这样写:

{
  "redirects": [
    { "source": "/:path*", "destination": "/new-site/:path*", "permanent": true },
    { "source": "/old", "destination": "/new", "permanent": true }
  ]
}

第二条永远跑不到,因为第一条已经吞掉了所有路径。

这条顺序规则到处都成立:Netlify _redirects第一条匹配的行胜出;Cloudflare Pages 里同一 source 取最上面那条——而且 Cloudflare 额外要求所有静态规则排在动态规则(splat / :placeholder)之前(Cloudflare Pages 文档,2026 年 6 月)。

如何判断:临时把 /old 提到列表最上面。如果立刻 work,就是顺序问题——把具体规则放到 catch-all 之前。

3. 有真实文件遮蔽了规则(Netlify / Cloudflare Pages)

这条是 Netlify 特有、又特别容易漏的。在 Netlify 上,一条未加 force的重定向只在源路径下不存在静态文件时才会触发。如果 /old(或 /old.html/old/index.html)在你的发布目录里存在,Netlify 会直接把那个文件返回,忽略你的重定向。这叫文件遮蔽(file shadowing)

修法是给规则加 force:

  • _redirects 里,给状态码后面加 !:/old /new 301!
  • netlify.toml 里,给规则设 force = true

Forced 规则会覆盖文件遮蔽,无论何种情况都生效。当你确实想让重定向赢过同路径下的现有内容时就用它(Netlify 文档,2026 年 6 月)。

如何判断:如果 curl -I 返回 200、body 是旧页面的 HTML,就检查 dist/ 里该路径下是否有文件。如果有、而你又确实想要重定向,就加 ! / force = true

4. CDN / 浏览器缓存了旧响应

如果上一次部署没有重定向、CDN 给那条 URL 缓存了 200 + HTML,新规则在那条缓存过期或被清掉之前不会生效。Cloudflare 默认并不缓存 HTML,但一旦你开了 HTML 缓存(Cache Rules)或前面还套了一层别的 CDN,被缓存的 200 就会一直留到它的 Edge Cache TTL 到期为止;Vercel 也会在边缘缓存重定向响应。

如何判断:curl -I "https://yourdomain.com/old-path?cb=$(date +%s)" 加一个唯一的 cache buster 查询参数。如果带 buster 的 URL 返回重定向、但不带的还是 200,就是缓存问题。在响应头里找 cf-cache-status: HITx-vercel-cache: HIT

5. trailing slash 不匹配

很多平台把 /old/old/ 当成两条不同 URL。Vercel 默认 trailingSlash: false,意思是对 /old/ 的请求会先被 308 重定向到 /old,你的规则还没轮到跑——所以一条 source 写成 /old/ 的规则永远匹配不到被归一化后的 /old。Netlify _redirects 里有同样的坑。

如何判断:如果 curl -I 显示一个 308、它的 location: 指回同一条路径只是加了或去了斜杠,那是平台的 trailing slash 归一化,不是你的规则。把规则的 source 和你站点实际的斜杠风格对齐再测。

6. 用了 rewrites 但其实想要 redirects

rewrites 是 URL 不变、内部把内容代理过来,访客地址栏看到的还是 /oldredirects 才会让浏览器跳到 /new,返回 3xx + location: 头。两个字段写错对方,就会得到”看起来跳了又没跳”的怪现象(或者反过来——你想让 URL 不变,它却变了)。

如何判断:curl -I 看状态码——200 是 rewrite(同一 URL 下返回了不同内容);301/302/307/308 才是真正的重定向。

排查对照表——把 curl 结果对到原因

curl -I -L "https://yourdomain.com/old-path?cb=$(date +%s)",看第一个响应行加响应头:

curl 显示什么最可能的原因跳到
200 + 旧页面 HTML,无 location规则不在产物里,或文件遮蔽原因 1、3
200 但配置里确实有这条重定向用了 rewrite 而非 redirect,或原因顺序错原因 6、2
308/307(Vercel)或 301/302(Netlify/CF)+ 正确 location正常工作,Vercel 的 308/307 是预期完成
308location 只是加/去了一个尾斜杠平台斜杠归一化,不是你的规则原因 5
只有加 ?cb=... 才重定向、纯 URL 不行CDN 缓存原因 4
cf-cache-status: HIT / x-vercel-cache: HITCDN 缓存原因 4

最短修复路径

按收益从高到低。前 3 步通常就能解决 80% 的情况。

Step 1:用 curl 看真实响应

不要只看浏览器地址栏——浏览器会自动跟随跳转、还可能命中本地缓存。直接 curl:

curl -I -L "https://yourdomain.com/old-path?cb=$(date +%s)"

读输出:

  • 第一个响应是 308 Permanent Redirect(Vercel)或 301 Moved Permanently(Netlify / Cloudflare)+ 一个 location: 头 → 重定向生效
  • 第一个响应是 307(Vercel)或 302(Netlify / Cloudflare)→ 临时重定向生效
  • 第一个响应是 200 → 规则没跑(看 Step 2 和 Step 3)
  • 第一个响应是 308location 只翻转了一个尾斜杠 → 是平台的 trailing slash 行为,不是你的规则(原因 5)
  • cf-cache-status: HIT / x-vercel-cache: HIT → 缓存问题(看 Step 4)

Step 2:确认规则文件在产物里、顺序正确

本地构建并检查:

npm run build
ls -la dist/ | grep -E '_redirects|vercel.json'
cat dist/_redirects 2>/dev/null || cat vercel.json

如果文件不在 dist/(或 .vercel/output/),按平台规范放正确位置:

平台规则文件必须放在
Vercelvercel.json仓库根目录
Netlify_redirectsnetlify.toml发布目录(public/)或根目录
Cloudflare Pages_redirects构建输出目录(常见 public/dist/)
Astro 静态_redirectspublic/_redirects

然后把具体规则放到通用规则之前。Vercel 示例:

{
  "redirects": [
    { "source": "/old", "destination": "/new", "permanent": true },
    { "source": "/blog/:slug", "destination": "/articles/:slug", "permanent": true },
    { "source": "/legacy/:path*", "destination": "/", "permanent": false }
  ]
}

等价的 Netlify _redirects(顺序很重要;若有文件遮蔽路径就用 ! 强制):

/old        /new            301!
/blog/*     /articles/:splat  301
/legacy/*   /               302

Step 3:如果 curl 返回 200 + 旧 HTML,检查文件遮蔽

在 Netlify(以及 Cloudflare Pages)上,源路径下的静态文件会赢过未加 force 的规则。先确认文件是否存在:

ls -la dist/old dist/old.html dist/old/index.html 2>/dev/null

如果存在、而你仍然想要这条重定向,就强制它:在 _redirects 里加 !,或在 netlify.toml 里设 force = true。重新部署后再跑 Step 1 的 curl。

Step 4:针对单个 URL 清 CDN

不要一上来就 purge everything——成本高、又很少需要。只清那条受影响的 URL:

  • Cloudflare:Caching → Configuration → Purge Custom URLs,粘贴完整 URL(含 https://)
  • Vercel:重新部署,或对该项目跑 vercel --prod --force
  • Netlify:Deploys → 选最新部署 → Trigger deployClear cache and deploy site

清完再用 Step 1 的 curl + cache buster 验证。

Step 5:加 CI 冒烟测试

最便宜的回归保护是在部署 hook 后跑几条 curl,断言响应码和 location。注意预期的状态码因平台而异——Vercel 是 308,Netlify / Cloudflare 是 301:

#!/usr/bin/env bash
set -e
BASE="https://yourdomain.com"
check() {
  local from="$1" expected="$2"
  local actual=$(curl -s -o /dev/null -w "%{http_code} %{redirect_url}" "$BASE$from")
  if [[ "$actual" != "$expected"* ]]; then
    echo "FAIL $from -> got '$actual', expected '$expected'"; exit 1
  fi
}
# Vercel 用 308;Netlify / Cloudflare Pages 用 301
check "/old" "308 ${BASE}/new"
check "/blog/hello" "308 ${BASE}/articles/hello"
echo "All redirects OK"

挂在 GitHub Actions 的 deployment_status 事件上,每次部署后自动跑。

如何确认已修好

  1. curl -I -L "https://yourdomain.com/old-path?cb=$(date +%s)" 第一跳返回预期的 3xx + location:,目的地返回 200
  2. 去掉 ?cb= buster 再跑同一条 curl——现在也应该重定向(证明 CDN 缓存是被清了,而不只是被绕过)。
  3. 用隐私 / 无痕窗口(干净的浏览器缓存)打开该 URL,确认地址栏落到 /new-path

留意平台限制

撞到限制会让靠后的规则静默消失,症状和”重定向不生效”一模一样:

  • Vercel:每次部署最多 1,024 条路由(redirectsrewritesheaders 三个数组共用这个额度);source / destination 最长 4,096 字符;/.well-known 路径是保留路径,不能被重定向(Vercel 文档,2026 年 6 月)。
  • Cloudflare Pages:_redirects2,000 条静态 + 100 条动态 = 2,100 条上限,每行最长 1,000 字符;超过就改用 Bulk Redirects(Cloudflare Pages 文档,2026 年 6 月)。

预防建议

  • 写完重定向立刻用 curl -I 验证——别只靠浏览器看。
  • 记住 Vercel 返回的是 308/307,不是 301/302;测试里断言正确的码。
  • 规则排序:具体在前、catch-all 在后;Cloudflare 上静态规则要放在动态规则之前。
  • 当源路径下可能存在真实文件时,给 Netlify 规则加 force(! / force = true)。
  • 全站统一一种 trailing slash 策略,让规则源与之一致。
  • 部署后跑 5-10 条关键 URL 的冒烟测试挂 CI;改大批量重定向前先 git commit,便于按文件 diff 回滚。

常见问题

为什么我的 Vercel 重定向返回 308 而不是 301? 这是预期行为。Vercel 把 permanent: true 映射为 308 Permanent Redirectpermanent: false 映射为 307 Temporary Redirect(截至 2026 年 6 月)。浏览器和搜索引擎都会把它们分别当作永久 / 临时重定向;而且 308/307 会保留 HTTP 方法,这点和 301/302 不同。没坏。

我的 Netlify _redirects 规则被忽略,但文件明明部署了,为什么? 几乎一定是文件遮蔽:源路径下已经存在一个静态文件,Netlify 就服务那个文件而不重定向。用 _redirects 里状态码后加 !(如 /old /new 301!)或 netlify.tomlforce = true 强制规则。

curl 显示重定向了,但浏览器还是加载旧页面,怎么办? 你的浏览器缓存了旧的 200(或之前的某个 301,浏览器对 301 缓存得很狠)。硬刷新、用无痕窗口测,并针对那一条 URL 清 CDN(Step 4)。永久的 301/308 响应能在浏览器缓存里留很久,所以一定要换个干净的配置文件重测。

为什么只有给 URL 加上 ?cb=12345 才会生效? 纯 URL 是从 CDN 缓存里返回的,而加了 cache buster 的变体是一次全新请求、命中了你的新规则。针对那条路径清 CDN(Step 4);清完之后纯 URL 也应该重定向。

我该用 redirect 还是 rewrite? 当 URL 应该在地址栏里改变、且搜索引擎应跟随这次迁移时,用 redirect(3xx + location:)。当你想从另一个路径取内容、同时让原 URL 保持可见时,用 rewrite(返回 200)。如果 curl -I 显示 200,你用的就是 rewrite。

怎么知道是不是撞到了重定向数量上限? 如果靠前的规则有效、靠近大文件末尾的规则失效,你可能超过了平台上限(Vercel 1,024 条路由;Cloudflare Pages 2,100)。把大批量重定向迁到 Cloudflare Bulk Redirects,或 Vercel Edge Middleware + Edge Config,别全塞进一个文件。

相关阅读

标签: #部署 / 托管 #排查 #排查