Edge và serverless lai: chạy code gần người dùng

1. Tổng quan

Người dùng ở xa máy chủ sẽ chịu độ trễ mạng cao, dù code của bạn chạy rất nhanh. Mô hình edge kết hợp serverless giải quyết chuyện này: phần logic nhẹ chạy ở các điểm gần người dùng, phần nặng chạy trong các hàm serverless ở một vài region trung tâm.

Bài này giải thích khái niệm, lý do mô hình này được quan tâm và cách chia việc giữa hai tầng. Bạn sẽ có ví dụ cụ thể với Spring Boot 3.x, Spring Cloud Function và một edge function nhỏ. Cuối bài là các trường hợp không nên dùng mô hình này.

2. Edge, serverless và mô hình lai

2.1. Edge là gì

Edge là hạ tầng phân tán ở nhiều địa điểm, thường gắn với mạng CDN. Trước đây edge chủ yếu dùng để cache file tĩnh. Ngày nay nhiều nền tảng cho phép chạy code ngay tại edge, gọi là edge function.

Edge runtime thường có giới hạn chặt:

  • Thời gian chạy mỗi request ngắn.
  • Bộ nhớ nhỏ, không có file system đầy đủ.
  • Thường hỗ trợ JavaScript/TypeScript hoặc WebAssembly, ít khi chạy JVM đầy đủ.
  • Không phù hợp để giữ kết nối lâu tới database truyền thống.

2.2. Serverless là gì

Serverless (Function-as-a-Service hoặc container tự co giãn) cho phép bạn deploy code mà không quản lý server. Nền tảng tự scale theo lượng request, kể cả scale về 0. Đổi lại, bạn phải chấp nhận cold start khi instance mới khởi động.

So với edge, serverless thường chạy ở ít region hơn. Bù lại, nó có nhiều tài nguyên hơn, hỗ trợ Java đầy đủ và nằm gần database.

2.3. Vì sao cần mô hình lai

Mỗi tầng đều có điểm mạnh riêng:

┌──────────┐    gần    ┌──────────────┐   xa hơn   ┌────────────────────┐    ┌──────────┐
│ Người    │ ────────▶ │ Edge         │ ─────────▶ │ Serverless origin  │ ─▶ │ Database │
│ dùng     │ ◀──────── │ (cache, định │ ◀───────── │ (Spring Boot /     │ ◀─ │          │
└──────────┘           │ tuyến, auth) │            │  Spring Cloud Func)│    └──────────┘
                       └──────────────┘            └────────────────────┘
  • Edge xử lý những việc cần nhanh và không phụ thuộc dữ liệu trung tâm: cache, chuyển hướng theo ngôn ngữ, kiểm tra token sơ bộ, chặn bot, A/B testing.
  • Serverless origin xử lý nghiệp vụ cần dữ liệu nhất quán: tính giá, tạo đơn hàng, thanh toán.

Nguyên tắc chung: đưa phần đọc nhiều và ít thay đổi ra edge, giữ phần ghi và phần cần nhất quán ở origin.

3. Vì sao mô hình này đang được quan tâm

Có một số lý do chính:

  1. Độ trễ cảm nhận. Nhiều request được trả lời ngay tại edge, không cần đi tới origin. Người dùng ở xa region trung tâm hưởng lợi rõ nhất.
  2. Giảm tải và chi phí origin. Mỗi request được cache ở edge là một lần hàm serverless không phải chạy.
  3. Cá nhân hoá theo vị trí. Edge biết người dùng đến từ đâu, nên có thể chọn ngôn ngữ, tiền tệ hoặc phiên bản nội dung phù hợp.
  4. Yêu cầu lưu trữ dữ liệu theo vùng. Edge có thể định tuyến request tới origin ở đúng khu vực pháp lý.
  5. Hệ sinh thái Java đã thích nghi tốt hơn. Native image với GraalVM và các kỹ thuật khởi động nhanh giúp giảm cold start, nên Java hợp hơn với serverless so với trước đây.

