三月十三日下午两点多我照例打开那个对话式搜索入口习惯性先扫了一眼地址栏——结果它又停在了那个带地区后缀的域名上。前一天同一个时间、同一台机器、同一套浏览器配置它是能正常落到国际站的。我第一反应是扩展挂了因为过去大半年我一直靠一个能改写请求头的浏览器扩展把Accept-Language、User-Agent里的几个字段调成我需要的样子用来给手上几个多语言页面做对照测试。禁用、重装、重写规则、换扩展折腾到快四点我才确认了一件不太情愿承认的事不是我的规则写错了是改请求头就能改变站点行为这套思路在这个产品上已经不好使了。这篇东西写给两类人。一类是把改请求头当成日常调试手段的前端、测试、运维同学你们大概这几天也遇到了类似的昨天还灵、今天全废另一类是纯粹好奇为什么一个改头的扩展会突然失效的技术爱好者。核心结论先摆在这儿客户端改请求头本来就是一副临时脚手架它失效不是意外是迟早的事。真正值得花时间的不是找一个新的扩展去补上这个洞而是把这次跳转到底是谁下的决定查清楚——查清楚之后你会发现大多数情况下你根本没必要去改头。1. 跳到区域站这个决定到底是谁下的很多人包括半个月前的我默认跳转是服务器看到我的 IP 或者我的请求头返回一个 302。这个心智模型只对了一小部分。从你在地址栏敲下回车到地址栏变成区域站中间至少有四个层级的角色在参与决策而请求头只影响其中很窄的一块。1.1 四个可能的决策者逐个点名第一层浏览器自己。这里最容易背锅也最容易被忽略。HSTS 预加载、之前缓存下来的 301注意 301 默认会被浏览器长期缓存、Service Worker 里写死的跳转逻辑、扩展注入的规则、甚至书签里存的旧 URL任何一个都可能在请求真正发出去之前就把你送走了。我踩过最坑的一次是某个站点的 301 被缓存了将近一年清 Cookie 完全没用得单独清缓存的重定向那一项才恢复。第二层DNS 解析与边缘层。现在稍微有点规模的站点域名解析出来的都是内容分发网络的边缘地址。边缘层拿到的第一个信息就是这个连接从哪个网段来不少产品的地域路由就是在这里一次性完成的压根不进入应用服务器。这一层的判定你在浏览器里改任何请求头都碰不到因为边缘层看的是 TCP 连接的对端地址不是你的 HTTP 头。第三层应用层。到这里才轮到请求头说话。常见参与判定的信号有Accept-Language、Cookie里的地区偏好、登录态里的市场设置、User-Agent里的平台信息以及Sec-CH-*这一族客户端提示头。注意关键词是参与而不是决定——大部分产品会把 IP 归属和 Cookie 偏好排在语言头的前面。第四层前端脚本。页面已经 200 加载完了JS 调一个我在哪的接口拿到地区码然后location.replace过去。这种跳转在 Network 面板里看不到 302只有一条 XHR 加一次新的文档请求特别容易被误判成服务端跳的。把这张图铺开问题就清楚了你改请求头最多只能影响第三层里读头的那部分判定逻辑。如果对方的判定重心在第一层缓存、第二层边缘或者第三层里的 IP 和 Cookie那你改一百个头也没用。1.2 三步定位法先看再猜我把排查顺序固定成三步每次遇到行为变了都按这个走省得来回猜。第一步用命令行看原始响应绕开浏览器所有的缓存和扩展干扰curl -sSI -H Accept-Language: en-US,en;q0.9 https://example.com/ | head -n 30重点盯四个东西状态码是 200 还是 302、Location指向哪里、有没有Cache-Control和Vary、有没有Set-Cookie。如果命令行里行为是正常的不跳而浏览器里跳那问题几乎百分之百在浏览器这一层——缓存、Cookie 或者扩展。反过来如果命令行也跳那基本可以确定是服务端策略。第二步在开发者工具里勾上 Preserve log保留日志重新走一遍。你要判断的是第一个文档请求返回的是 302还是 200 之后由 JS 发起跳转。这一条决定了你后面往哪个方向查。很多人跳过这步直接去改请求头方向从一开始就错了。第三步用无痕窗口 禁用全部扩展做一次对照。无痕窗口清掉了大部分扩展和缓存如果无痕下行为正常、普通窗口异常那就是本地环境问题两边都异常就是服务端变了。这一步花不了一分钟但能砍掉一半的无效排查。1.3 四种跳转机制的区分表同样是地址栏变了背后的机制完全不同排查手段也不一样。我整理了一张自己常用的对照表机制典型特征首要排查手段HTTP 3xx 重定向Network 里第一条请求状态码就是 301/302Location明确命令行curl -I看头部检查Cache-Control前端 JS 跳转首条请求 200随后出现一条 XHR 和一次新的文档请求在 Sources 里全局搜location.replacemeta refreshHTML 源码里有http-equivrefresh直接看响应体前几十行Cookie 粘性只有在带某个 Cookie 时才跳清掉就恢复正常逐条删除 Cookie 二分定位再补一个容易被漏掉的细节看响应头里有没有Vary: Accept-Language。如果没有这个头说明缓存键里压根不包含语言那么即使源站真的读了你改的语言头你也很可能拿到的是别人或者上一分钟的自己缓存的版本改与不改看起来没差别。这个细节能解释很多改了但没反应的困惑。2. 请求头改写这条路的三个结构性缺陷我在那半天里最深的感受不是某个扩展坏了而是我依赖的这条路从设计上就不结实。把缺陷拆开看大致是三块。2.1 扩展改头的能力边界一直在收窄早期浏览器扩展可以拿到全量的网络请求拦截权限在请求发出前任意修改头部甚至能改写响应体。这套机制能力强、副作用也大后来主流浏览器陆续把阻塞式请求拦截从普通扩展的能力里拿掉了换成了声明式的规则接口你只能提前声明匹配这个 URL 的请求把某个头设成某个值不能在里面写逻辑、不能读响应、不能根据结果动态决策。这个变化带来的直接后果有三个。一是规则有数量上限静态规则集和动态规则各有各的额度规则一多就得做取舍二是一部分头部被浏览器列为受保护扩展改了也会被覆盖或者直接丢弃三是不同浏览器的实现细节不一样同一份规则在两个浏览器上表现可能完全不同。换句话说扩展能改头这件事本身建立在浏览器愿意给你这个特权的前提上特权一收紧整条方案就跟着塌。2.2 服务端压根不读你改的那个头这是认知层面最大的坑。按规范Accept-Language只是一个偏好提示服务端完全可以忽略它。不少产品做国际化时语言是靠账号设置或者 Cookie 决定的请求头只用于首次落地时的兜底判断甚至完全不参与。客户端提示头Sec-CH-UA、Sec-CH-UA-Platform这一族更麻烦需要服务端先在响应里通过Accept-CH主动声明我要这些信息浏览器才会在后续请求里带上。也就是说主动权在服务端手里你单方面去改改了也未必会被采信。还有一个隐蔽的现象某些头部是由边缘层在转发过程中重写或注入的它们带着地理位置信息客户端根本无从伪造。当你看到响应里出现一个你从来没发过的头基本就能判断判定发生在我够不着的地方。2.3 灰度发布让昨天还好用成为最坏的证据三月十三日下午这个时间点特别值得说。很多团队做发布和灰度习惯安排在自家工作时间的下午因为出问题还有人能立刻回滚。一次后端改动把地域判定从读请求头整体挪到读 IP Cookie 优先对绝大多数用户是无感的不会有任何公告前端代码一行没变。但对你这种靠改头来改变行为的少数用户来说体验就是突然全废。这时候最误导人的恰恰是你自己的经验。昨天还好用会让你把所有注意力放在客户端——重装扩展、换规则、换浏览器而真正变化的地方在服务端你连看都没看一眼。我后来给自己定了个规矩先证明服务端行为变了再回头怀疑客户端。顺序反了半天就白扔了。3. 那次失效的完整排查链路下面这段是我那半天真实走过的路径按顺序列出来包括每步的结论。之所以完整写出来而不是只给答案是因为排查思路本身比结论更可复用——下次换个产品、换个场景你照着走一遍就行。3.1 我按顺序做的九件事无痕窗口 禁用全部扩展。结果无痕下依然跳。结论不是扩展冲突也不是缓存污染问题在服务端或者网络侧。命令行curl -I直接看响应头。结果返回 302Location指向区域站。结论确实是 HTTP 层重定向不是前端 JS 干的。在 curl 里手动加Accept-Language和自定义 UA。结果行为不变还是 302。结论语言头和 UA 至少不是这一层的主要判定依据。检查响应里的Set-Cookie。结果第一次落地时下发了一个带地区标识的 Cookie。结论存在粘性后面所有请求都可能被这个 Cookie 带着走。清掉这个 Cookie 再请求一次。结果依然 302 到区域站但下发的 Cookie 值变了。结论Cookie 不是根因是被动写入的结果。换一条路径比如访问一个具体的子页面而不是根路径。结果子页面正常返回 200。结论重定向规则是路径级的挂在根路径或特定的入口路径上。抓一遍完整请求看有没有客户端提示头。结果有Sec-CH-UA系列但没有Sec-CH-UA-Platform-Version之类更细的。结论服务端确实采集了客户端信息但采集范围有限。换一个网络环境做对照比如切到手机热点。结果行为一致。结论不是某一条出口线路的特殊处理是普适策略。对比响应头里有没有边缘层注入的地理信息字段。结果找到一个没被我发送过、但出现在响应里的字段。结论判定发生在我够不着的地方实锤。走完这九步答案已经很清楚了判定逻辑在边缘层和应用层之间主要依据是连接来源请求头只是辅助信号。这条路上没有任何改头的操作空间。3.2 现象与成因对照表排查过程中我记了一堆现象事后整理成表方便以后快速对照现象更可能的成因下一步验证命令行正常、浏览器异常本地缓存、Cookie 或扩展无痕窗口对照命令行和浏览器都异常服务端策略变更抓响应头看 302 与Location改了头但响应完全没变化该头不参与判定检查响应里有没有Vary改头偶尔生效偶尔不生效边缘缓存命中不同版本加随机查询参数重试清 Cookie 后行为短暂改变粘性 Cookie 被动重写逐条二分删 Cookie只有某个路径异常路径级重定向规则换路径做对照3.3 三个反直觉的发现发现一改 UA 的效果其实是错觉。我一度以为改 UA 起了作用因为改完之后偶尔能落到国际站。后来加了随机参数反复测了二十次命中率大概三成——这不是生效这是命中了不同的边缘缓存版本。判断一个改动是否真的生效一定要重复足够多次别用单次结果下结论。发现二请求头里有Accept-Language但服务端可能只拿它排序。有些产品会用语言头决定页面用哪种语言渲染但用 IP 决定去哪个站点两件事是分开的。你改了语言头页面语言变了域名没变于是你以为改头有用但不够实际上是两个完全独立的判定链路。发现三状态码 302 和 307 的区别在这类场景里会咬人。302 在历史上被浏览器实现成可以改方法307 则严格保持原方法。如果你在调试一个 POST 请求的重定向看到 302 之后方法变成了 GET、请求体丢了别怀疑是自己的代码问题那是规范历史包袱。这类细节在排查跳转链路时经常被忽略。4. 换一条路把改头升级成可控的测试上下文查清楚之后我做了一个决定不再在日常工作里依赖任何改请求头的扩展。不是因为它不好用而是因为它不可控、不可复现、不可进流水线。取而代之的是一套能写进代码、能跑在 CI 里的测试上下文。这套东西本来是给我自己产品的多语言页面做回归用的但用它来观察第三方站点的行为差异同样好使。4.1 用测试框架的上下文参数替代扩展以目前用得比较多的一个端到端测试框架为例它支持在创建浏览器上下文时直接指定语言、时区、额外的请求头而且每个上下文之间是完全隔离的const { chromium } require(playwright); (async () { const browser await chromium.launch(); const ctx await browser.newContext({ locale: en-US, timezoneId: America/Los_Angeles, extraHTTPHeaders: { Accept-Language: en-US,en;q0.9, }, }); const page await ctx.newPage(); const resp await page.goto(https://example.com/, { waitUntil: domcontentloaded, }); console.log(status:, resp.status()); console.log(final url:, page.url()); console.log(server headers:, JSON.stringify(resp.headers(), null, 2)); await browser.close(); })();这段代码比扩展强在四个地方语言和时区是浏览器层面真实生效的不只是加一个头每次运行都是干净上下文没有历史 Cookie 污染能直接拿到状态码、最终 URL 和完整响应头可以写断言、可以进 CI、可以跑一百次统计命中率。可复现这三个字是它和手动改头之间最本质的差别。4.2 把我在哪的接口拦下来做分支验证如果你的目的是验证自己产品在不同地区下的前端分支逻辑最干净的做法是把地理判定接口 mock 掉而不是去伪造网络环境await page.route(**/api/geo**, async (route) { await route.fulfill({ status: 200, contentType: application/json, body: JSON.stringify({ country: SG, region: ap-southeast }), }); }); await page.goto(https://your-app.example.com/); await expect(page).toHaveURL(/\/sg\//);这样做的好处是判定的输入完全受你控制输出可以被断言而且不依赖任何外部条件。产品的地域路由逻辑改一次你改一下 mock 的返回值就能覆盖新分支比在浏览器里跟扩展规则较劲高效得多。4.3 一份能直接抄的目录结构我把这套东西整理成了一个小仓库结构大概是这样geo-i18n-check/ ├── cases/ │ ├── locale-en.json │ ├── locale-ja.json │ └── locale-zh.json ├── fixtures/ │ └── geo-responses.json ├── specs/ │ ├── redirect.spec.js │ └── locale-render.spec.js └── report/ └── summary.json每个 case 文件里只放三样东西语言、时区、期望落地的路径前缀。跑完之后生成一份汇总哪几个组合通过了、哪几个失败了一目了然。这套结构的价值在于它把我印象中昨天是这样的变成了这份报告里第 7 条断言失败了。4.4 为什么我劝你彻底放弃用客户端改头对抗服务端策略这里说句实在话。服务方按区域做路由、做内容分发是它自己的产品策略和合规安排。客户端靠改请求头去绕一来技术上不成立——你已经看到了判定根本不在你够得着的那一层二来这件事本身也不合适你今天绕过去了明天它换个判定维度你又得重来一遍永远在追。真正值得投入的方向有两个一是在自己的可控环境里做多地区、多语言的验证用上面那套上下文和 mock 的方式二是用产品本身提供的语言、市场设置。前者是工程能力后者是正常使用方式两者都不会因为某次灰度发布而一夜作废。5. 只想正常用产品的话先动这几处设置不是所有人都要做工程验证。如果你只是想让那个入口按你期望的语言和站点打开那顺序应该是从产品给的正规开关开始而不是从装个扩展改头开始。5.1 产品自带的语言与市场设置优先级最高绝大多数国际化产品在设置页里都有语言、地区、内容市场的选项。这些设置一般会写进账号或者长效 Cookie优先级高于任何请求头。先去把这几项调对再看行为有没有变化。我见过太多人一上来就装扩展结果产品设置里那一项从来没动过——方向反了。顺带说一个观察账号级设置和匿名态设置的行为经常不一样。登录之后按账号走退出登录之后按 Cookie 或者连接来源走。所以你在两种状态下测出来的结论可能互相矛盾这不是 bug是两套链路。5.2 清理顺序很关键别一上来就清全部清数据这件事顺序错了等于白清。我一般的顺序是先只删该站点的那条地区 Cookie看行为变不变再清该站点的全部 Cookie 和站点数据然后清缓存的响应特别是 3xx 重定向缓存最后才考虑清全部浏览数据。为什么这么排因为你需要知道是哪一项在起作用。一上来就全清问题暂时消失了但你不知道原因下次遇到还是抓瞎。而且全清的代价很大一堆登录态全丢得不偿失。提示清完数据之后第一次请求往往会重新触发一次判定 写入 Cookie这时候看到的行为才是最接近新用户的。想看真实的首访行为务必在这个状态下观察。5.3 判断这是策略不是 bug的三个信号到某个点你就该停手了别在没意义的地方耗时间。我的判断标准是三条一是命令行和浏览器行为一致说明不是本地环境问题二是响应头里出现了你没发过的、带地理含义的字段说明判定在边缘层三是换网络、换设备、换浏览器结论都一样。三条里中了两条基本可以确定这是产品策略不是你哪里配错了收手去做别的事更划算。最后分享一个个人的习惯。现在每次我想装个扩展去改某个站点的行为之前我会先问自己一句**如果这个改动明天失效了我今天做的事是不是全白费**如果答案是是那我就先去把观测手段搭好——抓包、对照实验、可复现的脚本。观测能力不会失效扩展会。这半天折腾下来我唯一不后悔的就是把那九步排查链路完整记了下来后面遇到同类问题直接照着走一遍半小时出结论。