Tối ưu Node.js trong production: đo đúng chỗ trước khi tối ưu
Quy trình tối ưu Node.js mình dùng thật: đo p99 và event loop delay, profile CPU bằng --cpu-prof và flame graph, gỡ code chặn event loop, worker pool, keep-alive, pool Postgres, stream, cache chống stampede và xử lý memory leak.
Dashboard báo orders-api có p50 chỉ 40ms nhưng p99 lên 1,8 giây. Team tăng từ 4 lên 8 pod, p99 gần như không đổi. Nguyên nhân hoá ra nằm ở một route khác hẳn: endpoint xuất báo cáo gọi JSON.stringify trên một object vài chục MB. Trong lúc nó chạy, mọi request khác trên cùng process phải đứng chờ. Thêm pod chỉ làm xác suất "dính" thấp đi một chút, không chữa được bệnh.
Node.js chạy JavaScript của bạn trên một thread. Một thao tác đồng bộ chậm không chỉ làm chậm request của nó mà làm chậm tất cả request đang xử lý trên process đó. Vì vậy tối ưu Node gần như luôn bắt đầu bằng câu hỏi: event loop có bị chặn không, và bị chặn bởi cái gì?
Bài này là quy trình mình dùng theo đúng thứ tự: đo, tìm chỗ nóng, sửa, rồi đo lại. Mỗi bước có lệnh và code cụ thể, kèm cái giá của từng cách sửa.
TL;DR:
- Nhìn p95/p99 và event loop delay, đừng nhìn latency trung bình.
- Profile CPU dưới tải thật bằng
--cpu-profhoặc flame graph trước khi sửa bất cứ thứ gì. - Thủ phạm hay gặp: API
*Sync,JSON.parse/stringifypayload lớn, regex backtracking, vòng lặp lớn. CPU work nặng thì đẩy sang worker pool. - Phía I/O: keep-alive, pool Postgres có giới hạn và timeout, stream có backpressure, cache có chống stampede.
- Heap limit phải nhỏ hơn memory limit của container một khoảng đáng kể.
Bước 0: đo đúng thứ, p99 và event loop delay
Latency trung bình che giấu đúng thứ bạn cần thấy. 99 request mất 20ms và 1 request mất 2 giây cho trung bình khoảng 40ms, trông rất ổn. Nhưng một trang gọi 20 API sẽ có xác suất 1 - 0.99^20 ≈ 18% dính ít nhất một request chậm cỡ p99. Với user, p99 của backend là trải nghiệm bình thường chứ không phải trường hợp hiếm.
Chỉ số thứ hai là event loop delay: một timer hẹn chạy sau X ms thực tế bị trễ thêm bao lâu. Node có sẵn histogram cho việc này:
// src/observability/event-loop.ts
import { monitorEventLoopDelay, performance } from 'node:perf_hooks'
import { logger } from '../logger.js'
const delay = monitorEventLoopDelay({ resolution: 20 }) // lấy mẫu mỗi 20ms
delay.enable()
let lastElu = performance.eventLoopUtilization()
setInterval(() => {
const now = performance.eventLoopUtilization()
const elu = performance.eventLoopUtilization(now, lastElu) // delta trong 10s vừa qua
lastElu = now
logger.info(
{
eventLoopDelayP50Ms: delay.percentile(50) / 1e6, // histogram tính bằng nanosecond
eventLoopDelayP99Ms: delay.percentile(99) / 1e6,
eventLoopDelayMaxMs: delay.max / 1e6,
eventLoopUtilization: Number(elu.utilization.toFixed(3)),
},
'event loop stats',
)
delay.reset()
}, 10_000).unref()
Đọc hai số này kết hợp với p99 của request:
- p99 request cao + event loop delay cao + ELU gần 1: JavaScript đang ăn CPU hoặc có code đồng bộ chặn loop. Sang bước profile CPU.
- p99 request cao + event loop delay thấp + ELU thấp: process đang rảnh và chờ thứ gì đó (DB, upstream, pool cạn connection). Profile CPU sẽ không thấy gì; hãy xem phần kết nối và pool bên dưới.
- Event loop delay p99 cỡ vài ms là bình thường. Đỉnh vài trăm ms nghĩa là có thứ đang chặn loop.
Nếu đã dùng prom-client, collectDefaultMetrics() có sẵn nodejs_eventloop_lag_p99_seconds. Cách đưa nó lên dashboard và alert có trong bài logging và observability.
Để tái hiện vấn đề, tạo tải bằng autocannon:
# 50 connection đồng thời trong 30 giây, chạy với build production
NODE_ENV=production node dist/server.js &
npx autocannon -c 50 -d 30 "http://localhost:3000/api/orders?status=PAID"
# Output minh hoạ
┌─────────┬───────┬───────┬────────┬────────┬──────────┬──────────┬─────────┐
│ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │
├─────────┼───────┼───────┼────────┼────────┼──────────┼──────────┼─────────┤
│ Latency │ 12 ms │ 38 ms │ 410 ms │ 980 ms │ 61.2 ms │ 120.4 ms │ 1840 ms │
└─────────┴───────┴───────┴────────┴────────┴──────────┴──────────┴─────────┘
Khoảng cách lớn giữa 50% và 99% (38ms so với 980ms) là dấu hiệu kinh điển của event loop bị chặn từng đợt. Vài lưu ý để số đo có ý nghĩa: chạy load generator trên máy khác nếu được (cùng máy thì nó tranh CPU với server), dùng build production, dữ liệu có kích thước giống thật, và chạy vài lần để loại nhiễu.
Bước 1: CPU profile, biết chính xác hàm nào ăn CPU
Có ba cách, mình dùng theo thứ tự này:
# Cách 1: ghi file .cpuprofile khi process thoát
NODE_ENV=production node --cpu-prof --cpu-prof-dir=./profiles dist/server.js
# terminal khác: npx autocannon -c 50 -d 30 http://localhost:3000/api/orders
# rồi Ctrl+C server -> ./profiles/CPU.<ngày>.<pid>....cpuprofile
# Cách 2: attach Chrome DevTools vào process đang chạy
node --inspect dist/server.js
# mở chrome://inspect -> inspect -> tab Performance -> Record trong lúc chạy tải
# Cách 3: flame graph HTML với 0x
npx 0x -- node dist/server.js
# Ctrl+C khi chạy tải xong -> mở flamegraph.html trong thư mục <pid>.0x
Bẫy với cách 1: --cpu-prof chỉ ghi file khi process thoát "tử tế". Nếu process bị signal giết mà không có handler, bạn sẽ không có file nào. Server production nên có sẵn handler SIGTERM/SIGINT đóng server rồi gọi process.exit(); có nó thì --cpu-prof chạy đúng. File .cpuprofile kéo thả vào tab Performance của Chrome DevTools hoặc mở thẳng trong VS Code.
Cách đọc profile:
- Bottom-Up, sắp xếp theo Self Time: hàm nào tự nó tốn CPU nhiều nhất (không tính hàm con). Đây thường là chỗ cần sửa.
- Flame graph: chiều ngang là thời gian trên CPU, chiều dọc là call stack. Tìm các "cao nguyên" rộng ở đỉnh, đó là code đang thực sự chạy.
- Mục
(garbage collector)chiếm nhiều nghĩa là code tạo quá nhiều object tạm. Tối ưu allocation chứ không phải logic. - Hàm ẩn danh hiện thành
(anonymous), rất khó đọc. Đặt tên cho callback quan trọng:rows.map(function toInvoiceLine(row) {...}).
Khi cần profile ngay trên production, làm trên một instance đã rút khỏi load balancer nếu được. Inspector có thể bật lúc runtime bằng kill -USR1 <pid> (Linux/macOS), sau đó tunnel port qua SSH (ssh -L 9229:127.0.0.1:9229 user@host). Đừng bao giờ mở --inspect=0.0.0.0 ra mạng: ai kết nối được tới port đó là chạy được code tuỳ ý trong process của bạn.
Bước 2: gỡ những thứ chặn event loop
Đây là bốn nhóm thủ phạm mình gặp nhiều nhất trong các profile.
API đồng bộ trong request path. fs.readFileSync, crypto.pbkdf2Sync, crypto.scryptSync, zlib.gzipSync chạy trên main thread. Bản async của chúng chạy trên libuv threadpool, mặc định chỉ có 4 thread (UV_THREADPOOL_SIZE), dùng chung cho fs, dns.lookup, crypto và zlib. 4 lần hash mật khẩu đồng thời có thể làm việc đọc file và resolve DNS phải xếp hàng. Nếu service hash nhiều, tăng UV_THREADPOOL_SIZE qua biến môi trường lúc khởi động (không set trong code sau khi đã dùng threadpool).
JSON lớn. JSON.parse và JSON.stringify là đồng bộ. Trên laptop của mình, parse một JSON khoảng 20 MB mất cỡ 70ms (số minh hoạ, máy production thường chậm hơn), và trong 70ms đó process không phục vụ ai khác. Cách sửa theo thứ tự ưu tiên: phân trang để đừng tạo payload lớn; stream (JSON Lines/CSV, xem bước 5); giới hạn body đầu vào (express.json() mặc định limit: '100kb', đừng tăng lên vài chục MB cho tiện); cuối cùng mới đẩy sang worker.
Regex backtracking (ReDoS). Regex có quantifier lồng nhau có thể chạy theo hàm mũ với input "gần khớp":
// Sai: ([a-z0-9]+-?)* có nhiều cách chia cùng một chuỗi -> backtrack hàm mũ
const UNSAFE_SLUG = /^([a-z0-9]+-?)*$/
// Đúng: mỗi ký tự chỉ khớp theo một cách duy nhất
const SAFE_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
const input = 'a'.repeat(29) + '!'
console.time('unsafe')
UNSAFE_SLUG.test(input)
console.timeEnd('unsafe')
console.time('safe')
SAFE_SLUG.test(input)
console.timeEnd('safe')
Trên máy mình (Node 24), bản unsafe mất khoảng 2 giây với 30 ký tự input, và mỗi ký tự thêm vào làm thời gian xấp xỉ gấp đôi. Bản safe dưới 1ms kể cả với 100.000 ký tự. Phòng thủ: giới hạn độ dài input trước khi chạy regex, bật rule regexp/no-super-linear-backtracking của eslint-plugin-regexp, và với regex do user nhập (bộ lọc, rule engine) thì dùng package re2 vì nó không backtrack.
Vòng lặp đồng bộ lớn. Tính toán trên 200.000 dòng trong một vòng for chặn loop từ đầu tới cuối. Nếu không muốn dùng worker, chia nhỏ và nhường loop:
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
type OrderRow = { totalVnd: number; status: string }
export async function sumPaidOrders(rows: OrderRow[]): Promise<number> {
let total = 0
for (let i = 0; i < rows.length; i++) {
if (rows[i].status === 'PAID') total += rows[i].totalVnd
if (i % 5_000 === 0) await yieldToEventLoop() // cho request khác chen vào
}
return total
}
Cái giá: tổng thời gian của tác vụ này tăng nhẹ và CPU vẫn tiêu trên main thread. Cách này giảm tail latency cho request khác chứ không tăng throughput.
Bước 3: đẩy CPU work sang worker_threads với Piscina
Khi tác vụ thực sự tốn CPU (parse file upload lớn, tổng hợp báo cáo, render PDF bằng JS thuần), hãy chạy nó trên worker thread. Đừng new Worker() cho mỗi request: mỗi worker là một V8 isolate riêng, tốn thời gian khởi động và vài chục MB bộ nhớ. Dùng pool, ví dụ Piscina:
// src/workers/revenue-report.ts (build ra dist/workers/revenue-report.js)
export type ReportTask = { csv: string }
export type ReportResult = Record<string, number>
export default function buildRevenueReport({ csv }: ReportTask): ReportResult {
const revenueByDay: ReportResult = {}
for (const line of csv.split('\n').slice(1)) {
const [createdAt, totalVnd, status] = line.split(',')
if (status !== 'PAID' || !createdAt) continue
const day = createdAt.slice(0, 10)
revenueByDay[day] = (revenueByDay[day] ?? 0) + Number(totalVnd)
}
return revenueByDay
}
// src/workers/pool.ts
import { availableParallelism } from 'node:os'
import { Piscina } from 'piscina'
import type { ReportResult, ReportTask } from './revenue-report.js'
export const reportPool = new Piscina<ReportTask, ReportResult>({
filename: new URL('./revenue-report.js', import.meta.url).href,
maxThreads: Number(process.env.REPORT_THREADS ?? Math.max(1, availableParallelism() - 1)),
maxQueue: 100, // hàng đợi đầy thì run() reject ngay, không phình RAM
idleTimeout: 30_000,
})
// src/routes/reports.ts
import express from 'express'
import { reportPool } from '../workers/pool.js'
export const reports = express.Router()
reports.post('/reports/revenue', express.text({ type: 'text/csv', limit: '20mb' }), async (req, res) => {
const result = await reportPool.run({ csv: String(req.body) }, { signal: AbortSignal.timeout(30_000) })
res.json(result)
})
Những điều cần biết trước khi dùng:
- Dữ liệu gửi sang worker được copy bằng structured clone. Gửi chuỗi 20 MB tức là copy 20 MB. Với dữ liệu nhị phân, dùng
transferListđể chuyển quyền sở hữuArrayBufferthay vì copy. availableParallelism()trả số CPU mà OS thấy. Trong container bị giới hạn CPU bằng quota, con số này có thể lớn hơn phần CPU bạn thực sự được dùng. Vì vậy mình cho override bằng biến môi trườngREPORT_THREADS.- Worker không tăng throughput nếu container chỉ có 1 vCPU, nhưng vẫn giữ main thread phản hồi được với request nhẹ.
maxQueuelà backpressure: queue đầy thì trả 503 cho client sớm còn hơn để hàng nghìn task chờ trong RAM.
Bước 4: kết nối, keep-alive, pool DB và timeout ở mọi nơi
Khi ELU thấp mà p99 vẫn cao, process đang chờ. Ba chỗ hay gây chờ là connection ra ngoài, pool database và timeout không được đặt.
HTTP client. fetch có sẵn (dùng undici bên dưới) và http.globalAgent từ Node 19 đã bật keep-alive mặc định. Vẫn nên có Agent riêng cho upstream quan trọng để kiểm soát số connection và timeout:
// src/http-client.ts
import { Agent, fetch } from 'undici'
const inventoryAgent = new Agent({
connections: 50, // tối đa 50 socket tới origin này
keepAliveTimeout: 10_000, // giữ socket idle 10s để tái sử dụng
connect: { timeout: 2_000 },
headersTimeout: 5_000,
bodyTimeout: 10_000,
})
export async function getStock(sku: string): Promise<{ sku: string; available: number }> {
const res = await fetch(`${process.env.INVENTORY_URL}/items/${encodeURIComponent(sku)}`, {
dispatcher: inventoryAgent,
signal: AbortSignal.timeout(3_000), // timeout tổng cho cả request
})
if (!res.ok) {
await res.body?.cancel() // không đọc body thì socket không được trả về pool kịp
throw new Error(`inventory responded ${res.status}`)
}
return (await res.json()) as { sku: string; available: number }
}
Mình import fetch và Agent từ cùng package undici để chắc chắn hai bên cùng version. Trộn Agent của npm undici với fetch có sẵn của Node có thể vướng khác biệt version.
Server phía sau load balancer. Mặc định server.keepAliveTimeout của Node là 5 giây. AWS ALB giữ connection idle tới 60 giây (mặc định). Kết quả là Node đóng socket trong khi ALB vẫn định dùng lại nó, và bạn thấy những lỗi 502 lác đác không tái hiện được:
// src/server.ts
import { app } from './app.js'
const server = app.listen(Number(process.env.PORT ?? 3000))
server.keepAliveTimeout = 65_000 // lớn hơn idle timeout của load balancer
server.headersTimeout = 20_000 // chống slowloris: client gửi header nhỏ giọt
server.requestTimeout = 30_000 // trần cho toàn bộ request (mặc định 300s)
Pool Postgres. pg.Pool mặc định max: 10 và connectionTimeoutMillis: 0, nghĩa là khi pool cạn, request chờ vô hạn. Đó là cách một query chậm biến thành cả service treo:
// src/db.ts
import pg from 'pg'
import { logger } from './logger.js'
export const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: Number(process.env.PG_POOL_MAX ?? 10),
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 2_000, // pool cạn quá 2s -> lỗi rõ ràng thay vì treo
statement_timeout: 5_000, // Postgres tự huỷ query chạy quá 5s
query_timeout: 6_000, // phía client, phòng khi network treo
application_name: 'orders-api', // nhìn thấy trong pg_stat_activity
})
// Client idle gặp lỗi (DB restart, failover): không có handler này là process crash
pool.on('error', (error) => logger.error({ err: error }, 'idle postgres client error'))
setInterval(() => {
logger.info({ total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount }, 'pg pool')
}, 30_000).unref()
Tính kích thước pool từ phía database chứ không phải từ phía app: số pod × max ≤ max_connections − phần dự trữ cho migration, cron, admin. Postgres mặc định max_connections = 100; 8 pod × max: 20 là 160 connection và bạn sẽ gặp too many clients already đúng lúc autoscale. Pool to hơn không làm query nhanh hơn. Nếu waitingCount thường xuyên lớn hơn 0, hãy tìm query chậm trước (xem bài tối ưu SQL với PostgreSQL). Nhiều instance quá thì đặt PgBouncer ở transaction mode phía trước.
Bước 5: stream và backpressure cho dữ liệu lớn
Export 2 triệu đơn hàng bằng const { rows } = await pool.query(...) rồi res.json(rows) sẽ kéo toàn bộ vào heap: vài GB RAM, một lần stringify khổng lồ, và nhiều khả năng là OOM. Stream giữ bộ nhớ ở mức một batch:
// src/routes/export-orders.ts
import type { Request, Response } from 'express'
import { pipeline } from 'node:stream/promises'
import { createGzip } from 'node:zlib'
import QueryStream from 'pg-query-stream'
import { pool } from '../db.js'
type OrderRow = { id: string; created_at: Date; total_vnd: string; status: string }
async function* toCsv(rows: AsyncIterable<OrderRow>) {
yield 'id,created_at,total_vnd,status\n'
for await (const row of rows) {
yield `${row.id},${row.created_at.toISOString()},${row.total_vnd},${row.status}\n`
}
}
export async function exportOrders(req: Request, res: Response) {
const from = typeof req.query.from === 'string' ? req.query.from : '2026-01-01'
const client = await pool.connect()
try {
const rows = client.query(
new QueryStream(
'SELECT id, created_at, total_vnd, status FROM orders WHERE created_at >= $1 ORDER BY id',
[from],
{ batchSize: 1_000 },
),
)
res.setHeader('content-type', 'text/csv; charset=utf-8')
res.setHeader('content-encoding', 'gzip')
res.setHeader('content-disposition', 'attachment; filename="orders.csv"')
await pipeline(rows, toCsv, createGzip(), res)
client.release()
} catch (error) {
client.release(true) // huỷ connection: cursor có thể còn dở
throw error // headers đã gửi -> error middleware để Express đóng connection
}
}
Backpressure là cơ chế để bên nhanh chờ bên chậm. Khi client tải chậm, buffer socket đầy, res.write() trả về false. pipeline tự dừng đọc từ gzip, gzip dừng đọc từ generator, generator dừng for await, và QueryStream không lấy batch tiếp theo từ Postgres. Nhờ vậy bộ nhớ chỉ ở mức cỡ batchSize dòng, dù export 2 triệu hay 20 triệu dòng.
Vì sao dùng pipeline từ node:stream/promises thay vì .pipe(): .pipe() không chuyển lỗi qua các stage và không huỷ stream khác khi một stage lỗi. Client đóng tab giữa chừng thì pipeline reject với ERR_STREAM_PREMATURE_CLOSE, huỷ cả chuỗi, và mình huỷ luôn connection DB. Lỗi này là bình thường, log ở info thôi.
Bước 6: cache, TTL và chống cache stampede
Cache stampede xảy ra khi một key "hot" hết hạn: 500 request đồng thời cùng miss, cùng chạy query nặng vào DB, đúng lúc DB đang chịu tải cao nhất. Mình chặn ở hai tầng:
// src/cache/product-cache.ts
import { Redis } from 'ioredis'
import { LRUCache } from 'lru-cache'
type Product = { id: string; name: string; priceVnd: number }
declare function loadProductFromDb(id: string): Promise<Product | undefined>
const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379')
// Tầng 1: trong process. fetch() gộp các lời gọi đồng thời cho cùng một key thành 1
const local = new LRUCache<string, Product>({
max: 10_000, // luôn có giới hạn, nếu không đây là memory leak
ttl: 30_000,
allowStale: true, // trả bản cũ trong lúc refresh
fetchMethod: (id) => getFromRedisOrDb(id),
})
export function getProduct(id: string): Promise<Product | undefined> {
return local.fetch(id)
}
async function getFromRedisOrDb(id: string): Promise<Product | undefined> {
const key = `product:v1:${id}`
const cached = await redis.get(key)
if (cached) return JSON.parse(cached) as Product
// Tầng 2: giữa các instance, chỉ một instance được rebuild key này
const gotLock = (await redis.set(`lock:${key}`, '1', 'PX', 5_000, 'NX')) === 'OK'
if (!gotLock) {
await new Promise((resolve) => setTimeout(resolve, 150))
const afterWait = await redis.get(key)
if (afterWait) return JSON.parse(afterWait) as Product
}
try {
const product = await loadProductFromDb(id)
if (product) {
const ttlSeconds = 300 + Math.floor(Math.random() * 60) // jitter: các key không hết hạn cùng lúc
await redis.set(key, JSON.stringify(product), 'EX', ttlSeconds)
}
return product
} finally {
if (gotLock) await redis.del(`lock:${key}`)
}
}
Cái giá và edge case:
- Cache trong process làm các pod lệch nhau tối đa bằng TTL tầng 1 (30 giây ở đây). Giá, tồn kho hay quyền truy cập mà cần chính xác ngay thì đừng cache local, hoặc dùng Redis pub/sub để invalidate.
- Lock dạng
SET NX PXở trên là bản đơn giản. Nếu load DB lâu hơn TTL của lock, instance khác sẽ vào rebuild, vàdelcó thể xoá nhầm lock của người khác. Khi cần chặt chẽ hơn, lưu token ngẫu nhiên vào lock và xoá bằng script Lua so sánh token. - Instance không lấy được lock thì chờ một nhịp rồi đọc lại. Nếu vẫn miss nó tự load: chấp nhận vài query trùng còn hơn treo request.
- Prefix version (
v1) trong key giúp "xoá" toàn bộ cache khi đổi shape dữ liệu, chỉ bằng cách tăng version.
Quy tắc: chỉ dùng cache local khi dữ liệu đọc nhiều, ít đổi và chấp nhận được lệch vài chục giây (danh mục, cấu hình). Cần nhất quán giữa các pod thì dùng Redis. Cache ở tầng HTTP/CDN có bài riêng: HTTP caching từ A tới Z.
Bước 7: bộ nhớ, logging và NODE_ENV
Memory leak: ba nguồn phổ biến
import express from 'express'
import { EventEmitter } from 'node:events'
const app = express()
const orderEvents = new EventEmitter()
// 1. Map toàn cục chỉ có thêm, không có xoá
const lastSeenByUser = new Map<string, Date>()
app.use((req, _res, next) => {
lastSeenByUser.set(String(req.headers['x-user-id']), new Date()) // mỗi user mới = 1 entry vĩnh viễn
next()
})
// 2. Listener gắn theo request vào emitter sống lâu, không bao giờ gỡ
app.get('/orders/stream', (req, res) => {
res.setHeader('content-type', 'text/event-stream')
const onUpdate = (order: { id: string }) => res.write(`data: ${JSON.stringify(order)}\n\n`)
orderEvents.on('updated', onUpdate)
req.on('close', () => orderEvents.off('updated', onUpdate)) // thiếu dòng này = leak
})
// 3. Closure giữ object lớn: interval không bao giờ clear
function startReportPolling(report: Buffer) {
return setInterval(() => console.log(report.length), 60_000) // `report` sống mãi
}
Bản sửa cho (1) là LRUCache có max hoặc đưa dữ liệu ra Redis. Với (2), Node in MaxListenersExceededWarning khi một event có quá 10 listener; đừng tắt cảnh báo đó, nó thường là leak thật. Với (3), giữ handle để clearInterval, hoặc chỉ giữ dữ liệu nhỏ mà closure thật sự cần.
Tìm leak bằng heap snapshot:
# Chụp snapshot khi nhận signal, không cần inspector
node --heapsnapshot-signal=SIGUSR2 dist/server.js
kill -USR2 <pid> # ghi file .heapsnapshot vào thư mục hiện tại
# Tự chụp khi heap sắp chạm limit (tối đa 2 file)
node --heapsnapshot-near-heap-limit=2 --max-old-space-size=1536 dist/server.js
# Xem Node thực sự chọn heap limit bao nhiêu (chạy bên trong container)
node -e "console.log(require('node:v8').getHeapStatistics().heap_size_limit / 1024 / 1024, 'MB')"
Quy trình: chụp snapshot 1 sau khi warm-up, chạy tải 10-15 phút, chụp snapshot 2, rồi mở cả hai trong tab Memory của Chrome DevTools, chọn view Comparison, sắp xếp theo Size Delta. Constructor nào tăng đều (thường là Object, Array, closure hoặc class của bạn) thì xem panel Retainers để biết ai đang giữ nó. Cái giá: chụp snapshot làm đứng process trong lúc ghi và cần thêm bộ nhớ cỡ kích thước heap. Trong container sát limit, việc chụp có thể chính là thứ làm container bị OOM-kill, nên hãy chụp trên instance đã rút khỏi traffic.
Heap limit và memory limit của container
--max-old-space-size chỉ giới hạn old space của V8. RSS thực tế còn có Buffer (bộ nhớ ngoài heap), code đã compile, stack của worker thread, native addon. Đặt --max-old-space-size bằng đúng memory limit của container thì kernel sẽ OOM-kill process (exit code 137, SIGKILL) trước khi V8 kịp báo JavaScript heap out of memory. Bạn không có log, không có snapshot.
Quy tắc của mình: heap khoảng 70-75% memory limit với service chủ yếu xử lý JSON, thấp hơn nữa nếu service xử lý nhiều Buffer (upload, ảnh, stream). Node 22.21+ và các bản 24/26 hiện tại có --max-old-space-size-percentage=75 để khỏi hard-code số MB; tài liệu nói nó tính theo phần trăm "available memory", nên hãy kiểm tra heap_size_limit bên trong container như lệnh ở trên.
Logging và NODE_ENV
console.log(JSON.stringify(order)) cho mỗi request tốn hơn bạn nghĩ: serialize trên main thread, và ghi ra stdout có thể là đồng bộ tuỳ loại đích (file, TTY, pipe) và hệ điều hành. pino nhanh hơn nhờ serializer tối ưu, có thể ghi async (pino.destination({ sync: false })) hoặc qua transport chạy trên worker thread. Hai lưu ý: không dùng pino-pretty ở production; và tham số log vẫn được tính kể cả khi level bị tắt, nên với dữ liệu đắt hãy bọc trong if (logger.isLevelEnabled('debug')).
NODE_ENV=production không phải hình thức. Express dùng nó để cache view template và ẩn stack trace trong error handler mặc định. Nhiều thư viện (React khi SSR chẳng hạn) chuyển sang build production nhanh hơn. Đặt ENV NODE_ENV=production trong Dockerfile ở stage chạy app. Bẫy: npm ci với NODE_ENV=production sẽ bỏ devDependencies, nên nếu bước build cần TypeScript thì hãy dùng multi-stage build và chỉ set biến này ở stage runtime.
Những lỗi thường gặp
Tối ưu dựa trên cảm giác, đo bằng dev build. next dev, tsx watch hay nodemon chạy chậm hơn production và có profile CPU khác hẳn. Luôn đo build production với dữ liệu thật.
Tăng pool để chữa query chậm:
// Sai: query chậm vẫn chậm, giờ DB phải chạy 50 query chậm đồng thời
new pg.Pool({ max: 50 })
// Đúng: giữ pool nhỏ, đặt timeout, tìm query bằng EXPLAIN ANALYZE và pg_stat_statements
new pg.Pool({ max: 10, connectionTimeoutMillis: 2_000, statement_timeout: 5_000 })
Gọi tuần tự những thứ độc lập:
import pLimit from 'p-limit'
// Sai: 30 SKU x 40ms = 1,2 giây
for (const sku of skus) stocks.push(await getStock(sku))
// Đúng: song song nhưng có giới hạn (p-limit) để không bắn 1.000 request cùng lúc
const limit = pLimit(10)
const stocks = await Promise.all(skus.map((sku) => limit(() => getStock(sku))))
Bỏ qua backpressure:
// Sai: client chậm -> dữ liệu dồn vào RAM của server
source.on('data', (chunk) => res.write(chunk))
// Đúng
await pipeline(source, res)
new Worker() cho mỗi request: chi phí khởi động và bộ nhớ của mỗi worker làm hệ thống chậm hơn cả khi không dùng worker. Dùng pool có maxQueue.
Cache không có giới hạn: const cache = new Map() không có max hay TTL là memory leak có chủ đích.
--max-old-space-size bằng memory limit của container: container bị OOM-kill im lặng. Chừa ít nhất 25% cho phần ngoài heap.
Checklist
- Dashboard có p95/p99 theo route và event loop delay p99, không chỉ latency trung bình.
- Có script
autocannon(hoặc tương đương) chạy với build production và dữ liệu cỡ thật. - Đã profile CPU dưới tải bằng
--cpu-profhoặc0xvà đọc Bottom-Up theo Self Time trước khi sửa. - Không có
*Sync, JSON khổng lồ hay regex có quantifier lồng nhau trong request path; body cólimit. - CPU work nặng chạy trong Piscina pool có
maxQueuevà timeout. - Mọi call ra ngoài có timeout;
keepAliveTimeoutlớn hơn idle timeout của load balancer. -
pg.PoolcóconnectionTimeoutMillis,statement_timeout, handlererror, vàsố pod × maxnhỏ hơnmax_connections. - Export/import dữ liệu lớn dùng
pipelinethay vì load hết vào RAM. - Cache có
max, TTL có jitter, key hot được chống stampede. - Heap limit khoảng 70-75% memory limit của container,
NODE_ENV=productionở stage runtime.
Đọc thêm
- Node.js: Don't block the event loop
- Node.js: perf_hooks,
monitorEventLoopDelay - Node.js CLI options (
--cpu-prof,--heapsnapshot-signal,--max-old-space-size-percentage) - autocannon, 0x, Piscina, lru-cache
- Bài liên quan: Logging và observability cho Node.js, Debugging có hệ thống với DevTools và Node.js inspector, Tối ưu SQL query với PostgreSQL