0Pricing
FastAPI Backend Development Bootcamp · บทเรียน

กลยุทธ์การแยกผู้เช่าและข้อแลกเปลี่ยน

เปรียบเทียบโมเดลการแยกแบบใช้สคีมาร่วมกัน แยกสคีมาตามผู้เช่า และแยกฐานข้อมูลตามผู้เช่าสำหรับเวิร์กโหลด SaaS

กลยุทธ์การแยกผู้เช่าและข้อแลกเปลี่ยน เป็นบทเรียน FastAPI Backend Development Bootcamp ฟรีบน CoddyKit นี่คือบทเรียนที่ 1 จากทั้งหมด 4 บทเรียน คุณสามารถอ่านบทเรียนทั้งหมดด้านล่างฟรี — จากนั้นลองปฏิบัติด้วยตัวคุณเองในเบราว์เซอร์พร้อมตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7 บทเรียนนี้เป็นส่วนหนึ่งของเส้นทางการเรียน FastAPI Backend Development Bootcamp และความก้าวหน้าของคุณจะซิงค์ข้ามเว็บและแอป CoddyKit คอร์ส FastAPI Backend Development Bootcamp มีบทเรียนทั้งหมด 4 บทเรียน

บางส่วนของบทเรียนนี้ยังไม่ได้รับการแปล และแสดงเป็นภาษาอังกฤษ

Why Tenant Isolation Matters

In a multi-tenant SaaS, many customers (tenants) share one running FastAPI application. The central question is: how isolated is each tenant's data?

Isolation affects four things you must trade off constantly:

  • Security & blast radius — can a bug leak Tenant A's rows to Tenant B?
  • Cost — how much infra does each tenant consume?
  • Operational complexity — migrations, backups, restores.
  • Per-tenant customization — can one tenant get extra columns or a custom schema?

Three canonical models exist: shared-schema, schema-per-tenant, and database-per-tenant. The rest of this lesson compares them for FastAPI workloads.

Shared-Schema: One Table, a tenant_id Column

The simplest model: every tenant's rows live in the same tables, distinguished by a tenant_id column. Every query must filter on it.

This is the cheapest and most scalable option for thousands of small tenants, but isolation is purely logical — one missing WHERE tenant_id = ... leaks data across tenants.

Below is the typical SQLAlchemy model shape. Note the indexed tenant_id on every tenant-owned table.

from sqlalchemy import String, Integer, ForeignKey, Index
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

class Base(DeclarativeBase):
    pass

class Invoice(Base):
    __tablename__ = "invoices"
    id: Mapped[int] = mapped_column(primary_key=True)
    tenant_id: Mapped[str] = mapped_column(String, index=True)
    amount_cents: Mapped[int] = mapped_column(Integer)
    customer_email: Mapped[str] = mapped_column(String)

    # Composite index: nearly every query filters by tenant first
    __table_args__ = (Index("ix_invoices_tenant_id", "tenant_id"),)

Resolving the Tenant from the Request

Before any query runs, you must know which tenant the request belongs to. Common strategies:

  • Subdomain — acme.app.com → tenant acme.
  • JWT claim — the access token carries a tenant_id.
  • Header — X-Tenant-ID (internal/service-to-service).

In FastAPI this becomes a dependency that resolves and validates the tenant once, then injects it everywhere. Never trust a tenant id the client can freely set unless it is cryptographically bound (e.g. inside a signed JWT).

from fastapi import Depends, HTTPException, Request

async def get_current_tenant(request: Request) -> str:
    host = request.headers.get("host", "")
    sub = host.split(".")[0]
    if not sub or sub in {"www", "app"}:
        raise HTTPException(status_code=400, detail="Tenant could not be resolved")
    return sub

# Usage in a route:
# @app.get("/invoices")
# async def list_invoices(tenant_id: str = Depends(get_current_tenant)):
#     ...

The Shared-Schema Danger: Forgetting the Filter

The #1 risk in shared-schema is a developer forgetting WHERE tenant_id = :tenant. The query still succeeds — it just returns everyone's data.

Two defenses scale better than discipline:

  • A repository layer that always injects tenant_id, so route code can't issue a raw unscoped query.
  • Postgres Row-Level Security (RLS) as a database-enforced backstop (covered next).

Here a thin repository guarantees scoping at the application layer.

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

class InvoiceRepository:
    def __init__(self, session: AsyncSession, tenant_id: str):
        self.session = session
        self.tenant_id = tenant_id

    async def list(self):
        # tenant_id is ALWAYS applied — callers cannot bypass it
        stmt = select(Invoice).where(Invoice.tenant_id == self.tenant_id)
        result = await self.session.execute(stmt)
        return result.scalars().all()

    async def get(self, invoice_id: int):
        stmt = select(Invoice).where(
            Invoice.id == invoice_id,
            Invoice.tenant_id == self.tenant_id,
        )
        return (await self.session.execute(stmt)).scalar_one_or_none()

