jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Node.js Production Engineering 01 — V8, libuv và Event Loop

Hiểu đường đi của một tác vụ qua V8, libuv và event loop; từ đó kiểm soát concurrency, stream, worker thread, bộ nhớ và độ trễ production.

20 MIN READ Updated JUL 25, 2026

Một API có thể chỉ dùng 20% CPU nhưng vẫn timeout hàng loạt. Nguyên nhân thường không nằm ở “Node.js chậm”, mà ở một callback giữ event loop quá lâu, một thread pool bị bão hòa, hoặc một batch bất đồng bộ mở nhiều công việc hơn dependency chịu được. Muốn chẩn đoán đúng, ta phải nhìn xuyên qua cú pháp async/await xuống runtime bên dưới.

Bài mở đầu xây mental model đó trên Node.js 24 LTS và TypeScript. Sau khi đọc, bạn có thể:

  • lần theo một tác vụ từ JavaScript qua V8, binding, libuv tới hệ điều hành;
  • dự đoán thứ tự callback, microtask, timer và I/O mà không học thuộc output;
  • phân biệt concurrency với parallelism, event loop với worker pool;
  • chọn Promise combinator, giới hạn concurrency, hủy công việc và stream dữ liệu đúng chỗ;
  • nhận diện các failure mode làm tăng p99 latency, bộ nhớ hoặc thời gian shutdown.

Kiến thức cần có: JavaScript hiện đại, Promise và TypeScript cơ bản. Code minh họa giả định ESM; với project build ra JavaScript, hãy giữ tsconfig và runtime contract nhất quán.


Lộ trình 26 phần

01 Runtime → 02 HTTP → 03 Express → 04 Data Layer → 05 Security

06 Patterns → 07 Delivery → 08 Performance → 09 Testing → 10 Architecture

11 PostgreSQL → 12 Prisma → 13 Redis → 14 NestJS → 15 Identity

16 Queues → 17 GraphQL → 18 Microservices/gRPC → 19 Realtime → 20 Observability

21 Reliability → 22 Streams → 23 Diagnostics → 24 Multi-tenant

25 Webhook/Payment → 26 Production Game Day

Series đi từ runtime và protocol tới data, security, delivery, distributed systems và observability; sáu phần cuối chuyển kiến thức thành các case study production. Mỗi phần trả lời ba câu hỏi: hệ thống hoạt động thế nào, sẽ hỏng ở đâu, và chứng minh bản sửa bằng gì. Bài này là nền để suy luận các phần còn lại.


1. Node.js thực sự là gì

Đa số người mới nghĩ “Node = JavaScript trên server.” Đúng, nhưng nó che giấu thiết kế làm nên sự đặc biệt của Node. Node.js là một chương trình C++ nhúng một engine JavaScript và một thư viện I/O bất đồng bộ. Ba tầng làm việc:

  • engine của Google. Nó parse, compile (JIT) và chạy JS, đồng thời quản lý heap + bộ thu gom rác.
  • thư viện C cho Node event loop, một thread pool, và lớp trừu tượng I/O bất đồng bộ đa nền tảng.
  • “binding” C++ phơi tính năng OS cho JS, và một tầng JavaScript bọc chúng thành API thân thiện bạn gọi.
        Your JavaScript / TypeScript
   ┌─────────────────────────────────────────────┐
   │  Node core JS:  fs  http  events  stream     │  ← what you import
   ├─────────────────────────────────────────────┤
   │  C++ bindings (process.binding / internalBinding)
   ├──────────────────────────┬──────────────────┤
   │  V8                       │  libuv           │
   │  • parse + JIT compile JS │  • event loop    │
   │  • heap + garbage collect │  • thread pool   │
   │                           │  • async I/O     │
   ├──────────────────────────┴──────────────────┤
   │                Operating System              │
   └─────────────────────────────────────────────┘

Mô hình một luồng JavaScript, hướng sự kiện là ý tưởng cốt lõi: thay vì giữ một luồng OS chờ cho mỗi request, Node đăng ký công việc I/O rồi để event loop tiếp tục phục vụ callback khác. Event loop có thể ngủ hiệu quả ở pha poll khi chưa có sự kiện; điều cần tránh là để code JavaScript của ứng dụng giữ luồng đó quá lâu.