4. Origin: để Spring Boot “dạy” edge cách cache

Mô hình lai bắt đầu từ origin. Edge chỉ cache đúng khi origin trả về header Cache-Control rõ ràng. Hãy xét một API danh mục sản phẩm:

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", "Nón lá Huế", new BigDecimal("150000")),
        "p-2", new Product("p-2", "Áo bà ba", new BigDecimal("320000"))
    );

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

Controller trả về header cache khác nhau cho từng loại dữ liệu:

@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"));
    }
}

Ý nghĩa từng chỉ thị:

  • max-age=30: trình duyệt được dùng bản cache trong 30 giây.
  • public: cache dùng chung (edge, CDN) được phép lưu response.
  • s-maxage=300: riêng cache dùng chung giữ bản cache 5 phút. Edge giữ lâu hơn trình duyệt, vì bạn có thể chủ động xoá cache ở edge nhưng không xoá được cache trên máy người dùng.
  • stale-while-revalidate=60: khi hết hạn, edge vẫn trả bản cũ trong tối đa 60 giây và làm mới ở chế độ nền. Người dùng không phải chờ origin.
  • Giỏ hàng là dữ liệu cá nhân, nên dùng no-store để không tầng nào lưu lại.

Kiểm tra bằng 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":"Nón lá Huế","price":150000}

5. Nghiệp vụ ở serverless với Spring Cloud Function

Phần tính toán cần dữ liệu trung tâm nên chạy trong hàm serverless. Spring Cloud Function cho phép bạn viết logic dưới dạng Function thuần, rồi chạy cùng một code trên HTTP cục bộ hoặc qua adapter của nền tảng serverless.

Thêm dependency (khai báo BOM spring-cloud-dependencies với phiên bản tương thích với Spring Boot bạn đang dùng):

<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>

Viết một hàm báo giá đơn hàng theo quốc gia:

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));
        };
    }
}

Khi chạy cục bộ, spring-cloud-function-web tự expose bean quoteOrder tại endpoint POST /quoteOrder:

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

Logic nằm trong một Function thuần, không phụ thuộc HTTP hay nền tảng. Nhờ vậy bạn có thể chuyển giữa các nhà cung cấp serverless chỉ bằng cách đổi adapter, không phải sửa nghiệp vụ.

Lưu ý: Với Java trên serverless, cold start là vấn đề thật. Hãy giữ hàm gọn, hạn chế bean không cần thiết và cân nhắc native image nếu nền tảng hỗ trợ.

6. Edge function: định tuyến, chặn sớm và chuyển tiếp

Tầng edge thường chạy JavaScript. Nhiều edge runtime theo mô hình Fetch API chuẩn web: nhận Request, trả về Response. Ví dụ dưới đây minh hoạ ý tưởng. Cách đặt tên header vị trí và cách deploy tuỳ nền tảng bạn dùng.

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. Chuyển hướng trang chủ theo ngôn ngữ, không cần gọi origin
    if (url.pathname === "/") {
      const lang = country === "VN" ? "vi" : "en";
      return Response.redirect(`${url.origin}/${lang}/`, 302);
    }

    // 2. Chặn sớm request giỏ hàng không có token
    if (url.pathname.startsWith("/api/cart") && !request.headers.has("Authorization")) {
      return new Response("Unauthorized", { status: 401 });
    }

    // 3. Chuyển tiếp tới origin, kèm thông tin quốc gia
    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 này làm ba việc:

  1. Chuyển hướng theo quốc gia ngay tại edge. Request về trang chủ không bao giờ tới origin.
  2. Chặn sớm request thiếu token. Origin không tốn một lần chạy hàm serverless cho request chắc chắn bị từ chối. Việc xác thực token đầy đủ vẫn phải làm ở origin.
  3. Chuyển tiếp các request còn lại. Response từ origin mang header Cache-Control ở mục 4, nên lớp cache của edge biết response nào được lưu và lưu bao lâu.

