Đặt một API Gateway trước đám microservice là chuyện gần như mặc định. Nó gom nhiều dịch vụ vào một địa chỉ, giữ chỗ cho xác thực, ghi log tập trung, giới hạn tần suất. Cái ít ai nói là nó tốn bao nhiêu, và những chỗ nào trong một gateway tự viết dễ đắt gấp mấy lần bản thân chặng đi thêm.

Tôi dựng ba dịch vụ hậu nguồn và một gateway Vert.x trong cùng một JVM, rồi đo.

Gateway tối giản

WebClient wc = WebClient.create(vertx, new WebClientOptions()
        .setMaxPoolSize(200).setKeepAlive(true));

Router r = Router.router(vertx);
r.get("/di/:dv").handler(c -> {
    int cong = switch (c.pathParam("dv")) { case "a" -> B1; case "b" -> B2; default -> B3; };
    wc.get(cong, "localhost", "/du-lieu").send()
      .onSuccess(res -> c.response().putHeader("content-type","application/json").end(res.body()))
      .onFailure(e   -> c.response().setStatusCode(502).end(e.getMessage()));
});

Vậy thôi. Không thread pool, không callback lồng nhau — WebClient trả về Future, và cùng một event loop vừa nhận request vào vừa chờ hậu nguồn trả lời.

Chặng đi thêm đắt bao nhiêu

100 kết nối, 12 giây, cùng một dịch vụ hậu nguồn:

Thông lượng p50 p99
Gọi thẳng hậu nguồn 110 867 req/s 0,8 ms 2,0 ms
Qua gateway 89 200 req/s 1,0 ms 2,7 ms

Mất 19,5% thông lượng và cộng thêm 0,2 ms vào p50. Con số này rẻ hơn tôi tưởng, và có một lý do khiêm tốn: gateway ở đây chạy chung JVM với hậu nguồn nên chặng thêm chỉ là loopback. Đặt gateway sang máy khác thì cộng thêm đúng độ trễ mạng giữa hai máy — thường 0,2–1 ms trong cùng vùng khả dụng, tức cùng cỡ với chính phần xử lý.

Điều đáng nói là 19,5% đó là giá sàn. Mọi thứ bạn thêm vào gateway — giải mã JWT, ghi log, đếm tần suất — cộng thẳng vào đó. Và có một thứ, nếu quên, đắt hơn cả chặng đi thêm rất nhiều.

Quên keep-alive: mất thêm 74%

Tôi dựng bản sao của cùng route, chỉ đổi một dòng cấu hình client:

WebClient wcRoi = WebClient.create(vertx, new WebClientOptions()
        .setMaxPoolSize(200).setKeepAlive(false));   // <-- chi khac dong nay
Thông lượng p50 p99
Gateway giữ keep-alive 90 029 req/s 1,0 ms 2,6 ms
Gateway đóng mỗi lần 23 670 req/s 3,9 ms 10,9 ms

Gấp 3,8 lần chênh lệch, chỉ vì bắt tay TCP lại từ đầu cho từng request. Vert.x mặc định keepAlive = true nên bạn phải cố ý tắt nó mới gặp cảnh này — nhưng có ba cách rơi vào đó mà không hề tắt gì:

  • Tạo WebClient mới bên trong handler. Mỗi request một client là mỗi request một pool rỗng. WebClient phải là một trường của Verticle, tạo một lần trong start().
  • Hậu nguồn đóng kết nối. Nó gửi Connection: close, client tôn trọng, và pool của bạn vô dụng dù cấu hình đúng.
  • maxPoolSize mặc định là 5. Đủ cho một client thường, quá nhỏ cho một gateway. Vượt quá thì request xếp hàng chờ chứ không lỗi — nên triệu chứng là độ trễ tăng chứ không phải lỗi, và rất khó lần ra.

Nói cách khác, cái đắt nhất trong một gateway thường không phải chặng đi thêm mà là cách quản lý kết nối phía sau nó.

