← Lập trình JavaScript nâng cao

Bài 7 · Nâng cao · 24 phút

Buffer & Stream

Biên soạn bởi Nguyễn Anh Tuấn

Xử lý dữ liệu nhị phân và luồng (stream) trong Node: Buffer/TypedArray, các stream readable/writable/transform, và backpressure khi dữ liệu về quá nhanh.

Chuỗi JavaScript che mất một sự thật: đĩa cứng và đường mạng không biết "ký tự" là gì - chúng chỉ chuyên chở byte, như bài Hệ nhị phân (khoá Máy tính hoạt động thế nào?) đã chỉ ra. Node sinh ra để đụng vào tệp và mạng, nên cần một kiểu dữ liệu đại diện cho dãy byte trần trụi: Buffer. Nhìn nó mổ xẻ một chữ tiếng Việt:

buffer.mjs - một ký tự KHÔNG phải một byte; encoding là luật quy đổi chữ ↔ byte

const b = Buffer.from("Mèo", "utf8");

console.log(b);                       // nhìn thẳng vào từng byte
console.log(b.length, "Mèo".length);  // số byte ≠ số ký tự!
console.log(b.toString("hex"));       // mỗi byte = 2 chữ số hex
console.log(b.toString("base64"));    // encoding khác, cùng dãy byte
console.log(Buffer.from("4dc3a86f", "hex").toString("utf8"));   // khứ hồi
console.log(b instanceof Uint8Array);             // Buffer là Uint8Array mở rộng
console.log(b.subarray(0, 2).toString("utf8"));   // cắt GIỮA chữ "è"!

Kết quả khi chạy

<Buffer 4d c3 a8 6f>
4 3
4dc3a86f
TcOobw==
Mèo
true
M�

Bốn byte cho ba ký tự: 4d là "M", 6f là "o" - còn chữ "è" chiếm hai byte c3 a8, vì UTF-8 mã hoá chữ ngoài bảng ASCII bằng nhiều byte. Vậy nên cắt theo vị trí byte có thể chém đôi một ký tự: dòng cuối giữ 4d c3, "è" mất nửa sau, thành ký tự lỗi �.

  • Buffer = vùng nhớ chứa byte thô; số byte (length) khác số ký tự khi có chữ ngoài ASCII.
  • encoding (utf8, hex, base64…) là luật quy đổi chữ ↔ byte - đổi qua lại khứ hồi không mất mát.
  • Buffer là Uint8Array mở rộng: chỗ nào nhận TypedArray đều nhận được Buffer.

Ở bài Làm việc với tệp & thư mục, readFile nạp TRỌN tệp vào RAM rồi mới đưa cho mèo con. Tiện - cho đến ngày gặp tệp log 10 GB trên máy 8 GB RAM: chưa kịp xử lý gì đã hết bộ nhớ. Stream giải bài toán bằng cách cho dữ liệu CHẢY qua chương trình từng miếng nhỏ - mỗi miếng là một chunk; RAM chỉ cần đủ chỗ cho miếng đang xử lý. Dựng một tệp thí nghiệm cố định rồi đếm thử:

dem.mjs - đọc từng chunk: RAM giữ tối đa 64 KiB dù tệp 2,5 MB

import { writeFile } from "node:fs/promises";
import { createReadStream } from "node:fs";

// dựng tệp thí nghiệm cố định: 100.000 dòng văn bản
let noiDung = "";
for (let i = 1; i <= 100_000; i++) noiDung += `meo con ghi dong so ${i}\n`;
await writeFile("nhat-ky.log", noiDung);

let soChunk = 0, soByte = 0, soDong = 0;
for await (const chunk of createReadStream("nhat-ky.log")) {
  soChunk += 1;
  soByte += chunk.length;
  for (const byte of chunk) if (byte === 10) soDong += 1;   // 10 = mã byte của "\n"
}
console.log("chunk:", soChunk, "| byte:", soByte, "| dòng:", soDong);

Kết quả khi chạy

chunk: 40 | byte: 2588895 | dòng: 100000

Để ý vòng lặp: for await...of - không có cú pháp mới nào ở đây cả. Readable stream implement đúng async iterator protocol mèo con học ở bài Generator & async iterator: mỗi vòng là một lượt await lấy chunk kế tiếp, hết tệp thì vòng lặp tự kết thúc - break giữa chừng cũng an toàn, stream tự được huỷ.

  • Stream = dữ liệu chảy từng chunk (64 KiB mỗi chunk với stream tệp) - tệp 10 GB hay 10 KB tốn RAM như nhau.
  • 2.588.895 byte ÷ 64 KiB ≈ 40 chunk - khớp đúng con số chương trình đếm được.
  • for await...of trên stream chính là async iterator của bài trước - kiến thức cũ, sân khấu mới.