7. Test hành vi chính

Hợp đồng giữa origin và edge chính là header cache. Vì vậy hãy viết test để header này không bị đổi ngoài ý muốn:

@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");
    }
}

Hàm serverless chỉ là một Function, nên bạn test nó như code Java thông thường:

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. Lỗi hay gặp và khi nào không nên dùng

8.1. Lỗi hay gặp

  • Cache nhầm dữ liệu cá nhân. Nếu bạn quên no-store hoặc private cho response phụ thuộc người dùng, edge có thể trả giỏ hàng của người này cho người khác. Đây là lỗi nghiêm trọng nhất.
  • Cache key không khớp với cách cá nhân hoá. Nếu origin trả nội dung khác nhau theo X-Edge-Country mà cache key ở edge không tính tới header đó, mọi người dùng sẽ nhận cùng một phiên bản. Hãy thêm header Vary phù hợp hoặc cấu hình cache key ở edge.
  • Gọi database trực tiếp từ edge. Mỗi request đi từ hàng loạt điểm edge về một database ở một region. Độ trễ không giảm mà còn tăng, kèm nguy cơ cạn kết nối.
  • Logic nghiệp vụ bị nhân đôi. Quy tắc tính giá viết một lần bằng JavaScript ở edge, một lần bằng Java ở origin, rồi dần lệch nhau. Edge chỉ nên làm việc hạ tầng: định tuyến, cache, lọc.
  • Tin tưởng tuyệt đối kiểm tra ở edge. Kiểm tra token ở edge chỉ để lọc sớm. Origin vẫn phải xác thực và phân quyền đầy đủ.

8.2. Khi nào KHÔNG nên dùng

Lưu ý: Mô hình lai làm tăng số tầng phải debug, deploy và giám sát. Chỉ dùng khi lợi ích đo được rõ ràng.

  • Người dùng tập trung ở một khu vực, gần region origin. Edge compute khi đó mang lại ít lợi ích. Một CDN cache tĩnh thường là đủ.
  • Ứng dụng chủ yếu ghi dữ liệu hoặc cần nhất quán mạnh, ví dụ hệ thống thanh toán hay quản lý tồn kho. Hầu hết request vẫn phải về origin.
  • Workload chạy lâu hoặc nặng CPU, như xử lý video hay báo cáo lớn. Chúng không hợp với giới hạn của edge, và thường hợp với service chạy liên tục hơn là hàm serverless.
  • Traffic ổn định và cao liên tục. Một service chạy thường trực có thể đơn giản và rẻ hơn serverless.
  • Đội ngũ nhỏ, chưa có observability tốt. Lỗi trải trên edge, serverless và database sẽ rất khó truy vết nếu thiếu tracing xuyên suốt.

9. Kết luận

Mô hình edge kết hợp serverless không thay thế kiến trúc truyền thống. Nó là cách chia việc theo khoảng cách: việc nhẹ, đọc nhiều và không cần dữ liệu trung tâm chạy ở edge; nghiệp vụ cần nhất quán chạy ở serverless origin gần database.

Trong bài, bạn đã thấy origin Spring Boot điều khiển cache ở edge bằng Cache-Control với s-maxage và stale-while-revalidate. Bạn cũng đã viết nghiệp vụ dưới dạng Function với Spring Cloud Function để dễ test và dễ chuyển nền tảng, rồi dùng edge function để chuyển hướng, chặn sớm và chuyển tiếp request.

Hãy bắt đầu từ bước đơn giản nhất: đặt header cache đúng cho từng endpoint và đo hiệu quả. Chỉ đưa logic ra edge khi số liệu của chính hệ thống bạn cho thấy điều đó đáng giá.

edge computingserverlessspring bootspring cloud functionkiến trúc