Vì sao cần Reactive: thread đứng chờ I/O

4 câu hỏi~20 phútTừ khóa: blocking I/O, thread-per-request, Tomcat 200 threads, CompletableFuture, non-blocking, Reactive, WebFlux, R2DBC

Mình đã học Async rồi, nhưng chưa học tới Mono, WebFlux, R2DBC.

Đúng rồi. Trước đây chúng ta đã học Thread, Thread Pool, CompletableFuture và @Async. Có một điều bạn đã hiểu, và nó chính là lý do phải học tiếp:

Dùng CompletableFuture.supplyAsync() để chạy JDBC trên worker thread không làm JDBC thành non-blocking. Worker thread vẫn đứng chờ.

Bài này chưa viết Mono. Chỉ trả lời một câu: vì sao Reactive ra đời?

1. Bài toán: checkout trong shop-app

Vẫn là project shop-app ở chương Concurrency. API checkout gọi hai service khác:

POST /api/checkout
   |
   |-- GET  user-service/users/{id}      (~300ms)
   |-- POST payment-service/payments     (~2s, gọi ngân hàng)
   |
   v
 trả kết quả

Code Spring MVC quen thuộc:

@RestController
@RequestMapping("/api/checkout")
public class CheckoutController {

    private final RestClient restClient;

    public CheckoutController(RestClient.Builder builder) {
        this.restClient = builder.build();
    }

    @PostMapping
    public CheckoutResult checkout(@RequestBody CheckoutRequest req) {
        User user = restClient.get()
                .uri("http://user-service/users/{id}", req.userId())
                .retrieve().body(User.class);          // thread đứng chờ ~300ms

        Payment payment = restClient.post()
                .uri("http://payment-service/payments")
                .body(new PaymentRequest(user.id(), req.amount()))
                .retrieve().body(Payment.class);       // thread đứng chờ ~2s

        return new CheckoutResult(user.id(), payment.status());
    }
}

2. Vấn đề: thread-per-request

Tomcat mặc định có 200 worker threads. Mỗi request chiếm một thread từ đầu tới cuối:

Tomcat Thread-1   [nhận req]──chờ user──chờ payment 2s──[trả về]
Tomcat Thread-2   [nhận req]──chờ user──chờ payment 2s──[trả về]
...
Tomcat Thread-200 [nhận req]──chờ user──chờ payment 2s──[trả về]
Request 201       xếp hàng... chờ thread rảnh

Thử cách đã học: CompletableFuture.

CompletableFuture.supplyAsync(() -> callPayment(req), paymentPool);
Tomcat Thread-1  ──giao việc──> rảnh
Payment Worker-1 ──────chờ ngân hàng 2s (BLOCKED)──────

Tomcat thread được giải phóng, nhưng Payment Worker-1 vẫn bị block. Vấn đề chỉ chuyển sang thread khác.

3. Giải pháp: không cần thread đứng chờ I/O

Ý tưởng mới: gửi request ra network, đăng ký "khi nào có kết quả thì làm tiếp", rồi thread đi làm việc khác.

Thread-1: gửi request tới payment ──> đi xử lý request khác
          ...
OS báo: "socket payment có data"
Thread-1: chạy tiếp bước "trả kết quả checkout"

Đó là non-blocking I/O. Còn Reactive Programming là cách viết code phản ứng với dữ liệu khi nó xuất hiện:

Truyền thống:  User user = getUser();  // lấy ngay, chờ tới khi có
Reactive:      "Tôi muốn lấy User.
                Khi User xuất hiện  -> gọi payment.
                Nếu có lỗi          -> xử lý lỗi."

Bản đồ tên gọi để khỏi rối:

Khái niệmHiểu đơn giản
Reactive ProgrammingPhong cách lập trình phản ứng với dữ liệu/sự kiện
Reactive StreamsChuẩn interface: Publisher, Subscriber, Subscription
Project ReactorThư viện Java cài đặt chuẩn đó
Mono / FluxKiểu dữ liệu của Reactor: 0..1 phần tử / 0..N phần tử
Spring WebFluxWeb framework reactive của Spring, chạy trên Netty
WebClientHTTP client non-blocking
R2DBCTruy cập database quan hệ theo kiểu reactive

4. Code từng bước (xem trước)

Bước 1: Dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

Bước 2: Config: không cần gì thêm, Spring Boot tự chạy Reactor Netty.

server:
  port: 8080

Bước 3: Code Java, cùng checkout nhưng viết reactive. Chưa cần hiểu từng dòng, các bài sau sẽ mổ xẻ:

@RestController
@RequestMapping("/api/checkout")
public class CheckoutController {

    private final WebClient webClient;

    public CheckoutController(WebClient.Builder builder) {
        this.webClient = builder.build();
    }

    @PostMapping
    public Mono<CheckoutResult> checkout(@RequestBody CheckoutRequest req) {
        return webClient.get()
                .uri("http://user-service/users/{id}", req.userId())
                .retrieve().bodyToMono(User.class)
                .flatMap(user -> webClient.post()
                        .uri("http://payment-service/payments")
                        .bodyValue(new PaymentRequest(user.id(), req.amount()))
                        .retrieve().bodyToMono(Payment.class)
                        .map(p -> new CheckoutResult(user.id(), p.status())));
    }
}

Bước 4: Test

