跳至内容
·9 分钟阅读

Edge Functions:是什么、何时用、何时不用

Edge 运行时、V8 Isolate、冷启动的真相、地理路由、A/B 测试、边缘认证,以及为什么我把一些东西搬回了 Node.js。关于边缘计算的平衡视角。

分享:X / TwitterLinkedIn

"Edge" 这个词现在被滥用得厉害。Vercel 在说,Cloudflare 在说,Deno 也在说。每场关于 Web 性能的技术分享都不可避免地提到"在边缘运行",仿佛这是一个能让你的应用瞬间变快的魔法咒语。

我曾经也深信不疑。我把 middleware、API 路由,甚至一些渲染逻辑都搬到了 edge runtime。其中一些决定确实很明智,但还有一些,我在凌晨两点调试连接池错误三周后,又悄悄搬回了 Node.js。

这篇文章就是这段经历的平衡版本——edge 到底是什么,它在哪里真正发光,在哪里完全不行,以及我如何为应用的每个部分选择运行时。

什么是 Edge?#

先从地理开始说起。当有人访问你的网站时,请求会从他们的设备出发,经过 ISP,穿过互联网到达你的服务器,经过处理后,响应再沿原路返回。如果你的服务器在 us-east-1(弗吉尼亚),而用户在东京,这一趟往返大约是 14,000 公里。光在光纤中的传播速度,单程大约就是 70ms——仅仅是物理层面。加上 DNS 解析、TLS 握手和处理时间,用户看到第一个字节之前轻松就要 200-400ms。

"Edge" 的意思就是在全球分布的服务器上运行你的代码——也就是那些一直以来都在提供静态资源的 CDN 节点,只不过现在它们也能执行你的逻辑了。不再是弗吉尼亚的一台源服务器,而是你的代码在全球 300 多个位置运行。东京的用户访问东京的服务器,巴黎的用户访问巴黎的服务器。

延迟的计算简单而令人信服:

Traditional (single origin):
  Tokyo → Virginia: ~140ms round trip (physics alone)
  + TLS handshake: ~140ms more (another round trip)
  + Processing: 20-50ms
  Total: ~300-330ms

Edge (local PoP):
  Tokyo → Tokyo edge node: ~5ms round trip
  + TLS handshake: ~5ms more
  + Processing: 5-20ms
  Total: ~15-30ms

这是初始响应的 10-20 倍提升。这是真实的、可测量的,对于某些操作来说是颠覆性的。

但营销话术掩盖了一点:edge 并不是一个完整的服务器环境,它是根本不同的东西。

V8 Isolates vs Node.js#

传统的 Node.js 运行在一个完整的操作系统进程中。它能访问文件系统,能打开 TCP 连接,能启动子进程,能以流的方式读取环境变量,基本上能做 Linux 进程能做的任何事。

Edge functions 不运行在 Node.js 上。它们运行在 V8 isolates 上——也就是驱动 Chrome 的那个 JavaScript 引擎,但被精简到了核心。可以把 V8 isolate 想象成一个轻量级沙盒:

typescript
// This works in Node.js but NOT at the edge
import fs from "fs";
import { createConnection } from "net";
import { execSync } from "child_process";
 
const file = fs.readFileSync("/etc/hosts");        // ❌ No filesystem
const conn = createConnection({ port: 5432 });     // ❌ No raw TCP
const result = execSync("ls -la");                  // ❌ No child processes
process.env.DATABASE_URL;                           // ⚠️  Available but static, set at deploy time

在 edge 上你能用的是 Web API 接口——和浏览器中一样的那些 API:

typescript
// These all work at the edge
const response = await fetch("https://api.example.com/data");
const url = new URL(request.url);
const headers = new Headers({ "Content-Type": "application/json" });
const encoder = new TextEncoder();
const encoded = encoder.encode("hello");
const hash = await crypto.subtle.digest("SHA-256", encoded);
const id = crypto.randomUUID();
 
// Web Streams API
const stream = new ReadableStream({
  start(controller) {
    controller.enqueue("chunk 1");
    controller.enqueue("chunk 2");
    controller.close();
  },
});
 
// Cache API
const cache = caches.default;
await cache.put(request, response.clone());

这些限制是真实而严格的:

  • 内存:每个 isolate 128MB(Cloudflare Workers),某些平台为 256MB
  • CPU 时间:10-50ms 的实际 CPU 时间(不是挂钟时间——await fetch() 不算,但 JSON.parse() 解析 5MB 数据就算)
  • 没有原生模块:任何需要 C++ 绑定的东西(bcrypt、sharp、canvas)都用不了
  • 没有持久连接:你无法在请求之间保持数据库连接
  • 打包体积限制:整个 worker 脚本通常 1-5MB

