Multi-tenancy: isolation versus cost

Microservices · lesson 32 of 33 · 6 min read

Three models, and why the cheapest one is the hardest to get right.

Open this lesson in the learning hub

Key points

  • Three models, in increasing isolation: shared schema with a tenant column, schema per tenant in one database, and database per tenant.
  • Shared schema is cheapest to run and hardest to make safe. Every query needs the tenant predicate, and one missing WHERE clause is a cross-tenant data leak - the most serious bug this class of system produces.
  • Do not rely on developers remembering. Enforce it structurally: row-level security in the database, or a Hibernate filter applied globally, so a query without the predicate cannot be written by accident.
  • Database per tenant gives real isolation and per-tenant backup and restore, but migrations must now run across hundreds of databases, and connection pooling becomes a genuine problem.
  • Noisy neighbours are the shared-model failure mode: one large tenant consumes the shared resource and every other tenant degrades. Per-tenant rate limits and query timeouts are what contain it.
  • Most systems end up hybrid - shared schema for the long tail, dedicated infrastructure for the largest customers who are also the ones contractually demanding isolation.

Example

-- Shared schema, enforced by the DATABASE rather than by discipline.
-- Postgres row-level security: a query with no tenant predicate returns
-- nothing rather than everything, which is the correct failure direction.
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

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

-- The application sets it once per transaction, from the authenticated
-- principal - never from a request parameter a caller could forge.
SET LOCAL app.tenant_id = 'a1b2c3d4-...';

---

/* The three models, side by side:
 *
 *                    SHARED SCHEMA   SCHEMA/TENANT   DATABASE/TENANT
 *  cost per tenant    lowest          medium          highest
 *  blast radius       all tenants     one schema      one tenant
 *  noisy neighbour    severe          moderate        none
 *  migration effort   one run         N schemas       N databases
 *  restore one tenant hard            moderate        trivial
 *  connection pooling simple          simple          hard
 *
 * The honest summary: shared schema scales operationally and fails
 * catastrophically when it fails. Database per tenant is the opposite.
 */

// Per-tenant limits are what stop one customer degrading the rest.
@Component
class TenantRateLimiter {

    private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();

    boolean tryConsume(String tenantId) {
        return buckets.computeIfAbsent(tenantId, id -> Bucket.builder()
                .addLimit(Bandwidth.simple(1000, Duration.ofMinutes(1)))
                .build()).tryConsume(1);
    }
}

Enforce tenant isolation in the database, not in application discipline - a missing WHERE clause must return nothing, not everything.

This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the Microservices course, and every lesson in it is listed on the Microservices contents page.