curl -X POST localhost:8080/api/checkout \
  -H "Content-Type: application/json" \
  -d '{"userId":1,"amount":100}'

5. Cách hoạt động

Spring MVC (Tomcat)                    Spring WebFlux (Netty)
-------------------                    ----------------------
1 request = 1 thread                   vài event loop threads (~số core)
thread chờ I/O                         thread đăng ký callback rồi đi tiếp
200 threads -> ~200 req đồng thời      8 threads -> hàng nghìn kết nối chờ I/O

6. So sánh

Đặc điểmMVC + RestClientCompletableFuture + blockingWebFlux + WebClient
I/OBlockingBlockingNon-blocking
Ai chờRequest threadWorker threadKhông thread nào đứng chờ
Số thread cần~ số request đồng thời~ số request đồng thời~ số CPU core
Độ khó code/debugDễTrung bìnhKhó hơn

Lưu ý: Reactive không làm một request chạy nhanh hơn. Payment vẫn mất 2s. Lợi ích là phục vụ được nhiều request đồng thời hơn với ít thread.

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

Mình chưa biết gì về Reactive, Mono, WebFlux. Đi chậm lại từ đầu giúp mình.

OK, đi chậm lại. Quên Mono, WebFlux đi. Chỉ nhớ một hình ảnh: quán cà phê.

Kiểu blocking:
  Nhân viên nhận order -> đứng cạnh máy pha chờ 3 phút -> đưa ly -> nhận khách tiếp
  10 khách = cần 10 nhân viên

Kiểu non-blocking:
  Nhân viên nhận order -> bỏ vào máy -> nhận khách tiếp
  Máy kêu "ting" -> nhân viên đưa ly
  1-2 nhân viên phục vụ được nhiều khách

Bài sau sẽ xem "ai nghe tiếng ting" (event loop). Sau đó mới tới Mono.

Vậy checkout mình bọc @Async là thành non-blocking rồi đúng không?

Chưa đúng, chỉ cần chỉnh một điểm. @Async là asynchronous (người gọi không chờ), nhưng code bên trong vẫn blocking (một thread của executor đứng chờ).

AsynchronousNon-blocking I/O
Câu hỏiNgười gọi có phải chờ không?Có thread nào bị giữ để chờ I/O không?
@Async + RestClientCó asyncKhông, worker vẫn block
WebClientCó asyncCó non-blocking

Tương tự, Reactive cũng không tự động non-blocking: bọc JDBC trong Mono thì JDBC vẫn block.

Phỏng vấn hỏi vì sao cần Reactive / khi nào dùng WebFlux thì trả lời sao?

Q1. Vì sao cần Reactive Programming?

🇻🇳 Tiếng Việt:

Mô hình thread-per-request giữ một thread suốt thời gian chờ I/O. Với nhiều request chậm, thread pool cạn và request phải xếp hàng. Reactive kết hợp non-blocking I/O cho phép vài thread phục vụ rất nhiều kết nối, vì không thread nào đứng chờ. Nó tăng khả năng chịu tải, không làm từng request nhanh hơn.

🇬🇧 English:

The thread-per-request model holds a thread for the whole time it waits on I/O. With many slow calls the pool runs out and requests queue up. Reactive programming with non-blocking I/O lets a few threads serve many connections because no thread sits waiting. It improves scalability under load, not the latency of a single request.

Q2. CompletableFuture có giải quyết được không?

🇻🇳 Tiếng Việt:

Chỉ một phần. Nó giải phóng request thread nhưng nếu bên trong vẫn gọi JDBC hay HTTP client blocking thì worker thread vẫn bị giữ. Blocking chỉ chuyển chỗ, không biến mất.

🇬🇧 English:

Only partly. It frees the request thread, but if the task still calls JDBC or a blocking HTTP client, a worker thread is still blocked. The blocking moves to another pool; it does not disappear.

Q3. Khi nào không nên dùng WebFlux?

🇻🇳 Tiếng Việt:

Khi app dùng JDBC/JPA hoặc thư viện blocking, logic chủ yếu CPU, hoặc team chưa quen reactive. Khi đó Spring MVC, có thể thêm virtual threads trên Java 21, đơn giản và dễ debug hơn. WebFlux hợp với gateway, service gọi nhiều API, streaming.

🇬🇧 English:

When the app relies on JDBC/JPA or other blocking libraries, when the work is CPU-bound, or when the team is not familiar with reactive code. Spring MVC, possibly with virtual threads on Java 21, is simpler and easier to debug. WebFlux fits gateways, services that fan out to many APIs, and streaming.

Cách nhớ ngắn gọn


Tự kiểm tra

Thread-per-request có vấn đề gì khi gọi payment mất 2s?

Mỗi request giữ một thread suốt 2s. Tomcat 200 threads cạn nhanh, request sau phải xếp hàng.

supplyAsync(() -> jdbcQuery()) có non-blocking không?

Không. Request thread được giải phóng nhưng worker thread vẫn block chờ database.

Reactive có làm một request nhanh hơn không?

Không. Nó giúp phục vụ nhiều request đồng thời hơn với ít thread.

Mono và Flux khác nhau thế nào (một dòng)?

Mono: 0 hoặc 1 phần tử. Flux: 0 đến N phần tử.

App CRUD dùng JPA có nên chuyển sang WebFlux?

Thường không. JPA blocking, nên dùng Spring MVC (có thể kèm virtual threads).