Implementasi arsitektur multi-tenant pada Django umumnya bermuara pada dua pendekatan utama: Shared Database dengan Tenant ID (pendekatan berbasis baris/Row-Level) dan Isolated Schema per Tenant (umumnya menggunakan ekosistem PostgreSQL via paket seperti django-tenants). Keputusan memilih salah satu pendekatan berdampak langsung pada integritas isolasi data, kompleksitas pipeline CI/CD, efisiensi pooling koneksi, dan stabilitas internal database engine saat beban bertambah.
1. Perbandingan Karakteristik Arsitektur
Dua pola ini menyelesaikan masalah pemisahan data dengan batas isolasi (isolation boundary) yang berbeda:
- Shared Database (Tenant ID / Row-Level Scoping): Semua tenant berbagi skema database yang sama (biasanya skema
public). Setiap tabel bisnis memiliki kolom referensi sepertitenant_id. Isolasi ditegakkan pada level aplikasi via Django ORM Managers, atau pada level database via PostgreSQL Row-Level Security (RLS). - Isolated Schema per Tenant (django-tenants): Satu database PostgreSQL dibagi menjadi banyak skema terpisah. Setiap tenant memiliki namespace skema sendiri yang berisi replikasi tabel-tabel aplikasi, sementara data global (seperti user login atau katalog tenant) berada di skema
public. Pergantian konteks dilakukan dengan memanipulasi parameter sesisearch_path.
2. Trade-off Teknis: Migrasi, Isolasi, dan Connection Pooling
Kompleksitas Migrasi Skema
Pada pendekatan Shared Database, migrasi skema identik dengan aplikasi monolitik standar. Perintah python manage.py migrate dieksekusi satu kali per database. Waktu migrasi bergantung pada ukuran tabel dan ada tidaknya operasi yang memicu exclusive table lock, bukan pada jumlah tenant.
Sebaliknya, pada Isolated Schema, eksekusi migrasi bersifat linear terhadap jumlah tenant: O(N) di mana N adalah jumlah skema. Jika aplikasi memiliki 1.000 tenant dan satu migrasi memakan waktu 0,5 detik per skema, total durasi deployment mencapai minimal 500 detik. Kegagalan migrasi di tengah proses (misalnya pada skema ke-450) menempatkan sistem pada kondisi split-brain skema yang rumit untuk di-rollback secara otomatis.
Batas Isolasi Data
Isolated Schema memberikan isolasi logis yang kuat. Kebocoran data akibat bug query (misalnya developer lupa menambahkan klausa filter(tenant_id=current_tenant) atau kesalahan join query) tereliminasi secara struktural karena PostgreSQL membatasi pembacaan tabel hanya pada skema di search_path aktif. Operasi backup dan restore per-tenant juga trivial menggunakan perintah pg_dump -n schema_name.
Shared Database membebankan keamanan data sepenuhnya pada layer aplikasi. Kesalahan penulisan raw SQL query via cursor.execute() tanpa parameterisasi tenant berisiko fatal membocorkan seluruh data tenant lain. Mitigasi wajib dilakukan dengan memberlakukan PostgreSQL RLS jika compliance menuntut batasan di level engine.
Efisiensi Connection Pooling (PgBouncer)
Efisiensi resource koneksi sangat dipengaruhi oleh pooling mode:
- Shared Database: Sangat kompatibel dengan PgBouncer pada mode Transaction Pooling. Koneksi fisik ke database dapat dikembalikan ke pool segera setelah transaksi SQL selesai. Ribuan worker Django (misalnya Gunicorn/Uvicorn) dapat dilayani dengan aman oleh 50-100 koneksi PostgreSQL fisik.
- Isolated Schema: Mengubah skema tenant bergantung pada eksekusi
SET search_path TO tenant_schema, public;. Operasi ini mengubah session state koneksi. Pada mode Transaction Pooling, PgBouncer mendistribusikan query berikutnya dari client yang sama ke koneksi backend acak, merusak kontekssearch_pathdan menimbulkan kebocoran cross-tenant. Akibatnya, arsitektur skema sering dipaksa menggunakan mode Session Pooling, yang mengikat koneksi fisik selama proses worker hidup, meniadakan efisiensi multiplexing koneksi.
3. Dampak Biaya Operasional dan PostgreSQL Overhead
Peningkatan jumlah tenant pada pendekatan Isolated Schema membawa dampak tersembunyi pada internal catalog PostgreSQL:
- System Catalog Bloat: PostgreSQL menyimpan metadata setiap tabel, kolom, index, dan constraint di system catalog (
pg_class,pg_attribute,pg_index). Jika aplikasi memiliki 80 tabel dan 1.000 tenant, PostgreSQL harus melacak setidaknya 80.000 relasi dasar ditambah seluruh index-nya. Dampaknya, query planner melambat karena pencarian metadata memakan memory lebih besar dan tidak muat lagi di CPU L1/L2 cache. - Memory Footprint (Shared Buffers & Cache Invalidation): Setiap relasi terbuka membutuhkan memori (relcache/relation cache) pada setiap backend connection PostgreSQL. Ketika ratusan tenant aktif secara bersamaan, memori overhead engine meningkat tajam di luar buffer data aktual.
- File Descriptors: PostgreSQL memetakan setiap tabel dan index ke file fisik di disk. Bertambahnya skema membuat OS file handle habis lebih cepat, membutuhkan penyesuaian parameter OS (
nofile) dan database (max_files_per_process).
Pada pendekatan Shared Database, catalog metadata tetap konstan terlepas dari apakah ada 10 atau 100.000 tenant. Penambahan volume diselesaikan melalui strategi database standar: indexing komposit (tenant_id, id) dan PostgreSQL native partitioning (declarative partitioning) berbasis list/hash tenant.
4. Implementasi Shared Database dengan TenantAwareManager
Berikut implementasi minimal multi-tenancy berbasis baris menggunakan contextvars untuk isolasi thread/coroutine safe dan custom ORM Manager.
a. Penyimpanan Konteks Tenant (context.py)
import contextvars
_current_tenant_id = contextvars.ContextVar("current_tenant_id", default=None)
def set_current_tenant_id(tenant_id: int | None):
_current_tenant_id.set(tenant_id)
def get_current_tenant_id() -> int | None:
return _current_tenant_id.get()
b. Model Abstract dan Scoped Manager (models.py)
from django.db import models
from django.core.exceptions import ImproperlyConfigured
from .context import get_current_tenant_id
class TenantQuerySet(models.QuerySet):
def filter_by_current_tenant(self):
tenant_id = get_current_tenant_id()
if tenant_id is None:
# Cegah kebocoran data jika konteks tidak diset
return self.none()
return self.filter(tenant_id=tenant_id)
class TenantAwareManager(models.Manager):
def get_queryset(self):
return TenantQuerySet(self.model, using=self._db).filter_by_current_tenant()
class Tenant(models.Model):
name = models.CharField(max_length=100)
schema_key = models.SlugField(unique=True)
class TenantAwareModel(models.Model):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, db_index=True)
objects = TenantAwareManager()
unscoped = models.Manager() # Bypass untuk migrasi atau background cron
class Meta:
abstract = True
def save(self, *args, **kwargs):
if not self.tenant_id:
current_id = get_current_tenant_id()
if current_id is None:
raise ImproperlyConfigured("Operasi save ditolak: tenant_id konteks tidak valid.")
self.tenant_id = current_id
super().save(*args, **kwargs)
c. Middleware Ekstraksi Tenant (middleware.py)
from django.http import Http404
from .models import Tenant
from .context import set_current_tenant_id
class TenantResolutionMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Contoh ekstraksi dari subdomain atau custom request header
subdomain = request.get_host().split(".")[0]
try:
tenant = Tenant.objects.get(schema_key=subdomain)
set_current_tenant_id(tenant.id)
request.tenant = tenant
except Tenant.DoesNotExist:
set_current_tenant_id(None)
raise Http404("Tenant tidak ditemukan.")
response = self.get_response(request)
set_current_tenant_id(None) # Reset context setelah request selesai
return response
Catatan Keamanan: Implementasi Django ORM scoping di atas tetap membutuhkan validasi sanitasi pada serializer/form. Untuk isolasi absolut di level engine pada Shared Database, kombinasikan pendekatan ini dengan setting PostgreSQL app.current_tenant_id via session variable yang diikat pada PostgreSQL Row-Level Security (RLS).5. Panduan Keputusan: Kapan Memilih Shared DB vs Schema
Gunakan acuan kuantitatif berikut untuk menentukan arsitektur yang paling tepat:
| Kriteria | Pilih Shared Database (Tenant ID) | Pilih Isolated Schema (django-tenants) |
|---|---|---|
| Jumlah Tenant | > 500 hingga jutaan tenant (SaaS B2C / Low-tier B2B). | < 300 tenant (Enterprise B2B high-ACV). |
| Ukuran Tim Engineering | Tim kecil-menengah (1-8 developer). Tidak membutuhkan manajemen migrasi rumit. | Tim menengah ke atas dengan dedicated DevOps/DBA. |
| Kebutuhan Kepatuhan (Compliance) | Umum (SOC 2 Type II, ISO 27001) via RLS, audit log, dan enkripsi kolom. | Regulasi ketat (HIPAA, FedRAMP, per-tenant data shredding/export on-demand). |
| Infrastruktur Database | High-density connection pooling (PgBouncer Transaction Pooling). Budget DB terukur. | Database instances berukuran besar (RAM tinggi untuk mengakomodasi catalog size). |
| Kustomisasi Skema | Skema seragam untuk semua client. | Membutuhkan modifikasi tabel khusus per-client secara parsial. |
Kesimpulannya, jika target bisnis adalah platform SaaS dengan volume tenant besar dan margin terukur, Shared Database dengan Tenant ID adalah pilihan yang lebih terukur (scalable) dan hemat biaya operasional. Pilih Isolated Schema hanya jika kontrak enterprise menuntut pemisahan data fisik/skema dan nilai kontrak klien sebanding dengan lonjakan biaya maintenance infrastruktur PostgreSQL yang timbul.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!