System designDeep dive10 min

Bulkheads

A ship's hull is divided into watertight compartments, so a hole floods one section instead of sinking the ship. Bulkheads do the same for software: they split shared resources into separate pools, so one slow dependency, one heavy customer or one bad deploy can only use up its own share.

1 · The problem

A shared pool lets the worst caller use up everyone's share

An appointment-booking service is used by 4,000 clinics and salons. Each server calls three outside services through one HTTP client with a pool of 50 connections: a payment provider for deposits, an SMS provider for reminders, and a calendar-sync service. Normally they need few connections at once (in flight = arrival rate × time per call, from Throughput, utilization & queueing):

Calls to

Normal load

Connections in use

Payment provider

40 a second × 0.25 s

10

SMS provider

30 a second × 0.2 s

6

Calendar sync

20 a second × 0.5 s

10

Total

26 of 50

Then the SMS provider slows to 10 seconds a message. Reminder calls now want 30 × 10 = 300 connections. They can't have 300, but within a couple of seconds they hold all 50, and every payment call queues behind them. Patients can't book, because of a reminder service nobody was watching.

The same thing happens one level up. A chain of 300 salons starts a bulk import of its old appointments. The import runs through the same servers and the same database connections as live bookings, and every other customer's booking page slows to a crawl. One heavy customer degrading everyone else is a noisy neighbor.

Both are the same failure: a shared resource with no limit per user. A bulkhead is that limit. It splits the resource into compartments, so one caller's trouble can fill its own compartment and no more. Unlike a circuit breaker, which stops calling a failing dependency (The circuit breaker pattern), a bulkhead caps how much of you any one thing can use, failing or not.

2 · In one process

Give each dependency its own small pool

Inside one server, the fix is a separate pool, or a separate concurrency limit, per dependency. Size each from its normal load with room for bursts:

Booking serverPayments pool20 slots, 10 usedSMS pool10 slots, all stuckCalendar pool15 slots, 10 usedPayment providerSMS provider10 s per messageCalendar syncBooking serverPayments pool20 slots, 10 usedSMS pool10 slots, all stuckCalendar pool15 slots, 10 usedPayment providerSMS provider10 s per messageCalendar sync

The slow SMS provider fills its own 10 slots and nothing else. Payments and calendar sync keep their capacity, so bookings carry on.

When a compartment is full, the next call must not wait forever. The usual choices:

  • Reject at once. Resilience4j's semaphore bulkhead, for example, allows 25 concurrent calls by default and waits 0 ms for a free slot before refusing.
  • Wait briefly in a small bounded queue, then refuse.
  • Defer the work. A refused reminder goes back on a queue to be sent later; nobody needs it within the second.

Two ways to build the walls:

  • A semaphore counts calls in flight and refuses past the limit. It is cheap and works with any threading model.
  • A thread pool per dependency runs the calls on its own fixed set of threads with a bounded queue, so the caller's threads are never the ones stuck waiting, at the cost of extra threads and a hand-off.

Bulkheads pair well with breakers. The bulkhead keeps a slow dependency from taking more than its share while the breaker decides it is failing; then the breaker stops the calls altogether.

3 · Between workloads

Separate the work that must not wait from the work that can

The bulk import is a different problem: not a slow dependency, but a big consumer competing for the same servers, queues and database. Here the walls go around kinds of work, so that the work that must stay fast gets capacity nobody else can take:

API gatewayroutes by kind of workBooking serverslive requests onlyImport queueImport workers5 workers, maxDatabasepool: 80 live, 20 importbookingsimports, reportsAPI gatewayroutes by kind of workBooking serverslive requests onlyImport queueImport workers5 workers, maxDatabasepool: 80 live, 20 importbookingsimports, reports

Live bookings and bulk jobs run on different servers and draw on separate shares of the database's connections. A huge import can only make the import queue longer.

Each layer has its own wall, and each wall has an ordinary tool:

Shared resource

The bulkhead

Example

Outbound connections

A pool or concurrency limit per dependency

10 SMS connections, 20 for payments

CPU and memory

Separate processes or containers with resource limits

Import workers capped at 2 CPUs each

Servers

Separate groups of instances per kind of consumer

Booking servers never run imports

Queues

A queue and workers per kind of message

Reminders can back up without delaying payments

Shared capacity per customer

Per-tenant quotas and rate limits

One customer's API traffic capped (Rate limiting algorithms)

Prefer the limits your platform already enforces, such as container resource limits, database connection limits per user and gateway rate limits, over rebuilding them in application code.

4 · Cells

Cells split the whole system so a failure reaches only some customers

Pools protect against one dependency or one kind of work. But some failures hit everything a server does: a bad deploy, a corrupt configuration, a request that crashes any server that handles it (a poison pill). Against those, the largest bulkhead is a cell: a complete, independent copy of the system that serves only some of the customers.

