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. 为什么这个模型现在受到关注
有几个主要原因:
- 感知延迟。 许多请求在 edge 立即应答,无需到达源服务。距离中央区域远的用户受益最明显。
- 减少源服务负载和成本。 每个在 edge 缓存的请求都是一次 Serverless 函数不需要执行的情况。
- 基于位置的个性化。 Edge 知道用户来自哪里,因此可以选择合适的语言、货币或内容版本。
- 数据驻留要求。 Edge 可以将请求路由到正确的法律司法管辖区的源服务。
- 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 做三件事:
- 按国家重定向 立即在 edge 进行。关于主页的请求从不到达源服务。
- 早期阻止 缺少令牌的请求。源服务不会浪费一次 Serverless 函数执行来处理肯定会被拒绝的请求。完整的令牌认证仍然必须在源服务进行。
- 转发 其余请求。来自源服务的响应携带第 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。