System designDeep dive13 min

Multi-tenancy

One deployment serving thousands of customers is what makes software as a service cheap to run. It also means every query, cache, queue and admin script is one mistake away from showing a customer someone else's data, letting one customer slow everyone down, or breaking hundreds of customers at once. Multi-tenancy is the set of decisions that keep those three failures apart.

1 · The problem

Sharing is cheap until one tenant sees, slows or breaks another

A help-desk product serves 6,000 companies. A dental clinic with two agents and a national retailer with 900 agents use the same servers, the same database and the same search cluster. Each company is a tenant: the unit whose data, users and settings belong together and must stay apart from every other's. For a business product, a tenant is usually one customer organization, though a large customer may want several.

Sharing is what keeps the price low. With one set of infrastructure there is one thing to deploy, patch and monitor, and capacity follows the total load instead of being reserved per customer. The price of sharing is three new ways to fail:

Failure

What happens

Help-desk example

Leak

One tenant sees another's data

The clinic's search returns the retailer's tickets

Noisy neighbor

One tenant's load slows the others

The retailer imports 2 million old tickets at 10 a.m.

Shared blast radius

One fault or bad command hits many tenants

A cleanup script deletes the wrong accounts

Each section below takes one of these in turn. The first decision, though, is how much to share at all, because every answer changes how hard each failure is to prevent.

2 · The models

Silo, pool and bridge are points on one spectrum, chosen part by part

At one end, each tenant gets a complete copy of the system: its own servers, database and network. This is the silo model. At the other end, everything is shared and each row carries a tenant id: the pool model. Most real systems are a bridge between them, sharing some layers and separating others, and the choice can differ for each component and each customer tier:

Aspect

Silo

Pool

Bridge

Shared

Nothing

Everything

Some layers, such as web servers

Leak risk

Low; the boundary is the infrastructure

Highest; the boundary is code and queries

Per layer

Noisy neighbors

None

Must be managed

Only in shared layers

Cost

Roughly linear in tenants

Follows total load

Between the two

Operations

Many copies to deploy and upgrade

One deployment, one upgrade

A few copies plus the pool

Silos are expensive: if one tenant needs a given amount of infrastructure, 100 tenants probably need 100 times as much, and every change has to be rolled out to each copy. Silos earn their cost when customers have strict regulatory isolation requirements or won't accept sharing, and a silo also limits how far one outage or bad release spreads.

A common split for the help desk: the web tier and application servers are pooled for everyone, while the data tier is pooled for small customers and dedicated for the few that pay for it. The same pattern shows up as tiers, with the premium tier running a dedicated copy of exactly the same version as the pool so that it doesn't become a one-off to maintain.

One shape to avoid inside a shared database is a separate set of tables for each tenant. It doesn't scale to large numbers of tenants and becomes hard to query, manage and upgrade; use shared tables with a tenant id column, or separate databases. Custom columns for one tenant's needs are the same trap, and belong in a table for per-tenant custom data.

3 · Isolation

In a pool, the tenant must come from a verified identity and be enforced below the application

The first leak is usually one forgotten filter. The ticket search runs SELECT ... WHERE subject LIKE ... and someone forgot AND tenant_id = ... in one code path. In pooled systems, sharing increases the chance of cross-tenant access, which is a reason for more diligence, never for relaxing isolation.

Two rules close most of the gaps. First, the tenant comes from something the server has verified, such as a claim in a signed token, not from a header or URL the client can change. Even when the request arrives on the tenant's own domain, check that the caller is authorized for that tenant. Second, the tenant is applied by a layer every query passes through, so one forgotten clause can't leak:

Agent's browsersigned tokenGatewayverify tokenTicket servicetenant from claimShared databaserow-level policyrequest + tokentenant = clinic-17query, tenant setAgent's browsersigned tokenGatewayverify tokenTicket servicetenant from claimShared databaserow-level policyrequest + tokentenant = clinic-17query, tenant set

