Node.js streams và backpressure: khi nơi ghi chậm hơn nơi đọc Trung cấp
Thời gian đọc ước tính: 7 phút
Vấn đề
Phần tiêu đề “Vấn đề”Một API nhận file upload và ghi từng chunk sang object storage. Client gửi nhanh hơn storage ghi. Nếu code cứ write() mà không đọc giá trị trả về, chunk dồn vào memory của process. Một file nhỏ vẫn pass test; 200 upload 500 MB chạy cùng lúc thì RSS tăng, GC chạy dày, latency tăng, cuối cùng OOM. Thêm RAM chỉ trì hoãn lỗi.
Backpressure là tín hiệu từ nơi tiêu thụ báo “đừng đẩy nhanh hơn tôi xử lý”. Với Writable, tín hiệu đó là write() trả false và sự kiện drain. Bài này dùng một Writable cố ý chậm để nhìn thấy tín hiệu, rồi chỉ ra cách ghi đúng.
Bảng tín hiệu
Phần tiêu đề “Bảng tín hiệu”Một bảng duy nhất cho mọi tín hiệu của Writable:
| Tín hiệu | Nghĩa là | Không có nghĩa là |
|---|---|---|
write() trả true |
Buffer sau khi nhận chunk vẫn nhỏ hơn highWaterMark |
Chunk đã được ghi bền |
write() trả false |
Chunk đã được nhận; đừng ghi chunk tiếp theo cho tới drain |
Chunk bị từ chối, cần ghi lại |
writableNeedDrain |
true khi buffer đã đầy và stream sẽ phát drain |
Một metric memory |
drain |
Buffer đã được tiêu thụ hết, ghi tiếp được | Toàn pipeline đã xong |
finish |
Sau end(), mọi dữ liệu đã được flush xuống hệ thống bên dưới |
Remote storage đã commit |
close |
Stream và tài nguyên bên dưới (ví dụ file descriptor) đã đóng; không còn sự kiện nào nữa | Ghi thành công (cũng phát khi bị destroy) |
error |
Có lỗi; stream thường bị huỷ ngay sau đó |
highWaterMark là ngưỡng đổi tín hiệu, không phải giới hạn cứng của memory: nếu cứ write() bất chấp false, Node vẫn buffer tiếp.
highWaterMark mặc định
Phần tiêu đề “highWaterMark mặc định”| Loại stream | Mặc định | Ghi chú |
|---|---|---|
| Byte stream (Writable, Readable, Transform) | 64 KiB từ Node 22 | Trước Node 22 là 16 KiB |
| Object mode | 16 object | Đếm theo số object, không theo byte |
fs.createReadStream / createWriteStream |
64 KiB | Đã đo trên Node 24.17 |
Đã đo trên Node 24.17.0: new Writable().writableHighWaterMark là 65536, object mode là 16, stream.getDefaultHighWaterMark(false) là 65536 và (true) là 16. Node 22.0.0 liệt kê “stream: bump default highWaterMark” trong các thay đổi semver-major.
Nếu service chạy trên nhiều phiên bản Node, đừng dựa vào mặc định: đặt highWaterMark tường minh hoặc đọc stream.getDefaultHighWaterMark().
Buffer fill theo thời gian
Phần tiêu đề “Buffer fill theo thời gian”Demo ghi các chunk 8 byte vào Writable có highWaterMark: 16 và sink mất 15 ms mỗi chunk. Trace thật (Node 24.17):
| Thời điểm | Sự kiện | writableLength |
writableNeedDrain |
|---|---|---|---|
| +0 ms | write #1 trả true |
8 / 16 | false |
| +0 ms | write #2 trả false |
16 / 16 | true |
| +32 ms | drain, producer tiếp tục |
0 | false |
| +32 ms | write #3 trả true, write #4 trả false |
16 / 16 | true |
| +64 ms | drain |
0 | false |
Mỗi chu kỳ producer dừng khoảng 32 ms, đúng thời gian sink xử lý hai chunk. Đó là backpressure: tốc độ producer bị kéo về tốc độ consumer. Demo đầy đủ 10 chunk in { chunks: 10, paused: 5 }.
chunkProducer gọi write. Chunk được nhận vào buffer của Writable và chuyển dần tới sink.
Cách ghi đúng
Phần tiêu đề “Cách ghi đúng”Cách 1: dùng pipeline (mặc định nên chọn). stream/promises.pipeline nối backpressure, truyền lỗi và dọn tài nguyên cho cả chuỗi:
import { pipeline } from 'node:stream/promises';
const ac = new AbortController();res.on('close', () => { if (!res.writableFinished) ac.abort(); }); // client ngắt kết nối
await pipeline(req, gzip, storageWritable, { signal: ac.signal });Khi signal bị abort, pipeline gọi destroy với AbortError lên toàn bộ chuỗi (theo tài liệu Node). Nếu một stream bị huỷ giữa chừng, pipeline reject với ERR_STREAM_PREMATURE_CLOSE (đã kiểm chứng ở Node 24.17).
Cách 2: tự ghi bằng vòng lặp. Cần khi nguồn không phải stream. Điểm hay bị bỏ sót: await once(sink, 'drain') tự reject nếu sink phát error trong lúc chờ, nhưng nếu sink bị destroy() không kèm lỗi thì cả drain lẫn error đều không bao giờ phát và vòng lặp treo mãi (đã đo: sau 300 ms vẫn treo). Phải chờ thêm close:
import { once } from 'node:events';
async function writeAll(sink: Writable, chunks: Iterable<Buffer>) { for (const chunk of chunks) { if (sink.write(chunk)) continue; const ac = new AbortController(); try { await Promise.race([ once(sink, 'drain', { signal: ac.signal }), once(sink, 'close', { signal: ac.signal }).then(() => { throw new Error('sink đóng sớm'); }), ]); } finally { ac.abort(); // gỡ listener còn lại } } sink.end(); await once(sink, 'finish');}Với sink bị huỷ giữa chừng, hàm này ném sink đóng sớm; với sink bình thường nó chạy xong. Cả hai đã kiểm chứng ở Node 24.17.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Hai điểm liên quan đến phía đọc: for await (const chunk of readable) rồi writable.write(chunk) vẫn phải xử lý false hoặc dùng pipeline; vòng lặp đọc không tự nhường cho Writable bất kỳ. Với res.write() trong HTTP, client đọc chậm tạo false giống hệt, và route sinh dữ liệu vô hạn không chờ drain sẽ làm memory tăng theo một client duy nhất.
Checklist production
Phần tiêu đề “Checklist production”- Dùng
stream/promises.pipelinecho mọi chuỗi stream; chỉ tựwrite()khi nguồn không phải stream. - Truyền
AbortSignalvàopipelinevà huỷ khi client ngắt kết nối hoặc hết deadline. - Tự ghi thì xử lý
falsebằngdrain, đồng thời ngheerrorvàcloseđể không treo vĩnh viễn. - Đặt
highWaterMarktường minh nếu hành vi không được phụ thuộc phiên bản Node (mặc định đổi ở Node 22). - Giới hạn số upload/stream đồng thời và kích thước body: backpressure từng stream không thay cho budget tổng.
- Theo dõi
writableLength(hoặcwritableNeedDrain) của các stream quan trọng và RSS của process. - Chỉ coi dữ liệu là bền sau khi storage xác nhận, không phải sau
finishcủa một adapter tự viết.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Tóm tắt
Phần tiêu đề “Tóm tắt”Tự kiểm tra: (1) Sau write() trả false, có ghi lại chunk đó không? (2) Khi nào await once(sink, 'drain') treo mãi? (3) finish và close khác nhau thế nào khi stream bị destroy()?
Chạy demo:
npx --prefix examples ts-node examples/src/node-backpressure/backpressure-demo.ts