Edge 与 Serverless 混合:在靠近用户的地方运行代码

1. 概述

离服务器远的用户会遭受高网络延迟,即使你的代码运行得很快。Edge 与 Serverless 混合模型解决了这个问题:轻量级逻辑在靠近用户的各个点运行,重量级逻辑在中央几个区域的 Serverless 函数中运行。

本文解释概念、该模型受关注的原因以及如何在两个层之间分配工作。你将获得 Spring Boot 3.x、Spring Cloud Function 和一个小型 edge function 的具体示例。文末讨论了不应该使用此模型的情况。

2. Edge、Serverless 和混合模型

2.1. 什么是 Edge

Edge 是分布在多个地点的基础设施,通常与 CDN 网络相关联。过去,Edge 主要用于缓存静态文件。如今,许多平台允许直接在 edge 运行代码,称为 edge function。

Edge 运行时通常有严格的限制:

  • 每个请求的执行时间短。
  • 内存小,没有完整的文件系统。
  • 通常支持 JavaScript/TypeScript 或 WebAssembly,很少运行完整的 JVM。
  • 不适合与传统数据库保持长期连接。

2.2. 什么是 Serverless

Serverless(Function-as-a-Service 或自动弹性伸缩的容器)允许你部署代码而无需管理服务器。平台根据请求量自动扩展,甚至可以扩展到零。作为交换,你必须接受新实例启动时的冷启动。

与 Edge 相比,Serverless 通常在较少的区域运行。但作为补偿,它拥有更多资源,完全支持 Java,并且靠近数据库。

2.3. 为什么需要混合模型

每个层都有其优势:

┌──────────┐    靠近   ┌──────────────┐   较远    ┌────────────────────┐    ┌──────────┐
│ 用户     │ ────────▶ │ Edge         │ ─────────▶ │ Serverless 源服务  │ ─▶ │ 数据库   │
│          │ ◀──────── │ (缓存、路由  │ ◀───────── │ (Spring Boot /     │ ◀─ │          │
└──────────┘           │ 、认证)      │            │  Spring Cloud Func)│    └──────────┘
  • Edge 处理需要快速且不依赖中央数据的任务:缓存、语言重定向、初步令牌检查、机器人阻止、A/B 测试。
  • Serverless 源服务 处理需要数据一致性的业务逻辑:价格计算、订单创建、付款。

一般原则是:将读取频繁但变化不多的部分移出到 Edge,在源服务器保留需要写入和一致性的部分。

3. 为什么这个模型现在受到关注

有几个主要原因:

  1. 感知延迟。 许多请求在 edge 立即应答,无需到达源服务。距离中央区域远的用户受益最明显。
  2. 减少源服务负载和成本。 每个在 edge 缓存的请求都是一次 Serverless 函数不需要执行的情况。
  3. 基于位置的个性化。 Edge 知道用户来自哪里,因此可以选择合适的语言、货币或内容版本。
  4. 数据驻留要求。 Edge 可以将请求路由到正确的法律司法管辖区的源服务。
  5. Java 生态适应性更好。 使用 GraalVM 的原生镜像和快速启动技术有助于减少冷启动,使 Java 比以前更适合 Serverless。

4. 源服务:用 Spring Boot “教” Edge 如何缓存

混合模型从源服务开始。Edge 只有在源服务返回明确的 Cache-Control 头时才能正确缓存。让我们考虑一个产品目录 API:

public record Product(String id, String name, BigDecimal price) {}

@Component
public class ProductCatalog {
    private final Map<String, Product> products = Map.of(
        "p-1", new Product("p-1", "顺化尖角帽", new BigDecimal("150000")),
        "p-2", new Product("p-2", "三层衣", new BigDecimal("320000"))
    );

    public Optional<Product> findById(String id) {
        return Optional.ofNullable(products.get(id));
    }
}

Controller 为每种数据类型返回不同的缓存头:

@RestController
@RequestMapping("/api")
public class ProductController {

    private final ProductCatalog catalog;

    public ProductController(ProductCatalog catalog) {
        this.catalog = catalog;
    }

    @GetMapping("/products/{id}")
    public ResponseEntity<Product> getProduct(@PathVariable String id) {
        return catalog.findById(id)
            .map(product -> ResponseEntity.ok()
                .cacheControl(CacheControl.maxAge(Duration.ofSeconds(30))
                    .cachePublic()
                    .sMaxAge(Duration.ofMinutes(5))
                    .staleWhileRevalidate(Duration.ofMinutes(1)))
                .body(product))
            .orElse(ResponseEntity.notFound().build());
    }

