Áp lực ngược (backpressure) dễ hình dung nhất qua hình ảnh một dây người chuyền xô nước dập lửa: nước múc từ giếng, chuyền tay qua từng người, tới người cuối hắt vào đám cháy. Nếu người cuối hắt chậm mà những người phía trên vẫn múc và chuyền hết tốc lực, xô sẽ dồn lại, nước đổ tràn khắp sân. Muốn không tràn, tín hiệu "khoan đã, tôi chưa kịp" phải truyền ngược từ người cuối lên tận người múc ở giếng. Đứt ở một mắt nào là nước ứ ngay tại đó.

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 — đúng như dây chuyền xô nước: chỉ cần một người ở giữa cứ nhận và chuyền hết tốc lực bất kể người sau, thì nước ứ ngay tại chỗ ngườ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 Kafka và consumer 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.

Muốn tự tìm chỗ hở trong mã của mình thì cũng nhanh thôi. 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.

Mẫu số chung

Một nguồn nhanh, một người nhận chậm, và một bộ đệm không chặn ở giữa — đó là công thức nổ bộ nhớ kinh điển, và lời giải ở đâu cũng cùng một hình: làm cho bộ đệm có trần, rồi truyền tín hiệu "chậm lại" ngược về nguồn. TCP đã làm sẵn với cửa sổ nhận (receive window); Reactive Streams đặt hẳn nó vào giao diện với request(n) — người nhận xin đúng số phần tử nó kham nổi; Project Reactor và RxJava gói cùng ý dưới onBackpressure*; channel có trần của Go chặn người gửi khi đầy; highWaterMark của Node stream và pipe() cũng thế; hàng đợi có trần trước một thread pool thì từ chối thay vì nuốt vô hạn. Cái bẫy chung là những API nhận việc rồi trả về ngay mà không bao giờ báo nó đang ngộp — write() của Vert.x là một ca: nó không chặn, không ném, không trả cờ, nên bạn phải chủ động hỏi writeQueueFull(). Nơi nào bộ đệm ở giữa vô hạn, nơi đó nguồn nhanh sẽ ép chết hệ thống, chỉ là sớm hay muộn.

Điều thứ hai, ngược với trực giác: áp lực ngược không phải là cái phanh làm chậm — phép đo trên cho thấy bản có áp lực ngược phục vụ nhiều dữ liệu hơn trong ít thời gian hơn (26,0 MB/18 s so với 21,1 MB/20 s). Lý do là một hệ bị ngộp thì phần lớn công sức đổ vào chuyện quản lý đống dồn ứ — cấp phát, gom rác, tráo bộ nhớ — chứ không phải vào việc thật, đúng cái thrashing mà ai từng nhìn một máy chủ quá tải đều thấy: thêm tải vào lại làm thông lượng tụt. Chạy đúng nhịp mà người nhận theo kịp thì nhanh hơn chạy hết ga rồi sặc. "Chậm lại để nhanh hơn" nghe như nghịch lý, nhưng nó là lý do mọi hệ truyền dữ liệu nghiêm túc đều có một dạng điều tiết luồng nào đó ở tận đáy.

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ả.