“Đơn luồng” là nói về JavaScript của bạn. Bên dưới libuv vẫn giữ một pool nhỏ các luồng OS, và kernel làm I/O song song — nên Node xử lý hàng chục nghìn kết nối đồng thời trên một core.


2. Event loop, từng pha một

Đây không chỉ là câu hỏi lý thuyết. Nó giải thích vì sao timer trễ, vì sao một callback CPU-bound kéo chậm mọi request, và vì sao cùng một đoạn code có thể đổi thứ tự giữa top-level và bên trong I/O callback.

Sau khi Node chạy code đồng bộ top-level, libuv vào một vòng lặp với các pha riêng biệt, mỗi pha có hàng đợi callback riêng:

   ┌───────────────────────────┐
┌─►│           timers          │  setTimeout / setInterval callbacks
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │     pending callbacks     │  some system/TCP error callbacks
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │       idle, prepare       │  internal only
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │           poll            │  ← retrieve I/O events; may BLOCK here
│  └─────────────┬─────────────┘     (this is where Node "waits")
│  ┌─────────────▼─────────────┐
│  │           check           │  setImmediate callbacks
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │      close callbacks      │  socket.on('close'), etc.
│  └─────────────┬─────────────┘
└────────────────┘  loop again while there is work

Sơ đồ trên là mental model ở mức ứng dụng, không phải đặc tả từng bước nội bộ. Từ libuv 1.45 (được Node.js 20+ sử dụng), timer được xử lý sau pha poll thay vì cả trước lẫn sau như các phiên bản cũ; vì vậy đừng dựa vào một sơ đồ cũ để cam kết thứ tự giữa setTimeout(0)setImmediate.

Pha bạn cần quan tâm nhất là poll: đây là nơi loop đỗ lại và chờ I/O hoàn tất. Nếu không còn việc, Node ngủ ở đây hiệu quả thay vì quay vòng tốn CPU.

Microtask: hàng đợi giữa các pha

Promise không nằm trong các pha libuv kia. Chúng chạy trong hai hàng microtask mà Node rút cạn sau mỗi callback và giữa các pha:

  1. hàng process.nextTick — ưu tiên cao nhất.
  2. hàng microtask Promise — rút ngay sau nextTick.

Quy tắc vàng giải thích mọi thứ tự kỳ lạ: mỗi khi call stack rỗng, Node rút cạn nextTick, rồi rút cạn microtask Promise, trước khi lấy macrotask kế.

console.log('1: sync start');

setTimeout(() => console.log('2: setTimeout (timers phase)'), 0);
setImmediate(() => console.log('3: setImmediate (check phase)'));

Promise.resolve().then(() => console.log('4: promise microtask'));
process.nextTick(() => console.log('5: nextTick — beats promises'));

console.log('6: sync end');

// Output:
// 1: sync start
// 6: sync end
// 5: nextTick — beats promises
// 4: promise microtask
// 2: setTimeout      (order of 2 vs 3 here is not guaranteed)
// 3: setImmediate

thứ tự setTimeout(fn, 0) vs setImmediate(fn) ở top-level không đảm bảo (tùy thời điểm loop). Nhưng bên trong một I/O callback, setImmediate luôn chạy trước setTimeout, vì loop đã qua timers và tới check kế tiếp.

process.nextTick là cái bẫy

Vì nextTick rút cạn hoàn toàn trước khi loop tiếp tục, một nextTick đệ quy bỏ đói event loop — I/O không bao giờ tới lượt:

// ❌ This freezes all I/O forever — the poll phase is never reached
function loop() {
  process.nextTick(loop);
}
loop();

Khuyến nghị mặc định: ưu tiên queueMicrotask khi cần microtask theo chuẩn web, hoặc setImmediate khi muốn nhường event loop sang lượt kế. Chỉ dùng process.nextTick cho trường hợp tương thích API Node cần chạy trước khi event loop tiếp tục; không dùng nó để tạo vòng lặp công việc dài.

Thread pool của libuv — phần người ta hay quên

I/O mạng (socket TCP/HTTP) bất đồng bộ thật ở mức OS, nên dùng epoll/kqueuekhông cần thread. Nhưng vài thao tác không có API async ở OS, nên libuv chạy chúng trên thread pool (mặc định 4 luồng):

  • thao tác hệ thống tệp
  • dns.lookup (nhưng không phải dns.resolve)
  • crypto và nén zlib