Cell routercustomer ID → cellCell 1500 customersCell 2500 customers, bad deployCells 3–83,000 customersCell routercustomer ID → cellCell 1500 customersCell 2500 customers, bad deployCells 3–83,000 customers

Eight cells, each with its own servers, queues and database. A failure in one cell reaches 1 ÷ 8 = 12.5% of customers instead of all of them.

The design rules that make cells work:

  • Partition by a key that most requests already carry, here the customer ID, so a request never needs two cells.
  • Keep the router thin. It only decides which cell, because it is the one piece every request still shares.
  • Share no state between cells, or the shared part becomes the new single point of failure.
  • Deploy cell by cell. A bad release breaks the first cell it reaches and is rolled back before it reaches the rest.

Cells don't have to mean more hardware: 30 servers can become 6 cells of 5. What they cost is the work of routing, provisioning cells and moving customers between them. The scope of impact shrinks with the number of cells, so 20 cells means a 5% blast radius. Availability zones and regions are the same idea applied by the cloud provider (Redundancy & failure domains).

5 · Shuffle sharding

Random overlapping shards isolate customers far better than fixed ones

Cells give each customer one compartment shared with many others. Shuffle sharding does better with the same machines. Take 8 workers. Ordinary sharding splits them into 4 fixed shards of 2, and a customer whose requests break their workers takes down a quarter of everyone. Shuffle sharding instead gives every customer its own random pair of the 8, and there are 28 different pairs (8 × 7 ÷ 2).

No sharding
100 %
4 fixed shards of 2 workers
25 %
Shuffle shards: 2 of 8 workers
3.6 %
Shuffle shards: 4 of 8 workers
1.4 %

Customers who lose every worker they have when one customer's workers fail: 1 ÷ 4 = 25%, 1 ÷ 28 ≈ 3.6% and 1 ÷ 70 ≈ 1.4%. Everyone else shares at most part of the bad shard.

Suppose one clinic's malformed requests crash both of its workers. Only customers with exactly the same pair, about 1 in 28, lose both. Everyone else shares at most one worker with it and still has a healthy one, as long as their clients retry on the other worker of their shard. Shuffle sharding only works with clients that retry like this.

The numbers grow fast. Amazon Route 53 spreads its DNS service over 2,048 virtual name servers and gives each customer domain 4 of them: about 730 billion possible shards, enough that no two customer domains share more than two name servers. A flood of traffic aimed at one domain overwhelms its four name servers, and every other domain keeps at least two that work.

6 · The catch

Every wall leaves capacity idle

A shared pool of 50 connections can lend any idle connection to whoever needs it. Pools of 20, 10 and 15 can't: on a normal day the payment pool may be full while the SMS pool sits idle. Bulkheads trade efficiency for isolation, and that trade has to be sized on purpose:

  • Too small a compartment rejects ordinary bursts, so you have built an outage of your own.
  • Too large, and it no longer protects anything.
  • Too many walls make the system hard to size, monitor and reason about.

Mistake

What happens

Better

Sizing pools by guesswork

Rejections at the first busy hour

Size from rate × duration at peak, plus headroom, and alert on rejections

Walls around the servers, one shared database behind them

The database is still the hull with one compartment

Separate connection limits per workload, or per-cell databases

Cells that share a configuration store or deploy at once

One bad push reaches every cell

Deploy and configure cell by cell

Waiting forever for a free slot

The wait itself ties up the caller

Short waits and a clear rejection or fallback

Watch every compartment separately. A bulkhead that is full or rejecting is either doing its job during an incident or too small in normal times; per-pool usage and rejection counts tell you which (Observability: metrics, logs & traces).

Check yourself

4 questions

1. Three outside services share one pool of 50 connections. One of them slows from 0.2 s to 10 s per call at 30 calls a second. What happens to calls to the other two?
2. What does a bulkhead do that a circuit breaker doesn't?
3. A service runs 8 cells, and a bad deploy breaks one of them. What share of customers is affected?
4. With 8 workers and each customer assigned a random pair of them, one customer's requests crash both of its workers. Who loses service entirely?

Takeaways

Remember this

  • A bulkhead splits a shared resource into compartments so one slow dependency, heavy customer or bad deploy can only exhaust its own share.
  • Inside a process, give each dependency its own pool or concurrency limit, sized from rate × duration, and reject or defer when it is full.
  • Separate work that must stay fast from bulk work: different servers, queues and shares of the database.
  • Cells are whole-system bulkheads: a failure reaches only the customers of one cell, and deploys go cell by cell.
  • Shuffle sharding gives each customer a random subset of workers, shrinking the share that loses everything from 1 in 4 to 1 in 28 with the same 8 workers.