M9 · Benchmark mô hình chạy
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ờ.
- Bỏ qua milestone này, bạn sẽ chọn mô hình theo cảm tính, rồi gặp: thread pool Tomcat 200 cạn khi pricing chậm 1s, hoặc WebFlux bị block bởi JDBC mà không biết.
- Câu phỏng vấn senior hay gặp: "Vì sao virtual threads không làm request nhanh hơn?" — cần trả lời bằng Little's law, không phải bằng khẩu hiệu.
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
- MVC vs WebFlux vs Virtual threads: khi nào chọn cái nào, lý thuyết nền.
- Virtual threads: pinning,
synchronized, giới hạn bằng semaphore chứ không bằng pool. - CompletableFuture:
supplyAsyncvới executor riêng,thenCombine, timeout. - Thread pool: cỡ pool, queue, rejection.
- Event loop: vì sao block event loop là thảm họa.
- R2DBC: driver non-blocking, pool riêng.
- Async MVC: trả
CompletableFuturetừ controller giải phóng Tomcat thread. - Connection pool: pool DB thường là giới hạn thật.
- Profiling JVM: đo CPU, thread, đọc JFR.
3. Đề bài
Yêu cầu chức năng
- 4 cài đặt của cùng một logic, response JSON giống hệt nhau (test so sánh).
- Pricing mock có tham số độ trễ:
GET /api/pricing/quote?eventId=..&delayMs=..(hoặc Toxiproxy latency). - Cùng giới hạn tài nguyên cho mọi lần chạy: container
cpus: 2,mem_limit: 1g, cùng heap, cùng DB pool (vd 20). - Expose metric:
jvm_threads_live,process_cpu_usage,hikaricp_*/r2dbc_pool_*,http_server_requests. - Không cache trong benchmark (tắt L1/L2 của M8) để đo mô hình chạy, không đo cache.
API
| Method | Path | Body | Response |
|---|---|---|---|
| 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ản | Pricing delay | Tải | Mục đích |
|---|---|---|---|
| S1 nhẹ | 20ms | constant-arrival-rate 200 rps | baseline, mọi model gần nhau |
| S2 I/O chậm | 500ms | ramping 100 → 2000 rps | thấy MVC sync cạn thread |
| S3 spike | 200ms | 0 → 3000 rps trong 10s | hành vi khi quá tải, tỷ lệ lỗi |
| S4 CPU-bound | 0ms + tính giá nặng (~5ms CPU) | ramping tới bão hòa | chứng minh VT/WebFlux không giúp khi CPU là cổ chai |
| S5 DB là cổ chai | 500ms nhưng pool DB = 10 | như S2 | pool DB giới hạn mọi model |
4. Acceptance criteria
- Test tương đương: 4 endpoint trả JSON giống nhau (trừ
computedBy) cho 20 event mẫu. - Mỗi kịch bản S1–S5 chạy cho cả 4 model, ≥ 3 lần, báo trung vị và độ lệch.
- 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.
- 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ế. - Chỉ ra ít nhất 1 trường hợp mô hình "mới" không thắng, và giải thích.
- 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).
- Phát hiện và ghi lại pinning của virtual thread nếu có (
-Djdk.tracePinnedThreads=fullhoặc JFR eventjdk.VirtualThreadPinned). - Kết luận "chọn mô hình nào cho booking-service" trong ADR, dựa trên số.
5. Test cases
| ID | Kịch bản | Input / điều kiện | Kỳ vọng |
|---|---|---|---|
| BM-01 | Tương đương kết quả | 20 eventId, cả 4 endpoint | JSON giống nhau |
| BM-02 | Async song song thật | DB 100ms, pricing 100ms | B, C: latency ≈ 100ms; A, D: ≈ 200ms |
| BM-03 | Timeout | pricing 5s, timeout 1s | cả 4 trả lỗi chuẩn trong ~1s, không treo |
| BM-04 | Không block event loop | BlockHound bật | không có BlockingOperationError |
| BM-05 | Thread count | S2 ở 1000 rps | A chạm max 200 thread; D thread platform thấp, VT cao |
| BM-06 | Pinning | thê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ạo | Hành vi mong đợi (ghi lại từng model) |
|---|---|---|
| Pricing treo | Toxiproxy timeout toxic | Timeout 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ạn | DB_POOL_SIZE=5 | Mọi model bị giới hạn ~ pool / W_db; VT không cứu được |
| Block event loop | cố ý gọi JDBC trong WebFlux | Throughput sụp, p99 tăng vọt; BlockHound bắt được |
| Hết bộ nhớ | S3 spike + pricing 2s | Quan 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ản | Model | RPS | p50 | p95 | p99 | Error % | CPU % | Threads max | L = λ×W |
|---|---|---|---|---|---|---|---|---|---|
| S2 | mvc-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
- ☐ Acceptance criteria 1–8 đạt, test tương đương xanh.
- ☐ Failure scenario chạy thật cho từng model, ghi kết quả.
- ☐ Bảng benchmark đầy đủ + dữ liệu thô trong
docs/benchmarks/. - ☐ ADR chọn mô hình cho booking-service, dẫn số liệu.
- ☐ Script chạy lại toàn bộ benchmark bằng 1 lệnh.
- ☐ Giới hạn tài nguyên giống nhau giữa các lần chạy, ghi trong tài liệu.
- ☐ Không hardcode, tham số qua
${ENV}. - ☐ Qua lượt review và phỏng vấn AI.
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:
- k6 có bão hòa không? Nếu dùng
constant-vus, mỗi VU chờ response rồi mới gửi tiếp → tải bị tự giới hạn (closed model). Dùngarrival-ratevà xem k6 có báo "insufficient VUs" không. - Pool DB / pool HTTP client: RestClient/WebClient có giới hạn connection per route (vd 20–50). Áp Little's law cho từng pool.
- Pricing mock có phải chính nó là cổ chai (chạy MVC sync 200 thread)?
- Máy chạy k6 có chung CPU với app không?
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ĩ:
- MVC sync có 200 thread + accept queue: quá tải thì request xếp hàng ở cửa, tự có backpressure.
- VT không giới hạn: spike 3000 rps → hàng nghìn VT cùng xin connection DB/HTTP → timeout hàng loạt ở pool phía sau. Bạn đã chuyển giới hạn từ "thread" sang "không ai cả".
- Thử: thêm
Semaphoregiới hạn đồng thời cho lời gọi pricing/DB, hoặc giới hạn request ở gateway, rồi chạy lại S3. So sánh error rate. - Kiểm tra thêm pinning: driver/thư viện nào còn
synchronizedquanh I/O?
Mình làm xong rồi, review và phỏng vấn mình đi.
Checklist review code AI sẽ soi
- Model B:
supplyAsynccó dùng executor riêng không (dùngForkJoinPool.commonPoolcho I/O là sai)? CóorTimeout? - Model C: có
.block(), JDBC,Thread.sleeptrong chuỗi reactive không? WebClient có timeout? - Model D: có
synchronizedquanh I/O,ThreadLocalnặng, pool thread tự tạo đè lên VT không? - Benchmark: có warm-up, lặp lại, cố định tài nguyên, ghi phiên bản; không so sánh lần chạy đầu tiên với lần thứ ba.
- Kết luận có dựa trên p99 và error rate, không chỉ trung bình.
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.