M9 · Benchmark mô hình chạy

4 câu hỏi~2–3 ngày code + 1 ngày đoTừ khóa: Spring MVC, CompletableFuture, WebFlux, virtual threads, k6, p99, throughput, Little's law

Mình hay nghe "WebFlux nhanh hơn", "virtual threads thay reactive". Mình muốn tự đo trên project của mình cho chắc.

Rất tốt, vì câu trả lời đúng nhất là "tùy tải", và bạn chỉ nói được câu đó tự tin khi có số liệu của chính mình. Milestone này: cùng một endpoint, 4 cách cài, cùng một bộ test, một bảng số, một lời giải thích.

1. Bối cảnh & bài toán

Endpoint thật của Ticket Booking: GET /api/bench/{model}/events/{id}/quote lấy seat map (catalog, có I/O DB) và gọi pricing (HTTP sang payment-service hoặc mock pricing, độ trễ cấu hình được). Đây là dạng I/O-bound điển hình: thread chủ yếu chờ.

4 module (cùng chạy trong benchmark-service hoặc 4 profile):
 A. mvc-sync      Tomcat 200 platform threads, JDBC + RestClient tuần tự
 B. mvc-async     Tomcat + CompletableFuture trên executor riêng, 2 lời gọi song song
 C. webflux       Netty event loop, R2DBC + WebClient, Mono.zip
 D. mvc-vt        Tomcat + spring.threads.virtual.enabled=true, JDBC + RestClient

2. Ôn lại trước khi code

3. Đề bài

Yêu cầu chức năng

API

MethodPathBodyResponse
GET/api/bench/mvc-sync/events/{id}/quote–200 {eventId, availableSeats, priceTiers:[...], computedBy}
GET/api/bench/mvc-async/events/{id}/quote–
GET/api/bench/webflux/events/{id}/quote–
GET/api/bench/mvc-vt/events/{id}/quote–

MVC và WebFlux không chạy chung một app được (một servlet, một reactive). Gợi ý: 2 app (bench-mvc với 3 profile, bench-webflux) hoặc 4 container.

Skeleton

public record Quote(UUID eventId, int availableSeats, List<PriceTier> priceTiers, String computedBy) {}

// A
@GetMapping("/api/bench/mvc-sync/events/{id}/quote")
Quote sync(@PathVariable UUID id) { /* TODO: seatRepo rồi pricingClient, tuần tự */ }

// B
@GetMapping("/api/bench/mvc-async/events/{id}/quote")
CompletableFuture<Quote> async(@PathVariable UUID id) { /* TODO: 2 supplyAsync trên ioExecutor, thenCombine, orTimeout */ }

// C
@GetMapping("/api/bench/webflux/events/{id}/quote")
Mono<Quote> reactive(@PathVariable UUID id) { /* TODO: Mono.zip(r2dbcSeat, webClientPricing) */ }

// D: code giống A, chỉ khác cấu hình
# application-vt.yml
spring.threads.virtual.enabled: true
# docker-compose.bench.yml (trích)
services:
  bench-mvc:
    deploy: { resources: { limits: { cpus: "2", memory: 1g } } }
    environment:
      SPRING_PROFILES_ACTIVE: ${BENCH_PROFILE:sync}   # sync | async | vt
      JAVA_TOOL_OPTIONS: "-Xmx512m -XX:+UseG1GC"
      DB_POOL_SIZE: ${DB_POOL_SIZE:20}
      PRICING_DELAY_MS: ${PRICING_DELAY_MS:100}

Kịch bản k6 (chạy cho từng model, mỗi kịch bản ≥ 3 lần, warm-up 1 phút bỏ đi):

Kịch bảnPricing delayTảiMục đích
S1 nhẹ20msconstant-arrival-rate 200 rpsbaseline, mọi model gần nhau
S2 I/O chậm500msramping 100 → 2000 rpsthấy MVC sync cạn thread
S3 spike200ms0 → 3000 rps trong 10shành vi khi quá tải, tỷ lệ lỗi
S4 CPU-bound0ms + tính giá nặng (~5ms CPU)ramping tới bão hòachứng minh VT/WebFlux không giúp khi CPU là cổ chai
S5 DB là cổ chai500ms nhưng pool DB = 10như S2pool DB giới hạn mọi model