Database-Enforced Isolation with Postgres RLS

Row-Level Security lets Postgres itself reject rows that don't match the current tenant — even if the app forgets the filter. You set a session variable per request and the policy uses it.

This turns shared-schema from "hope the WHERE is there" into "the database guarantees it." The trade-off: every connection must set the variable, which interacts carefully with connection pooling (set it per-transaction).

-- One-time DDL
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.current_tenant', true));

-- Per request / per transaction, the app runs:
-- SET LOCAL app.current_tenant = 'acme';
-- Now SELECT * FROM invoices only returns acme's rows automatically.

Wiring RLS into a FastAPI Request

To make RLS work, each request opens a transaction, issues SET LOCAL app.current_tenant, then runs all queries inside it. SET LOCAL is scoped to the transaction, so a pooled connection won't leak the value to the next tenant.

This pattern combines an application dependency (resolve tenant) with database enforcement (RLS policy) — defense in depth.

from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession

async def tenant_session(
    tenant_id: str = Depends(get_current_tenant),
) -> AsyncSession:
    async with SessionLocal() as session:
        async with session.begin():
            # bind param avoids SQL injection of the tenant value
            await session.execute(
                text("SET LOCAL app.current_tenant = :t"),
                {"t": tenant_id},
            )
            yield session

Schema-per-Tenant: Same DB, Separate Namespaces

In schema-per-tenant, one Postgres database holds many schemas — tenant_acme.invoices, tenant_globex.invoices. Tables are identical but physically separated by schema.

Pros: stronger isolation than shared-schema, no tenant_id column needed, per-tenant backup/restore is easier, and you can drop a tenant by dropping a schema.

Cons: migrations must run across every schema (N times), and Postgres degrades with very high schema/table counts (thousands of schemas bloat the catalog). Best for tens to low-hundreds of larger tenants.

Routing Queries by Schema (search_path)

Postgres resolves unqualified table names using the search_path. Per request, you set it to the tenant's schema, and the same ORM models now read/write that tenant's tables.

As with RLS, use SET LOCAL search_path inside a transaction so a pooled connection never carries one tenant's schema into another tenant's request.

from sqlalchemy import text

async def schema_scoped_session(
    tenant_id: str = Depends(get_current_tenant),
):
    schema = f"tenant_{tenant_id}"
    async with SessionLocal() as session:
        async with session.begin():
            # quote_ident-style guard: validate before interpolating identifiers
            if not tenant_id.isalnum():
                raise HTTPException(400, "Invalid tenant identifier")
            await session.execute(text(f'SET LOCAL search_path TO "{schema}", public'))
            yield session

Migrations Across Many Schemas

The operational tax of schema-per-tenant is migrations. A single Alembic upgrade must be applied to each tenant schema. You iterate over the tenant list, set the schema, and run the migration.

Plan for partial failure: if schema 200 of 300 fails, you need idempotent, resumable migrations. This is the main reason teams cap schema-per-tenant at hundreds, not thousands, of tenants.

# Conceptual loop run by an Alembic env.py or a management command
tenant_schemas = ["tenant_acme", "tenant_globex", "tenant_initech"]

def run_migrations_for_all(connection, run_one):
    failures = []
    for schema in tenant_schemas:
        try:
            connection.execute(f'SET search_path TO "{schema}"')
            run_one(connection)  # apply the same upgrade per schema
        except Exception as exc:  # noqa: BLE001
            failures.append((schema, str(exc)))
    if failures:
        raise RuntimeError(f"Migration failed for: {failures}")

Database-per-Tenant: Maximum Isolation

Database-per-tenant gives each tenant its own database (sometimes its own server). Isolation is the strongest possible: separate connections, separate backups, even separate regions for data-residency compliance.

Pros: hard security boundary, trivial per-tenant restore, easy "noisy neighbor" isolation, simple per-tenant data deletion (drop the DB).

Cons: highest cost and ops overhead — you maintain a connection pool per database, can't easily run cross-tenant analytics, and onboarding a tenant means provisioning a DB. Best for few, large, high-compliance tenants (e.g. enterprise B2B).

Connection Routing for Database-per-Tenant

The app keeps a registry mapping tenant → database URL and a cache of engines/pools. A dependency resolves the tenant, looks up its DSN, and hands back a session bound to that database.

Caching engines is essential: creating a new engine per request exhausts connections. Cache one engine per tenant and reuse its pool.

from functools import lru_cache
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker

TENANT_DSNS = {
    "acme": "postgresql+asyncpg://app@db-acme/acme",
    "globex": "postgresql+asyncpg://app@db-globex/globex",
}

@lru_cache(maxsize=256)
def engine_for(tenant_id: str):
    dsn = TENANT_DSNS.get(tenant_id)
    if dsn is None:
        raise HTTPException(404, "Unknown tenant")
    return create_async_engine(dsn, pool_size=5, max_overflow=2)

