Bulkhead
บริการของคุณติดต่อกับ dependency หลายตัว ตัวอย่างเช่น หน้า product page อาจเรียก catalog service, reviews service และ recommendations service เพื่อประกอบเป็น response เดียว การเรียกทั้งสามใช้ทรัพยากรชุดเดียวกันที่มีจำกัดภายใน process ของคุณ: thread pool เดียวกันที่จัดการ request ที่เข้ามา, connection pool ของฐานข้อมูลเดียวกัน, จำนวน HTTP connection ที่กำลังทำงานคงที่จำนวนเดียวกัน
การแชร์นั้นมองไม่เห็นและสะดวก — จนกระทั่ง dependency ตัวหนึ่งมีพฤติกรรมผิดปกติ
สมมติว่า recommendations service ซึ่งสำคัญน้อยที่สุดในสามตัว เริ่มทำงานช้า ทุก request ที่เรียกไปจะจอด thread ทิ้งไว้จนกว่าจะ timeout และเพราะทุก dependency ดึง thread จาก pool ที่แชร์กันชุดเดียว thread ที่ถูกจอดไว้จึงไม่ใช่ “thread ของ recommendations” แต่เป็น thread ชุดเดียวกัน กับที่การเรียก catalog และ reviews ต้องใช้ พอ recommendations request สะสมมากขึ้น ก็กิน pool จนหมด ทีนี้ request ของ catalog ที่ยังแข็งแรงดีก็ขอ thread ไม่ได้เช่นกัน สรุปคือความล้มเหลวของ dependency ที่สำคัญน้อยที่สุด ล้มตัวที่สำคัญที่สุดลงไปด้วย
นี่คือปัญหาการแย่งทรัพยากรผ่าน pool ที่แชร์กัน dependency ตัวเดียวที่ตะกละหรือติดขัดทำให้ตัวอื่นอดอยากทั้งหมด และ circuit breaker เพียงอย่างเดียวก็แก้ไม่ครบ เพราะมีช่วงเวลาก่อน breaker จะ trip ที่ pool ที่แชร์กันถูกดูดจนแห้งไปแล้ว
แรงที่ขัดกันจึงเป็นแบบนี้ คุณมีทรัพยากรจำกัด มี dependency หลายตัวแย่งกันใช้ และคุณต้องการจำกัดโควตาของ dependency ที่ช้า ไม่ให้กินส่วนแบ่งที่งานอื่นซึ่งยังแข็งแรงดีต้องใช้
วิธีแก้
หัวข้อที่มีชื่อว่า “วิธีแก้”bulkhead แบ่งกั้นทรัพยากรเพื่อให้ dependency แต่ละตัวดึงจาก pool ที่มีขอบเขตของตัวเองแทนที่จะเป็น pool ที่แชร์กันชุดเดียว ชื่อนี้มาจากการต่อเรือ: ตัวเรือถูกแบ่งออกเป็นช่องกันน้ำด้วย bulkhead เพื่อให้รอยรั่วในช่องหนึ่งท่วมเฉพาะช่องนั้นและเรือยังคงลอยอยู่ ในซอฟต์แวร์ คุณมอบ thread pool, connection pool หรือ — อย่างง่ายที่สุด — bounded semaphore ให้ dependency แต่ละตัว ซึ่งจำกัดจำนวนการเรียกแบบ concurrent ที่ไปยัง dependency นั้นที่กำลังทำงานพร้อมกันได้
พอ recommendations dependency อิ่มตัว สิ่งที่ยึดไปได้มากที่สุดคือ permit ในช่อง ของตัวเอง เท่านั้น เมื่อ permit หมด การเรียก recommendation ครั้งถัดไปจะโดนปฏิเสธทันที แล้ว fall back ไปเป็น “ไม่มี recommendations” ได้ ส่วนช่องของ catalog และ reviews ไม่ถูกแตะเลย ความเสียหายถูกจำกัดวงอยู่แค่นั้น
flowchart TB
R[Incoming requests] --> C{Per-dependency<br/>bulkheads}
C --> CAT
C --> REV
C --> REC
subgraph CAT[Catalog compartment]
CP[8 permits] --> CS[Catalog service]
end
subgraph REV[Reviews compartment]
VP[4 permits] --> VS[Reviews service]
end
subgraph REC[Recommendations compartment]
RP[2 permits — saturated] --> RS[Recommendations service]
end
RP -. "extra calls rejected fast" .-> FB[Fallback: no recs] ตัวอย่าง
หัวข้อที่มีชื่อว่า “ตัวอย่าง”นี่คือ bulkhead ที่ implement เป็น bounded semaphore: การเรียกไปยัง dependency หนึ่งจะรันพร้อมกันได้มากที่สุด N ครั้ง และผู้เรียกที่ไม่สามารถได้ permit ทันทีจะถูกปฏิเสธแทนที่จะถูกปล่อยให้กองพะเนิน แต่ละตัวอย่างเป็นอิสระในตัวเองและเขียนตามแบบฉบับของแต่ละภาษา
class BulkheadFullError extends Error {}
class Bulkhead { private inFlight = 0; constructor(private readonly limit: number) {}
async run<T>(fn: () => Promise<T>): Promise<T> { if (this.inFlight >= this.limit) { throw new BulkheadFullError('bulkhead full'); } this.inFlight += 1; try { return await fn(); } finally { this.inFlight -= 1; } }}
// One compartment per dependency — sized independently.const catalog = new Bulkhead(8);const reviews = new Bulkhead(4);const recs = new Bulkhead(2);
const recommendations = await recs .run(() => recsClient.fetch(productId)) .catch(() => []); // recommendations saturated → degrade, do not block catalogimport asynciofrom typing import Awaitable, Callable, TypeVar
T = TypeVar("T")
class BulkheadFullError(Exception): pass
class Bulkhead: def __init__(self, limit: int): self._sem = asyncio.Semaphore(limit)
async def run(self, fn: Callable[[], Awaitable[T]]) -> T: # Reject immediately instead of queuing when full. if self._sem.locked(): raise BulkheadFullError("bulkhead full") acquired = self._sem.acquire() await asyncio.wait_for(acquired, timeout=0) try: return await fn() finally: self._sem.release()
# One compartment per dependency — sized independently.catalog = Bulkhead(8)reviews = Bulkhead(4)recs = Bulkhead(2)
try: recommendations = await recs.run(lambda: recs_client.fetch(product_id))except BulkheadFullError: recommendations = [] # degrade, do not block catalogvar ErrBulkheadFull = errors.New("bulkhead full")
// A buffered channel of tokens is an idiomatic Go semaphore.type Bulkhead struct { tokens chan struct{}}
func NewBulkhead(limit int) *Bulkhead { return &Bulkhead{tokens: make(chan struct{}, limit)}}
func (b *Bulkhead) Run(fn func() error) error { select { case b.tokens <- struct{}{}: // acquire a permit, or... defer func() { <-b.tokens }() return fn() default: // ...reject immediately when full. return ErrBulkheadFull }}
// One compartment per dependency — sized independently.var ( catalog = NewBulkhead(8) reviews = NewBulkhead(4) recs = NewBulkhead(2))
if err := recs.Run(func() error { return fetchRecs(productID) }); err != nil { recommendations = nil // degrade, do not block catalog}use std::future::Future;use std::sync::Arc;use tokio::sync::Semaphore;
pub struct Bulkhead { sem: Arc<Semaphore>,}
impl Bulkhead { pub fn new(limit: usize) -> Self { Self { sem: Arc::new(Semaphore::new(limit)) } }
pub async fn run<T, F, Fut>(&self, fn_: F) -> Result<T, &'static str> where F: FnOnce() -> Fut, Fut: Future<Output = T>, { // try_acquire rejects immediately instead of queuing when full. match self.sem.clone().try_acquire_owned() { Ok(_permit) => Ok(fn_().await), // permit released on drop Err(_) => Err("bulkhead full"), } }}
// One compartment per dependency — sized independently.let catalog = Bulkhead::new(8);let reviews = Bulkhead::new(4);let recs = Bulkhead::new(2);
let recommendations = recs .run(|| fetch_recs(product_id)) .await .unwrap_or_default(); // degrade, do not block catalogผลลัพธ์ที่ตามมา
หัวข้อที่มีชื่อว่า “ผลลัพธ์ที่ตามมา”สิ่งที่คุณได้รับ:
- fault isolation dependency ที่ช้าหรือล้มเหลวกินได้มากสุดแค่ทรัพยากรในช่องของตัวเอง ส่วน dependency ที่ยังแข็งแรงก็รักษาโควตาของตัวเองไว้ได้ ความล้มเหลวจุดเดียวจึงไม่ลากทั้ง service ลงไปอีกต่อไป
- การใช้ทรัพยากรที่คาดเดาได้ ความ concurrency สูงสุดของ dependency แต่ละตัวถูกระบุชัดเจนและมีขอบเขต ซึ่งทำให้การวางแผนกำลังและการ load testing จัดการได้ง่ายขึ้นมาก — คุณรู้กรณีเลวร้ายที่สุดของแต่ละช่อง
- การเสื่อมประสิทธิภาพอย่างนุ่มนวล (graceful degradation) เมื่อช่องเต็ม การเรียกจะถูกปฏิเสธอย่างรวดเร็วและสามารถ fall back ได้ ดังนั้นผู้ใช้จึงได้หน้า product page ที่ไม่มี recommendations แทนที่จะไม่ได้หน้าเลย
สิ่งที่คุณต้องจ่าย:
- ต้องกำหนดขนาดของแต่ละช่อง ตอนนี้คุณมีหลาย pool ให้จูนแทนที่จะมีแค่อันเดียว ตั้งช่องเล็กไปก็ปฏิเสธการเรียกที่ dependency ซึ่งยังแข็งแรงรับไหว ตั้งใหญ่ไปก็เลิกเป็นเส้นแบ่งที่ได้ผล
- peak utilization ลดลง การกันโควตาไว้ให้แต่ละ dependency แปลว่า permit บางส่วนจะนั่งว่างตอนที่ dependency นั้นไม่มีงาน เท่ากับคุณแลก throughput ดิบไปนิดหน่อยเพื่อได้ isolation ปกติแล้วคุ้ม แต่ก็ยังเป็นการแลกอยู่ดี
- ชิ้นส่วนที่ขยับมากขึ้น pool แยกตาม dependency เพิ่มพื้นผิวของการ config และการ monitoring แต่ละช่องคุ้มค่ากับ metric หนึ่งตัวเพื่อให้คุณเห็นว่าตัวไหนกำลังอิ่มตัว
เนื้อหาที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “เนื้อหาที่เกี่ยวข้อง”- Circuit Breaker — bulkhead ควบคุมความเสียหาย จากนั้น breaker จะหยุดเรียก dependency ที่พังโดยสิ้นเชิง
- Retry and Timeout — กำหนดขอบเขตว่าการเรียกแต่ละครั้งจะถือ permit ได้นานแค่ไหน ซึ่งกันไม่ให้ช่องเต็มอยู่ตลอดกาล
- Rate Limiting — จำกัด อัตรา ของงาน ส่วน bulkhead จำกัด ความ concurrency ของงาน
| ข้อดี | ข้อแลกเปลี่ยน |
|---|---|
| ป้องกัน cascade failure — resource pool หนึ่งหมดไม่กระทบ pool อื่น | เพิ่ม resource ที่ต้องจัดสรรล่วงหน้า — อาจ underutilize บางส่วน |
| isolate critical path จาก non-critical — ให้ priority ได้ | configuration ซับซ้อน — ต้องรู้จัก workload pattern |
| ระบบยังทำงานได้บางส่วนแม้ dependency ล้ม | จำนวน pool มากเกินทำให้ manage ยาก |
| ลด blast radius เมื่อ dependency ช้าหรือล้ม | ต้องทดสอบ failure scenario — hard ถ้าไม่มี chaos engineering |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”Bulkhead ที่ Partition ผิด — แบ่ง pool ตาม technical boundary ไม่ใช่ failure boundary อาการ:
- แบ่ง pool ตาม thread type แต่ไม่แบ่งตาม dependency ที่อาจล้ม
- failure ยังลาม เพราะ partition ไม่ตรงกับแหล่ง failure จริง
Pool ขนาดเดียวสำหรับทุกอย่าง — ไม่ปรับ pool size ตาม traffic pattern อาการ:
- critical payment flow ได้ thread เท่ากับ batch report
- payment ช้าเพราะแย่งกับ non-critical workload
💡 ตัวอย่างจากของจริง
Netflix:
- ใช้ Hystrix (thread pool isolation) สำหรับทุก external call
- แต่ละ dependency มี thread pool ของตัวเอง
- recommendation service ล้ม → thread pool ของตัวเองเต็ม → ไม่กระทบ payment thread pool
Resilience4j (successor ของ Hystrix):
- semaphore-based bulkhead สำหรับ reactive systems
- ใช้แพร่หลายในระบบที่ใช้ Spring Boot + reactive programming