Backpressure: khi producer nhanh hơn consumer

4 câu hỏi~20 phútTừ khóa: backpressure, request(n), Subscription, BaseSubscriber, onBackpressureBuffer, onBackpressureDrop, onBackpressureLatest, limitRate, Flux.create, interval

Tiếp: hồi bài Publisher có nhắc request(n), giờ học backpressure nhé.

Đúng, đây là thứ mà callback hay CompletableFuture không có. Backpressure = consumer nói cho producer biết nó nhận được bao nhiêu.

1. Bài toán: đối soát đơn hàng cuối ngày

shop-app có job đối soát: đọc 1 triệu đơn hàng từ DB (R2DBC), mỗi đơn gọi payment-service để kiểm tra trạng thái. DB đọc 50.000 dòng/giây, payment-service chỉ chịu 100 request/giây.

DB (producer)       ──── 50.000/s ────>   gọi payment (consumer) 100/s

2. Vấn đề

Nếu producer cứ đẩy không quan tâm consumer:

đơn chờ xử lý trong RAM: 49.900 thêm mỗi giây
sau 20s: ~1 triệu object trong heap  ->  OutOfMemoryError
hoặc bắn 50.000 req/s vào payment-service  ->  sập payment

Với Kafka/queue thì message nằm trên đĩa. Còn ở đây mọi thứ nằm trong bộ nhớ JVM.

3. Giải pháp: pull theo nhu cầu

Reactive Streams biến luồng thành push-pull: Subscriber gọi subscription.request(n), Publisher chỉ được phát tối đa n phần tử cho tới lần request tiếp theo.

CáchÝ nghĩa
request(n)Consumer xin n phần tử
limitRate(n)Xin theo lô n (bổ sung khi tiêu thụ được 75%)
flatMap(fn, concurrency)Chỉ xin thêm khi còn chỗ trong số task song song
onBackpressureBuffer(max)Nguồn không nghe lời: đệm tối đa max, tràn thì lỗi/chiến lược
onBackpressureDrop()Consumer chưa xin thì vứt phần tử mới
onBackpressureLatest()Chỉ giữ phần tử mới nhất

4. Code từng bước

Bước 1: Dependency: spring-boot-starter-webflux, spring-boot-starter-data-r2dbc + driver (ví dụ r2dbc-postgresql).

Bước 2: Config (mật khẩu lấy từ biến môi trường, không hardcode):

spring:
  r2dbc:
    url: r2dbc:postgresql://localhost:5432/shop
    username: ${DB_USER}
    password: ${DB_PASSWORD}

Bước 3: Code Java. Thấy request(n) bằng tay:

Flux.range(1, 10)
    .log()                                   // in ra request(n), onNext...
    .subscribe(new BaseSubscriber<Integer>() {
        @Override
        protected void hookOnSubscribe(Subscription s) {
            request(2);                      // chỉ xin 2
        }
        @Override
        protected void hookOnNext(Integer v) {
            System.out.println("Xử lý " + v);
            request(1);                      // xong 1 cái xin thêm 1
        }
    });
| request(2)
| onNext(1)  Xử lý 1  | request(1)
| onNext(2)  Xử lý 2  | request(1)
...

Job đối soát thực tế, không cần viết Subscriber tay:

@Service
public class ReconcileJob {

    private final OrderRepository orderRepository;   // ReactiveCrudRepository
    private final PaymentClient paymentClient;

    public Mono<Long> run(LocalDate day) {
        return orderRepository.findByCreatedDate(day)        // Flux<Order>, 1 triệu dòng
                .limitRate(500)                              // xin DB từng lô 500
                .flatMap(o -> paymentClient.status(o.id())   // tối đa 20 call song song
                                  .map(s -> check(o, s)), 20)
                .filter(Result::mismatch)
                .concatMap(r -> orderRepository.markMismatch(r.orderId()))
                .count();
    }
}

Nguồn không hiểu backpressure (ví dụ sự kiện từ listener ngoài, Flux.interval):

// Giá sản phẩm cập nhật liên tục, UI chỉ cần giá mới nhất
Flux<Price> prices = priceTicker.stream()     // phát mỗi 10ms
        .onBackpressureLatest();

// Log click chuột: mất vài cái cũng được
Flux<Click> clicks = clickSource.stream()
        .onBackpressureDrop(c -> metrics.increment("click.dropped"));

// Đơn hàng: không được mất, đệm có giới hạn, tràn thì báo lỗi
Flux<OrderEvent> events = orderSource.stream()
        .onBackpressureBuffer(10_000);

Bước 4: Test

StepVerifier.create(Flux.range(1, 100), 0)   // ban đầu request 0
    .thenRequest(3)
    .expectNext(1, 2, 3)
    .thenCancel()
    .verify();
curl -X POST localhost:8080/admin/reconcile?day=2026-10-13
# theo dõi: heap ổn định, payment-service nhận ~20 request song song

5. Cách hoạt động

Subscriber                     limitRate(500)             R2DBC (DB)
   | -- request(20) --> flatMap |                           |
   |                            | -- request(500) ------->  |
   |                            | <-- 500 rows ------------ |
   |   flatMap chạy 20 call song song
   |   xong 1 call -> request(1) thêm
   |                            | đã dùng 375 (75%) -> request(375)
   |                            | <-- 375 rows ------------ |