The tenant is taken from the verified token at the edge and passed down, never chosen by the client. The database applies it again, so a query that forgets the filter returns nothing extra.

The database layer can be row-level security, which PostgreSQL and Azure SQL Database both offer. In PostgreSQL, a policy adds a condition to every query on the table, and once row security is enabled, a table with no policy shows no rows at all:

One policy for every ticket query

sql
ALTER TABLE tickets ENABLE ROW LEVEL SECURITY;
ALTER TABLE tickets FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON tickets
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

-- per request, inside its transaction:
SET LOCAL app.current_tenant = '0b6f...';

It has sharp edges:

  • Owners and superusers bypass it. Superusers and roles with BYPASSRLS always skip policies, and so does the table's owner unless the table is set to FORCE ROW LEVEL SECURITY. An application that connects as the owner has no protection at all.
  • Constraint checks bypass it. Unique and foreign-key checks see every row, which can reveal that a value exists in another tenant.
  • It's harder to build and test. Many multitenant systems skip row-level security because the tenant has to be carried into every query.

The tenant setting itself can leak through a connection pool. A plain SET lasts for the whole database session, and pooled connections outlive requests:

A code path that skips setting the tenant inherits the previous request's. With SET LOCAL inside a transaction, the setting ends with the transaction, committed or not, so a path that skips it finds no tenant set instead of the previous request's.

The same discipline applies to everything else that holds tenant data: cache keys, search indexes, file paths in object storage, queue messages and logs all carry the tenant id, and every read checks it. A cache key of ticket:4411 instead of clinic-17:ticket:4411 is the same leak as a missing WHERE clause.

4 · Noisy neighbors

One tenant's peak becomes everyone's slowdown unless each tenant has limits

At 10 a.m. the retailer starts importing 2 million old tickets. The import saturates the shared database, and the clinic's agents wait 8 seconds for every page. This noisy neighbor problem has two forms: one tenant using a disproportionate share, and many small tenants whose peaks coincide, so that each uses a little but together they exceed capacity.

No shared resource can rule it out entirely; the work is making it unlikely and contained. The mitigations, in roughly the order to reach for them:

Mitigation

In the help desk

Per-tenant limits and quotas

Each tenant gets its own request rate and concurrent-import limit (Rate limiting algorithms)

Restrict heavy operations

Searches capped in rows and time; imports and exports run as background jobs

Move work off-peak

Bulk imports queue and run at night unless the tenant pays for priority

Measure per tenant

Dashboards and alerts per tenant, not just totals, so the noisy one is visible

Rebalance or isolate

Move the retailer to its own database, or split tenants across copies of the system

Per-tenant measurement does double duty. Shared services rarely report usage by tenant, so the product has to measure it, both to price plans and to see which tenants cost more to serve than they pay. A tenant that uses little storage but runs heavy reports may cost far more than its data volume suggests.

Isolating the shared resources themselves, with separate pools per tenant class, complete copies of the system called cells, or shuffle sharding, is covered in Bulkheads. The point here is that a tenant id on every request is what makes any of those limits possible.

5 · Placement

A tenant catalog decides where each tenant lives, so tenants can move

As the help desk grows, one database is no longer enough, and some tenants need their own. The answer is to shard by tenant (Sharding & partitioning): each shard holds many small tenants, or one large one, and a tenant catalog maps every tenant to the shard or deployment that holds its data. Every request looks up its tenant first, then goes to the right place:

Requesttenant from tokenTenant catalogtenant → locationPooled shard 12,400 small tenantsPooled shard 23,599 small tenantsRetailer databaseone tenantwhere is clinic-17?shard 1other tenantsdedicated

Because every request consults the catalog, it has to be fast and highly available, usually behind a cache. Changing one entry in it is how a tenant moves.

Separate copies of a system are called deployment stamps, and they're good practice for multitenant systems, even when each stamp is itself pooled or sharded. A stamp per geography also serves tenants whose data must stay in one region (Privacy, residency & retention).