    @GetMapping("/cart")
    public ResponseEntity<List<String>> getCart(@RequestHeader("Authorization") String token) {
        return ResponseEntity.ok()
            .cacheControl(CacheControl.noStore())
            .body(List.of("p-1"));
    }
}

每个指令的含义:

  • max-age=30:浏览器可以使用缓存版本 30 秒。
  • public:共享缓存(edge、CDN)允许存储响应。
  • s-maxage=300:共享缓存保持缓存 5 分钟。Edge 保留时间比浏览器长,因为你可以主动清除 edge 上的缓存,但无法清除用户机器上的缓存。
  • stale-while-revalidate=60:过期时,edge 在最多 60 秒内仍然返回旧版本,并在后台重新验证。用户不需要等待源服务。
  • 购物车是个人数据,所以使用 no-store 以防任何层存储。

使用 curl 验证:

curl -i http://localhost:8080/api/products/p-1
HTTP/1.1 200
Cache-Control: max-age=30, public, s-maxage=300, stale-while-revalidate=60
Content-Type: application/json

{"id":"p-1","name":"顺化尖角帽","price":150000}

5. 使用 Spring Cloud Function 在 Serverless 中处理业务逻辑

需要中央数据的计算部分应该在 Serverless 函数中运行。Spring Cloud Function 允许你以纯 Function 的形式编写逻辑,然后在本地 HTTP 上或通过 Serverless 平台的适配器运行相同的代码。

添加依赖(声明 spring-cloud-dependencies BOM,版本与你正在使用的 Spring Boot 兼容):

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-function-web</artifactId>
    </dependency>
</dependencies>

编写一个根据国家计算订单报价的函数:

public record OrderRequest(String country, BigDecimal subtotal) {}

public record OrderQuote(BigDecimal subtotal, BigDecimal tax, BigDecimal total) {}

@Configuration
public class PricingFunctions {

    private static final Map<String, BigDecimal> TAX_RATES = Map.of(
        "VN", new BigDecimal("0.10"),
        "SG", new BigDecimal("0.09")
    );

    @Bean
    public Function<OrderRequest, OrderQuote> quoteOrder() {
        return request -> {
            BigDecimal rate = TAX_RATES.getOrDefault(request.country(), BigDecimal.ZERO);
            BigDecimal tax = request.subtotal().multiply(rate).setScale(0, RoundingMode.HALF_UP);
            return new OrderQuote(request.subtotal(), tax, request.subtotal().add(tax));
        };
    }
}

在本地运行时,spring-cloud-function-web 自动在 POST /quoteOrder 端点暴露 quoteOrder bean:

curl -s -X POST http://localhost:8080/quoteOrder \
  -H "Content-Type: application/json" \
  -d '{"country":"VN","subtotal":150000}'
{"subtotal":150000,"tax":15000,"total":165000}

逻辑位于纯 Function 中,不依赖于 HTTP 或平台。因此,你可以通过仅更改适配器来在 Serverless 提供商之间切换,而无需修改业务逻辑。

注意: 对于 Serverless 上的 Java,冷启动是一个真实问题。保持函数简洁,限制不必要的 bean,如果平台支持的话考虑使用原生镜像。

6. Edge Function:路由、早期阻止和转发

Edge 层通常运行 JavaScript。许多 edge 运行时遵循标准 Web Fetch API 模型:接收 Request,返回 Response。下面的示例说明了这个概念。位置头的命名方式和部署方式取决于你使用的平台。

const ORIGIN = "https://origin.nonla.example";

export default {
  async fetch(request) {
    const url = new URL(request.url);
    const country = request.headers.get("x-edge-country") ?? "VN";

    // 1. 根据语言重定向主页,无需调用源服务
    if (url.pathname === "/") {
      const lang = country === "VN" ? "vi" : "en";
      return Response.redirect(`${url.origin}/${lang}/`, 302);
    }

    // 2. 早期阻止没有令牌的购物车请求
    if (url.pathname.startsWith("/api/cart") && !request.headers.has("Authorization")) {
      return new Response("未授权", { status: 401 });
    }

    // 3. 转发到源服务,并附带国家信息
    const headers = new Headers(request.headers);
    headers.set("X-Edge-Country", country);
    return fetch(new Request(ORIGIN + url.pathname + url.search, {
      method: request.method,
      headers,
      body: request.body,
    }));
  },
};

