Node.js Production Engineering 21 — Reliability và Overload Control
Giữ service ổn định khi dependency chậm hoặc traffic tăng: deadline, cancellation, concurrency budget, load shedding, retry, circuit breaker và graceful shutdown.
11:58, một dependency bắt đầu chậm từ 80 ms lên 4 giây. Order API vẫn nhận request mới, vẫn retry và vẫn giữ connection database cho những request gần như chắc chắn sẽ timeout. Hai phút sau dependency hồi phục, nhưng Order API không hồi: queue nội bộ đã dài, pool đã kín và client retry tạo thêm traffic.
Sự cố không còn là “dependency chậm”. Nó đã trở thành overload do chính hệ thống khuếch đại.
Reliability không có nghĩa mọi request đều thành công. Khi demand vượt capacity, một service tốt phải bảo vệ phần công việc còn có thể hoàn tất: từ chối sớm, hủy công việc vô nghĩa, không retry mù và hồi phục nhanh sau khi áp lực giảm.
Sau bài này, bạn có thể:
- biến timeout rời rạc thành một deadline end-to-end;
- truyền cancellation qua HTTP, database và queue boundary;
- đặt concurrency budget thay vì mở Promise không giới hạn;
- load shed bằng
429/503trước khi resource cạn; - phối hợp retry, circuit breaker và bulkhead mà không tạo retry storm;
- test graceful shutdown dưới tải bằng acceptance criteria rõ.
1. Mental model: service có một capacity envelope hữu hạn
Mỗi request tiêu nhiều budget:
request
├─ 1 HTTP connection
├─ 1 vị trí trong in-flight budget
├─ 0..1 database connection
├─ memory cho body/result/context
├─ event-loop time
└─ downstream calls + retry
Capacity không chỉ là CPU. Một service dùng 25% CPU vẫn có thể bão hòa vì PostgreSQL pool kín, socket chờ upstream, heap tăng hoặc event loop bị giữ bởi serialization.
Theo Little’s Law, số công việc đang nằm trong hệ thống gần bằng throughput nhân thời gian ở lại:
concurrency ≈ throughput × latency
Nếu traffic giữ 500 request/giây nhưng latency tăng từ 100 ms lên 4 giây, in-flight work tăng xấp xỉ từ 50 lên 2.000. Không cần traffic spike; latency spike tự tạo concurrency spike.
Mục tiêu vì vậy không phải “queue mọi thứ”. Mục tiêu là giữ:
admitted work ≤ capacity có thể hoàn tất trong deadline
2. Deadline khác timeout
Timeout thường là giới hạn cục bộ: database query tối đa 500 ms. Deadline là thời điểm operation toàn bộ không còn giá trị.
Client budget: 1.500 ms
gateway: 100 ms
order logic: 150 ms
inventory: 500 ms
payment: 500 ms
response: 100 ms
safety margin: 150 ms
Sai:
gateway timeout 2 s
└─ order timeout 2 s
└─ payment timeout 2 s
Request đã hết budget ở gateway nhưng downstream vẫn làm việc thêm nhiều giây. Đúng hơn là truyền deadline còn lại và chừa budget để trả response/cleanup.
class Deadline {
constructor(readonly expiresAt: number) {}
remainingMs(): number {
return Math.max(0, this.expiresAt - Date.now());
}
signal(maxStepMs: number, parent?: AbortSignal): AbortSignal {
const stepBudget = Math.max(1, Math.min(maxStepMs, this.remainingMs()));
const timeout = AbortSignal.timeout(stepBudget);
return parent ? AbortSignal.any([parent, timeout]) : timeout;
}
}
Đây là skeleton. Clock giữa service có thể lệch; internal protocol cần convention rõ: truyền absolute deadline với clock discipline, hoặc duration budget được trừ ở từng hop. Không tin một header deadline tùy ý từ public client để cấp resource vô hạn.
3. Cancellation phải đi tới nơi đang giữ resource
Trả 504 nhưng query vẫn chạy, upload vẫn đọc và job vẫn ghi dữ liệu không phải
cancellation. Đó chỉ là ngừng chờ kết quả.
Trong HTTP handler, nối client disconnect với operation signal:
function requestAbortSignal(
req: import('node:http').IncomingMessage,
res: import('node:http').ServerResponse
): AbortSignal {
const controller = new AbortController();
req.once('aborted', () => controller.abort(new Error('request aborted')));
res.once('close', () => {
if (!res.writableEnded) {
controller.abort(new Error('client disconnected'));
}
});
return controller.signal;
}
Sau đó truyền signal qua adapter nào hỗ trợ:
const requestSignal = requestAbortSignal(req, res);
const deadline = new Deadline(Date.now() + 1_200);
const response = await fetch(inventoryUrl, {
signal: deadline.signal(450, requestSignal),
});
Không phải database driver nào cũng hỗ trợ AbortSignal. Khi không hỗ trợ, cần
statement timeout phía database và connection/pool policy tương ứng. Hủy ở
application mà server database vẫn chạy query đắt chỉ chuyển leak sang tầng khác.
Sau khi side effect đã commit, client disconnect không được “rollback bằng hy vọng”. Operation tạo order/payment cần idempotency key để client có thể hỏi lại kết quả an toàn.
4. Concurrency budget: queue hữu hạn trước dependency hữu hạn
Promise.all() trên 10.000 item không tạo 10.000 CPU core. Nó tạo 10.000
operation cạnh tranh socket, pool và memory.
Đặt budget theo bottleneck:
Postgres pool: 30 connections
reserved cho health/admin: 3
long transaction allowance: 2
query concurrency cho HTTP: 25
Một admission gate tối thiểu:
class ConcurrencyGate {
#active = 0;
constructor(private readonly limit: number) {}
tryEnter(): (() => void) | undefined {
if (this.#active >= this.limit) return undefined;
this.#active += 1;
let released = false;
return () => {
if (released) return;
released = true;
this.#active -= 1;
};
}
get active(): number {
return this.#active;
}
}
const checkoutGate = new ConcurrencyGate(80);
async function checkout(req: Request, res: Response) {
const release = checkoutGate.tryEnter();
if (!release) {
res.set('Retry-After', '1').status(503).json({
code: 'CHECKOUT_OVERLOADED',
message: 'Checkout is temporarily busy. Retry safely.',
});
return;
}
try {
await handleCheckout(req, res);
} finally {
release();
}
}
Production có thể cần queue ngắn thay vì chỉ tryEnter, nhưng queue phải có:
- chiều dài tối đa;
- thời gian chờ tối đa nhỏ hơn request deadline;
- metric active/queued/rejected;
- fairness/priority có chủ đích;
- cancellation xóa waiter khỏi queue.
Một queue không giới hạn chỉ đổi overload từ connection sang heap.
5. Load shedding là hành vi đúng, không phải thất bại xấu hổ
Từ chối 2% request sớm có thể giữ 98% còn lại dưới SLO. Nhận 100% rồi để 70% timeout chậm là kết quả tệ hơn cho user và hệ thống.
Chọn tín hiệu admission có quan hệ nhân quả:
| Tín hiệu | Có thể bảo vệ |
|---|---|
| in-flight theo route | handler/dependency budget |
| DB pool wait | data layer |
| queue age/depth | worker throughput |
| event-loop delay | JavaScript execution |
| RSS/heap pressure | memory envelope |
| downstream circuit state | dependency failure domain |
429 Too Many Requests phù hợp khi caller/tenant vượt quota. 503 Service Unavailable phù hợp khi service tạm không có capacity. Retry-After chỉ hữu ích
khi client thực sự tuân thủ và retry là an toàn.
Đừng chỉ shed request rẻ và giữ request đắt. Có thể chia lane:
critical checkout lane ── budget riêng ──▶ payment
admin export lane ── queue riêng ──▶ worker
analytics lane ── drop/defer ──▶ warehouse
Đó là bulkhead: một workload không dùng hết resource của workload quan trọng hơn.
6. Retry có ba điều kiện
Chỉ retry khi:
- lỗi có khả năng tạm thời;
- operation an toàn khi lặp hoặc có idempotency key;
- còn đủ deadline và retry budget.
import { setTimeout } from 'node:timers/promises';
async function retryTransient<T>(
operation: (signal: AbortSignal) => Promise<T>,
deadline: Deadline,
parent: AbortSignal
): Promise<T> {
const backoffs = [40, 100];
let lastError: unknown;
for (let attempt = 0; attempt <= backoffs.length; attempt += 1) {
if (deadline.remainingMs() < 120) break;
try {
return await operation(deadline.signal(400, parent));
} catch (error) {
lastError = error;
if (!isTransient(error) || attempt === backoffs.length) break;
const jitterMs = Math.random() * backoffs[attempt];
await setTimeout(backoffs[attempt] + jitterMs, undefined, {
signal: deadline.signal(backoffs[attempt] * 2, parent),
});
}
}
throw lastError;
}
Ví dụ dùng setTimeout từ node:timers/promises; isTransient phải dựa vào error
contract thật, không retry mọi 5xx. 400, authentication failure, validation
error và invariant conflict thường không tự biến mất.
Chỉ retry ở một tầng sở hữu policy. Nếu SDK retry 3 lần, service retry 3 lần
và gateway retry 3 lần, một request có thể thành 27 attempts. Theo dõi
attempts_per_operation, không chỉ request count.
7. Circuit breaker không chữa dependency
Circuit breaker ngừng gửi call khi xác suất thành công quá thấp:
closed ── failure threshold ──▶ open
▲ │
└──── probe success ◀── half-open after cooldown
Nó bảo vệ caller khỏi chờ vô ích và cho dependency không gian hồi phục. Nhưng breaker cần scope đúng:
- theo dependency/operation, không phải một global switch cho mọi endpoint;
- volume tối thiểu để 2 lỗi đầu tiên không mở circuit;
- half-open probe hữu hạn;
- metric state transition/rejection;
- fallback chỉ khi semantics cho phép.
Trả giá cache cũ có gắn stale có thể hợp lệ. “Giả vờ payment thành công” không
bao giờ là fallback.
Circuit breaker, retry và timeout cùng dùng một budget. Thêm từng library riêng lẻ mà không có contract chung thường tạo state machine không ai hiểu khi incident.
8. Graceful shutdown là overload control theo thời gian
Khi nhận SIGTERM, instance phải:
- đổi readiness để ngừng traffic mới;
- ngừng nhận HTTP connection/request mới;
- ngừng lấy job mới;
- drain in-flight trong deadline;
- đóng dependency và flush telemetry;
- force-close khi budget hết.
let shuttingDown = false;
async function shutdown(signal: NodeJS.Signals) {
if (shuttingDown) return;
shuttingDown = true;
readiness.set(false);
const forceTimer = setTimeout(() => {
server.closeAllConnections();
process.exit(1);
}, 25_000);
forceTimer.unref();
try {
await Promise.all([closeServer(server), workers.close()]);
await database.end();
await telemetrySdk.shutdown();
} finally {
clearTimeout(forceTimer);
}
}
process.once('SIGTERM', () => void shutdown('SIGTERM'));
process.once('SIGINT', () => void shutdown('SIGINT'));
server.close() ngừng nhận connection mới và drain HTTP phù hợp, nhưng upgraded
protocol như WebSocket cần tracking/close riêng. closeAllConnections() là force
tool ở cuối budget, không phải bước đầu.
Trong Kubernetes, preStop và shutdown cùng tiêu
terminationGracePeriodSeconds; hook dài lấy mất thời gian drain. Readiness,
EndpointSlice removal và load balancer propagation không tức thời, nên test luồng
thật trên platform thay vì dựa vào một sleep truyền miệng.
9. Observability cho overload
Dashboard tối thiểu nối demand → saturation → outcome:
- request rate, good/bad-event ratio, p50/p95/p99;
- in-flight, queued, rejected theo operation/priority;
- deadline exceeded và cancellation theo source;
- retry attempts, circuit state/rejection;
- DB pool active/wait duration;
- event-loop delay/utilization, heap/RSS;
- shutdown duration, forced connection/job count.
Alert theo user impact/error-budget burn. Saturation signal giúp chẩn đoán và capacity planning; CPU 80% tự nó chưa nói user đang hỏng.
Log rejection bằng reason có tập giá trị hữu hạn:
logger.warn(
{
event: 'admission.rejected',
operation: 'checkout',
reason: 'concurrency_limit',
active: checkoutGate.active,
limit: 80,
},
'checkout rejected before execution'
);
Không log mỗi rejection ở traffic lớn nếu nó tạo log storm; dùng counter và log sampling.
10. Game day: làm dependency chậm có kiểm soát
Kịch bản:
- Baseline 300 RPS, payment p99 100 ms, checkout SLO dưới 800 ms.
- Proxy fault làm payment latency thành 3 giây trong 5 phút.
- So sánh hai bản: không admission/retry budget và có reliability policy.
- Xác minh bản có policy giữ heap, pool wait và in-flight trong envelope.
- Xác minh request bị từ chối sớm có error contract/idempotency instruction rõ.
- Hồi phục dependency; đo thời gian service trở lại SLO và kiểm retry storm.
- Gửi
SIGTERMgiữa bài test; kiểm traffic chuyển instance và job không duplicate.
Acceptance criteria không phải “zero error”. Nó là:
- resource không tăng không giới hạn;
- critical journey giữ mục tiêu đã định hoặc fail nhanh;
- không có side effect mồ côi/duplicate;
- service hồi phục mà không restart cưỡng bức;
- dashboard giải thích được admission decision.
11. Checklist trước khi ship
- Mỗi user journey có deadline end-to-end và safety margin.
- Client disconnect/timeout hủy được công việc tới adapter có thể hủy.
- Mỗi resource hữu hạn có concurrency/queue budget tương ứng.
- Queue hữu hạn cả size và wait time; rejection có contract.
- Retry chỉ cho transient + idempotent + còn budget, và chỉ một tầng sở hữu.
- Circuit/fallback giữ đúng business semantics.
- Workload quan trọng có bulkhead/priority riêng.
- Shutdown đổi readiness, drain rồi mới đóng dependency/telemetry.
- Có metric cho saturation, rejection, retry và recovery time.
- Đã chạy slow-dependency + SIGTERM game day dưới tải.
Nếu chỉ nhớ 5 điều
- Latency tăng sẽ tự làm concurrency tăng dù traffic không đổi.
- Deadline là budget end-to-end; timeout cục bộ không đủ.
- Cancellation chỉ có thật khi resource bên dưới ngừng làm việc.
- Load shedding sớm bảo vệ phần traffic còn có thể hoàn tất.
- Retry, circuit breaker, bulkhead và shutdown phải dùng chung một reliability contract.
Tài liệu chính thức
- Node.js HTTP API
- Node.js globals: AbortController và AbortSignal
- Node.js timers/promises
- Kubernetes: Pod lifecycle
- Kubernetes: Container lifecycle hooks
- Kubernetes: Liveness, readiness và startup probes
- Google SRE Book: Handling overload
Phần tiếp theo
Overload control giữ số công việc trong capacity envelope. Phần 22 áp nguyên lý đó vào byte: xây upload, export và transform pipeline có backpressure để file 20 GB không biến thành 20 GB heap, đồng thời xử lý abort, partial failure và cleanup.