CDN 边缘函数 把分流与鉴权推到离用户最近的地方
边缘函数(Edge Functions)是在 CDN 节点上运行的轻量 JS/WASM 运行时,能力介于"CDN 配置规则"和"源站逻辑"之间。它的正确用法不是把业务逻辑搬到边缘,而是处理那些必须在用户附近决定、且不适合回到源站的事:分流、鉴权跳转、响应改写、缓存策略。
一、什么该放在边缘
适合:A/B 与灰度分流、地区与语言路由、未登录时跳转登录并带 redirect、SEO 与 UA 相关的头部处理、响应头改写(安全头、CORS)、简单缓存绕过判断、图片格式协商。
不适合:需要长时间持有连接、重计算、访问源站数据库、任何有状态写操作。
判断标准是运行时约束:边缘运行时通常有几毫秒到几十毫秒的 CPU 预算、禁止 Node 内置模块、子请求次数受限。把这段代码当作"一个只能跑几十毫秒、不能读数据库的无状态函数"来设计。
// edge/ab-test.ts
export default {
async fetch(request) {
const url = new URL(request.url);
if (url.pathname !== "/api/v1/search") return fetch(request);
const uid = request.headers.get("x-user-id");
const bucket = uid ? fnv32(uid) % 100 : 100;
const variant = bucket < 10 ? "b" : "a";
const up = new Request(request);
up.headers.set("x-experiment", variant);
const res = await fetch(up);
res.headers.set("x-experiment", variant);
return res;
},
};
function fnv32(s) {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); }
return h >>> 0;
}
按用户主体哈希而不是随机,才能保证同一用户始终落在同一个桶里——这是做分流最常见的 bug。
二、鉴权跳转的最佳落点
未登录访问受保护页面时跳转登录,放在边缘最合适:省一次回源、延迟最低、逻辑最短。
export default {
async fetch(request) {
const url = new URL(request.url);
if (!protectedPath(url.pathname)) return fetch(request);
const token = readCookie(request.headers.get("cookie"), "okgoes_token");
if (token) {
const r = await fetch(request);
return r.status === 401 ? redirectToLogin(request, url) : r;
}
return redirectToLogin(request, url);
},
};
function redirectToLogin(req, url) {
const next = encodeURIComponent(url.pathname + url.search); // 保留原路径
return new Response(null, {
status: 302,
headers: { location: `/login?redirect=${next}`, "cache-control": "no-store" },
});
}
两个细节决定正确性:redirect 参数必须编码,否则路径里带 ?a=b 会被截断;跳转响应必须 no-store,否则 CDN 会把 302 缓存成永久跳转,登录之后依然被踢回登录页。这类缓存污染问题在边缘场景极常见。
边缘只能判断"有没有 token",不能验证签名(验签要么回源要么在边缘做 HMAC,前者延迟高、后者密钥管理复杂)。边缘永远不是授权决策的最后防线,源站的鉴权校验照常执行,边缘只做体验优化。
三、响应改写要保守
修改响应头是边缘最常见的正当用途:
function harden(res) {
const h = new Headers(res.headers);
h.set("x-content-type-options", "nosniff");
h.set("referrer-policy", "strict-origin-when-cross-origin");
h.set("permissions-policy", "geolocation=(), camera=(), microphone=()");
if (new URL(res.url).pathname.startsWith("/static/")) {
h.set("cache-control", "public, max-age=31536000, immutable");
}
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });
}
改写响应体要非常小心——它会破坏流式传输与压缩。需要替换少量内容(比如注入一个 meta 标签)时应使用 HTMLStream 之类的流式改写器,而不是 await res.text() 全量缓冲:
return new HTMLStream(res.body, [
["meta[name=description]", { set: { content: desc } }],
]);
全量缓冲响应体会使首字节延迟上升、内存占用增加,也让大文件下载场景直接崩掉。这是边缘函数最主要的性能陷阱。
四、缓存交互的三条规则
边缘函数与 CDN 缓存的关系,决定了性能上限。
- 确认函数是否参与缓存键。多数平台默认把 URL 与部分 header 纳入缓存键,自定义 header 不参与;分流用了自定义 header 就要显式把它加入缓存键,否则所有用户共享同一份缓存(实验彻底失效);
- 带 cookie 的响应禁止缓存,或把 cookie 归一化后加入缓存键,否则会出现用户之间内容串台;
Vary要精确,Vary: *等于关闭缓存。
res.headers.set("cache-control", "public, max-age=60, stale-while-revalidate=600");
res.headers.set("vary", "Accept");
if (hasSessionCookie(request)) res.headers.set("cache-control", "private, no-store");
stale-while-revalidate 在边缘非常划算:命中过期缓存时立即返回旧内容并后台回源,既降低回源压力又降低尾延迟。
五、可观测与灰度
边缘代码分布在几十上百个节点,出问题很难复现,事前要求比事后调试重要得多。
必备三件事:每个请求生成 request-id,在边缘与源站日志里都带上;分流变体打进响应头(x-experiment),排查用户反馈时能立刻确定命中哪个版本;灰度从 1% 起步,边缘函数发布通常全网生效,务必用平台自带的百分比发布能力,而不是直接全量。
const rid = request.headers.get("x-request-id") || crypto.randomUUID();
up.headers.set("x-request-id", rid);
res.headers.set("x-request-id", rid);
日志采集上,边缘的同步日志接口会阻塞响应,尽量用异步日志通道,并且只采样打(比如 1%)。在高频路径打全量日志会让成本与延迟一起失控。
六、密钥与依赖的边界
边缘运行时的安全约束要注意两点:不要把长期有效的密钥放边缘(代码在边缘节点执行,信任边界比源站弱),尽量用短期令牌或平台提供的密钥管理服务;第三方依赖要极轻,边缘对包体积与冷启动敏感,一个几百 KB 的依赖就能让冷启动延迟明显上升。
同理,边缘不要放任何写操作密钥、数据库凭据、内部服务地址。它属于面向公网的一侧。
小结
边缘函数的定位是在用户附近的无状态短决策:分流、跳转、改头、缓存控制。四条落地纪律——分流按主体哈希、跳转保留并编码 redirect 且响应 no-store、改写响应体必须走流式、缓存键与 Vary 精确对齐。同时要清楚它的边界:不是运行时环境的替代品,不做授权决策的最终防线,不放长期密钥。把边缘当作"更聪明的 CDN 规则"来用,收益最大;把它当作"分布式的 Node 后端"来用,问题最多。
评论(0)
登录 后参与评论。