Ở quầy tính tiền siêu thị, người quét mã vạch thường nhanh hơn người đóng túi. Nếu người quét cứ quét liên tục bất kể người đóng túi có kịp hay không, hàng hoá dồn thành đống chình ình giữa hai người. Cách duy nhất để đống đó không tràn là người quét ngó chừng người đóng túi, đầy thì ngừng tay một nhịp. Đó chính xác là "áp lực ngược" trong truyền dữ liệu — và bài này đo chuyện gì xảy ra khi bạn quên ngó chừng: 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.
Cách nhanh nhất để thấy cột trái tận mắt là giả lập một client chậm rồi nhìn heap:
# 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.
Mẫu số chung
Hễ một bên sinh ra dữ liệu nhanh hơn bên kia tiêu thụ, giữa chúng luôn có một cái đệm — và nếu không có đường phản hồi để bảo bên nhanh chậm lại, cái đệm đó phình cho tới khi giết bạn. response.write() không chặn và không báo đầy chính là "ngầm tin bên nhận nhanh vô hạn" — người quét cứ quét, mặc kệ người đóng túi. Cách chữa ở mọi tầng đều là một đường phản hồi từ chậm về nhanh: TCP có cửa sổ trượt để bên nhận ghìm bên gửi, Reactive Streams có request(n) để bên tiêu thụ kéo đúng số nó kham nổi, một Go channel có dung lượng chặn goroutine ghi khi đầy, một thread-pool queue có trần. Nguyên tắc: hai công đoạn khác tốc độ mà thiếu tín hiệu ngược thì cái nhanh sẽ làm ngập cái chậm — và "ngập" ở đây đo bằng RAM.
Điều thứ hai, và nó quyết định bạn có thấy được lỗi này trước khi ra production hay không: thủ phạm là một người tiêu thụ chậm, không phải tải cao — ứng dụng gục vì một khách mạng yếu chứ không vì đông khách. Mà đúng cái điều kiện kích hoạt đó lại tàng hình trên máy lập trình viên: curl trên localhost đọc nhanh hơn đĩa đẩy ra, nên hàng chờ chẳng bao giờ đầy và hai cách viết trông y hệt. Đây là lý do phải kiểm bằng một bên tiêu thụ cố tình chậm — một client giới hạn tốc độ, một mạng bị bóp băng thông, một đĩa giả lập chậm — chứ không phải bằng cái nhanh nhất mình có sẵn. Cái đệm chỉ lộ ra khi đầu ra nghẽn, và môi trường dev trơn tru là nơi cuối cùng bạn thấy nó đầy.
Bài sau: WebSocket — giữ hàng nghìn kết nối mở, và bộ nhớ mỗi kết nối.