Tenants have to move: a small tenant grows, a customer upgrades to a dedicated tier, a stamp gets too full. Build tools that make moving repeatable and safe rather than doing it by hand. With a catalog, a move is a data migration followed by one catalog change:

  1. Copy

    Copy the tenant's rows to the new database while the tenant keeps working on the old one.

  2. Catch up

    Apply the changes made since the copy started, from the old database's change stream (Change data capture).

  3. Switch

    Pause the tenant's writes for seconds, apply the last changes, and point the catalog at the new location.

  4. Verify, then delete

    Compare counts and checksums, keep the old copy until the move is trusted, and only then delete it.

6 · Operations

Every operation needs a tenant scope, or one mistake reaches every customer

On April 5, 2022, Atlassian ran a script to remove a retired app from customer sites. The team that requested the deletion supplied the IDs of the customers' entire sites instead of the app's IDs, and the deletion API accepted either kind of ID without asking which was meant. Between 07:38 and 08:01 UTC the script deleted 883 sites belonging to 775 customers. Some customers were without their products for up to 14 days.

Atlassian's review explains why the restore took so long. Each site's data was spread across many services and stores, and while any single store could be restored to an earlier point in time, there was no automated way to restore a large set of sites with all their connected products. The first change it listed was universal soft deletes: no customer data is removed without first passing through a reversible delete.

The lessons carry to any multi-tenant system. Every administrative action, backup and restore needs to work at the level of one tenant, and a bulk action across tenants needs the same staged rollout as code:

Operation

Single-tenant question

Help-desk answer

Restore

Can one tenant be restored without rolling back the others?

Restore the shard to a side copy, then copy that tenant's rows back

Delete or offboard

Is it reversible, and is everything found?

Soft delete, a grace period, then removal from every store, keyed by tenant id

Bulk scripts

What kind of ID is this, and how many tenants?

Typed IDs, a dry run, a small first batch, then the rest

Releases

Who sees a bad release first?

Internal tenants, then a small share of the pool, then everyone

Restoring one tenant from a shared database is the hard case. It may mean restoring the whole database to a separate resource and copying that tenant's data back; dedicated databases make it simpler, though restoring a shard still touches every tenant on it. Plan and test the single-tenant restore before a customer asks for it.

7 · The checklist

Decide what to share, then make the tenant explicit everywhere

For the help desk, and most software sold to many organizations:

Decision

Help-desk choice

What to share

Pooled web and application servers; pooled shards for most tenants; dedicated databases for those who need and pay for them

Where the tenant comes from

A claim in a verified token, enforced again by row-level policies

Where the tenant id goes

Rows, cache keys, search documents, object paths, messages, logs and metrics

Fair sharing

Per-tenant limits, background imports, per-tenant dashboards

Placement

A tenant catalog, so tenants can move between shards, tiers and regions

Operations

Restore, delete, migrate, bulk scripts and releases all scoped to tenants

Testing

Automated tests that sign in as one tenant and try to read another's data

Whatever the model, test that one tenant's data isn't leaked to another, and that the noisy neighbor effects you accept are the ones you expected.

Check yourself

4 questions

1. The help desk reads the tenant from an X-Tenant-Id header the browser sends with each request. What is wrong?
2. Row-level security is enabled on the tickets table with a tenant policy, but tests still see every tenant's rows. What is the likely cause?
3. The retailer's bulk import slows every other tenant's searches. Which fix addresses the cause rather than the symptom?
4. Why route every request through a tenant catalog instead of computing the shard from the tenant id with a hash?

Takeaways

Remember this

  • A multi-tenant system must prevent three failures: one tenant seeing another's data, slowing it down, or being hit by an operation meant for someone else.
  • Silo, pool and bridge are a spectrum; choose per component and per customer tier, trading cost against isolation.
  • Take the tenant from verified identity, enforce it below the application, and put it in every key, cache entry, message and log.
  • Limit and measure usage per tenant, so a noisy neighbor is both throttled and visible.
  • A tenant catalog and tenant-scoped operations make it possible to move, restore and delete one tenant without touching the rest.