Ghép nhiều dịch vụ: chỗ phép đo đi ngược lời khuyên

Lời khuyên phổ biến là gateway nên gọi các dịch vụ song song thay vì tuần tự. Tôi viết cả hai bản, gọi ba hậu nguồn:

// tuan tu
wc.get(B1,"localhost","/du-lieu").send()
  .compose(a -> { out.put("a", a.bodyAsJsonObject()); return wc.get(B2,"localhost","/du-lieu").send(); })
  .compose(b -> { out.put("b", b.bodyAsJsonObject()); return wc.get(B3,"localhost","/du-lieu").send(); })
  .onSuccess(...);

// song song
var fa = wc.get(B1,"localhost","/du-lieu").send();
var fb = wc.get(B2,"localhost","/du-lieu").send();
var fc = wc.get(B3,"localhost","/du-lieu").send();
Future.all(fa, fb, fc).onSuccess(...);

Kết quả với 100 kết nối:

tuan tu   : 45 849 req/s | p50 2,0 ms | p99 4,9 ms
song song : 45 627 req/s | p50 2,0 ms | p99 4,9 ms

Giống hệt nhau. Song song không nhanh hơn một chút nào — nếu chỉ đo đúng cấu hình này thì kết luận sẽ là "Future.all chẳng để làm gì", và kết luận đó sai.

Hạ xuống một kết nối, tức đo độ trễ chứ không đo lúc bão hoà:

tuan tu   :  6 893 req/s
song song : 10 629 req/s      gap 1,54 lan

Rồi cho hậu nguồn chậm đi 50 ms (dùng vertx.setTimer, không chặn event loop):

Thông lượng p50
1 kết nối, tuần tự 6 req/s 157,7 ms
1 kết nối, song song 18 req/s 52,6 ms
100 kết nối, tuần tự 632 req/s 156,8 ms
100 kết nối, song song 1 882 req/s 52,7 ms

Đúng ba lần, đúng như số học: tuần tự là 3×50 ms, song song là 1×50 ms.

Lời giải cho cái kết quả "giống hệt" ở trên: khi hậu nguồn trả lời trong micro giây, tổng thời gian của một request gần như toàn bộ là công sức xử lý, không phải thời gian chờ. Ghép song song chỉ chồng các khoảng chờ lên nhau; nó không làm giảm số byte phải phân tích cú pháp hay số lần chuyển ngữ cảnh. Ở 100 kết nối, hệ thống đã bão hoà CPU, và cả hai cách làm đúng cùng một lượng việc.

Vậy nên luật đúng không phải "luôn ghép song song" mà là: song song trả lại đúng phần thời gian chờ mà bạn tiết kiệm được. Hậu nguồn nhanh và hệ thống đang bão hoà thì nó không cho gì; hậu nguồn chậm hoặc ở xa thì nó cho tất cả. Ba lời gọi tới ba dịch vụ trong ba vùng khả dụng khác nhau chính là trường hợp thứ hai — nên trong thực tế lời khuyên kia vẫn đúng, chỉ là không phải vì lý do người ta hay nói.

Có một nhắc nhở đi kèm: Future.all hỏng một là hỏng cả. Muốn suy biến duyên dáng — dịch vụ gợi ý chết thì vẫn trả trang, chỉ thiếu phần gợi ý — thì bọc từng Future bằng .recover() trước khi đưa vào Future.all, hoặc dùng Future.join. Và hãy nghĩ tới circuit breaker của phần 25: gateway là chỗ tập trung mọi lời gọi ra ngoài, tức là chỗ mà một hạ nguồn chết dễ kéo sập mọi thứ khác nhất.

Header: mặc định gateway của bạn giấu sạch

Đây là chỗ dễ mất máu nhất, và nó chẳng liên quan gì tới hiệu năng. Gateway tối giản ở đầu bài chuyển tiếp bao nhiêu header của khách? Tôi dựng một route cho hậu nguồn soi lại header nó nhận được, rồi gọi kèm ba header:

curl -H "X-Request-Id: abc-123" -H "Authorization: Bearer xyz" \
     -H "X-Forwarded-For: 203.0.113.9" localhost:19700/header-mac-dinh

Hậu nguồn nhìn thấy:

{ "user-agent": "Vert.x-WebClient/4.5.11", "host": "localhost:19701" }

Không một header nào của khách đi qua. Authorization biến mất — nghĩa là nếu hậu nguồn tự xác thực thì mọi request đều thành ẩn danh, còn nếu nó tin rằng "không có header xác thực nghĩa là lời gọi nội bộ đáng tin" thì bạn vừa dựng một đường vòng qua toàn bộ hệ thống phân quyền. X-Request-Id cũng mất, nên log của gateway và log của hậu nguồn không nối lại được với nhau.

Bản có chuyển tiếp:

c.request().headers().forEach(e -> {
    String k = e.getKey().toLowerCase();
    if (!k.equals("host") && !k.equals("connection") && !k.equals("content-length"))
        req.putHeader(e.getKey(), e.getValue());
});
String xff = c.request().getHeader("x-forwarded-for");
req.putHeader("x-forwarded-for", xff == null ? c.request().remoteAddress().host()
                                             : xff + ", " + c.request().remoteAddress().host());
req.putHeader("x-forwarded-proto", c.request().isSSL() ? "https" : "http");
req.putHeader("x-request-id", c.request().getHeader("x-request-id") != null
        ? c.request().getHeader("x-request-id") : java.util.UUID.randomUUID().toString());
{
  "authorization": "Bearer xyz",
  "x-forwarded-for": "203.0.113.9, 0:0:0:0:0:0:0:1",
  "x-forwarded-proto": "http",
  "x-request-id": "abc-123",
  "host": "localhost:19701"
}

Ba chi tiết trong đoạn code đó không được bỏ:

  • Phải loại Host, Connection, Content-Length. Chép nguyên Host của khách sang là hậu nguồn định tuyến sai; chép Content-Length cũ là hỏng thân request nếu gateway có sửa nội dung.
  • X-Forwarded-For phải nối thêm, không phải ghi đè. Nó là một chuỗi, mỗi chặng thêm một mắt. Ghi đè là xoá mất IP thật của khách.
  • Vì thế mà X-Forwarded-For không đáng tin để ra quyết định bảo mật ở tầng ứng dụng: khách tự đặt được header đó, y như tôi vừa làm với curl. Chỉ mắt do chính chặng ngoài cùng — nơi bạn kiểm soát — thêm vào mới đáng tin.

Nếu bạn định chuyển tiếp mọi header thì nhớ chặn X-Request-Id đến từ bên ngoài khi bạn dùng nó làm định danh vết: một khách gửi cùng một giá trị cho mọi request sẽ làm hệ thống truy vết của bạn thành vô nghĩa.

Thử ba mươi giây

Đây là bài kiểm tra một dòng cho gateway của bạn: dựng một route soi header ở hậu nguồn rồi gọi qua gateway.

r.get("/soi-header").handler(c -> {
    JsonObject j = new JsonObject();
    c.request().headers().forEach(e -> j.put(e.getKey().toLowerCase(), e.getValue()));
    c.response().putHeader("content-type","application/json").end(j.encode());
});
curl -H "Authorization: Bearer xyz" -H "X-Request-Id: abc-123" \
     http://localhost:8080/duong-dan-qua-gateway/soi-header

Không thấy authorization trong kết quả thì gateway của bạn đang nuốt nó — và bạn cần biết ngay hôm nay hậu nguồn đang xử lý những request ẩn danh đó như thế nào.

Phần sau viết giới hạn tần suất chạy thẳng trên event loop, và đo xem cửa sổ trượt với token bucket cái nào chính xác hơn, cái nào rẻ hơn.