这不是 CDN 上的 Node.js,而是一个拥有不同思维模型的不同运行时。

冷启动:神话与现实#

你大概听说过 edge functions 有"零冷启动"。这……基本上是真的,而且对比确实很惊人。

传统的基于容器的 serverless 函数(AWS Lambda、Google Cloud Functions)是这样工作的:

  1. 请求到达
  2. 平台分配一个容器(如果没有可用的)
  3. 容器启动操作系统
  4. 运行时初始化(Node.js、Python 等)
  5. 你的代码加载并初始化
  6. 请求被处理

第 2-5 步就是冷启动。对于 Node.js Lambda,通常是 200-500ms。对于 Java Lambda,可能是 2-5 秒。对于 .NET Lambda,500ms-1.5s。

V8 isolates 的工作方式不同:

  1. 请求到达
  2. 平台创建一个新的 V8 isolate(或复用一个温热的)
  3. 你的代码加载(部署时已经编译为字节码)
  4. 请求被处理

第 2-3 步不到 5ms,通常不到 1ms。Isolate 不是容器——没有操作系统要启动,没有运行时要初始化。V8 在微秒级别就能创建一个新的 isolate。"零冷启动"是营销话术,但现实(低于 5ms 的启动)已经足够接近零,对大多数场景来说无关紧要。

但冷启动在以下情况仍然会咬你一口:

大打包体积。 如果你的 edge function 引入了 2MB 的依赖,这些代码仍然需要加载和解析。我吃过亏——当我把一个校验库和一个日期格式化库打包进 edge middleware 后,冷启动从 2ms 涨到了 40ms。仍然很快,但不是"零"了。

冷门节点。 Edge 服务商有几百个 PoP,但不是所有 PoP 都能保持你的代码活跃。如果你每小时只从内罗毕收到一个请求,那个 isolate 在请求之间就会被回收。下一个请求又得重新付出启动成本。

单个请求多个 isolate。 如果你的 edge function 调用了另一个 edge function(或者 middleware 和 API route 都在 edge 上),你可能会为一个用户请求启动多个 isolate。

实用建议:保持 edge function 的打包体积小。只导入你需要的东西。积极做 tree-shake。打包越小,冷启动越快,"零冷启动"的承诺就越能兑现。

typescript
// ❌ Don't do this at the edge
import dayjs from "dayjs";
import * as yup from "yup";
import lodash from "lodash";
 
// ✅ Do this instead — use built-in APIs
const date = new Date().toISOString();
const isValid = typeof input === "string" && input.length < 200;
const unique = [...new Set(items)];

Edge Functions 的完美用例#

经过大量实验后,我发现了一个清晰的模式:edge functions 擅长的是在请求到达源服务器之前做出快速决策。它们是守门人、路由器和转换器——不是应用服务器。

1. 基于地理位置的重定向#

这是杀手级用例。请求到达最近的 edge 节点,节点已经知道用户在哪里。不需要 API 调用,不需要 IP 查询数据库——平台直接提供地理数据:

typescript
// middleware.ts — runs at the edge on every request
import { NextRequest, NextResponse } from "next/server";
 
export const config = {
  matcher: ["/", "/shop/:path*"],
};
 
export function middleware(request: NextRequest) {
  const country = request.geo?.country ?? "US";
  const city = request.geo?.city ?? "Unknown";
  const region = request.geo?.region ?? "Unknown";
 
  // Redirect to country-specific store
  if (request.nextUrl.pathname === "/shop") {
    const storeMap: Record<string, string> = {
      DE: "/shop/eu",
      FR: "/shop/eu",
      GB: "/shop/uk",
      JP: "/shop/jp",
      TR: "/shop/tr",
    };
 
    const storePath = storeMap[country] ?? "/shop/us";
    if (request.nextUrl.pathname !== storePath) {
      return NextResponse.redirect(new URL(storePath, request.url));
    }
  }
 
  // Add geo headers for downstream use
  const response = NextResponse.next();
  response.headers.set("x-user-country", country);
  response.headers.set("x-user-city", city);
  response.headers.set("x-user-region", region);
  return response;
}

这在 5ms 内完成,就在用户身边。另一种方案——把请求一路发到源服务器做 IP 查询再重定向——对于远离源服务器的用户来说要花 100-300ms。

2. 无闪烁的 A/B 测试#

客户端 A/B 测试会导致讨厌的"原始内容闪烁"——用户先看到版本 A 一瞬间,然后 JavaScript 才替换成版本 B。在 edge 上,你可以在页面开始渲染之前就分配变体:

typescript
import { NextRequest, NextResponse } from "next/server";
 