# If your app does heavy crypto/fs, the default 4 threads bottleneck.
# Raise it BEFORE the process starts (read once at startup):
UV_THREADPOOL_SIZE=16 node server.js

Một bcrypt.hash triển khai trên worker pool có thể không chặn event loop, nhưng vẫn chiếm một slot của pool dùng chung. Khi pool bão hòa, thao tác filesystem hoặc crypto khác phải xếp hàng; tăng UV_THREADPOOL_SIZE chỉ là một quyết định capacity cần load test, không phải bản sửa mặc định.


3. Lập trình bất đồng bộ có kiểm soát

Tiến hóa

Node khởi đầu với callback error-first. Lồng vài cái và bạn có callback hell — code trôi sang phải, xử lý lỗi lặp khắp nơi:

import { readFile } from 'node:fs';

readFile('a.txt', 'utf8', (err, a) => {
  if (err) return console.error(err);
  readFile('b.txt', 'utf8', (err2, b) => {
    // rightward drift begins...
    if (err2) return console.error(err2);
    // ...the abyss
  });
});

Promise là máy trạng thái ba trạng thái — pending → fulfilled hoặc pending → rejected — và chỉ settle một lần. async/await là cú pháp đường trên chúng, đọc như code đồng bộ mà vẫn non-blocking:

import { readFile } from 'node:fs/promises'; // the promise-based variant

async function load(): Promise<[string, string]> {
  const a = await readFile('a.txt', 'utf8');
  const b = await readFile('b.txt', 'utf8');
  return [a, b];
}

Tuần tự vs song song — phản xạ hiệu năng số 1

load ở trên đọc hai file độc lập lần lượt — lãng phí thời gian:

// ❌ Sequential — ~200ms if each takes 100ms
const a = await readFile('a.txt', 'utf8');
const b = await readFile('b.txt', 'utf8');

// ✅ Parallel — ~100ms; fire both, then await together
const [a, b] = await Promise.all([
  readFile('a.txt', 'utf8'),
  readFile('b.txt', 'utf8'),
]);

Quy tắc: await tuần tự chỉ khi một bước thật sự phụ thuộc bước trước; nếu không thì Promise.all.

Bốn combinator Promise — biết chính xác khi nào dùng

// all      → resolves with ALL results; rejects on the FIRST failure (fail-fast)
const users = await Promise.all(ids.map((id) => fetchUser(id)));

// allSettled → never rejects; gives {status, value|reason} per item (resilient)
const results = await Promise.allSettled(ids.map((id) => fetchUser(id)));
const ok = results.filter((r) => r.status === 'fulfilled');

// race     → settles with the FIRST to settle (win or fail) — used for timeouts
// any      → resolves with the FIRST to SUCCEED; rejects only if ALL fail
const fastest = await Promise.any([fromCacheA(), fromCacheB()]);

Kiểm soát đồng thời — đừng tự DoS dependency của mình

Promise.all trên 10.000 item có thể khởi tạo 10.000 lời gọi gần như cùng lúc và làm bão hòa DB. Hãy giới hạn concurrency theo capacity đã đo của dependency:

/** Run an async mapper over items with a hard concurrency limit. */
async function mapLimit<T, R>(
  items: readonly T[],
  limit: number,
  fn: (item: T, index: number) => Promise<R>
): Promise<R[]> {
  const results: R[] = new Array(items.length);
  let cursor = 0;

  async function worker(): Promise<void> {
    while (cursor < items.length) {
      const index = cursor++; // claim a slot atomically (single-threaded)
      results[index] = await fn(items[index], index);
    }
  }

  // Spin up `limit` workers that pull from the shared cursor.
  const size = Math.min(limit, items.length);
  await Promise.all(Array.from({ length: size }, worker));
  return results;
}

// 1000 URLs, at most 8 in flight at any moment
const bodies = await mapLimit(urls, 8, (url) =>
  fetch(url).then((r) => r.text())
);

Hủy & timeout với AbortController

Cách hủy hiện đại, chuẩn — được fetch, fs, timers, stream, event hỗ trợ:

// Built-in timeout signal (Node 17.3+) — no manual setTimeout bookkeeping
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });

// Combine your own cancel + a timeout
const ac = new AbortController();
const signal = AbortSignal.any([ac.signal, AbortSignal.timeout(10_000)]);
button.onClick = () => ac.abort(); // cancel on user action
const data = await fetch(url, { signal });

Xử lý lỗi không nói dối

try {
  const data = await risky();
} catch (err: unknown) {
  // In TS, caught errors are `unknown` — narrow before use, never assume Error
  if (err instanceof Error) console.error(err.message, { cause: err.cause });
  else console.error('Non-Error thrown:', err);
}

Hai lưới an toàn mức process — log rồi thoát, đừng nuốt lỗi:

process.on('unhandledRejection', (reason) => {
  console.error('Unhandled rejection:', reason);
  process.exitCode = 1; // let in-flight work drain, then exit non-zero
});

process.on('uncaughtException', (err) => {
  console.error('Uncaught exception:', err);
  process.exit(1); // state is corrupt — exit hard, let the orchestrator restart
});

Context theo request với AsyncLocalStorage

AsyncLocalStorage mang request id hoặc trace context qua chuỗi gọi async mà không phải truyền qua từng tham số hàm:

import { AsyncLocalStorage } from 'node:async_hooks';

interface RequestContext {
  requestId: string;
  userId?: string;
}
export const als = new AsyncLocalStorage<RequestContext>();

// At the edge (e.g. an HTTP middleware):
als.run({ requestId: crypto.randomUUID() }, () => handle(req, res));

// Anywhere deep inside, with no plumbing:
function log(msg: string): void {
  const ctx = als.getStore();
  console.log(`[${ctx?.requestId}] ${msg}`);
}

4. Module: ESM vs CommonJS

Node hỗ trợ hai hệ module; khác biệt resolution và interop giữa chúng là nguồn lỗi phổ biến khi đóng gói ứng dụng hoặc thư viện.

Khía cạnhCommonJS (CJS)ES Modules (ESM)
Nhậpconst x = require('x')import x from 'x'
Xuấtmodule.exports = …export / export default
Tảiđồng bộbất đồng bộ
__dirnamecó sẵnuse import.meta.dirname (Node 20+)
Top-level awaitkhông

Chọn ESM cho dự án mới — đặt một lần trong package.json:

{
  "type": "module",
  "exports": {
    ".": "./dist/index.js",
    "./utils": "./dist/utils.js"
  }
}
// ESM essentials you'll use constantly
import { readFile } from 'node:fs/promises';

const here = import.meta.dirname; // replaces __dirname
const isMain = import.meta.main; // Node.js 24: "run directly?"

// Dynamic import — load on demand, or conditionally (returns a Promise)
const { default: chalk } = await import('chalk');

Nên dùng tiền tố node: cho built-in để biểu đạt rõ đây là module lõi và tránh nhầm với dependency có tên gần giống. Lợi ích chính là tính tường minh, không phải một cam kết tối ưu tốc độ resolution.

Ghi chú TypeScript: Node.js 24 có thể chạy .ts chứa erasable syntax bằng type stripping. Runtime không type-check, không đọc tsconfig.json, không hỗ trợ .tsx, và không biến đổi cú pháp cần sinh JavaScript như enum hoặc parameter property. Dùng tsx/compiler khi dự án cần đầy đủ semantics TypeScript; dù chạy trực tiếp, CI vẫn phải chạy tsc --noEmit.


5. Core module — đào sâu

Thành thạo chúng và bạn phụ thuộc ít package npm hơn nhiều.

fs — hệ thống tệp

import { readFile, writeFile, mkdir, readdir, stat } from 'node:fs/promises';

await mkdir('data', { recursive: true }); // mkdir -p, no error if exists
await writeFile('data/out.txt', 'hello', 'utf8');
const entries = await readdir('.', { withFileTypes: true }); // Dirent[] — has isDirectory()
const info = await stat('data/out.txt'); // size, mtime, mode...

Ưu tiên node:fs/promises hơn bản sync — readFileSync chặn cả event loop. Và đừng kiểm-tra-rồi-làm (đua TOCTOU); cứ thử và xử lý mã lỗi:

// ❌ Race: the file can vanish between exists() and readFile()
// ✅ Just attempt it and branch on the error code
async function readOrNull(path: string): Promise<string | null> {
  try {
    return await readFile(path, 'utf8');
  } catch (err) {
    if (err instanceof Error && 'code' in err && err.code === 'ENOENT')
      return null;
    throw err; // ENOENT = not found; anything else is a real problem
  }
}

path & os — đừng tự nối đường dẫn

import { join, resolve, basename, extname, parse } from 'node:path';
import { cpus, tmpdir, homedir, platform } from 'node:os';

join('data', 'cache', 'file.json'); // cross-platform separators (\ on Windows)
extname('report.pdf'); // '.pdf'
parse('/a/b/c.txt'); // { dir:'/a/b', base:'c.txt', name:'c', ext:'.txt' }
cpus().length; // core count → size your worker/cluster pool

events — mẫu EventEmitter

Stream, server, socket, chính process — phần lớn Node xây trên sự kiện. Gõ type cho sự kiện để an toàn:

import { EventEmitter, once } from 'node:events';

interface JobEvents {
  progress: [percent: number];
  done: [result: string];
}

const job = new EventEmitter<JobEvents>();
job.on('progress', (p) => console.log(`${p}%`));
job.emit('progress', 50);

// Await an event as a Promise (great for "wait until ready")
const [result] = await once(job, 'done');

Ba lưu ý: (1) sự kiện 'error' đặc biệt — phát mà không có listener sẽ crash process; (2) listener chạy đồng bộ, listener chậm chặn emitter; (3) quá 10 listener trên một sự kiện sẽ cảnh báo rò rỉ bộ nhớ.

stream — xử lý dữ liệu quá lớn cho bộ nhớ

Đọc file 5 GB vào một Buffer giết process. Stream xử lý theo chunk với bộ nhớ phẳng. Có bốn loại: Readable (nguồn), Writable (đích), Duplex (cả hai), Transform (Duplex biến đổi, vd gzip).

Công cụ mặc định là pipeline, nối stream và phối hợp backpressure, error propagation cùng việc đóng các stream:

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

// Compress a file of ANY size using almost no memory
await pipeline(
  createReadStream('big.log'),
  createGzip(), // a Transform stream
  createWriteStream('big.log.gz')
);

Tự dựng Transform — vd viết hoa mỗi dòng:

import { Transform } from 'node:stream';

const upper = new Transform({
  transform(chunk, _enc, cb) {
    cb(null, chunk.toString().toUpperCase()); // push transformed chunk downstream
  },
});

Node hiện đại còn nói Web Streams API, chuyển đổi bằng Readable.fromWeb / Readable.toWeb.

buffer — byte thô, và một bẫy bảo mật

Buffer là một khối dữ liệu nhị phân độ dài cố định (lớp con của Uint8Array). Byte không phải ký tự:

const buf = Buffer.from('café', 'utf8');
console.log(buf.length); // 5 — 'é' is 2 bytes in UTF-8, not 1

// ✅ Buffer.alloc zero-fills.  ❌ allocUnsafe reuses memory (may leak old data)
const safe = Buffer.alloc(1024); // zeroed, safe to send anywhere
const fast = Buffer.allocUnsafe(1024); // faster, but MUST fully overwrite first

util, timers, crypto, process

import { promisify, parseArgs, styleText } from 'node:util';
import { setTimeout as sleep } from 'node:timers/promises';
import { randomUUID, createHash } from 'node:crypto';

await sleep(1000); // promise-based delay, no callback
const id = randomUUID(); // RFC 4122 v4 UUID
const digest = createHash('sha256').update('data').digest('hex');

// Parse CLI flags without a dependency (Node 18.3+)
const { values } = parseArgs({
  options: {
    top: { type: 'string' },
    verbose: { type: 'boolean', short: 'v' },
  },
});

// process — your window into the runtime
process.argv; // [node, script, ...args]
process.env.NODE_ENV;
process.cwd(); // current working dir
process.on('SIGTERM', () => server.close()); // graceful shutdown on orchestrator signal

6. Đồng thời, song song, worker thread & cluster

Một phân biệt cần nói rõ khi thiết kế capacity:

  • Đồng thờixử lý nhiều việc bằng đan xen trên một luồng (mô hình I/O của event loop).
  • Song songthực thi nhiều việc cùng một thời điểm trên nhiều core CPU.