Đọc từng chunk rồi GHI từng chunk sang nơi khác - thế là thành copy. Stream chia hai phe: readable (nguồn phát: đọc tệp, HTTP request đến…) và writable (đích nhận: ghi tệp, response trả đi…). Cách cổ điển là nghe sự kiện data / end rồi tự write(), hoặc gọi nguon.pipe(dich); cách hiện đại - bài này dùng từ đây về sau - là pipeline, phiên bản Promise để await được:

copy.mjs - nối readable vào writable, pipeline lo phần còn lại

import { createReadStream, createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import { stat } from "node:fs/promises";

await pipeline(
  createReadStream("nhat-ky.log"),      // vòi nước: readable
  createWriteStream("ban-sao.log")      // cống thoát: writable
);

console.log("gốc    :", (await stat("nhat-ky.log")).size, "byte");
console.log("bản sao:", (await stat("ban-sao.log")).size, "byte");

Kết quả khi chạy

gốc    : 2588895 byte
bản sao: 2588895 byte

Mỗi stream mang theo một vùng đệm nội bộ (internal buffer) - chỗ chứa tạm những chunk đã đọc mà chưa ai lấy, hoặc đã write() mà chưa kịp xuống đĩa. Định mức của vùng đệm ấy là highWaterMark (nghĩa đen: vạch nước cao - mức đánh dấu "đầy"), mặc định 64 KiB cho stream tệp. Nó KHÔNG phải giới hạn cứng - vậy để làm gì? Bước kế trả lời.

  • readable = nguồn phát chunk, writable = đích nhận; copy tệp = nối hai đầu lại.
  • pipeline() trả Promise: await được, lỗi ở mắt xích nào cũng nổi lên đúng một chỗ.
  • highWaterMark = định mức vùng đệm nội bộ - ngưỡng báo "đầy", không phải trần cứng.

Câu hỏi trung tâm của mọi hệ thống stream: đọc từ SSD được hàng trăm MB mỗi giây, nhưng đầu ghi - qua mạng chẳng hạn - chỉ nuốt nổi vài MB mỗi giây. Producer (bên tạo dữ liệu) nhanh hơn consumer (bên tiêu thụ), thì những chunk chưa kịp tiêu dồn lại trong vùng đệm - không ai hãm producer thì vùng đệm phình tới khi ăn hết RAM: công cụ sinh ra để tiết kiệm bộ nhớ lại làm tràn bộ nhớ. Tín hiệu hãm có sẵn trong API: write(chunk) trả về boolean. false nghĩa là "vùng đệm đã chạm highWaterMark - khoan ghi tiếp"; producer tử tế sẽ dừng, đợi sự kiện drain (vùng đệm đã rút) rồi mới ghi tiếp. Cơ chế dòng dưới báo ngược lên dòng trên này gọi là backpressure (áp lực dội ngược). Xem điều gì xảy ra khi ta LỜ tín hiệu:

o-at.mjs - rót 1000 miếng 1 KiB, mặc kệ write() nói gì

import { createWriteStream } from "node:fs";

const ws = createWriteStream("o-at.bin", { highWaterMark: 16 * 1024 });
const mieng = Buffer.alloc(1024, 97);   // mỗi miếng 1 KiB, toàn byte 97 ("a")

let lanFalse = 0;
for (let i = 0; i < 1000; i++) {
  if (!ws.write(mieng)) lanFalse += 1;   // false = "khoan rót!" - nhưng ta lờ đi
}
ws.end();

console.log("write() trả false:", lanFalse, "/ 1000 lần");

Kết quả khi chạy

write() trả false: 985 / 1000 lần

Vùng đệm 16 KiB nhận đủ 15 miếng đầu; từ miếng 16 trở đi, cả 985 lần write() đều kêu "khoan!". Chương trình vẫn chạy xong, tệp vẫn đủ 1.024.000 byte - vì false chỉ là lời khuyên - nhưng gần như toàn bộ 1000 KiB đã nằm chờ trong RAM thay vì tối đa ~16 KiB. Phiên bản tử tế (ngoan.mjs) chỉ cần import thêm hàm once từ node:events và đổi vòng lặp:

ngoan.mjs - như o-at.mjs, nhưng dừng khi false, đợi drain rồi rót tiếp

let lanDoi = 0;
for (let i = 0; i < 1000; i++) {
  if (!ws.write(mieng)) {        // vùng đệm chạm vạch →
    lanDoi += 1;
    await once(ws, "drain");     // ...đứng chờ rút cạn rồi mới rót tiếp
  }
}
ws.end();

console.log("số lần phải dừng chờ drain:", lanDoi);

Kết quả khi chạy

số lần phải dừng chờ drain: 62

62 lần dừng ấy là nhịp thở của backpressure: ghi - đầy - dừng - rút - ghi tiếp. Nghịch mô phỏng dưới đây để thấy nhịp đó bằng mắt: kéo tốc độ ghi lên, rồi thử tắt backpressure xem vùng đệm đi về đâu.

đọc: 3/tick · highWaterMark: 24
vạch highWaterMark = 24
buffer nội bộ: 0 đơn vị thang đo: 48
● Đang ghi Chưa chạy - bấm Tick để producer bắt đầu ghi.
đã ghi (totalWritten)
0
đã đọc (totalRead)
0
đỉnh buffer (peak)
0
tick 0/60

Trung thực: mô hình tick là bản giản lược

Mô phỏng cho mọi thứ chạy theo nhịp tick đều cho dễ nhìn; Node thật đo bằng byte và sự kiện, không có "tick". Khác biệt đáng nói nhất: trong mô phỏng, producer được đánh thức khi vùng đệm rút còn nửa vạch; còn writable stream của Node phát drain khi vùng đệm rút cạn hẳn. Nhịp ghi-dừng-ghi và việc RAM bị ghìm quanh highWaterMark thì y như nhau.
  • write() trả false = vùng đệm đã chạm highWaterMark; là tín hiệu khuyên dừng, không phải lỗi.
  • Producer dừng khi gặp false, nghe drain một lần rồi ghi tiếp - đỉnh RAM bị ghìm quanh vạch thay vì phình theo dữ liệu.
  • pipe() và pipeline() tự làm trọn vòng này - lý do thật sự để nối stream bằng chúng thay vì tự write trong vòng lặp.

Mảnh ghép cuối: transform - stream hai mặt, vừa writable (nhận chunk vào) vừa readable (nhả chunk ra), đứng giữa dây chuyền để BIẾN ĐỔI dữ liệu chảy qua: nén, mã hoá, đổi định dạng… Node có sẵn một transform kinh điển: createGzip() của node:zlib. Ghép ba mắt xích là thành máy nén tệp không tốn RAM:

nen.mjs - readable → transform → writable; tệp log lặp nhiều nên nén còn ~10%

import { createReadStream, createWriteStream } from "node:fs";
import { createGzip } from "node:zlib";
import { pipeline } from "node:stream/promises";
import { stat } from "node:fs/promises";

await pipeline(
  createReadStream("nhat-ky.log"),   // readable: nguồn
  createGzip(),                      // transform: nén từng chunk đi qua
  createWriteStream("nhat-ky.log.gz")// writable: đích
);

console.log("trước khi nén:", (await stat("nhat-ky.log")).size, "byte");
console.log("sau khi nén  :", (await stat("nhat-ky.log.gz")).size, "byte");

Kết quả khi chạy

trước khi nén: 2588895 byte
sau khi nén  : 262688 byte

Vì sao bài này một mực dùng pipeline() thay vì chuỗi .pipe().pipe()? Vì lỗi. Khi một mắt xích gãy giữa chừng (tệp biến mất, đĩa đầy…), chuỗi .pipe() không tự huỷ các stream còn lại - file descriptor treo, vùng đệm kẹt trong RAM; còn pipeline() huỷ TẤT CẢ mắt xích, dọn tài nguyên, đưa lỗi về đúng một chỗ cho try...catch. Và stream không phải tính năng ngách của module fs: process.stdout là writable; nén, băm, parse từng dòng là transform - hiểu một khái niệm, dùng được khắp Node.

  • transform = writable + readable: nhận chunk, biến đổi, nhả ra - createGzip là ví dụ chuẩn mực.
  • Tệp thí nghiệm 2.588.895 byte nén còn 262.688 byte (~10%) - văn bản lặp nhiều nén rất sâu.
  • pipeline() khi lỗi: huỷ mọi mắt xích + dọn tài nguyên + reject một chỗ - hơn hẳn chuỗi .pipe().

Tiếp theo: stream gặp mạng

Bài kế - Mạng: HTTP, fetch & API - mèo con dựng server thật. Hãy mang theo con mắt của bài này: req mà server nhận chính là một readable stream, res trả về là một writable - nhận upload tệp lớn hay trả video, tất cả đều là pipeline mèo con vừa học.

Câu hỏi thường gặp

Phần DỮ LIỆU của Buffer được cấp phát ngoài heap V8 (bộ nhớ "external"), chỉ phần vỏ object nằm trong heap. Mèo con vẫn không phải dọn tay: khi vỏ object hết tham chiếu và bị garbage collector thu, vùng dữ liệu bên ngoài được giải phóng theo. Muốn thấy Buffer đang chiếm bao nhiêu, hãy nhìn cột external của process.memoryUsage() thay vì chỉ nhìn heapUsed.

Không. highWaterMark chỉ là NGƯỠNG để write() bắt đầu trả false - một lời khuyên, không phải lệnh cấm. Mọi chunk bạn đưa vẫn được nhận và xếp hàng đầy đủ, không mất byte nào (hai chương trình ở Bước 4 đều ghi ra tệp đủ 1.024.000 byte). Cái giá của việc lờ lời khuyên là RAM: hàng đợi cứ phình ra vô hạn định.

Tệp nhỏ và cần trọn nội dung một lúc (tệp cấu hình, JSON vài MB) → readFile gọn và dễ đọc. Tệp lớn, không đoán trước được kích thước, hoặc dữ liệu chỉ "đi ngang qua" chương trình (log, video, upload, backup) → stream, vì RAM chỉ giữ một chunk mỗi lúc.

Được, theo hai cách. Cách kinh điển: kế thừa stream.Transform và viết hàm _transform(chunk, encoding, callback). Cách gọn hơn trong Node hiện đại: đặt thẳng một async generator function vào giữa pipeline(nguon, async function* (nguon) { ... yield ... }, dich) - đúng kiểu generator của bài trước. Nhận chunk vào, biến đổi, yield chunk ra - thế là thành một mắt xích transform.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Chạy Buffer.from("Mèo", "utf8") rồi b.length"Mèo".length cho 4 và 3. Vì sao lệch?

  1. 1

    Tên mèo con ra byte

    Đưa họ tên đầy đủ (có dấu) của mèo con vào Buffer.from(...), in length và chuỗi hex. Trước khi chạy, đoán số byte của từng chữ cái.

    Hoàn thành khi: Chỉ ra đúng chữ nào 1 byte, chữ nào 2-3 byte (vd "ệ" chiếm 3 byte) - và giải thích vì sao tổng byte lớn hơn số ký tự.

  2. 2

    Đếm một triệu dòng

    Sửa chương trình Bước 2 để sinh tệp 1.000.000 dòng, đếm dòng bằng for await...of, in process.memoryUsage().rss trước khi kết thúc. Làm thêm bản đối chứng đọc bằng readFile.

    Hoàn thành khi: Cả hai bản đếm ra đúng 1000000 dòng; bản stream chiếm RAM (rss) thấp hơn rõ rệt bản readFile.

  3. 3

    Tìm vết cắt an toàn

    Lấy Buffer.from("Mèo Ham Học"), lần lượt subarray(0, k) với k chạy từ 1 đến hết rồi toString(). Ghi lại những giá trị k làm xuất hiện ký tự lỗi �.

    Hoàn thành khi: Khoanh được các k cắt vào giữa chữ có dấu, và giải thích bằng bảng byte: byte mở đầu c3/e1 mà bị tách khỏi các byte sau thì hỏng chữ.

  4. 4

    Nới rộng vùng đệm

    Chạy lại hai chương trình Bước 4 với highWaterMark lần lượt 16 KiB, 64 KiB, 256 KiB. Lập bảng: số lần write() trả false và số lần chờ drain theo từng mức.

    Hoàn thành khi: highWaterMark càng lớn → hai con số càng giảm; kích thước tệp ghi ra luôn đúng 1.024.000 byte ở mọi mức.

  5. 5

    Khứ hồi nén

    Dùng createGunzip() + pipeline() giải nén nhat-ky.log.gz của Bước 5 ra tệp mới, rồi đọc cả hai tệp và so bằng bufferGoc.equals(bufferGiaiNen).

    Hoàn thành khi: equals trả true - nén rồi giải nén khứ hồi giữ nguyên từng byte một.

  6. 6

    Phá dây chuyền

    Cho pipeline() một đường dẫn nguồn không tồn tại, bọc trong try...catch và in err.code. Sau đó thử lại bằng chuỗi .pipe() xem lỗi có rơi vào catch không.

    Hoàn thành khi: Bản pipeline ném lỗi ENOENT bắt gọn một chỗ; bản .pipe() thì không - phải tự nghe error trên từng stream, đúng lý do bài học khuyên dùng pipeline.