Một API đối tác bên thứ ba chậm 8 giây. Chỉ có endpoint tra phí vận chuyển gọi nó, mấy endpoint khác chẳng liên quan gì. Vậy mà nửa tiếng sau cả app đứng hình: đăng nhập chậm, giỏ hàng treo, health check timeout. Tất cả vì một dependency.
Chuyện này xảy ra đủ nhiều để mình xem nó như một định luật: trong một process, tài nguyên là của chung, và thứ gì ăn tài nguyên thì không tự biết dừng.
Tài nguyên dùng chung là gốc của tai hoạ
Node.js là một process: mọi request chia nhau RAM, connection, và trên hết là số tác vụ đang chờ cùng lúc.
Giả sử app giới hạn 50 request đồng thời để không nổ RAM. Một dependency chậm 8 giây, mỗi request đến chiếm đúng 1 slot suốt 8 giây đó. Traffic tra phí ship chỉ cần 10 request/giây là đủ phủ sạch 80 slot — nhiều hơn số mình đang có.
P99 của endpoint ship tăng thì đương nhiên. Nhưng endpoint đăng nhập cũng tăng theo, dù nó chưa bao giờ gọi API ship. Hai endpoint khác nhau nằm chung một hàng đợi thì cùng chịu một số phận.
Thêm Promise.all hay retry không cứu được gì. Vấn đề không nằm ở code gọi, mà ở chỗ không có ranh giới tài nguyên nào giữa các dependency.
Bulkhead — chia khoang như tàu thuỷ
Bulkhead là từ chỉ vách ngăn chia khoang trong thân tàu. Thủng một khoang thì chỉ khoang đó ngập, tàu vẫn nổi.
Áp vào phần mềm cũng y vậy: mỗi dependency có một pool tài nguyên riêng. API vận chuyển có 10 slot, API thanh toán có 20 slot, API gợi ý sản phẩm có 5 slot. Khoang nào đầy thì request vào khoang đó bị từ chối ngay, không được phép chen sang khoang khác hay ăn slot của cả app.
Điểm cốt lõi không phải là giới hạn mà là cô lập. Giới hạn chỉ để pool không bị ăn hết; cô lập mới giữ cho phần còn lại của hệ thống chạy đúng tốc độ khi một dependency đang giãy.
Viết bulkhead bằng TypeScript
Bản đủ dùng thật, khoảng 45 dòng:
interface BulkheadOptions {
maxConcurrent: number; // số call song song tối đa vào dependency này
maxQueue: number; // số call được phép xếp hàng chờ slot
timeoutMs: number; // chờ slot quá lâu thì bỏ, đừng để treo
}
export class BulkheadFullError extends Error {}
export class BulkheadTimeoutError extends Error {}
export class Bulkhead {
private running = 0;
private queue: Array<{ run: () => void; timer: NodeJS.Timeout }> = [];
constructor(
private readonly name: string,
private readonly opts: BulkheadOptions,
) {}
private acquire(): Promise<void> {
if (this.running < this.opts.maxConcurrent) {
this.running++;
return Promise.resolve();
}
if (this.queue.length >= this.opts.maxQueue) {
return Promise.reject(new BulkheadFullError(`[${this.name}] hàng đợi đầy`));
}
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
this.queue = this.queue.filter((w) => w.timer !== timer);
reject(new BulkheadTimeoutError(`[${this.name}] chờ slot quá lâu`));
}, this.opts.timeoutMs);
this.queue.push({ run: resolve, timer });
});
}
private release(): void {
this.running--;
const next = this.queue.shift();
if (next) {
clearTimeout(next.timer);
this.running++;
next.run();
}
}
async exec<T>(fn: () => Promise<T>): Promise<T> {
await this.acquire();
try {
return await fn();
} finally {
this.release();
}
}
}
finally là chỗ dễ sai nhất. Quên release() trong nhánh lỗi thì slot rò dần, vài giờ sau bulkhead tự khoá mình dù chẳng ai gọi gì.
Dùng ở tầng gọi service ngoài, mỗi dependency một bulkhead:
const shipping = new Bulkhead('shipping', { maxConcurrent: 10, maxQueue: 20, timeoutMs: 300 });
const payment = new Bulkhead('payment', { maxConcurrent: 20, maxQueue: 40, timeoutMs: 200 });
app.get('/checkout', async (req, res) => {
const [quote, token] = await Promise.all([
shipping.exec(() => shippingApi.quote(req.query.address)),
payment.exec(() => paymentApi.tokenize(req.body.card)),
]);
res.json({ quote, token });
});
Ship chậm 8 giây thì 10 slot của ship kẹt, nhưng 20 slot của payment vẫn rảnh. Checkout fail nhanh với lỗi rõ ràng thay vì treo cả app.
Bốn cái bẫy mình từng dính
Queue không giới hạn thì bulkhead vô nghĩa. maxQueue để mở là hàng đợi phình vô hạn, chỉ đổi chỗ chờ từ pool sang queue, RAM vẫn nổ.
Chờ slot mà không có timeout. Request xếp hàng 30 giây rồi mới chạy thì user đã bỏ đi từ lâu. Timeout ở tầng queue ngắn hơn timeout của chính call.
Đặt maxConcurrent theo cảm giác. Con số này phải lấy từ đo đạc: bao nhiêu request đồng thời giữ P99 còn chấp nhận được. Đặt thấp quá thì tự bóp throughput.
Không đếm số lần bị từ chối. Bị reject là tín hiệu dependency đang quá tải. Không log lại thì chỉ thấy app "chạy bình thường" mà không biết khoang nào đang ngập.
Đừng lẫn với chặn tần suất ở đầu vào
Chặn ở đầu vào — mỗi IP, mỗi user được bao nhiêu request mỗi phút — là để bảo vệ mình khỏi client. Bulkhead chặn ở đầu ra, quy định app được gọi cùng lúc bao nhiêu request sang một dependency, để bảo vệ mình khỏi chính dependency đó. Hai việc khác nhau, chạy chung không đụng nhau.
Bulkhead còn bổ trợ cho circuit breaker: breaker nói "dependency này hỏng, đừng gọi nữa", bulkhead nói "dependency này còn sống nhưng chậm, chỉ cho từng này call cùng lúc".
Kết
Bulkhead là pattern rẻ mà hiệu quả bất ngờ: vài chục dòng code, không cần hạ tầng gì thêm, nhưng đổi hẳn hành vi khi có sự cố. Hệ thống không nhanh hơn — nó chỉ hỏng cục bộ thay vì hỏng toàn bộ.
Nếu chưa viết tay bao giờ, hãy bắt đầu bằng đúng hai dependency hay gọi nhất: maxConcurrent lấy từ số đo thật, thêm log khi bị reject, rồi xem biểu đồ lỗi. Thấy khoang này ngập mà khoang kia vẫn thở được là hiểu ngay giá trị của nó.
📋 Phụ lục thuật ngữ
- Bulkhead — vách ngăn chia khoang: mỗi dependency có pool tài nguyên riêng, một khoang ngập không kéo cả tàu chìm.
- Cô lập tài nguyên (resource isolation) — tách pool theo từng dependency để hàng đợi của cái này không ăn slot của cái kia.
- maxConcurrent — số call song song tối đa được phép gửi sang một dependency.
- maxQueue — số call tối đa được xếp hàng chờ slot; vượt ngưỡng thì trả lỗi ngay.
- Semaphore — biến đếm cấp phát slot, bulkhead chính là một semaphore không chặn (async).
- P99 latency — độ trễ mà 99% request nằm dưới mức đó; chỉ số nhạy với đuôi chậm.