4. Acceptance criteria

  1. Test tương đương: 4 endpoint trả JSON giống nhau (trừ computedBy) cho 20 event mẫu.
  2. Mỗi kịch bản S1–S5 chạy cho cả 4 model, ≥ 3 lần, báo trung vị và độ lệch.
  3. Bảng kết quả có đủ cột: RPS đạt được, p50, p95, p99, error rate, CPU trung bình, số thread live max, heap max.
  4. Mỗi dòng trong S2 có tính toán Little's law: L = λ × W, so concurrency lý thuyết với số thread/pool thực tế.
  5. Chỉ ra ít nhất 1 trường hợp mô hình "mới" không thắng, và giải thích.
  6. Không có blocking call trên event loop của WebFlux (bật BlockHound trong test hoặc chứng minh bằng thread dump).
  7. Phát hiện và ghi lại pinning của virtual thread nếu có (-Djdk.tracePinnedThreads=full hoặc JFR event jdk.VirtualThreadPinned).
  8. Kết luận "chọn mô hình nào cho booking-service" trong ADR, dựa trên số.

5. Test cases

IDKịch bảnInput / điều kiệnKỳ vọng
BM-01Tương đương kết quả20 eventId, cả 4 endpointJSON giống nhau
BM-02Async song song thậtDB 100ms, pricing 100msB, C: latency ≈ 100ms; A, D: ≈ 200ms
BM-03Timeoutpricing 5s, timeout 1scả 4 trả lỗi chuẩn trong ~1s, không treo
BM-04Không block event loopBlockHound bậtkhông có BlockingOperationError
BM-05Thread countS2 ở 1000 rpsA chạm max 200 thread; D thread platform thấp, VT cao
BM-06Pinningthêm synchronized quanh lời gọi HTTP ở D (cố ý)phát hiện pinning, throughput giảm; gỡ ra thì hồi phục
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Testcontainers
class QuoteEquivalenceIT {
    @Container static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16");
    // TODO: WireMock cho pricing, delay cố định

    @ParameterizedTest
    @ValueSource(strings = {"mvc-sync", "mvc-async", "mvc-vt"})
    void sameResultAsSync(String model) {
        // TODO: gọi /api/bench/{model}/events/{id}/quote, so với mvc-sync bỏ computedBy
    }
}
// tests/k6/m09-bench.js
import http from 'k6/http';
export const options = {
  discardResponseBodies: true,
  scenarios: {
    s2: { executor: 'ramping-arrival-rate', startRate: 100, timeUnit: '1s',
          preAllocatedVUs: 500, maxVUs: 5000,
          stages: [{ target: 500, duration: '1m' }, { target: 2000, duration: '3m' }] },
  },
  summaryTrendStats: ['p(50)', 'p(95)', 'p(99)', 'max'],
};
export default function () {
  const id = __ENV.EVENT_IDS.split(',')[Math.floor(Math.random() * 20)];
  http.get(`${__ENV.BASE}/api/bench/${__ENV.MODEL}/events/${id}/quote`);
}
// TODO: thresholds, --out json, script chạy lặp 4 model × 5 kịch bản × 3 lần

6. Failure scenarios

Sự cốCách tạoHành vi mong đợi (ghi lại từng model)
Pricing treoToxiproxy timeout toxicTimeout client kích hoạt; A cạn thread nếu thiếu timeout; D tạo hàng chục nghìn VT, cần semaphore giới hạn
DB pool cạnDB_POOL_SIZE=5Mọi model bị giới hạn ~ pool / W_db; VT không cứu được
Block event loopcố ý gọi JDBC trong WebFluxThroughput sụp, p99 tăng vọt; BlockHound bắt được
Hết bộ nhớS3 spike + pricing 2sQuan sát heap; model nào giữ nhiều request treo nhất; không OOM nếu có giới hạn đồng thời

7. Benchmark / số liệu cần thu

Lưu docs/benchmarks/m09-models.md + file k6 JSON thô + ảnh Grafana. Ghi rõ máy, CPU, JDK, Spring Boot version.

Kịch bảnModelRPSp50p95p99Error %CPU %Threads maxL = λ×W
S2mvc-sync
mvc-async
webflux
mvc-vt
… lặp cho S1, S3, S4, S5

Little's law dùng thế nào: L = λ × W. Với W = 0.6s (DB 100ms + pricing 500ms) và λ mong muốn 1000 rps thì cần L = 600 request đang xử lý cùng lúc. Tomcat 200 thread ⇒ trần lý thuyết ~200 / 0.6 ≈ 333 rps cho model A. Hãy so con số này với số đo, và làm tương tự với pool DB: 20 connection / 0.1s = 200 rps cho phần DB nếu giữ connection suốt request. Chỗ nào số đo lệch xa lý thuyết thì chỗ đó có điều cần giải thích.

