Skip to content

Placing SaaS tenants across database shards

The problem

A multi-tenant Node application outgrows one database. You move to several shards, and now something has to decide which tenant lives where.

Tenant sizes are never uniform. One enterprise customer is routinely larger than a hundred trial accounts put together, so hashing the tenant id spreads count evenly and leaves bytes wildly uneven. The usual advice, isolating the largest tenants on their own nodes, is really an admission that placement needs to be weighted.

What makes this different from the two CI cases: moving an item costs money here. Relocating a tenant means copying its data, a maintenance window, and a cutover. So the question is not only "what is the best layout" but "what is the cheapest way to get there".

The mapping

aequitasHere
BinA database shard
Bin's maxIts disk budget
ItemA tenant
Item's weightGigabytes it occupies
violationsYou are out of capacity; provision a shard
lockedA tenant contractually pinned to a shard
rebalanceRe-place with the fewest gigabytes migrated

Initial placement

Ten tenants across three shards capped at 500 GB:

ts
import { suggest } from "aequitas";

const tenants = [
  { id: "contoso", weight: 340 },
  { id: "fabrikam", weight: 180 },
  { id: "northwind", weight: 155 },
  { id: "adventure-works", weight: 120 },
  { id: "acme", weight: 95 },
  { id: "globex", weight: 80 },
  { id: "initech", weight: 65 },
  { id: "umbrella", weight: 50 },
  { id: "tailspin", weight: 40 },
  { id: "wingtip", weight: 30 },
];

const shards = [
  { id: "shard-1", max: 500 },
  { id: "shard-2", max: 500 },
  { id: "shard-3", max: 500 },
];

const plan = suggest(tenants, shards);
plan.loads;       // → { "shard-1": 390, "shard-2": 385, "shard-3": 380 }
plan.violations;  // → 0

1155 GB split 390 / 385 / 380, against an even share of 385. Store that assignment; it is your tenant routing table.

Noticing you are out of room

Two quarters later everyone has grown and the total is 1530 GB. Feed the new sizes and the same three shards back in:

ts
const strained = rebalance(grown, shards, plan.assignments);
strained.loads;       // → { "shard-1": 510, "shard-2": 480, "shard-3": 540 }
strained.violations;  // → 50

rebalance takes the layout you are already running as its third argument, so it improves that rather than starting fresh, which is the whole point on this page. violations: 50 is the useful output. Three shards hold 1500 GB and you have 1530 GB, so no arrangement fits and the solver is telling you that rather than silently returning the least-bad option as though it were fine. Wire that into a monitor and you find out before a disk does.

The migration bill: rebalance against suggest

You provision shard-4. Both functions produce a valid layout, and here they produce an equally good one, so the only thing separating them is how much data you have to copy.

Fullest shardTenants movedData migrated
rebalance(grown, four, plan.assignments)420 GB4430 GB
suggest(grown, four)420 GB6625 GB

Identical peak. suggest copies 195 GB more for no benefit whatsoever, because it has no idea where anything currently lives and no reason to care.

This is the mirror image of the CI cases. In monorepo tasks moving a task was free and suggest won on wall clock. Here, moving is the expensive part, so rebalance wins outright:

ts
const four = [...shards, { id: "shard-4", max: 500 }];

const revised = rebalance(grown, four, plan.assignments);
revised.loads;       // → { "shard-1": 420, "shard-2": 350, "shard-3": 385, "shard-4": 375 }
revised.violations;  // → 0

Both land contoso alone on shard-1 at 420 GB, which is the floor: max(1530 / 4, 420) = 420, set by the largest tenant.

Diffing the old and new assignments gives you the migration plan directly:

ts
const migrations = plan.assignments.flatMap((before) => {
  const after = revised.assignments.find((a) => a.itemId === before.itemId);
  return after && before.binIds[0] !== after.binIds[0]
    ? [{ tenant: before.itemId, from: before.binIds[0], to: after.binIds[0] }]
    : [];
});
// 4 entries: adventure-works, globex, umbrella, wingtip

Pinning a contractual tenant

An enterprise contract says contoso sits on known infrastructure. Lock it and the solver arranges everything else around that:

ts
const pinned = plan.assignments.map((a) =>
  a.itemId === "contoso" ? { ...a, binIds: ["shard-1"], locked: true } : a,
);

rebalance(grown, four, pinned);
// contoso: { binIds: ["shard-1"], locked: true }

A lock pins the tenant, it does not reserve the shard

In the run above contoso did end up alone on shard-1, but that is a coincidence of the arithmetic, not a guarantee. At 420 GB against a 500 GB cap there is room for umbrella (55 GB) or wingtip (35 GB) to join it, and a slightly different set of sizes would put them there.

If "dedicated" is contractual, do not express it as a lock. Take the tenant and the shard out of the problem entirely, which is the same bin-list filtering Recipes uses to make a pairing impossible:

ts
const rest = grown.filter((t) => t.id !== "contoso");
const restShards = four.filter((s) => s.id !== "shard-1");

suggest(rest, restShards);
// → { "shard-2": 365, "shard-3": 375, "shard-4": 370 }

contoso owns shard-1 because you never offered it to anyone else. That is the only way to be certain.

When a tenant outgrows any shard

contoso keeps growing and hits 560 GB against a 500 GB cap:

ts
const over = suggest(whale, four);
over.loads;       // → { "shard-1": 560, "shard-2": 365, "shard-3": 375, "shard-4": 370 }
over.violations;  // → 60

aequitas places it and reports the 60 GB overshoot. It will not split one tenant across two shards, and you would not want it to: split divides an item's weight evenly, which for a database means half a customer's rows in each of two places. That is a decision about your data model, not about placement. The signal here means raise that shard's capacity, or shard the tenant internally.

Where this stops

One weight, and a shard has several limits. Disk is easy to measure, so it is the tempting weight, but shards also run out of connections, IOPS and CPU. A tenant that is small on disk and brutal on queries will look cheap here. Either weight by the resource that actually binds you, or build a composite and accept that it is a heuristic.

Snapshots, not forecasts. These weights are today's sizes. A tenant growing 20% a month will need moving again soon, and the plan does not know that. If you have growth data, weighting by projected size a quarter out gives a layout with a longer shelf life.

It tells you what to move, not how. There is no notion of a maintenance window, an acceptable amount of downtime, or a safe order for the migrations. The output is a diff; sequencing and executing it is yours.

Nothing enforces co-location. If two tenants must share a shard, or must never share one, neither is expressible. exclusions are pairs of bins, not pairs of items.

Released under the MIT License.