async def db_per_tenant_session(tenant_id: str = Depends(get_current_tenant)):
    maker = async_sessionmaker(engine_for(tenant_id), expire_on_commit=False)
    async with maker() as session:
        yield session

Quick Check: Choosing an Isolation Model

A B2B startup expects to onboard 5,000 small tenants in year one. They want the lowest infra cost and the simplest migration story, and they are willing to invest in strong query-scoping discipline plus a database-enforced backstop. Which isolation model fits best?

Recap: Picking the Right Isolation Strategy

Three models on a spectrum from cheap-and-shared to expensive-and-isolated:

  • Shared-schema — one tenant_id column. Cheapest, scales to thousands of small tenants, one migration. Risk: logical-only isolation; mitigate with a scoping repository plus RLS.
  • Schema-per-tenant — one schema each via search_path. Stronger isolation, easy per-tenant backup/drop. Cost: migrations run N times; caps at hundreds of tenants.
  • Database-per-tenant — one DB each, engine cached per tenant. Strongest isolation and data-residency control. Cost: highest ops/infra; best for few large, high-compliance tenants.

Decision drivers: tenant count and size, compliance/data-residency needs, migration tolerance, and budget. Many real systems are hybrid — shared-schema for the long tail of small customers, database-per-tenant for enterprise accounts. In FastAPI, the seam in all three is the same: a tenant-resolution dependency that injects the right session.

คำถามที่พบบ่อย

บทเรียน “กลยุทธ์การแยกผู้เช่าและข้อแลกเปลี่ยน” ฟรีหรือไม่

ใช่ — ข้อความเต็มของ “กลยุทธ์การแยกผู้เช่าและข้อแลกเปลี่ยน” ฟรีให้อ่านที่นี่บนเว็บ เพื่อปฏิบัติแบบโต้ตอบ (ตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7) และปลดล็อคส่วนที่เหลือของคอร์ส FastAPI Backend Development Bootcamp ให้อัปเกรดเป็น CoddyKit PRO คอร์ส FastAPI Backend Development Bootcamp มีบทเรียนทั้งหมด 4 บทเรียน

คุณจะเรียนรู้อะไรในบทเรียน “กลยุทธ์การแยกผู้เช่าและข้อแลกเปลี่ยน”

เปรียบเทียบโมเดลการแยกแบบใช้สคีมาร่วมกัน แยกสคีมาตามผู้เช่า และแยกฐานข้อมูลตามผู้เช่าสำหรับเวิร์กโหลด SaaS คุณปฏิบัติ FastAPI Backend Development Bootcamp ด้วยโค้ดที่ใช้งานได้จริงที่คุณเรียกใช้โดยตรงในเบราว์เซอร์ และติวเตอร์ AI ตลอด 24/7 ตอบคำถามของคุณขณะที่คุณไปผ่านบทเรียน

คุณต้องมีประสบการณ์ก่อนที่จะเริ่มเรียน FastAPI Backend Development Bootcamp หรือไม่

ไม่จำเป็นต้องมีประสบการณ์มาก่อน FastAPI Backend Development Bootcamp บน CoddyKit ออกแบบมาสำหรับผู้เริ่มต้นไปจนถึงผู้เรียนขั้นสูง คุณสามารถเริ่มต้นที่นี่หรือเริ่มจากตัวแรกและเรียนด้วยความเร็วของคุณเอง นี่คือบทเรียนที่ 1 จากทั้งหมด 4 บทเรียน

บทเรียน “กลยุทธ์การแยกผู้เช่าและข้อแลกเปลี่ยน” ใช้เวลานานแค่ไหน

บทเรียน CoddyKit ส่วนใหญ่ใช้เวลาประมาณ 5–10 นาที แต่ละบทเรียนจึงสั้นและเป็นแบบโต้ตอบ คุณสามารถก้าวหน้าอย่างต่อเนื่องและกลับมาเรียนต่อจากตรงที่เพิ่งหยุดบนเว็บและแอปได้เลย

ฉันเขียนและรันโค้ดในบทเรียน FastAPI Backend Development Bootcamp นี้ได้ไหม

ได้ บทเรียน FastAPI Backend Development Bootcamp ทุกบทมีตัวแก้ไขโค้ดในตัว คุณจึงเขียนและรันโค้ดจริงได้เลยในเบราว์เซอร์ และได้รับข้อเสนอแนะจาก AI ในทันที — ไม่ต้องติดตั้งในเครื่องของคุณ

บทเรียนทั้งหมดในหลักสูตรนี้

  1. กลยุทธ์การแยกผู้เช่าและข้อแลกเปลี่ยน
  2. การระบุบริบทผู้เช่าและมิดเดิลแวร์
  3. การรักษาความปลอดภัยระดับแถวและการแบ่งพาร์ทิชันข้อมูล
  4. การวัดการใช้งาน โควตา และฮุกการเรียกเก็บเงิน
← กลับไปที่ FastAPI Backend Development Bootcamp