8. Checklist hoàn thành

Mình chạy S2 mà 4 model ra gần như nhau, chênh nhau vài %. Có phải virtual threads vô dụng không?

Nhiều khả năng bạn đang đo cổ chai khác, không phải mô hình chạy. Tự kiểm tra lần lượt:

Gợi ý: xem metric "pending" của từng pool khi tải cao. Pool nào có hàng chờ, đó là cổ chai thật.

Sau khi tăng pool, VT thắng rõ ở S2. Nhưng ở S3 spike nó lỗi nhiều hơn MVC sync. Lạ thật.

Không lạ, đây là bài học hay nhất của milestone. Gợi ý hướng nghĩ:

Mình làm xong rồi, review và phỏng vấn mình đi.

Checklist review code AI sẽ soi

Câu hỏi phỏng vấn

1. Virtual threads có làm một request nhanh hơn không?

🇻🇳 Không. W (thời gian xử lý một request) gần như giữ nguyên. VT tăng L (số request xử lý đồng thời) mà tốn ít tài nguyên, nên theo λ = L / W, throughput tăng khi cổ chai là số thread.

🇬🇧 No. Per-request latency stays the same. Virtual threads raise concurrency cheaply, so by Little's law throughput rises when thread count was the bottleneck.

2. Khi nào WebFlux vẫn đáng chọn so với VT?

🇻🇳 Khi cần streaming, backpressure, SSE/WebSocket nhiều kết nối dài, hoặc cả stack đã reactive (R2DBC, WebClient). Với CRUD I/O-bound đơn giản, VT cho kết quả tương tự với code dễ đọc hơn.

🇬🇧 For streaming, backpressure, long-lived connections, or a fully reactive stack. For plain I/O-bound CRUD, virtual threads give similar results with simpler code.

3. Kết quả S4 (CPU-bound) cho thấy gì?

🇻🇳 Cả 4 model chạm trần CPU gần như cùng chỗ. Không mô hình chạy nào tạo ra thêm CPU; cần tối ưu thuật toán, cache hoặc scale ngang.

🇬🇧 All four hit the same CPU ceiling. No concurrency model creates CPU; you optimise the code, cache or scale out.

4. Dùng Little's law giải thích model A ở S2.

🇻🇳 W ≈ 0.6s, L tối đa = 200 thread ⇒ λ tối đa ≈ 333 rps. Vượt mức đó, request xếp hàng, W tăng, p99 nổ. Số đo của mình là … rps, khớp/lệch vì …

🇬🇧 With W ≈ 0.6s and 200 threads, max throughput ≈ 333 rps. Beyond that requests queue, latency grows and p99 explodes; my measurement was … because …

Cách nhớ ngắn gọn

λ = L / W
VT/WebFlux tăng L rẻ · không giảm W · không tạo thêm CPU
Cổ chai thật thường là pool (DB, HTTP), không phải thread
Bỏ giới hạn thread thì phải đặt giới hạn khác (semaphore, gateway).

Tự kiểm tra

Little's law phát biểu thế nào?

L = λ × W: số request đang xử lý = throughput × thời gian xử lý trung bình, áp dụng cho hệ ổn định.

Vì sao k6 nên dùng arrival-rate thay vì constant-vus khi so mô hình?

constant-vus là closed model: hệ chậm thì k6 gửi ít đi, che mất quá tải. arrival-rate giữ tốc độ đến cố định, lộ ra hàng chờ và lỗi.

Gọi JDBC trong WebFlux gây hậu quả gì?

Block một trong số ít thread event loop, mọi request trên thread đó bị treo theo; throughput sụp. Phải dùng R2DBC hoặc đẩy sang boundedElastic.

Pinning của virtual thread là gì?

VT bị giữ chặt carrier thread khi block trong synchronized hoặc native call (JDK 21), mất lợi thế; phát hiện bằng JFR hoặc jdk.tracePinnedThreads.

Vì sao VT có thể lỗi nhiều hơn MVC sync khi spike?

Không còn giới hạn thread tự nhiên, quá nhiều request cùng chen vào pool phía sau và timeout. Cần giới hạn đồng thời ở chỗ khác.