这个 Edge function 做三件事:

  1. 按国家重定向 立即在 edge 进行。关于主页的请求从不到达源服务。
  2. 早期阻止 缺少令牌的请求。源服务不会浪费一次 Serverless 函数执行来处理肯定会被拒绝的请求。完整的令牌认证仍然必须在源服务进行。
  3. 转发 其余请求。来自源服务的响应携带第 4 节的 Cache-Control 头,因此 edge 的缓存层知道哪些响应被存储以及存储多长时间。

7. 测试主要行为

源服务和 edge 之间的合约就是缓存头。因此,请编写测试以确保此头不会被无意中更改:

@WebMvcTest(ProductController.class)
@Import(ProductCatalog.class)
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void productResponseIsCacheableAtEdge() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/products/p-1")).andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(200);
        assertThat(result.getResponse().getHeader("Cache-Control"))
            .contains("public", "s-maxage=300", "stale-while-revalidate=60");
    }

    @Test
    void cartResponseIsNeverStored() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/cart")
                .header("Authorization", "Bearer demo"))
            .andReturn();

        assertThat(result.getResponse().getHeader("Cache-Control")).isEqualTo("no-store");
    }
}

Serverless 函数只是一个 Function,所以你可以像测试普通 Java 代码一样测试它:

class PricingFunctionsTest {

    private final Function<OrderRequest, OrderQuote> quoteOrder =
        new PricingFunctions().quoteOrder();

    @Test
    void addsVietnameseTax() {
        OrderQuote quote = quoteOrder.apply(new OrderRequest("VN", new BigDecimal("150000")));

        assertThat(quote.tax()).isEqualByComparingTo("15000");
        assertThat(quote.total()).isEqualByComparingTo("165000");
    }

    @Test
    void unknownCountryHasNoTax() {
        OrderQuote quote = quoteOrder.apply(new OrderRequest("XX", new BigDecimal("100")));

        assertThat(quote.total()).isEqualByComparingTo("100");
    }
}

8. 常见错误和何时不应使用

8.1. 常见错误

  • 缓存个人数据错误。 如果你忘记为依赖于用户的响应添加 no-store 或 private,edge 可能会将某个用户的购物车返回给另一个用户。这是最严重的错误。
  • 缓存键与个性化不匹配。 如果源服务根据 X-Edge-Country 返回不同的内容,但 edge 的缓存键没有考虑该头,所有用户都会收到相同版本。添加适当的 Vary 头或在 edge 配置缓存键。
  • 从 Edge 直接调用数据库。 每个请求都从众多 edge 点到单个区域的数据库。延迟不会减少,反而增加,并且有连接耗尽的风险。
  • 业务逻辑重复。 定价规则在 edge 用 JavaScript 编写一次,在源服务用 Java 编写一次,然后逐渐偏离。Edge 应该只处理基础设施工作:路由、缓存、过滤。
  • 完全信任 Edge 的检查。 Edge 中的令牌检查仅用于早期过滤。源服务仍然必须进行完整的认证和授权。

8.2. 何时不应使用

注意: 混合模型增加了必须调试、部署和监控的层数。只有在明确测量的好处时才使用。

  • 用户集中在一个地区,靠近源区域。在这种情况下,Edge 计算带来的好处很少。简单的 CDN 静态缓存通常足够。
  • 应用主要写入数据 或需要强一致性,例如支付系统或库存管理。大多数请求仍然必须回到源服务。
  • 长运行或 CPU 密集的工作负载,如视频处理或大型报告。它们不符合 edge 的限制,通常更适合持续运行的服务而不是 Serverless 函数。
  • 流量稳定且持续很高。 持续运行的服务可能比 Serverless 更简单、更便宜。
  • 团队规模小,可观测性不佳。 错误跨越 edge、Serverless 和数据库会非常难以追踪,如果缺乏端到端的追踪。

9. 结论

Edge 与 Serverless 混合模型不会替代传统架构。它是一种根据距离分配工作的方式:轻量级、读取密集且不需要中央数据的工作在 edge 运行;需要一致性的业务逻辑在靠近数据库的 Serverless 源服务运行。

在本文中,你了解了 Spring Boot 源服务如何通过带有 s-maxage 和 stale-while-revalidate 的 Cache-Control 控制 edge 的缓存。你还了解了如何使用 Spring Cloud Function 以 Function 形式编写业务逻辑,以便于测试和平台迁移,然后使用 edge function 进行重定向、早期阻止和转发请求。

从最简单的步骤开始:为每个端点设置正确的缓存头并测量效果。只有当你自己的系统数据显示值得时,才将逻辑移出到 edge。

edge computingserverlessspring bootspring cloud function架构