Producer luôn chạy chậm theo consumer. RAM ~ vài trăm đơn, không phải 1 triệu.

Với HTTP, backpressure còn đi xuống tầng TCP: client đọc chậm thì Netty ngừng đọc socket, TCP window đầy, phía gửi phải chậm lại.

6. So sánh

Chiến lượcMất dữ liệu?RAMDùng cho
request(n) / limitRateKhôngThấpNguồn hỗ trợ backpressure (R2DBC, Flux.range, WebClient)
onBackpressureBuffer(max)Không (tới khi tràn)Tăng tới maxBurst ngắn, không được mất
onBackpressureDropCó, phần tử mớiThấpMetric, log, click
onBackpressureLatestCó, giữ cái mới nhấtThấpGiá, trạng thái, vị trí GPS

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

Vậy cái gì phát theo thời gian như Flux.interval mà consumer chậm thì request(n) cũng làm nó chậm lại được à?

Không, chỉ cần chỉnh một điểm. Có hai loại nguồn:

Với loại thứ hai bạn phải chọn chiến lược: buffer, drop hay latest. Đó chính là lúc dùng các operator onBackpressureXxx.

Flux.interval(Duration.ofMillis(1))
    .onBackpressureDrop()
    .concatMap(t -> slowSave(t), 1)
    .subscribe();

Nghe giống rate limiting ở Gateway nhỉ?

Gần đúng, cùng mục đích bảo vệ phía chậm, nhưng khác cơ chế:

BackpressureRate limiting
Ai quyết địnhConsumer (theo khả năng thật lúc đó)Cấu hình cố định (100 req/s)
Hành viProducer chờ, không mất dữ liệuVượt ngưỡng bị từ chối (429)
Phạm viTrong một streamGiữa client và server

Trong job đối soát, flatMap(fn, 20) là backpressure. Còn nếu payment-service quy định "tối đa 100 req/s" thì thêm rate limit (ví dụ Resilience4j RateLimiter) ở phía gọi.

Phỏng vấn hỏi backpressure thì trả lời sao?

Q1. Backpressure là gì?

🇻🇳 Tiếng Việt:

Là cơ chế để consumer báo cho producer biết nó xử lý được bao nhiêu, tránh bị dồn dữ liệu gây OOM hoặc quá tải. Trong Reactive Streams, subscriber gọi subscription.request(n) và publisher chỉ phát tối đa n phần tử. Reactor có limitRate và tham số concurrency của flatMap để điều chỉnh.

🇬🇧 English:

It is the mechanism that lets a consumer tell the producer how much it can handle, so it is not flooded and does not run out of memory. In Reactive Streams the subscriber calls subscription.request(n) and the publisher emits at most n items. Reactor offers limitRate and the concurrency argument of flatMap to tune it.

Q2. Nguồn không hỗ trợ backpressure thì làm sao?

🇻🇳 Tiếng Việt:

Với nguồn theo thời gian hoặc sự kiện ngoài, phải chọn chiến lược: onBackpressureBuffer có giới hạn nếu không được mất dữ liệu, onBackpressureDrop nếu mất được, onBackpressureLatest nếu chỉ cần giá trị mới nhất. Buffer không giới hạn chỉ dời OOM ra sau.

🇬🇧 English:

For time-based or external event sources we must pick a strategy: a bounded onBackpressureBuffer when data must not be lost, onBackpressureDrop when losing items is acceptable, and onBackpressureLatest when only the newest value matters. An unbounded buffer just postpones the OutOfMemoryError.

Q3. Xử lý một triệu bản ghi bằng Flux mà không làm sập downstream?

🇻🇳 Tiếng Việt:

Stream bằng R2DBC thay vì load hết, dùng limitRate để đọc theo lô, flatMap với concurrency giới hạn khi gọi service khác, concatMap khi cần thứ tự. Bộ nhớ chỉ giữ vài trăm phần tử, và producer tự chạy chậm theo consumer.

🇬🇧 English:

Stream the rows with R2DBC instead of loading them all, use limitRate to fetch in batches, flatMap with bounded concurrency when calling other services, and concatMap when order matters. Memory holds only a few hundred items, and the producer slows down to match the consumer.

Cách nhớ ngắn gọn


Tự kiểm tra

Subscriber gọi request(2). Publisher được phát mấy phần tử?

Tối đa 2, cho tới khi subscriber request thêm.

limitRate(500) làm gì?

Xin upstream theo lô 500, và bổ sung khi đã tiêu thụ khoảng 75% lô.

Dữ liệu giá cổ phiếu, UI chỉ cần giá mới: chọn chiến lược nào?

onBackpressureLatest().

Vì sao onBackpressureBuffer() không tham số là nguy hiểm?

Buffer không giới hạn, consumer chậm mãi thì heap đầy, OOM.

Flux.interval(1ms) nối vào consumer 10ms/phần tử, không có chiến lược gì thì sao?

Ném OverflowException do thiếu request.