Spring MVC, WebFlux hay MVC + virtual threads?

4 câu hỏi~25 phútTừ khóa: thread-per-request, event loop, virtual threads, throughput, latency, Little's law, pinning, backpressure, streaming

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

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 threadsWebFlux
Kiểu codeTuần tự, blockingTuần tự, blockingReactive (Mono/Flux)
Concurrency I/OGiới hạn ~vài trămHàng chục nghìnHàng chục nghìn
Thư việnJPA, JDBC, RestClientNhư MVCPhải non-blocking: WebClient, R2DBC, reactive drivers
Debug, stack traceDễDễKhó, stack trace đứt đoạn
Backpressure, streamingKhôngKhông có sẵnCó sẵn (Flux, SSE, WebSocket)
Rủi ro chínhCạn threadPinning (synchronized/native trên JDK < 24), ThreadLocal nặng, dồn tải xuống DB poolLỡ block event loop, độ dốc học tập
Throughput I/O-boundThấp khi tải caoGần WebFluxCao, bộ nhớ ổn định nhất

Lập luận benchmark (không phải con số tuyệt đối):

7. Khi nào nên dùng / không nên dùng

Tình huốngChọ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 backpressureWebFlux
App nội bộ tải nhỏ, Java 17MVC thường
Đã có codebase reactive ổn địnhGiữ 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ó:

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

  1. Pinning: synchronized bao quanh I/O trên JDK 21–23 giữ chặt carrier thread. Đổi sang ReentrantLock, hoặc lên JDK 24+ (JEP 491 đã gỡ phần lớn trường hợp).
  2. ThreadLocal nặng: hàng triệu virtual thread × cache ThreadLocal = tốn bộ nhớ.
  3. 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


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.