export function middleware(request: NextRequest) {
  // Check if user already has a variant assignment
  const existingVariant = request.cookies.get("ab-variant")?.value;
 
  if (existingVariant) {
    // Rewrite to the correct variant page
    const url = request.nextUrl.clone();
    url.pathname = `/variants/${existingVariant}${url.pathname}`;
    return NextResponse.rewrite(url);
  }
 
  // Assign a new variant (50/50 split)
  const variant = Math.random() < 0.5 ? "control" : "treatment";
 
  const url = request.nextUrl.clone();
  url.pathname = `/variants/${variant}${url.pathname}`;
 
  const response = NextResponse.rewrite(url);
  response.cookies.set("ab-variant", variant, {
    maxAge: 60 * 60 * 24 * 30, // 30 days
    httpOnly: true,
    sameSite: "lax",
  });
 
  return response;
}

用户永远不会看到闪烁,因为 rewrite 发生在网络层。浏览器甚至不知道这是一个 A/B 测试——它直接收到变体页面。

3. Auth Token 验证#

如果你的认证使用 JWT(而不是数据库 session 查询),edge 就是完美选择。JWT 验证是纯加密操作——不需要数据库:

typescript
import { jwtVerify, importSPKI } from "jose";
import { NextRequest, NextResponse } from "next/server";
 
