从 Next.js 迁移到 React Router:生产级灰度切流的真实经验
把一个生产域名从旧应用逐步迁移到重写版,教科书方案通常是在应用层做灰度:middleware 读取 cookie,根据百分比分流,再 rewrite 到新版。第一版我们也是这么做的。然后线上发现:整个站的 CDN 缓存消失了。
这不是代码写错,而是架构位置放错。灰度分流首先是一个缓存问题,其次才是路由问题。
下面这些结论,来自把 lobehub.com 从 Next.js 迁移到 React Router 的两轮生产实践。每一条,都是线上踩出来的。
分流必须发生在缓存之前
middleware 通常运行在 CDN 缓存之后,命中缓存的请求根本不会进入 middleware。如果想让 middleware 每次执行,就必须让响应不可缓存。旧方案最终变成:
cache-control: no-store
cf-cache-status: DYNAMIC
整个生产站点被迫关闭边缘缓存。为了灰度,把全站 CDN 干掉,这笔交易显然不划算。
Cloudflare Worker 才是正确的位置
换成 Cloudflare Worker 后,执行顺序发生变化:
用户请求
↓
Cloudflare Worker
↓
CDN Cache
↓
Origin
Worker 位于缓存之前,它不需要修改响应缓存逻辑,只需要修改 origin。核心逻辑如下:
const fetchOrigin = (
request: Request,
env: Env,
bucket: Bucket,
url: URL
) => {
const originUrl = new URL(url);
originUrl.host =
bucket === 'canary'
? env.CANARY_ORIGIN
: env.STABLE_ORIGIN;
originUrl.protocol = 'https';
return fetch(new Request(originUrl, request));
};
stable 和 canary 使用不同 origin(stable.vercel.app 和 canary.vercel.app),因此缓存天然隔离。不需要 Vary cookie,不需要自定义 cache key,也不需要复杂缓存规则。cookie 只负责决定用户进入哪个桶,缓存负责缓存自己的桶。
谁在缓存后面分流,谁就必须牺牲缓存
middleware 的顺序是 CDN Cache → middleware,结果必须 no-store。Worker 的顺序是 Worker → CDN Cache,结果是缓存成为灰度系统的一部分。
灰度比例放 KV,结构放部署配置
灰度比例(percent = 10)属于运行时变量,应该放 KV:
wrangler kv key put percent 10
60 秒左右全球生效,可以随时调整为 10%、50%、100% 或 0%,回滚成本极低。
但 origin 地址(stable origin、canary origin、route)属于系统结构,应该进入部署配置。原因很简单:比例是旋钮,origin 是架构,架构变化必须留下部署记录。
每一层缓存都必须回答一个问题
你的缓存 key 里面有没有 bucket?
如果答案是否定的,stable HTML 经过缓存后被 canary 用户拿到,灾难就会发生。
第一次事故:Browser Cache TTL
线上发现 canary HTML 被浏览器缓存了 cache-control: max-age=14400。结果是调整灰度比例后,老用户 4 小时内仍然看到旧版本。
修复方式是把 HTML 的缓存策略改为:
cache-control:
public,
max-age=0,
must-revalidate
hash 资产的 immutable 保持不变。
第二次事故:Workers Cache
Cloudflare 新出的 Workers Cache 看起来很诱人:worker 前缓存,worker 不执行,请求成本下降。但缓存 key 不包含 cookie,同时 cloudflare-cdn-cache-control 优先级最高。如果 HTML 带 s-maxage=3600,那么 canary 用户经过 Workers Cache 后会直接拿到 stable 页面。
最终方案是:资产迁出 Worker 后,这套缓存逻辑一起删除。
第三次事故:规则其实生效了,只是匹配不到
一度怀疑 Cloudflare zone cache 对 Worker 子请求无效。实验方法是给 https://canary.vercel.app 加临时缓存规则,结果马上从 MISS 变成 HIT,说明规则一直有效。
问题在于原来的规则只匹配 lobehub.com,但 Worker 子请求是 canary.vercel.app,两个完全不同。修复方式是把两个 origin host 都加入缓存规则,之后 Worker 请求和直达请求就共享同一套缓存了。
资产不要参与灰度
cookie 粘桶最初有一个理由:两个 build 的 chunk hash 不一样,例如 /assets/entry.client-DpPgmTBG.js,如果用户跨桶就会 404。但更好的解法是让资产完全独立。
静态资源迁移到 R2
stable build 和 canary build 两个项目共同上传到 web-assets,使用独立域名 assets.catyuki.com,资产请求不经过 Worker。
不要把 deployment id 放进路径
错误设计是 /deployments/abc123/assets/app.js,结果每次部署 URL 全变,浏览器缓存和 CDN 全部失效。
正确的结构是:
project/
├── prod/
│ └── assets/
│
└── preview/
└── assets/
版本交给 hash 和上传时间,而不是路径。原因是 hash 文件名天然内容寻址,同名文件代表相同内容。
生命周期 GC 才是真正的版本管理
例如 prod 保留 180 天,preview 保留 30 天,由 R2 lifecycle 按前缀清理。但不要在上传时跳过已存在对象,否则一个半年没变化的 vendor chunk(比如 react-vendor.js)可能因为年龄过久被 GC,而它可能仍然在线上使用。
用环境变量控制资产 CDN
设计一个 STATIC_CDN_URL,分三种状态处理:
- 未设置:关闭 CDN,正常构建。
- 设置 + 密钥完整:启用 CDN。
- 设置但密钥缺失:直接失败。不要允许 HTML 已上线,但引用的
assets.catyuki.com/app.js实际上不存在。半配置状态是最危险状态。
灰度成本应该只覆盖 HTML
一次 pageview 有 197 个请求,其中 Worker 处理的只有 4 个,剩余 196 个是静态资源。灰度成本从"全站流量"降低到"文档流量",下降约 98%。
不要为不存在的问题写防御代码
第一版 Worker 里有两个 guard:
if (request.host === ORIGIN) {
return fetch(request)
}
看起来是为了防止循环,实际上 Cloudflare 平台不会让 zone 内子请求再次触发 Worker,循环根本不存在。但这个 guard 有真实风险:生产切换后,request.host === STABLE_ORIGIN 会让所有请求匹配,灰度系统直接失效,而 staging 测不出来,因为 staging host 不一样。
防御代码如果防的是想象中的问题,它自己可能成为 bug。
真正的问题:HTTP 降级导致死循环
上线后,所有文档都出现 308 redirect loop:
HTTP/2 308
location:
https://landing-staging.lobehub.com/
refresh:
0;url=https://landing-staging.lobehub.com/
原因是 Worker 子请求用的是 http://origin,而 Cloudflare SSL 模式是 Flexible,于是 http → https 无限跳转。
修复方式是让 origin 永远是 *.vercel.app,并且强制 originUrl.protocol = "https:"。真正需要防的不是循环,是降级。
探针必须模拟真实用户
三次线上"故障",其实都是测试工具骗人。
curl -I:HEAD 和 GET 不是同一个缓存行为。
默认 curl UA:刚好命中 AI crawler bypass rule,结果永远是 DYNAMIC。换成 GET + Browser UA,马上就能看到 cf-cache-status: HIT、age: 1278。
浏览器缓存:切流后首页仍然旧,最后通过 Performance API 发现 deliveryType: cache、transferSize: 0——浏览器正在播放旧录像,网络上的真实页面已经更新。排查前先用 fetch(url, { cache: "no-store" }) 排除本地缓存。
验证清单必须覆盖未来 origin
另一个坑是 R2 CORS 配置了 staging.example.com 和 *.vercel.app,却漏了 example.com。结果是 staging 正常,生产 canary 白屏,因为 Vite module script 带 crossorigin。
验证对象应该是"页面未来会出现在哪些 origin",而不是"我现在在哪里测试"。
最终上线流程
最后一步简单到几乎不像发布:
wrangler kv key put percent 1
之后所有放量都只是修改一个 KV,60 秒生效,随时可以撤回。
灰度系统最重要的能力
不是聪明,不是复杂,不是自动化,而是可撤销。
生产系统像海面,风浪一定会来。真正成熟的设计,不是假装没有风浪,而是每一次转向,都能快速回港。