Truyền một tệp lớn cho client là việc tưởng như không có gì để nói. Bài này đo hai cách viết chỉ khác nhau một dòng, và chênh lệch bộ nhớ giữa chúng là thứ làm sập ứng dụng trên sản xuất.
Hai cách viết
// Cách 1 — trông rất tự nhiên
file.handler(buf -> response.write(buf))
.endHandler(v -> response.end());
// Cách 2
file.pipeTo(response);
Cả hai đều gửi đúng nội dung tệp và client nhận đúng như nhau. Khác biệt nằm ở chỗ không thấy được: cách 1 đọc tệp nhanh hết mức đĩa cho phép, bất kể client có nhận kịp hay không.
Đo
Tệp 300 MB, một client cố tình đọc rất chậm (8 KB mỗi 50 ms). Chụp bộ nhớ máy chủ ở giây thứ sáu:
| Heap đang dùng | RSS | Client đã đọc được | |
|---|---|---|---|
| Không backpressure | 172 MiB | 353 MiB | 1,9 MB |
pipeTo |
46 MiB | 115 MiB | 1,9 MB |
Cùng một client, cùng nhận được 1,9 MB. Một bên giữ 172 MiB trong bộ nhớ để chờ, bên kia 46 MiB — và con số 172 vẫn đang tăng khi tôi chụp, nó chỉ dừng khi đọc hết cả tệp.
Vì sao
response.write(buf) không chặn. Nó nhận buffer, xếp vào hàng chờ ghi, rồi trả về ngay — đúng như mọi thứ khác trong Vert.x. Nếu bạn không hỏi hàng chờ đó đã đầy chưa, vòng lặp đọc tệp sẽ chạy hết tốc lực và hàng chờ phình theo.
pipeTo làm hộ bạn ba việc: khi hàng chờ ghi đầy thì tạm dừng luồng đọc, khi nó vơi thì đọc tiếp, và đóng cả hai đầu khi xong hoặc khi có lỗi.
Không dùng pipeTo thì phải tự viết đúng ba việc đó:
file.handler(buf -> {
response.write(buf);
if (response.writeQueueFull()) {
file.pause();
response.drainHandler(v -> file.resume());
}
});
Sáu dòng thay cho một, và bỏ sót bất kỳ dòng nào cũng đưa bạn về cột trái của bảng.
Chỗ nào cũng cần
Bảng trên dùng tệp cho dễ hình dung, nhưng cùng một vấn đề xuất hiện ở mọi chỗ có hai đầu chạy khác tốc độ:
| Đọc từ | Ghi ra | Rủi ro |
|---|---|---|
| Tệp trên đĩa | response HTTP | client mạng yếu |
| Response của upstream | response của client | dịch vụ proxy |
| Cursor của CSDL | response HTTP | truy vấn trả về triệu dòng |
| Request của client | tệp trên đĩa | upload từ mạng nhanh vào đĩa chậm |
Dòng thứ ba đáng chú ý nhất, và bài 40 của sê-ri sẽ đo riêng: nối ReadStream của CSDL thẳng vào response mà không có backpressure là cách nhanh nhất để một truy vấn làm hết bộ nhớ.
Luật
Hễ có hai đầu, dùng pipeTo. Nó ngắn hơn và đúng hơn.
Nếu phải tự viết vòng lặp, luôn hỏi writeQueueFull(). Viết write() mà không kiểm tra là mặc định tin rằng bên nhận nhanh vô hạn.
Đừng đo bằng client nhanh. Trên máy lập trình viên, curl đọc nhanh hơn đĩa nên hàng chờ không bao giờ đầy và cả hai cách trông giống hệt nhau. Cột trái của bảng chỉ hiện ra ở môi trường thật.
Bài sau: WebSocket — giữ hàng nghìn kết nối mở, và bộ nhớ mỗi kết nối.
Thử ba mươi giây
# gia lap mot client cham va nhin heap may chu
curl --limit-rate 50k http://localhost:8080/tai-tep -o /dev/null &
watch -n1 'jcmd <pid> GC.heap_info | head -2'
Heap tăng đều trong khi curl mới tải được vài trăm KB nghĩa là bạn đang ở cột trái. Với pipeTo thì con số đó gần như đứng yên.