Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.
01Apa itu bulkhead pattern dan bagaimana cara menahan kegagalan?
Bulkhead pattern memberi setiap dependency atau beban kerja jatah tetapnya sendiri dari sebuah resource, seperti connection pool, slot concurrency, queue atau container. Ketika satu dependency melambat, ia hanya bisa menghabiskan jatahnya sendiri. Sisanya tetap punya kapasitas, sehingga kegagalan terkurung di satu kompartemen, seperti bagian kapal yang kebanjiran.
02Mengapa disebut bulkhead pattern?
Namanya berasal dari sekat-sekat pada lambung kapal yang disebut bulkhead. Jika lambung bocor, hanya bagian yang rusak yang terisi air dan kapal tidak tenggelam. Azure Architecture Center dari Microsoft memakai perbandingan yang sama dan juga menyebut ide ini cell-based architecture.
03Bagaimana cara menentukan ukuran bulkhead?
Gunakan Little's law: concurrency sama dengan arrival rate dikali latency. Jalur yang menerima 40 request per detik dan memakan 0,05 detik butuh 40 x 0,05 = 2 slot. Lalu tambahkan headroom untuk lonjakan, dan pastikan semua bulkhead jika dijumlahkan tetap di dalam limit nyata database atau service di belakangnya.
04Apa bedanya bulkhead dengan circuit breaker?
Bulkhead membatasi seberapa banyak kapasitas yang bisa dipakai satu dependency selagi lambat, memakai limit statis pada panggilan concurrent. Circuit breaker berhenti memanggil dependency setelah teramati gagal, dan mengujinya lagi nanti. Keduanya menyelesaikan masalah berbeda, dan panduan pattern Azure menyarankan menggabungkannya dengan retry dan throttling.
05Apakah bulkhead yang penuh sebaiknya mengantre atau menolak request?
Tolak, atau izinkan antrean yang sangat pendek saja. Antrean mengubah overload menjadi latency, yang membuat thread dan koneksi milik pemanggil tetap tertahan dan menyebarkan masalahnya. Mengembalikan HTTP 503 segera memungkinkan client melakukan backoff atau menampilkan pesan yang jelas.
Bulkhead Pattern Explained: Isolate Failures in Backend Systems
Apa itu bulkhead pattern, bagaimana satu dependency yang lambat menguras shared pool, cara isolasi dengan pool, semaphore, queue dan container, serta cara menentukan ukurannya.
Bulkhead pattern membagi resource seperti connection pool, slot concurrency, queue atau container per dependency, sehingga satu dependency yang lambat atau gagal hanya bisa menghabiskan bagiannya sendiri. Tentukan ukuran tiap bagian dengan Little's law, yaitu concurrency sama dengan arrival rate dikali latency, dan tolak kelebihan request segera, bukan mengantrekannya.
Gangguan yang paling saya khawatirkan di ERP kasir atau carwash bukan crash. Crash itu berisik dan cepat diperbaiki. Yang berbahaya adalah yang diam-diam: query laporan bulanan yang lambat menahan koneksi database, endpoint checkout menunggu giliran koneksi di belakangnya, dan kasir melihat spinner di layar yang tidak ada hubungannya dengan laporan.
Post ini menjelaskan bulkhead pattern sebagai jawaban atas kegagalan itu. Saya tidak menjalankan benchmark apa pun. Pattern dan pilihannya berasal dari Azure Architecture Center, perilaku pool dari dokumentasi node-postgres, flag container dari dokumentasi Docker, dan perhitungan ukuran dari Little's law. Satu-satunya hasil terukur adalah jumlah yang dicetak demo TypeScript yang saya jalankan, sedangkan angka lain adalah aritmetika dari asumsi yang saya sebutkan.
Apa itu bulkhead pattern?
Bulkhead memisahkan bagian-bagian aplikasi ke dalam pool yang berbeda, sehingga jika satu bagian gagal, bagian lain tetap berjalan. Namanya berasal dari sekat-sekat pada lambung kapal: jika lambung bocor, hanya bagian yang rusak yang terisi air dan kapal tetap mengapung. Azure Architecture Center dari Microsoft menjelaskannya begitu, dan menyebut ide yang sama juga dikenal sebagai cell-based architecture.
Yang dilindungi adalah resource, bukan request. Thread, koneksi database, panggilan HTTP yang sedang berjalan, atau sebagian memory jumlahnya terbatas, dan bulkhead memberi setiap dependency jatah tetapnya sendiri. Kegagalan lalu tetap berada di jatah tempat ia mulai. Ini berbeda dari retry atau timeout: keduanya menentukan apa yang dilakukan satu panggilan, sedangkan bulkhead menentukan seberapa banyak sistem yang boleh dipakai satu dependency.
Bagaimana satu dependency yang lambat menguras shared pool?
Bayangkan API NestJS di satu VPS yang semua query-nya lewat satu Pool node-postgres. Dokumentasi memberi max default 10 client, dan menyebut bahwa ketika pool penuh dan semua client sedang dipakai, request menunggu di antrean FIFO sampai ada client yang dilepas. Antrean itulah masalahnya: pemanggil yang lambat tidak gagal, ia membuat semua orang ikut mengantre.
Little's law membuat kerusakannya bisa dihitung. Rumusnya L = λW, dengan L rata-rata jumlah item di dalam sistem, λ arrival rate, dan W rata-rata waktu tiap item di dalamnya. Asumsikan checkout berjalan 40 request per detik dan menahan koneksi 0,05 detik, jadi butuh 40 × 0,05 = 2 koneksi. Asumsikan laporan datang 2 per detik dan memakan 1,5 detik, jadi butuh 2 × 1,5 = 3. Totalnya 5 dari 10 koneksi, pool yang lega.
Sekarang query laporan memburuk menjadi 30 detik. Laporan kini menginginkan 2 × 30 = 60 koneksi, padahal pool hanya punya 10. Kedatangan 2 per detik mengisi 10 slot dalam sekitar 5 detik, dan sesudahnya checkout mengantre di antrean yang sama di belakang laporan. Tidak ada yang crash dan tidak ada error. Checkout mati karena berbagi pool, tepat yang dicegah bulkhead.
Bagaimana mengimplementasikan bulkhead di TypeScript?
Bulkhead paling sederhana yang berguna adalah semaphore dengan aturan masuk: hitung panggilan yang sedang berjalan, dan ketika jumlahnya mencapai batas, biarkan sejumlah terbatas menunggu atau langsung lempar error. Class di bawah melakukan itu. Saat sebuah task selesai, ia menyerahkan slotnya langsung ke waiter berikutnya, sehingga active count tidak turun lalu naik lagi. Dengan maxQueue default 0, ia tidak pernah menunggu sama sekali.
export class BulkheadFullError extends Error {
readonly bulkhead: string;
constructor(bulkhead: string) {
super(`bulkhead "${bulkhead}" is full`);
this.bulkhead = bulkhead;
}
}
export class Bulkhead {
private active = 0;
private readonly waiters: Array<() => void> = [];
private readonly name: string;
private readonly maxConcurrent: number;
private readonly maxQueue: number;
// maxQueue 0 means: reject the moment every slot is taken.
constructor(name: string, maxConcurrent: number, maxQueue = 0) {
this.name = name;
this.maxConcurrent = maxConcurrent;
this.maxQueue = maxQueue;
}
async run<T>(task: () => Promise<T>): Promise<T> {
if (this.active < this.maxConcurrent) {
this.active++;
} else if (this.waiters.length < this.maxQueue) {
// The releasing task hands its slot straight to us, so `active` stays the same.
await new Promise<void>((resolve) => this.waiters.push(resolve));
} else {
throw new BulkheadFullError(this.name); // fail fast: nothing waits, no thread is held
}
try {
return await task();
} finally {
const next = this.waiters.shift();
if (next) next(); // pass the slot on
else this.active--; // or free it
}
}
}
Untuk melihat isolasinya, demo memberi 7 slot yang sama dalam dua susunan. Pertama, reports dan payments berbagi satu bulkhead berisi 7. Kedua, reports mendapat 3 dan payments mendapat 4. Tiap skenario menembakkan 10 panggilan report yang menggantung di sebuah gate, lalu 4 panggilan payment yang langsung menjawab. Hanya jumlah yang dicetak, karena yang dilihat adalah siapa yang diterima, bukan secepat apa.
const TOTAL_SLOTS = 7;
const REPORT_CALLS = 10; // slow dependency: every call hangs until the gate opens
const PAYMENT_CALLS = 4; // fast dependency: answers immediately
type Tally = { accepted: number; rejected: number };
function fire(box: Bulkhead, task: () => Promise<unknown>, n: number, tally: Tally) {
return Array.from({ length: n }, () =>
box.run(task).then(
() => void tally.accepted++,
(err) => {
if (!(err instanceof BulkheadFullError)) throw err;
tally.rejected++;
},
),
);
}
async function scenario(label: string, reports: Bulkhead, payments: Bulkhead) {
let open!: () => void;
const gate = new Promise<void>((resolve) => (open = resolve));
const reportTally: Tally = { accepted: 0, rejected: 0 };
const paymentTally: Tally = { accepted: 0, rejected: 0 };
const calls = [
...fire(reports, () => gate, REPORT_CALLS, reportTally), // hang, holding their slots
...fire(payments, async () => "ok", PAYMENT_CALLS, paymentTally),
];
open(); // let the hung report calls finish so the script can exit
await Promise.all(calls);
console.log(label);
console.log(` reports : sent ${REPORT_CALLS}, accepted ${reportTally.accepted}, rejected ${reportTally.rejected}`);
console.log(` payments: sent ${PAYMENT_CALLS}, accepted ${paymentTally.accepted}, rejected ${paymentTally.rejected}`);
}
const shared = new Bulkhead("shared", TOTAL_SLOTS);
await scenario(`One shared bulkhead of ${TOTAL_SLOTS}`, shared, shared);
await scenario(
"Two bulkheads: reports 3, payments 4",
new Bulkhead("reports", 3),
new Bulkhead("payments", 4),
);
Outputnya asli, dari menjalankan file dengan Node 26.10.0. Dengan satu shared bulkhead, 10 panggilan report yang menggantung mengambil ke-7 slot dan 3 ditolak, sehingga tidak ada slot untuk payments: keempatnya ditolak. Dengan pemisahan, reports dibatasi 3 dan 7 panggilannya sendiri ditolak, sementara keempat payments diterima. Total kapasitasnya sama, hanya pembagiannya yang berubah.
$ node bulkhead.ts
One shared bulkhead of 7
reports : sent 10, accepted 7, rejected 3
payments: sent 4, accepted 0, rejected 4
Two bulkheads: reports 3, payments 4
reports : sent 10, accepted 3, rejected 7
payments: sent 4, accepted 4, rejected 0
Resource apa saja yang bisa dipisah menjadi bulkhead?
Halaman pattern Azure menyebut beberapa level isolasi, dan masing-masing menukar kekuatan dengan biaya. Tabel di bawah mengurutkannya dari yang termurah ke yang terkuat. Pilih level termurah yang tidak bisa dilintasi oleh kegagalan yang Anda takutkan.
Level
Yang diisolasi
Biaya
Pakai ketika
Connection pool terpisah
Koneksi database atau HTTP per dependency
Hampir gratis, tetapi total pool harus tetap di bawah limit server
Satu database melayani beban kerja dengan bobot sangat berbeda
Semaphore concurrency
Panggilan yang sedang berjalan di dalam satu process
Beberapa baris kode, tanpa overhead process
Satu process memanggil beberapa service downstream
Queue dan worker terpisah
Pekerjaan asynchronous seperti email, export, webhook
Satu queue per jenis pekerjaan, plus worker-nya
Backlog satu jenis job tidak boleh menunda yang lain
Process atau container terpisah
CPU, memory dan crash
Overhead memory lebih besar, lebih banyak yang di-deploy dan dimonitor
Laporan yang lepas kendali bisa menghabiskan seluruh mesin
Di satu VPS, level container adalah tempat batas hardware ditegakkan. Docker mendokumentasikan memory sebagai jumlah maksimum yang boleh dipakai container, dan --cpus sebagai seberapa banyak CPU yang tersedia boleh dipakai container, setara dengan mengatur CPU period dan quota sekaligus. Mengatur --memory-swap sama dengan --memory menonaktifkan swap untuk container, sehingga report worker yang bocor tidak diam-diam mendorong host ke swap. Ukuran di bawah hanya ilustrasi.
# Separate containers, each with its own ceiling. Sizes are illustrative.
# --memory-swap equal to --memory disables swap for the container (Docker docs).
# --cpus=0.5 caps the container at half of one CPU.
docker run -d --name api --memory=512m --memory-swap=512m --cpus=1.0 myorg/api
docker run -d --name report-worker --memory=256m --memory-swap=256m --cpus=0.5 myorg/report-worker
Halaman Azure juga memberi contoh Kubernetes dengan requests dan limits untuk memory dan CPU, dan mencatat bahwa container menawarkan keseimbangan isolasi dan overhead rendah yang baik. Ia menambahkan peringatan yang layak diperhatikan: jika platform sudah menyediakan throttling dan isolasi, seperti rate limit API gateway, pakai itu dan jangan membangunnya ulang di kode aplikasi.
Seberapa besar tiap bulkhead seharusnya?
Mulailah dari Little's law dan hitung mundur. Kebutuhan steady-state sebuah dependency adalah arrival rate dikali latency, yang tadi menghasilkan 2 koneksi untuk checkout dan 3 untuk laporan. Bulkhead yang diset tepat di angka itu akan menolak panggilan di setiap lonjakan kecil, jadi tambahkan headroom. Faktornya penilaian, bukan hukum: saya akan memberi jalur cepat yang kritis sekitar tiga kali kebutuhannya, dan jalur lambat yang opsional hanya sedikit di atas kebutuhannya.
Diterapkan pada anggaran 10 koneksi tadi, checkout mendapat 6 (tiga kali kebutuhannya yang 2) dan laporan mendapat 4 (sepertiga di atas kebutuhannya yang 3), dan 6 + 4 = 10, jadi kedua pool tetap di dalam anggaran. Jika laporan memburuk menjadi 30 detik, paling banyak 4 panggilan report berjalan dan sisanya ditolak, sementara checkout tetap memegang 6 miliknya. Laporan jadi lebih buruk dan checkout tidak tersentuh, itulah trade-off yang dimaksud.
Dua pool di node-postgres adalah dua objek Pool yang terpisah, masing-masing dengan max sendiri. Baca properti waitingCount milik pool, yang menurut dokumentasi menunjukkan jumlah request dalam antrean, karena pool yang sering punya waiter berarti ukurannya terlalu kecil untuk trafiknya.
import { Pool } from "pg";
// Wrong: one pool, default max of 10, shared by checkout and reports.
// export const db = new Pool();
// Right: two pools whose max values add up to the budget of 10.
export const checkoutPool = new Pool({ max: 6 }); // need 2 (40/s x 0.05 s), headroom x3
export const reportPool = new Pool({
max: 4, // need 3 (2/s x 1.5 s), a third above it
connectionTimeoutMillis: 2000, // docs: the default 0 means no timeout
});
// pool.waitingCount is the number of queued requests: export it as a metric.
export const poolStats = () => ({
checkoutWaiting: checkoutPool.waitingCount,
reportWaiting: reportPool.waitingCount,
});
Ukur jumlah semua bulkhead terhadap limit nyata di belakangnya, bukan masing-masing sendirian. Jika empat pool berisi 10 menuju database yang menerima kurang dari 40 koneksi, bulkhead itu hanya hiasan dan database menjadi shared pool-nya.
Apakah bulkhead yang penuh harus mengantre atau menolak?
Tolak, atau antrekan hanya sangat sedikit. Antrean mengubah overload menjadi latency, dan latency itulah yang menahan resource milik pemanggil tetap terbuka. Dokumentasi Resilience4j punya kecenderungan yang sama: semaphore bulkhead-nya default maxConcurrentCalls 25 dan maxWaitDuration 0, jadi bulkhead yang penuh langsung menolak kecuali Anda mengatur waktu tunggu.
Di lapisan API, penolakan sebaiknya menjadi respons yang jujur dan murah. HTTP 503 berarti service tidak bisa menangani request saat ini, sehingga pemanggil atau antarmuka bisa menampilkan pesan yang jelas atau mencoba lagi sebentar lagi, bukan membeku. Kode di bawah membungkus query laporan dalam bulkhead sendiri dan memetakan kondisi penuh ke 503.
import { Inject, Injectable, ServiceUnavailableException } from "@nestjs/common";
import type { Pool } from "pg";
import { Bulkhead, BulkheadFullError } from "./bulkhead";
@Injectable()
export class ReportService {
// 4 slots, no queue: the fifth concurrent report is refused, not parked.
private readonly box = new Bulkhead("reports", 4);
constructor(@Inject("REPORT_POOL") private readonly pool: Pool) {}
async monthly(branchId: number) {
try {
return await this.box.run(() =>
this.pool.query("SELECT * FROM monthly_sales($1)", [branchId]),
);
} catch (err) {
if (err instanceof BulkheadFullError) {
// 503: the service cannot take this right now. Cheap, immediate, honest.
throw new ServiceUnavailableException("Report capacity reached, retry shortly");
}
throw err;
}
}
}
Jangan retry panggilan yang ditolak dalam loop rapat. Penolakan berarti partisi penuh, dan retry instan dari setiap client membangun kembali tekanan yang baru saja dihilangkan bulkhead. Retry dengan backoff dan jitter, atau biarkan pengguna menekan tombolnya lagi.
Bagaimana mengamati sebuah bulkhead?
Bulkhead yang tidak diawasi gagal secara diam-diam, karena penolakannya tampak seperti error biasa. Lacak empat angka per bulkhead: panggilan berjalan dibanding limit, panggilan menunggu, penolakan per interval, dan latency panggilan yang diterima. Pada class demo itu berarti mengekspos active count dan counter rejected, dan pada node-postgres berarti total pool dan waitingCount.
Beri alert pada rejection rate dan saturasi yang berkelanjutan, bukan satu lonjakan. Penolakan naik dengan latency panggilan diterima normal berarti limit terlalu rendah atau permintaan tumbuh. Penolakan naik dengan latency diterima ikut naik berarti dependency-nya sendiri memburuk, dan itu sinyal untuk memeriksa dependency, bukan bulkhead. Halaman Azure memberi saran yang sama untuk memonitor performa dan service-level agreement tiap partisi.
Bulkhead vs circuit breaker: kapan memakai yang mana?
Keduanya melindungi dari hal yang berbeda dan memang dimaksudkan untuk digabung, seperti dinyatakan langsung oleh halaman Azure. Bulkhead membatasi seberapa banyak yang bisa dipakai sebuah dependency selagi lambat. Circuit breaker berhenti memanggil dependency setelah terbukti gagal. Post saya tentang circuit breaker pattern di NestJS membahas yang kedua; tabel ini membandingkan keduanya.
Seberapa banyak kapasitas saya boleh dipakai dependency ini
Haruskah saya memanggil dependency ini sama sekali saat ini
Pemicu
Limit statis pada panggilan concurrent atau panjang queue
Rasio kegagalan atau timeout yang teramati dalam suatu window
Melindungi dari
Kelambatan yang menahan resource, dan tetangga yang berisik
Panggilan berulang ke sesuatu yang sudah gagal
Stateful
Hanya hitungan panggilan yang sedang berjalan
Ya: state closed, open dan half-open
Pakai bulkhead setiap kali dua beban kerja dengan tingkat kepentingan berbeda berbagi resource yang terbatas. Pakai checklist ini untuk menentukan di mana partisinya diletakkan.
Daftar resource terbatas di belakang service: koneksi database, panggilan HTTP keluar, CPU dan memory, slot worker.
Tandai tiap beban kerja sebagai kritis (checkout, payments, login) atau opsional (laporan, export, analytics).
Hitung kebutuhan tiap beban kerja sebagai arrival rate dikali latency, lalu tambahkan headroom.
Pastikan jumlah semua limit tidak melebihi limit nyata dari resource di belakangnya.
Jadikan penolakan, bukan menunggu, sebagai default, kembalikan 503, dan beri alert pada rejection rate.
Shared pool adalah janji bahwa tidak ada yang akan menguasainya, dan dependency paling lambat adalah yang mengingkari janji itu. Beri setiap dependency jatahnya sendiri, tentukan ukurannya dengan arrival rate dikali latency, tolak cepat ketika penuh, dan awasi rejection rate. Kegagalan lalu tetap berada di kompartemen tempat ia bermula.