Với I/O, concurrency của event loop là đủ. Với việc nặng CPU thật sự, một luồng chặn mọi thứ — đẩy sang worker thread:

// worker.ts — runs on its own thread, its own V8 isolate + event loop
import { parentPort, workerData } from 'node:worker_threads';
parentPort?.postMessage(heavyCompute(workerData));
// main.ts
import { Worker } from 'node:worker_threads';

function runHeavy(input: unknown): Promise<unknown> {
  return new Promise((resolve, reject) => {
    const worker = new Worker(new URL('./worker.ts', import.meta.url), {
      workerData: input,
    });
    worker.once('message', resolve);
    worker.once('error', reject);
    worker.once('exit', (code) => {
      if (code !== 0) reject(new Error(`Worker exited with code ${code}`));
    });
  });
}
// The main event loop stays responsive while the worker crunches.

Worker vs cluster: worker thread chia sẻ bộ nhớ và dành cho việc CPU trong một process. module cluster fork cả process chia sẻ cổng lắng nghe, để dùng hết core cho throughput I/O — dù production thường để PM2 / orchestrator làm thay.

Worker thêm chi phí khởi tạo, truyền dữ liệu và bộ nhớ cho mỗi isolate. Chỉ đưa công việc CPU-bound đã đo sang worker; với I/O bất đồng bộ, thêm worker thường không giải đúng bottleneck.


7. Bộ nhớ & thu gom rác trong production

V8 quản lý bộ nhớ trong một heap chia thành young generation (vật thể ngắn hạn, gom thường xuyên và rẻ) và old generation (vật thể sống sót, gom hiếm nhưng đắt).

const m = process.memoryUsage();
// rss: total resident memory; heapTotal/heapUsed: V8 heap; external: C++ buffers
console.log(`heapUsed: ${(m.heapUsed / 1024 / 1024).toFixed(1)} MB`);

Trần old-space mặc định ~2–4 GB tùy phiên bản; nâng lên cho job nặng bộ nhớ:

node --max-old-space-size=4096 server.js   # cap at 4 GB

Bốn nguồn rò rỉ kinh điển: (1) mảng/map cấp module phình mãi làm cache không evict; (2) setInterval/listener bị quên; (3) closure bắt giữ vật thể lớn; (4) Map/Set khóa bằng object không bao giờ xóa — dùng WeakMap/WeakRef.

Quy tắc ngăn phần lớn: stream dữ liệu lớn, giới hạn cache, và xóa mọi timer/listener bạn tạo.


8. Chẩn đoán dùng hằng ngày

node --inspect server.js        # open chrome://inspect or VS Code debugger
node --watch server.js          # built-in restart-on-change (no nodemon needed)
node --env-file=.env server.js  # load .env without dotenv (Node 20.6+)
node --test                     # built-in test runner (Phase 9)
import { performance } from 'node:perf_hooks';

const t0 = performance.now();
await doWork();
console.log(`took ${(performance.now() - t0).toFixed(1)}ms`);

9. Failure modes và hợp đồng độ trễ

Runtime chỉ hữu ích khi nối được với triệu chứng người dùng. Với frontend, một event loop bị giữ 400 ms không hiện ra dưới tên “event-loop bug”; nó hiện ra thành API timeout, skeleton kéo dài, thao tác bị retry hoặc WebSocket mất heartbeat. Vì vậy API contract cần quy định timeout, khả năng retry và request id thay vì để client tự đoán.

Failure modeDấu hiệu quan sát đượcCách xác nhậnHướng xử lý
Callback CPU-bound giữ event loopp99 tăng, event-loop delay tăng, CPU một core caoCPU profile và monitorEventLoopDelaychia nhỏ, tối ưu thuật toán hoặc chuyển sang worker pool riêng
libuv worker pool bão hòafs, DNS hoặc crypto cùng chậm dù loop rảnhload test từng nhóm việc và đo thời gian xếp hànggiảm concurrency, tách workload; chỉ tăng pool sau khi đo
Promise.all không giới hạndependency trả 429/timeout, socket và memory tăngmetric in-flight và error theo dependencysemaphore/queue, backoff và deadline
Buffer/cache không giới hạnRSS tăng nhưng heap có thể không tăng tương ứngprocess.memoryUsage() và heap snapshotstream, quota, TTL/LRU và backpressure
Timer/listener giữ process sốngdeploy không shutdown hết deadlinelog active phase, kiểm tra handle trong môi trường debugcleanup, unref() khi đúng semantics, graceful shutdown có deadline

