Phần trước kết thúc ở chỗ đọc theo luồng cứu được ứng dụng khỏi OutOfMemoryError. Bài này kiểm chứng câu đó cho tới cùng — vì hoá ra "đọc theo luồng" một mình nó không cứu được gì cả, và trong phép đo của tôi nó còn tệ hơn cách ngây thơ nhất.

Tình huống: một endpoint trả về 300 000 bản ghi (96,8 MB) từ PostgreSQL, và 20 client đọc rất chậm — mỗi client lấy 4 KB rồi nghỉ 50 ms. Đây là mô phỏng của một chuyện hoàn toàn bình thường: người dùng tải báo cáo qua mạng di động, hoặc một dịch vụ hạ nguồn xử lý chậm.

Ba cách viết cùng một endpoint

Cách 1 — nạp hết vào bộ nhớ rồi trả về. Cách ai cũng viết đầu tiên.

pg.preparedQuery("select id, ma, ten, mo_ta from ban_ghi").execute()
  .onSuccess(rs -> {
      StringBuilder sb = new StringBuilder();
      rs.forEach(row -> sb.append(...));
      c.response().end(sb.toString());
  });

Cách 2 — đọc theo luồng, ghi thẳng ra response. Cách người ta viết sau khi đọc được lời khuyên "hãy dùng streaming".

RowStream<Row> luong = ps.createStream(1000);
luong.handler(row -> c.response().write(...))     // ghi va di tiep, khong hoi han gi
     .endHandler(v -> pr.complete());

Cách 3 — đọc theo luồng, có áp lực ngược. Thêm bảy dòng.

res.setWriteQueueMaxSize(64 * 1024);
luong.handler(row -> {
    res.write(...);
    if (res.writeQueueFull()) {          // hang doi ghi day
        luong.pause();                    // -> ngung doc CSDL
        res.drainHandler(v -> luong.resume());   // -> doc tiep khi client da tieu thu bot
    }
});

Cả ba trả về đúng 96 866 685 byte giống hệt nhau. Với một client nhanh, cả ba đều ổn.

Với 20 client đọc chậm

RSS của JVM, heap tối đa 2 GB:

Lúc rảnh Đỉnh Tăng
Nạp hết vào bộ nhớ 108 MB 2 243 MB +2 135 MB
Luồng, không áp lực ngược 107 MB 3 421 MB +3 314 MB
Luồng, có áp lực ngược 29 MB 208 MB +179 MB

Cách 3 tốn ít hơn 18 lần cách 2. Và cách 2 — cách "đúng" theo lời khuyên phổ biến — tốn nhiều hơn 55% so với cách ngây thơ mà nó đáng lẽ phải thay thế.

Lý do là ở cách 2, bạn có hai chỗ phình ra cùng lúc thay vì một:

  • Hàng đợi ghi của response phình lên vì client không tiêu thụ kịp. write() không bao giờ chặn; nó nhận dữ liệu vào một hàng đợi trong bộ nhớ rồi trả về ngay.
  • RowStream vẫn tiếp tục kéo bản ghi từ PostgreSQL hết tốc lực, vì không có gì bảo nó dừng.

Cách 1 ít nhất còn có một giới hạn tự nhiên: chuỗi kết quả được dựng xong rồi mới đưa cho response, nên bộ nhớ dừng ở một bản sao. Cách 2 vừa giữ chuỗi đang chảy ra, vừa liên tục sinh thêm — nó là một cái vòi mở hết cỡ đổ vào một cái xô không đáy.

Đây là chỗ đáng nhớ nhất của bài: write() không bao giờ nói với bạn rằng nó đang không kịp. Không có ngoại lệ, không có giá trị trả về nào bị bỏ qua, không có cảnh báo. Bạn phải chủ động hỏi bằng writeQueueFull().

Với heap 512 MB

Số ở trên là trên máy rộng rãi. Siết heap xuống 512 MB, vẫn 20 client đọc chậm:

OutOfMemoryError /http (đường khác) sau đó
Nạp hết vào bộ nhớ 5 lần
Luồng, không áp lực ngược 8 lần
Luồng, có áp lực ngược 0 lần 200

Bản có áp lực ngược không những sống, mà một endpoint hoàn toàn khác trong cùng tiến trình vẫn trả 200 bình thường trong khi 20 client kia đang bò. RSS dừng ở 340 MB.

Và nó còn phục vụ nhiều dữ liệu hơn: 26,0 MB trong 18 giây, so với 21,1 MB trong 20 giây của bản không áp lực ngược. Áp lực ngược không phải là cái phanh làm chậm hệ thống — nó là thứ giữ cho hệ thống không tự dẫm lên chân mình.

Chi tiết đáng chú ý về cách nó hỏng: OutOfMemoryError không giết chết một request, nó giết chết tiến trình. Trong log có cả dòng

Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler
in thread "vertx-blocked-thread-checker"

— tức là ngay cả cơ chế giám sát của Vert.x cũng không còn bộ nhớ để chạy. Khi tới mức đó thì mọi người dùng đều mất, kể cả những người chỉ gọi một endpoint nhẹ tênh.

Nhìn cả chuỗi

Điều làm áp lực ngược khác với các kỹ thuật khác trong sê-ri này là nó phải liên tục từ đầu tới cuối. Chuỗi ở đây có ba mắt:

PostgreSQL  ──►  RowStream  ──►  HTTP response  ──►  client

Client chậm phải đẩy tín hiệu ngược lên tới tận PostgreSQL. Đứt ở bất kỳ mắt nào là dữ liệu dồn lại ở chính chỗ đứt. pause()/resume() chỉ là cách nối hai mắt cuối; ps.createStream(1000) — tham số 1000 là số bản ghi mỗi lô — là cách giới hạn mắt đầu.

Đây cũng chính là cấu trúc đã gặp ở consumer Kafkaconsumer RabbitMQ: ở đó pause()/resume() giữ cho broker không bơm nhanh hơn khả năng xử lý. Cùng một bài toán, cùng một hình dạng lời giải — chỉ đổi tên các mắt xích.

Vert.x còn có cách viết ngắn hơn cho trường hợp nối thẳng hai luồng:

luong.pipeTo(res);      // tu lo pause/resume/drainHandler

Dùng được khi bạn không cần biến đổi gì ở giữa. Trong bài này tôi viết tay pause/resume để thấy rõ cơ chế, nhưng trong mã thật thì pipeTo vừa ngắn vừa khó viết sai hơn.

Thử ba mươi giây

Tìm những chỗ ghi ra luồng mà không hỏi han gì:

grep -rn "response()\.write\|\.write(" --include=*.java src/ | grep -v "writeQueueFull\|pipeTo"

Với mỗi chỗ tìm được, hỏi: nguồn dữ liệu ở đây có thể nhanh hơn người nhận không? Nếu có, và nếu không có writeQueueFull() hay pipeTo ở gần đó, thì bạn đang có một cái xô không đáy.

Kiểm chứng bằng một client đọc chậm — không cần công cụ gì đặc biệt:

s = socket.create_connection(("127.0.0.1", 8080))
s.sendall(b"GET /duong-dan-lon HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
while True:
    s.recv(4096)      # doc mieng nho
    time.sleep(0.05)  # roi nghi

Mở hai chục cái như vậy rồi nhìn RSS của tiến trình. Nếu nó leo thẳng, bạn vừa tìm ra chỗ cần pipeTo.

Phần sau bàn về kiểm thử ứng dụng Vert.x, và vì sao test bất đồng bộ hay xanh giả.