Spring MVC, WebFlux hay MVC + virtual threads?
Học xong WebFlux với R2DBC rồi, nhưng chương Concurrency có virtual threads cũng giải quyết chuyện thread chờ I/O. Vậy dự án mới nên chọn cái nào?
Câu hỏi mà phỏng vấn senior rất hay hỏi. Không có đáp án "luôn đúng", nhưng có một cách lập luận rõ ràng. Mình dùng lại checkout của shop-app.
1. Bài toán: chọn stack cho checkout-service mới
GET /checkout/{userId}
|-- user-service ~100ms
|-- order-service ~100ms
|-- payment-service ~100ms
|-- PostgreSQL ~20ms
Mục tiêu: 5.000 request/giây lúc sale, p99 < 500ms
2. Vấn đề: thread-per-request cổ điển không đủ
Theo Little's law: số request đồng thời = throughput × latency.
5.000 req/s × 0,32s ≈ 1.600 request đang xử lý cùng lúc
Tomcat mặc định: 200 platform thread
=> 1.400 request xếp hàng, latency tăng vọt
Tăng lên 1.600 platform thread? Mỗi thread ~1MB stack + context switch của OS, tốn kém và không mở rộng tiếp được.
3. Giải pháp: ba lựa chọn
- Spring MVC (platform threads): mỗi request một thread OS. Đơn giản, giới hạn bởi số thread.
- Spring WebFlux: vài event loop thread, mọi I/O non-blocking, code viết bằng Mono/Flux.
- Spring MVC + virtual threads (Java 21, bài 3.11): vẫn viết code blocking bình thường, nhưng mỗi request chạy trên một virtual thread rẻ; khi block I/O, JVM tháo virtual thread khỏi carrier thread.
4. Code từng bước: cùng một checkout, ba kiểu
Bước 1: MVC + virtual threads chỉ cần một dòng config (Spring Boot 3.2+)
spring:
threads:
virtual:
enabled: true
Bước 2: code MVC (chạy được cả platform lẫn virtual threads)
@GetMapping("/checkout/{userId}")
public CheckoutView view(@PathVariable Long userId) throws Exception {
try (var scope = Executors.newVirtualThreadPerTaskExecutor()) {
Future<User> u = scope.submit(() -> userClient.get(userId)); // RestClient
Future<Cart> c = scope.submit(() -> orderClient.getCart(userId));
Future<PaymentInfo> p = scope.submit(() -> paymentClient.methods(userId));
return new CheckoutView(u.get(), c.get(), p.get());
}
}
Bước 3: code WebFlux (bài 4.5.8)
@GetMapping("/checkout/{userId}")
public Mono<CheckoutView> view(@PathVariable Long userId) {
return Mono.zip(userClient.get(userId), orderClient.getCart(userId), paymentClient.methods(userId))
.map(t -> new CheckoutView(t.getT1(), t.getT2(), t.getT3()));
}
Bước 4: đo bằng công cụ tải (cùng máy, cùng downstream giả lập 100ms)
# ví dụ với wrk / k6
wrk -t8 -c2000 -d60s http://localhost:8080/checkout/42
# so sánh: requests/sec, p99 latency, số thread (jcmd Thread.print), heap
5. Cách hoạt động
MVC platform: [T1 ██chờ██chờ██] [T2 ██chờ██chờ██] ... T200 → hết thread, xếp hàng
MVC + VT: VT1..VT2000 chờ I/O → unmount; vài carrier thread (≈ số core) chạy phần CPU
WebFlux: event loop 1..N: gửi I/O → đăng ký callback → xử lý request khác
6. So sánh
| Tiêu chí | MVC (platform) | MVC + virtual threads | WebFlux |
|---|---|---|---|
| Kiểu code | Tuần tự, blocking | Tuần tự, blocking | Reactive (Mono/Flux) |
| Concurrency I/O | Giới hạn ~vài trăm | Hàng chục nghìn | Hàng chục nghìn |
| Thư viện | JPA, JDBC, RestClient | Như MVC | Phải non-blocking: WebClient, R2DBC, reactive drivers |
| Debug, stack trace | Dễ | Dễ | Khó, stack trace đứt đoạn |
| Backpressure, streaming | Không | Không có sẵn | Có sẵn (Flux, SSE, WebSocket) |
| Rủi ro chính | Cạn thread | Pinning (synchronized/native trên JDK < 24), ThreadLocal nặng, dồn tải xuống DB pool | Lỡ block event loop, độ dốc học tập |
| Throughput I/O-bound | Thấp khi tải cao | Gần WebFlux | Cao, bộ nhớ ổn định nhất |
Lập luận benchmark (không phải con số tuyệt đối):
- Ở tải thấp (dưới ~200 concurrent), cả ba tương đương: nút thắt là downstream, không phải thread.
- Ở tải cao I/O-bound, MVC platform tụt vì xếp hàng; virtual threads và WebFlux đều giữ throughput, WebFlux thường dùng ít bộ nhớ hơn một chút.
- Việc CPU-bound: không mô hình nào giúp, số core mới là giới hạn.
- Nút thắt thật thường là connection pool DB và downstream; virtual threads không tăng số connection DB, nên vẫn cần giới hạn (semaphore, bulkhead).
7. Khi nào nên dùng / không nên dùng
| Tình huống | Chọn |
|---|---|
| CRUD nghiệp vụ, JPA, team quen MVC, Java 21+ | MVC + virtual threads |
| Gateway, BFF, fan-out nhiều API, streaming, SSE/WebSocket, cần backpressure | WebFlux |
| App nội bộ tải nhỏ, Java 17 | MVC thường |
| Đã có codebase reactive ổn định | Giữ WebFlux, không cần viết lại |
Vậy có virtual threads rồi thì WebFlux hết thời à?
Bạn hiểu gần đúng, chỉ cần chỉnh một điểm: virtual threads lấy đi lý do phổ biến nhất để chọn WebFlux, là "tiết kiệm thread khi chờ I/O". Nhưng WebFlux còn những thứ virtual threads không có:
- Mô hình stream: Flux, backpressure, SSE, WebSocket, xử lý dữ liệu vô hạn.
- Operator compose: timeout, retry, zip, merge… gọn trong một pipeline.
- Spring Cloud Gateway bản WebFlux, nhiều hệ thống event-driven.
Nên thực tế: dự án CRUD mới nghiêng về MVC + virtual threads; WebFlux vẫn là lựa chọn đúng cho tầng gateway và streaming.
Bật virtual threads rồi thì có cần đổi gì trong code cũ không?
Phần lớn là không, đó là điểm mạnh. Nhưng nên rà ba chỗ:
- Pinning:
synchronizedbao quanh I/O trên JDK 21–23 giữ chặt carrier thread. Đổi sangReentrantLock, hoặc lên JDK 24+ (JEP 491 đã gỡ phần lớn trường hợp). - ThreadLocal nặng: hàng triệu virtual thread × cache ThreadLocal = tốn bộ nhớ.
- Tài nguyên hữu hạn: 10.000 virtual thread vẫn chỉ có 20 connection DB. Đặt pool size và timeout hợp lý, không pool virtual thread.
Phỏng vấn hỏi chọn MVC, WebFlux hay virtual threads thì trả lời sao?
Q1. Khi nào chọn WebFlux thay vì Spring MVC?
🇻🇳 Tiếng Việt:
Em chọn WebFlux khi service chủ yếu là I/O với concurrency cao, fan-out nhiều API, cần streaming, SSE hoặc backpressure, và toàn bộ thư viện đều non-blocking như WebClient, R2DBC. Nếu là CRUD dùng JPA và team quen MVC, em chọn MVC, trên Java 21 thì bật virtual threads.
🇬🇧 English:
I choose WebFlux when the service is mostly I/O with high concurrency, fans out to many APIs, or needs streaming, SSE or backpressure, and when the whole stack is non-blocking, such as WebClient and R2DBC. For CRUD services on JPA with a team used to MVC, I pick Spring MVC, and on Java 21 I enable virtual threads.
Q2. Virtual threads có thay thế reactive programming không?
🇻🇳 Tiếng Việt:
Thay được phần lớn lợi ích về scalability cho I/O, mà vẫn giữ code tuần tự dễ đọc, dễ debug. Nhưng không thay được mô hình stream: backpressure, operator compose, SSE, dữ liệu vô hạn. Virtual threads cũng không tăng tài nguyên phía sau như connection pool DB.
🇬🇧 English:
They cover most of the I/O scalability benefits while keeping simple sequential code that is easy to read and debug. They do not replace the streaming model: backpressure, operator composition, SSE and unbounded data. They also do not increase downstream resources such as the database connection pool.
Q3. Benchmark ba mô hình thì kỳ vọng kết quả thế nào?
🇻🇳 Tiếng Việt:
Tải thấp thì ba cái gần như nhau vì nút thắt là downstream. Tải cao I/O-bound thì MVC platform thread bị xếp hàng theo Little's law, còn virtual threads và WebFlux giữ throughput gần nhau, WebFlux thường ít bộ nhớ hơn. Việc CPU-bound thì không mô hình nào giúp. Em luôn đo bằng chính workload của mình, xem p99 và số connection DB.
🇬🇧 English:
At low load all three perform about the same, because the bottleneck is downstream. At high I/O-bound load, platform-thread MVC queues up according to Little's law, while virtual threads and WebFlux keep similar throughput, with WebFlux usually using a bit less memory. For CPU-bound work none of them helps. I always benchmark my own workload and watch p99 latency and database connections.
Cách nhớ ngắn gọn
- Concurrency = throughput × latency (Little's law).
- CRUD + JPA + Java 21 → MVC + virtual threads.
- Gateway, fan-out, streaming, backpressure → WebFlux.
- Không mô hình nào giúp CPU-bound hay tăng connection DB.
Tự kiểm tra
2.000 req/s, mỗi request 0,5s: cần khoảng bao nhiêu request đồng thời?
2.000 × 0,5 = 1.000 (Little's law).
Bật virtual threads trong Spring Boot 3.2+ bằng cách nào?
spring.threads.virtual.enabled=true.
Pinning là gì?
Virtual thread bị giữ chặt trên carrier thread khi block bên trong synchronized hoặc native call (JDK < 24), mất lợi ích unmount.
Một tính năng WebFlux có mà MVC + VT không có sẵn?
Backpressure / streaming với Flux (SSE, WebSocket reactive).
10.000 virtual thread cùng gọi DB có pool 20 connection thì sao?
Chỉ 20 chạy, còn lại chờ connection; cần timeout và giới hạn, VT không tăng tài nguyên DB.