Chọn cơ chế thực thi

Công việcMặc địnhChuyển hướng khi
HTTP, database, socketI/O bất đồng bộ trên event loopdependency không có API async hoặc cần cô lập failure
Hash/parse/compress CPU nặngworker thread hoặc process chuyên dụngchi phí truyền dữ liệu lớn hơn lợi ích; benchmark trước
File lớnstream + pipelinecần random access có giới hạn rõ
Batch gọi APIconcurrency có giới hạn + deadlineprovider có batch API tốt hơn

Không có một con số concurrency đúng cho mọi hệ thống. Giá trị đó là một capacity decision: bắt đầu thấp, load test với latency/error budget của dependency, rồi tăng đến trước điểm p95/p99 xấu đi rõ rệt.


10. Dự án thực hành

Dựng cả ba — đây là cách khái niệm thấm vào.

  1. Bộ lọc log bằng stream: theo dõi thư mục bằng fs.watch; khi file .log đổi, stream-đọc chỉ dòng mới bằng createReadStream + readline và in dòng chứa ERROR — bộ nhớ phẳng kể cả log khổng lồ.

  2. CLI kiểu du: dựng lệnh đo dung lượng dùng parseArgs, duyệt đệ quy bằng fs/promises với song song có giới hạn qua mapLimit, in các file lớn nhất.

  3. Logger dựa trên EventEmitter: class Logger extends EventEmitter phát log/error; một listener ghi JSON ra file qua writable stream, một listener in console. Chứng minh điều gì xảy ra khi 'error' không có listener.

Bài tập thêm: dự đoán output script trộn setTimeout/setImmediate/Promise/nextTick (top-level trong I/O callback); chuyển API callback sang async/await bằng promisify; đẩy vòng hash nặng CPU sang worker thread và xác nhận loop chính vẫn phản hồi; viết mapLimit và chứng minh nó không vượt giới hạn.

Tiêu chí hoàn thành: mỗi project có lệnh chạy, test cho happy path và failure path, log thời gian xử lý, và một README ngắn giải thích vì sao chọn event loop, stream hay worker. Với bài mapLimit, test phải ghi nhận số tác vụ in-flight lớn nhất và chứng minh nó không vượt limit.


11. Checklist trước khi ship

  • Giải thích được các pha event loop và vị trí của Promise/nextTick.
  • Không chạy tác vụ CPU dài hoặc API đồng bộ trong request path; nếu có ngoại lệ, phải đo duration và cô lập tác động.
  • Mặc định Promise.all, nhưng giới hạn đồng thời cho batch lớn.
  • Hủy bằng AbortSignal và không nuốt lỗi.
  • Stream dữ liệu lớn và dọn mọi timer/listener.
  • Chỉ dùng worker thread cho việc CPU đã đo.

Nếu chỉ nhớ năm điều

  • JavaScript chạy trên một luồng chính; network I/O không đồng nghĩa với một thread cho mỗi request.
  • Microtask được rút trước khi event loop sang công việc kế, nên một chuỗi microtask vô hạn cũng có thể gây starvation.
  • Event loop và worker pool là hai tài nguyên khác nhau; phải đo cả hai.
  • Concurrency không giới hạn chỉ chuyển bottleneck sang database hoặc dịch vụ khác.
  • Stream, deadline, cancellation và cleanup là phần của correctness, không phải tối ưu thêm sau.

Phần tiếp theo

Giờ bạn có mental model để suy luận thay vì học thuộc: Node được dựng thế nào, event loop và microtask phối hợp ra sao, worker pool bị bão hòa như thế nào, và khi nào dùng stream hoặc worker thread.

Phase 2, ta lên một tầng tới HTTP & nền tảng web — giao thức chuyên sâu, dựng server Node.js thô không framework, parse request (kể cả upload file và body streaming), tự routing, và khám phá khái niệm middleware từ gốc.

Đọc tiếp: HTTP từ Wire đến API Contract.

Tài liệu chính thức