const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----`;
 
export async function middleware(request: NextRequest) {
  const token = request.cookies.get("session-token")?.value;
 
  if (!token) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
 
  try {
    const key = await importSPKI(PUBLIC_KEY, "RS256");
    const { payload } = await jwtVerify(token, key, {
      algorithms: ["RS256"],
      issuer: "https://auth.example.com",
    });
 
    // Pass user info downstream as headers
    const response = NextResponse.next();
    response.headers.set("x-user-id", payload.sub as string);
    response.headers.set("x-user-role", payload.role as string);
    return response;
  } catch {
    // Token expired or invalid
    const response = NextResponse.redirect(new URL("/login", request.url));
    response.cookies.delete("session-token");
    return response;
  }
}

这个模式很强大:edge middleware 验证 token 并将用户信息作为可信 header 传递给源服务器。你的 API 路由不需要再次验证 token——只需读取 request.headers.get("x-user-id")

4. 机器人检测和速率限制#

Edge functions 可以在恶意流量到达源服务器之前就将其拦截:

typescript
import { NextRequest, NextResponse } from "next/server";
 
// Simple in-memory rate limiter (per edge location)
const rateLimitMap = new Map<string, { count: number; timestamp: number }>();
 
export function middleware(request: NextRequest) {
  const ip = request.headers.get("x-forwarded-for")?.split(",").pop()?.trim()
    ?? "unknown";
  const ua = request.headers.get("user-agent") ?? "";
 
  // Block known bad bots
  const badBots = ["AhrefsBot", "SemrushBot", "MJ12bot", "DotBot"];
  if (badBots.some((bot) => ua.includes(bot))) {
    return new NextResponse("Forbidden", { status: 403 });
  }
 
  // Simple rate limiting
  const now = Date.now();
  const windowMs = 60_000; // 1 minute
  const maxRequests = 100;
 
  const entry = rateLimitMap.get(ip);
  if (entry && now - entry.timestamp < windowMs) {
    entry.count++;
    if (entry.count > maxRequests) {
      return new NextResponse("Too Many Requests", {
        status: 429,
        headers: { "Retry-After": "60" },
      });
    }
  } else {
    rateLimitMap.set(ip, { count: 1, timestamp: now });
  }
 
  // Periodic cleanup to prevent memory leak
  if (rateLimitMap.size > 10_000) {
    const cutoff = now - windowMs;
    for (const [key, val] of rateLimitMap) {
      if (val.timestamp < cutoff) rateLimitMap.delete(key);
    }
  }
 
  return NextResponse.next();
}

需要注意的是:上面的速率限制 map 是每个 isolate、每个节点独立的。如果你有 300 个 edge 节点,每个都有自己的 map。要做严格的速率限制,你需要分布式存储,比如 Upstash Redis 或 Cloudflare Durable Objects。但对于粗略的滥用防护,每个节点的限制效果出奇地好。

5. 请求重写和个性化 Header#

Edge functions 非常擅长在请求到达源服务器之前对其进行转换:

typescript
import { NextRequest, NextResponse } from "next/server";
 
export function middleware(request: NextRequest) {
  const response = NextResponse.next();
  const url = request.nextUrl;
 
  // Device-based content negotiation
  const ua = request.headers.get("user-agent") ?? "";
  const isMobile = /mobile|android|iphone/i.test(ua);
  response.headers.set("x-device-type", isMobile ? "mobile" : "desktop");
 
  // Feature flags from cookie
  const flags = request.cookies.get("feature-flags")?.value;
  if (flags) {
    response.headers.set("x-feature-flags", flags);
  }
 
  // Locale detection for i18n
  const acceptLanguage = request.headers.get("accept-language") ?? "en";
  const preferredLocale = acceptLanguage.split(",")[0]?.split("-")[0] ?? "en";
  const supportedLocales = [
    "en", "tr", "de", "fr", "es", "pt", "ja", "ko", "it",
    "nl", "ru", "pl", "uk", "sv", "cs", "ar", "hi", "zh",
  ];
  const locale = supportedLocales.includes(preferredLocale)
    ? preferredLocale
    : "en";
 
  if (!url.pathname.startsWith(`/${locale}`) && !url.pathname.startsWith("/api")) {
    return NextResponse.redirect(new URL(`/${locale}${url.pathname}`, request.url));
  }
 
  return response;
}

Edge 失败的场景#

这是营销页面跳过的部分。我踩过这里的每一个坑。

1. 数据库连接#

这是最大的问题。传统数据库(PostgreSQL、MySQL)使用持久 TCP 连接。Node.js 服务器在启动时打开连接池,然后跨请求复用这些连接。高效、成熟、广为人知。

Edge functions 做不到这一点。每个 isolate 都是临时的。没有"启动"阶段来打开连接。即使你能打开连接,isolate 可能在一个请求之后就被回收,浪费了连接建立的时间。

typescript
// ❌ This pattern fundamentally doesn't work at the edge
import { Pool } from "pg";
 
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10, // Connection pool of 10
});
 
// Each edge invocation would:
// 1. Create a new Pool (can't reuse across invocations reliably)
// 2. Open a TCP connection to your database (which is in us-east-1, not at the edge)
// 3. Do TLS handshake with the database
// 4. Run the query
// 5. Discard the connection when the isolate recycles
 
// Even with connection pooling services like PgBouncer,
// you're still paying the network latency from edge → origin database

数据库往返的问题是根本性的。你的数据库在一个区域,而你的 edge function 在 300 个区域。每次从 edge 发起的数据库查询都必须从 edge 节点到数据库所在区域再返回。对于在东京访问东京 edge 节点的用户来说,如果数据库在弗吉尼亚:

Edge function in Tokyo
  → Query to PostgreSQL in Virginia: ~140ms round trip
  → Second query: ~140ms more
  → Total: 280ms just for two queries

Node.js function in Virginia (same region as DB)
  → Query to PostgreSQL: ~1ms round trip
  → Second query: ~1ms more
  → Total: 2ms for two queries

在这个场景下,edge function 的数据库操作慢了 140 倍。edge function 启动更快并不重要——数据库往返主导了一切。

这就是基于 HTTP 的数据库代理存在的原因(Neon 的 serverless 驱动、PlanetScale 的 fetch 驱动、Supabase 的 REST API)。它们能工作,但仍然是在向单一区域的数据库发送 HTTP 请求。它们解决了"无法使用 TCP"的问题,但没有解决"数据库离得远"的问题。

typescript
// ✅ This works at the edge (HTTP-based database access)
// But it's still slow if the database is far from the edge node
import { neon } from "@neondatabase/serverless";
 
export const runtime = "edge";
 
export async function GET(request: Request) {
  const sql = neon(process.env.DATABASE_URL!);
  // This makes an HTTP request to your Neon database
  // Works, but latency depends on distance to the database region
  const posts = await sql`SELECT * FROM posts WHERE published = true LIMIT 10`;
  return Response.json(posts);
}

2. 长时间运行的任务#

Edge functions 有 CPU 时间限制,通常是 10-50ms 的实际计算时间。挂钟时间更宽裕(通常 30 秒),但 CPU 密集型操作会很快触及限制:

typescript
// ❌ These will exceed CPU time limits at the edge
export const runtime = "edge";
 
export async function POST(request: Request) {
  const data = await request.json();
 
  // Image processing — CPU intensive
  // (Also can't use sharp because it's a native module)
  const processed = heavyImageProcessing(data.image);
 
  // PDF generation — CPU intensive + needs Node.js APIs
  const pdf = generatePDF(data.content);
 
  // Large data transformation
  const result = data.items // 100,000 items
    .map(transform)
    .filter(validate)
    .sort(compare)
    .reduce(aggregate, {});
 
  return Response.json(result);
}

如果你的函数需要超过几毫秒的 CPU 时间,它就应该放在区域 Node.js 服务器上。没有讨论余地。

3. 仅限 Node.js 的依赖#

这一点让很多人措手不及。数量惊人的 npm 包依赖 Node.js 内置模块:

typescript
// ❌ These packages won't work at the edge
import bcrypt from "bcrypt";            // Native C++ binding
import sharp from "sharp";              // Native C++ binding
import puppeteer from "puppeteer";      // Needs filesystem + child_process
import nodemailer from "nodemailer";    // Needs net module
import { readFile } from "fs/promises"; // Node.js filesystem API
import mongoose from "mongoose";         // TCP connections + Node.js APIs
 
// ✅ Edge-compatible alternatives
import { hashSync } from "bcryptjs";    // Pure JS implementation (slower)
// For images: use a separate service or API
// For email: use an HTTP-based email API (Resend, SendGrid REST)
// For database: use HTTP-based clients

在把任何东西搬到 edge 之前,检查每一个依赖。一个藏在依赖树三层深处的 require("fs") 就会在运行时让你的 edge function 崩溃——不是在构建时。你部署了,一切看起来没问题,然后第一个请求命中那段代码路径时你就收到一个莫名其妙的错误。

4. 大打包体积#

Edge 平台有严格的打包体积限制:

  • Cloudflare Workers:1MB(免费),5MB(付费)
  • Vercel Edge Functions:4MB(压缩后)
  • Deno Deploy:20MB

这听起来够用了,直到你 import 一个 UI 组件库、一个校验库和一个日期库。我曾经有一个 edge middleware 膨胀到 3.5MB,因为我从一个 barrel file 导入,结果拉进了整个 @/components 目录。

typescript
// ❌ Barrel file imports can pull in way too much
import { validateEmail } from "@/lib/utils";
// If utils.ts re-exports from 20 other modules, all of them get bundled
 
// ✅ Import directly from the source
import { validateEmail } from "@/lib/validators/email";

5. Streaming 和 WebSocket#

Edge functions 可以做流式响应(Web Streams API),但长连接的 WebSocket 是另一回事。虽然某些平台支持 edge 上的 WebSocket(Cloudflare Workers、Deno Deploy),但 edge functions 的临时性本质使其不适合有状态的长连接。

Next.js Edge Runtime#

Next.js 让你可以非常方便地按路由选择 edge runtime。你不需要全面切换——你可以精确选择哪些路由在 edge 上运行。

Middleware(始终在 Edge)#

Next.js middleware 始终运行在 edge 上。这是设计如此——middleware 拦截每个匹配的请求,所以它需要快速且全球分布:

typescript
// middleware.ts — always runs at the edge, no opt-in needed
import { NextRequest, NextResponse } from "next/server";
 
export function middleware(request: NextRequest) {
  // This runs before every matching request
  // Keep it fast — no database calls, no heavy computation
  return NextResponse.next();
}
 
export const config = {
  // Only run on specific paths
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)",
  ],
};

Edge 上的 API 路由#

任何路由处理器都可以选择 edge runtime:

typescript
// app/api/hello/route.ts
export const runtime = "edge"; // This one line changes the runtime
 
export async function GET(request: Request) {
  return Response.json({
    message: "Hello from the edge",
    region: process.env.VERCEL_REGION ?? "unknown",
    timestamp: Date.now(),
  });
}

Edge 上的页面路由#

甚至整个页面都可以在 edge 上渲染,不过我会在这样做之前仔细考虑:

typescript
// app/dashboard/page.tsx
export const runtime = "edge";
 
export default async function DashboardPage() {
  // Remember: no Node.js APIs here
  // Any data fetching must use fetch() or edge-compatible clients
  const data = await fetch("https://api.example.com/dashboard", {
    headers: { Authorization: `Bearer ${process.env.API_KEY}` },
    next: { revalidate: 60 },
  }).then((r) => r.json());
 
  return (
    <main>
      <h1>Dashboard</h1>
      {/* render data */}
    </main>
  );
}

Edge Runtime 中可用的 API#

这是一份实用参考,告诉你什么能用什么不能用:

typescript
// ✅ Available at the edge
fetch()                          // HTTP requests
Request / Response               // Web standard request/response
Headers                          // HTTP headers
URL / URLSearchParams            // URL parsing
TextEncoder / TextDecoder        // String encoding
crypto.subtle                    // Crypto operations (signing, hashing)
crypto.randomUUID()              // UUID generation
crypto.getRandomValues()         // Cryptographic random numbers
structuredClone()                // Deep cloning
atob() / btoa()                  // Base64 encoding/decoding
setTimeout() / setInterval()     // Timers (but remember CPU limits)
console.log()                    // Logging
ReadableStream / WritableStream  // Streaming
AbortController / AbortSignal    // Request cancellation
URLPattern                       // URL pattern matching
 
// ❌ NOT available at the edge
require()                        // CommonJS (use import)
fs / path / os                   // Node.js built-in modules
process.exit()                   // Process control
Buffer                           // Use Uint8Array instead
__dirname / __filename           // Use import.meta.url
setImmediate()                   // Not a web standard

边缘认证:完整模式#

我想深入讲讲认证,因为它是 edge 最有影响力的用例之一,但也很容易搞错。

有效的模式是:在 edge 验证 token,将可信的 claims 传递给下游,永远不要在 middleware 中碰数据库。

typescript
// lib/edge-auth.ts — Edge-compatible auth utilities
import { jwtVerify, SignJWT, importSPKI, importPKCS8 } from "jose";
 
const PUBLIC_KEY_PEM = process.env.JWT_PUBLIC_KEY!;
const ISSUER = "https://auth.myapp.com";
const AUDIENCE = "https://myapp.com";
 
export interface TokenPayload {
  sub: string;
  email: string;
  role: "user" | "admin" | "moderator";
  iat: number;
  exp: number;
}
 
export async function verifyToken(token: string): Promise<TokenPayload | null> {
  try {
    const publicKey = await importSPKI(PUBLIC_KEY_PEM, "RS256");
    const { payload } = await jwtVerify(token, publicKey, {
      algorithms: ["RS256"],
      issuer: ISSUER,
      audience: AUDIENCE,
      clockTolerance: 30, // 30 seconds of clock skew tolerance
    });
 
    return payload as unknown as TokenPayload;
  } catch {
    return null;
  }
}
 
export function isTokenExpiringSoon(payload: TokenPayload): boolean {
  const now = Math.floor(Date.now() / 1000);
  const fiveMinutes = 5 * 60;
  return payload.exp - now < fiveMinutes;
}
typescript
// middleware.ts — The auth middleware
import { NextRequest, NextResponse } from "next/server";
import { verifyToken, isTokenExpiringSoon } from "./lib/edge-auth";
 
const PUBLIC_PATHS = ["/", "/login", "/register", "/api/auth/login"];
const ADMIN_PATHS = ["/admin"];
 
export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
 
  // Skip auth for public paths
  if (PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith("/api/public"))) {
    return NextResponse.next();
  }
 
  // Extract token
  const token = request.cookies.get("auth-token")?.value;
  if (!token) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
 
  // Verify token (pure crypto — no database call)
  const payload = await verifyToken(token);
  if (!payload) {
    const response = NextResponse.redirect(new URL("/login", request.url));
    response.cookies.delete("auth-token");
    return response;
  }
 
  // Role-based access control
  if (ADMIN_PATHS.some((p) => pathname.startsWith(p)) && payload.role !== "admin") {
    return NextResponse.redirect(new URL("/unauthorized", request.url));
  }
 
  // Pass verified user info to the origin as trusted headers
  const response = NextResponse.next();
  response.headers.set("x-user-id", payload.sub);
  response.headers.set("x-user-email", payload.email);
  response.headers.set("x-user-role", payload.role);
 
  // Signal if token needs refresh
  if (isTokenExpiringSoon(payload)) {
    response.headers.set("x-token-refresh", "true");
  }
 
  return response;
}
typescript
// app/api/profile/route.ts — Origin server reads trusted headers
export async function GET(request: Request) {
  // These headers were set by edge middleware after JWT verification
  // They're trusted because they come from our own infrastructure
  const userId = request.headers.get("x-user-id");
  const userRole = request.headers.get("x-user-role");
 
  if (!userId) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }
 
  // Now we can hit the database — we're on the origin server,
  // right next to the database, with a connection pool
  const user = await db.user.findUnique({ where: { id: userId } });
 
  return Response.json(user);
}

关键洞察:edge 做快的部分(加密验证),源服务器做慢的部分(数据库查询)。每个部分都在最高效的地方运行。

一个重要的注意事项:这只适用于 JWT。如果你的认证系统每次请求都需要数据库查询(比如基于 session ID cookie 的 session 认证),edge 帮不了忙——你仍然需要查询数据库,这意味着一次到源区域的往返。

边缘缓存#

在 edge 做缓存是事情变得有趣的地方。Edge 节点可以缓存响应,这意味着后续对同一 URL 的请求可以直接从 edge 提供,完全不需要请求源服务器。

正确使用 Cache-Control#

typescript
// app/api/products/route.ts
export const runtime = "edge";
 
export async function GET(request: Request) {
  const url = new URL(request.url);
  const category = url.searchParams.get("category") ?? "all";
 
  const products = await fetch(
    `${process.env.ORIGIN_API}/products?category=${category}`,
  ).then((r) => r.json());
 
  return Response.json(products, {
    headers: {
      // Cache on CDN for 60 seconds
      // Serve stale while revalidating for up to 5 minutes
      // Client can cache for 10 seconds
      "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300, max-age=10",
 
      // Vary by these headers so different variants get different cache entries
      Vary: "Accept-Language, Accept-Encoding",
 
      // CDN-specific cache tag for targeted invalidation
      "Cache-Tag": `products,category-${category}`,
    },
  });
}

stale-while-revalidate 模式在 edge 上特别强大。以下是它的工作原理:

  1. 第一次请求:Edge 从源服务器获取,缓存响应,返回给用户
  2. 60 秒内的请求:Edge 从缓存提供(0ms 源服务器延迟)
  3. 61-360 秒的请求:Edge 立即提供过期的缓存版本,但在后台从源服务器获取新版本
  4. 360 秒之后:缓存完全过期,下一个请求直接请求源服务器

你的用户几乎总是得到缓存的响应。新鲜度的权衡是显式且可调的。

Edge Config 实现动态配置#

Vercel 的 Edge Config(以及其他平台的类似服务)允许你存储键值配置,并复制到每个 edge 节点。这对于功能开关、重定向规则和 A/B 测试配置非常有用——你不需要重新部署就能更新:

typescript
import { get } from "@vercel/edge-config";
import { NextRequest, NextResponse } from "next/server";
 
export async function middleware(request: NextRequest) {
  // Edge Config reads are extremely fast (~1ms) because
  // the data is replicated to every edge location
  const maintenanceMode = await get<boolean>("maintenance_mode");
 
  if (maintenanceMode) {
    return NextResponse.rewrite(new URL("/maintenance", request.url));
  }
 
  // Feature flags
  const features = await get<Record<string, boolean>>("feature_flags");
  if (features?.["new_pricing_page"] && request.nextUrl.pathname === "/pricing") {
    return NextResponse.rewrite(new URL("/pricing-v2", request.url));
  }
 
  // Dynamic redirects (update redirects without redeploy)
  const redirects = await get<Array<{ from: string; to: string; permanent: boolean }>>(
    "redirects",
  );
 
  if (redirects) {
    const match = redirects.find((r) => r.from === request.nextUrl.pathname);
    if (match) {
      return NextResponse.redirect(
        new URL(match.to, request.url),
        match.permanent ? 308 : 307,
      );
    }
  }
 
  return NextResponse.next();
}

这是真正的游戏规则改变者。在 Edge Config 之前,更改功能开关意味着代码修改和重新部署。现在你只需在控制面板中更新一个 JSON 值,几秒钟内就全球生效。

真实的性能计算#

让我们做真实的计算而不是营销计算。我会比较三种架构,场景是一个需要查询数据库的典型 API 端点:

场景:用户资料 API(2 次数据库查询)#

架构 A:传统区域 Node.js

User in Tokyo → Origin in Virginia: 140ms
  + DB query 1 (same region): 2ms
  + DB query 2 (same region): 2ms
  + Processing: 5ms
  = Total: ~149ms

架构 B:Edge Function + HTTP 数据库

User in Tokyo → Edge in Tokyo: 5ms
  + DB query 1 (HTTP to Virginia): 145ms
  + DB query 2 (HTTP to Virginia): 145ms
  + Processing: 3ms
  = Total: ~298ms ← SLOWER than regional

架构 C:Edge Function + 区域数据库(只读副本)

User in Tokyo → Edge in Tokyo: 5ms
  + DB query 1 (HTTP to Tokyo replica): 8ms
  + DB query 2 (HTTP to Tokyo replica): 8ms
  + Processing: 3ms
  = Total: ~24ms ← Fastest, but requires multi-region DB

架构 D:Edge 做认证 + 区域服务器做数据

User in Tokyo → Edge middleware in Tokyo: 5ms (JWT verify)
  → Origin in Virginia: 140ms
  + DB query 1 (same region): 2ms
  + DB query 2 (same region): 2ms
  + Processing: 5ms
  = Total: ~154ms
  (But auth is already verified — origin doesn't need to re-verify)
  (And unauthorized requests are blocked at the edge — never reach origin)

要点总结:

  1. Edge + 源数据库 = 通常更慢,不如直接用区域服务器
  2. Edge + 多区域数据库 = 最快,但最贵最复杂
  3. Edge 做守门 + 区域做数据 = 最佳务实平衡
  4. 纯 Edge(无数据库)= 重定向和认证检查的无敌方案

架构 D 是我大多数项目使用的方案。Edge 处理它擅长的事(快速决策、认证、路由),区域 Node.js 服务器处理它擅长的事(数据库查询、重计算)。

Edge 真正获胜的时候:无数据库操作#

当不涉及数据库时,计算完全反转:

Redirect (edge):
  User in Tokyo → Edge in Tokyo → redirect response: ~5ms

Redirect (regional):
  User in Tokyo → Origin in Virginia → redirect response: ~280ms
Static API response (edge + cache):
  User in Tokyo → Edge in Tokyo → cached response: ~5ms

Static API response (regional):
  User in Tokyo → Origin in Virginia → response: ~280ms
Bot blocking (edge):
  Bad bot in anywhere → Edge (nearest) → 403 response: ~5ms
  (Bot never reaches your origin server)

Bot blocking (regional):
  Bad bot in anywhere → Origin in Virginia → 403 response: ~280ms
  (Bot still consumed origin resources)

对于不需要数据库的操作,edge 快 20-50 倍。这不是营销——这是物理。

我的决策框架#

在生产环境中使用 edge functions 一年后,以下是我为每个新端点或逻辑做决策时使用的流程图:

第一步:需要 Node.js API 吗?#

如果导入了 fsnetchild_process 或任何原生模块——Node.js 区域。没有讨论余地。

第二步:需要数据库查询吗?#

如果需要,而且你的用户附近没有只读副本——Node.js 区域(与数据库同一区域)。数据库往返会主导一切。

如果需要,而且你有全球分布的只读副本——Edge 可以工作,使用基于 HTTP 的数据库客户端。

第三步:是否是关于请求的决策(路由、认证、重定向)?#

如果是——Edge。这是最佳适用场景。你在请求到达源服务器之前做出快速决策。

第四步:响应是否可缓存?#

如果是——Edge 配合适当的 Cache-Control header。即使第一次请求到达源服务器,后续请求也会从 edge 缓存提供。

第五步:是否 CPU 密集?#

如果涉及大量计算(图片处理、PDF 生成、大数据转换)——Node.js 区域

第六步:对延迟有多敏感?#

如果是后台任务或 webhook——Node.js 区域。没人在等它。 如果是用户请求且每毫秒都重要——Edge,前提是满足其他条件。

速查表#

typescript
// ✅ PERFECT for edge
// - Middleware (auth, redirects, rewrites, headers)
// - Geolocation logic
// - A/B test assignment
// - Bot detection / WAF rules
// - Cache-friendly API responses
// - Feature flag checks
// - CORS preflight responses
// - Static data transformations (no DB)
// - Webhook signature verification
 
// ❌ KEEP on Node.js regional
// - Database CRUD operations
// - File uploads / processing
// - Image manipulation
// - PDF generation
// - Email sending (use HTTP API, but still regional)
// - WebSocket servers
// - Background jobs / queues
// - Anything using native npm packages
// - SSR pages with database queries
// - GraphQL resolvers that hit databases
 
// 🤔 IT DEPENDS
// - Authentication (edge for JWT, regional for session-DB)
// - API routes (edge if no DB, regional if DB)
// - Server-rendered pages (edge if data comes from cache/fetch, regional if DB)
// - Real-time features (edge for initial auth, regional for persistent connections)

我实际在 Edge 上运行的东西#

对于这个站点,以下是具体分工:

Edge(middleware):

  • 语言检测和重定向
  • 机器人过滤
  • 安全 header(CSP、HSTS 等)
  • 访问日志
  • 速率限制(基础)

Node.js 区域:

  • 博客内容渲染(MDX 处理需要通过 Velite 使用 Node.js API)
  • 涉及 Redis 的 API 路由
  • OG 图片生成(需要更多 CPU 时间)
  • RSS 订阅生成

静态(不需要运行时):

  • 工具页面(构建时预渲染)
  • 博客文章页面(构建时预渲染)
  • 所有图片和资源(CDN 提供)

最好的运行时通常是不需要运行时。如果你能在构建时预渲染某些东西并作为静态资源提供,那永远比任何 edge function 都快。Edge 是为那些在每次请求时确实需要动态处理的东西准备的。

诚实的总结#

Edge functions 不是传统服务器的替代品,而是补充。它们是你架构工具箱中的一个额外工具——对正确的用例来说极其强大,对错误的用例来说则有害。

我不断回到的启发式规则是:如果你的函数需要访问单一区域的数据库,把函数放在 edge 上不会有帮助——反而会拖累。 你多加了一跳。函数运行得更快了,但然后它花了 100ms+ 去请求数据库。最终结果:比把所有东西放在一个区域还慢。

但对于只需要请求本身信息就能做出的决策——地理位置、cookie、header、JWT——edge 是无与伦比的。那些 5ms 的 edge 响应不是合成基准测试,它们是真实的,你的用户能感受到差异。

不要把所有东西搬到 edge。也不要把所有东西都留在 edge 之外。把每个逻辑放在物理规律最有利的地方。

相关文章