π Multi-tenancy: Shared DB vs Schema-per-Tenant vs DB-per-Tenant
Kategori: System Architecture Β· Level: IntermediateβAdvanced
π― Tujuan Belajar
Setelah sesi ini kamu bisa:
- Memilih model isolasi tenant berdasarkan kebutuhan compliance, skala tenant, dan biaya operasional β bukan berdasarkan "yang lagi tren".
- Menjelaskan kenapa shared-DB bukan berarti tidak aman, dan kenapa DB-per-tenant bukan berarti bebas masalah.
- Mendesain tenant routing yang tidak bocor (leak) di level connection pool, cache, dan background job.
- Mengenali titik patah nyata: noisy neighbor, migration fan-out, connection exhaustion, dan cross-tenant query bug.
π§ Penjelasan Inti
Multi-tenancy = satu instance aplikasi melayani banyak organisasi (tenant), dengan jaminan bahwa data tenant A tidak pernah terlihat, termutasi, atau "terpengaruh performa" oleh tenant B.
Tiga sumbu yang selalu diperdagangkan:
| Sumbu | Shared DB (row-level) | Schema-per-tenant | DB-per-tenant |
|---|---|---|---|
| Isolasi data | Logis (WHERE tenant_id) | Logis + namespace | Fisik |
| Blast radius | Semua tenant | Satu schema | Satu instance DB |
| Biaya per tenant | ~0 | Rendah | Tinggi (instance/pod) |
| Jumlah tenant realistis | 10β΄β10βΆ | 10Β²β10Β³ | 10ΒΉβ10Β² (atau 10Β³ dengan automation) |
| Migrasi skema | 1x | Nx (fan-out) | Nx (fan-out + koordinasi) |
| Noisy neighbor | Tinggi | Sedang | Rendah |
| Per-tenant restore | Sulit | Mudah | Mudah |
Masalah yang diselesaikan multi-tenancy bukan "hemat server". Yang diselesaikan: satu codebase, satu deployment pipeline, satu observability stack untuk N pelanggan. Penghematan biaya adalah efek samping; yang utama adalah biaya rekayasa per tenant mendekati nol.
Prinsip penting: pilihan multi-tenancy adalah pilihan blast radius. Semua trade-off lain (biaya, kompleksitas migrasi, performa) adalah turunan dari seberapa besar kerusakan yang kamu izinkan terjadi bersamaan.
βοΈ Cara Kerja Internal
1. Shared DB + tenant_id (pool model)
Request β Auth β resolve tenant_id β SET app.tenant_id β query
β
WHERE tenant_id = current_setting('app.tenant_id')
Dua varian implementasi:
(a) Application-enforced β setiap query menulis WHERE tenant_id = ?. Cepat, tapi satu query yang lupa = kebocoran data lintas tenant. Ini bukan hipotesis; ini kelas bug paling umum di SaaS.
(b) Database-enforced (PostgreSQL RLS) β Row Level Security memaksa filter di level planner, bukan di level aplikasi:
ALTER TABLE invoice ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoice
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- Koneksi aplikasi memakai role NON-superuser, NON-owner:
-- owner/superuser/BYPASSRLS melewati policy sepenuhnya.
Detail yang sering salah: current_setting harus di-set per transaksi, bukan per session, kalau pakai connection pooler:
BEGIN;
SET LOCAL app.tenant_id = '...'; -- LOCAL = reset otomatis di akhir transaksi
SELECT ...;
COMMIT;
Pakai SET (bukan SET LOCAL) di belakang PgBouncer transaction-mode pooling = tenant berikutnya mewarisi tenant_id tenant sebelumnya. Ini cross-tenant data leak lewat connection reuse, dan tidak terdeteksi oleh test fungsional biasa.
2. Schema-per-tenant
db
βββ tenant_acme (tabel: invoice, user, ...)
βββ tenant_globex (tabel: invoice, user, ...)
βββ public (tabel: tenant_registry, migration_version)
search_pathdi-set per koneksi/transaksi:SET LOCAL search_path = tenant_acme, public.- Migrasi: satu file SQL dieksekusi N kali. Butuh migration runner yang idempoten + advisory lock per schema supaya tidak dobel jalan saat dua worker migrasi bersamaan.
- Batas praktis PostgreSQL: puluhan ribu schema masih jalan, tapi
pg_class,pg_attribute, dan catalog bloat mulai terasa; autovacuum punya lebih banyak relasi untuk dikelola;information_schemaquery jadi lambat. Angka pasti bergantung workload β ukur, jangan asumsikan.
3. DB-per-tenant
Control plane (shared): tenant_registry, billing, auth
Data plane (per tenant): postgres instance / logical DB / dedicated cluster
Yang sering dilupakan: DB-per-tenant memindahkan masalah, bukan menghilangkan.
- Connection pooling: 500 tenant Γ pool 20 = 10.000 koneksi ke Postgres. Tidak mungkin. Butuh pooler per tenant, atau pool yang di-provision on-demand dan di-reap saat idle.
- Migrasi: fan-out ke N database, harus resumable, harus bisa skip tenant yang sedang down, harus punya drift detection (schema tenant X ketinggalan versi 3 migrasi).
- Observability: N target scrape. Metrik harus di-aggregate per tenant dan tetap bisa drill-down.
- Biaya kontrol plane bisa melebihi biaya data plane kalau automation-nya lemah.
Varian tengah yang sering jadi jawaban nyata: logical DB per tenant dalam satu cluster (bukan instance terpisah). Isolasi backup/restore tercapai, biaya marginal rendah, tapi blast radius tetap satu cluster.
4. Hybrid / "pool + silo" (paling umum di produksi)
- Tenant kecil β pool (shared, RLS).
- Tenant enterprise (>X rows, butuh residency/compliance) β silo (DB sendiri).
- Registry memetakan
tenant_id β (mode, connection_string). - Aplikasi harus abstrak routing di satu tempat, bukan
if tenant.is_enterprisetersebar di 40 file.
TenantRouter.resolve(tenant_id) β DataSource
ββ POOL β shared pool, SET LOCAL app.tenant_id
ββ SILO β pool khusus tenant tsb
Aturan: tidak ada satu pun query bisnis yang boleh tahu mode tenancy. Kalau lapisan repository sudah menerima DataSource yang benar, sisanya identik.
ποΈ Diagram: jalur kebocoran
Request A (tenant=acme)
β pool.acquire() βββ
β SET app.tenant_id='acme' (session-level, SALAH)
β query βββββββββββββ
β conn.release() β koneksi kembali ke pool, tenant_id masih 'acme'
Request B (tenant=globex)
β pool.acquire() β dapat koneksi yang sama
β query tanpa SET (bug: dianggap sudah di-set)
β WHERE tenant_id='acme' β RLS menyelamatkan ATAU bocor
Tiga pertahanan berlapis, semuanya murah:
SET LOCAL(bukanSET) + transaksi eksplisit.- RLS aktif + role aplikasi tanpa
BYPASSRLS. - Test yang secara eksplisit reuse koneksi lintas tenant dan assert 0 rows.
βοΈ Trade-off & Kapan JANGAN Dipakai
Shared DB + RLS β jangan dipakai kalau:
- Ada kewajiban data residency (data tenant X harus fisik di yurisdiksi tertentu). RLS tidak memenuhi ini; butuh DB/region terpisah.
- Ada tenant yang butuh point-in-time restore independen ("restore data kami ke jam 14:00 kemarin, jangan sentuh tenant lain").
pg_dumpper tenant dari DB shared itu lambat dan mengunci; PITR itu per-cluster. - Ada noisy neighbor yang tidak bisa di-throttle di level query (misal full-table scan oleh tenant dengan dataset 1000x lebih besar). Connection pool + statement timeout membantu, tapi tidak menyelesaikan I/O contention.
- Tenant butuh custom schema/field (bukan sekadar custom value). Kalau kamu mulai menambah
custom_field_1..custom_field_50, itu sinyal sudah salah model.
Schema-per-tenant β jangan dipakai kalau:
- Tenant bisa mencapai ribuan+ dan kamu belum punya automation migrasi yang solid. Fan-out migrasi manual = drift permanen.
- Query lintas tenant dibutuhkan sering (analytics platform-wide, billing). Setiap cross-schema join menambah kompleksitas dan biaya planner.
- Kamu memakai connection pooler transaction-mode tanpa hati-hati β
search_pathadalah session state, masalah yang sama denganapp.tenant_id, hanya lebih halus.
DB-per-tenant β jangan dipakai kalau:
- Kamu belum punya control plane (provisioning, migrasi fan-out, drift detection, backup orchestration). DB-per-tenant tanpa automation = kamu jadi DBA manual untuk 200 pelanggan.
- Tenant < 50 dan homogenous. Overhead operasional tidak terbayar.
- Tim kecil (< 5 engineer). Beban kognitifnya nyata dan permanen.
Kapan pilihan "salah" justru benar
- Startup tahap awal: shared DB + RLS sejak hari pertama. Migrasi ke silo nanti lebih murah daripada membangun control plane yang tidak terpakai.
- Enterprise dengan 20 pelanggan besar dan kontrak compliance: DB-per-tenant sejak awal. Shared DB akan ditolak di security review.
- Jangan mulai dengan schema-per-tenant kecuali ada alasan spesifik β ia menggabungkan kerumitan fan-out dengan isolasi yang tetap logis (bukan fisik). Sering jadi worst of both.
π Contoh Produksi & Failure Mode
1. Connection pool + session state (kelas bug #1).
PgBouncer pool_mode=transaction + SET session-level. Request A set app.tenant_id, release; request B dapat koneksi sama, RLS mengevaluasi dengan tenant A. Terdeteksi hanya kalau ada assertion lintas tenant. Mitigasi: SET LOCAL di dalam BEGIN, dan pertimbangkan pool_mode=session untuk beban rendah.
2. Migration drift di schema-per-tenant.
Deploy migrasi ke 400 schema; 12 schema gagal karena advisory lock timeout / koneksi putus. Tidak ada yang gagal di CI karena CI hanya test 1 schema. Hasil: 12 tenant jalan dengan kode baru + skema lama β error runtime yang muncul berhari-hari kemudian. Mitigasi: migration runner idempoten, migration_version per schema, health check yang membandingkan versi skema semua tenant terhadap versi deploy.
3. Noisy neighbor di shared DB.
Satu tenant upload CSV 5 juta baris β COPY + index rebuild β I/O saturasi β p99 semua tenant naik 10x. Mitigasi berlapis: statement timeout, work_mem per role, queue untuk job berat, dan (kalau perlu) pindahkan tenant tersebut ke silo. Deteksi dini butuh metrik per tenant (pg_stat_statements di-group by app.tenant_id via query_tag, atau sampling).
4. Cross-tenant cache poisoning.
Cache key invoice:123 tanpa prefix tenant. Tenant A dan B punya invoice dengan ID 123 (karena sequence per-schema, atau UUID yang kebetulan sama setelah restore). Mitigasi: selalu prefix key dengan tenant_id; jadikan itu invariant di satu helper, bukan tanggung jawab tiap developer.
5. Background job tanpa tenant context.
Worker mengambil job dari queue, tapi job payload tidak membawa tenant_id β worker query dengan koneksi yang kebetulan masih punya app.tenant_id lama, atau query tanpa filter. Ini sumber kebocoran yang paling sering lolos code review karena jalurnya bukan HTTP request. Mitigasi: tenant_id wajib ada di payload job, dan worker men-set context sebelum menyentuh DB.
6. Tenant deletion / GDPR.
"Lupakan tenant X" di shared DB = DELETE lintas ~80 tabel dengan FK ordering, plus cache invalidation, plus backup yang masih menyimpan data (backup retention vs right-to-erasure β konflik nyata yang sering diabaikan). DB-per-tenant: drop database, selesai. Ini argumen kuat untuk silo pada tenant dengan beban compliance tinggi.
7. Sequence & ID collision.
Shared DB dengan tenant_id + auto-increment global aman. Schema-per-tenant dengan sequence per schema β ID sama di tenant berbeda. Kalau ID itu bocor ke sistem eksternal (webhook, export, URL publik), kamu punya ambiguitas. Mitigasi: UUID/ULID, atau selalu sertakan tenant_id di identifier eksternal.
β οΈ Miskonsepsi Umum
"Shared DB tidak aman, DB-per-tenant aman."
DB-per-tenant mengamankan dari bug query, tapi tidak dari bug routing β salah DataSource = tenant A dilayani dengan koneksi tenant B. Isolasi fisik memindahkan letak kesalahan, tidak menghapusnya. Yang benar-benar mengamankan adalah invariant yang diuji, bukan topologi.
"RLS itu gratis." RLS menambah predikat ke setiap query. Pada query dengan index yang tidak cocok, planner bisa memilih plan yang jauh lebih buruk. Juga: RLS tidak berlaku untuk owner tabel β kalau aplikasi connect sebagai owner (umum di setup dev yang ikut ke prod), RLS tidak berefek sama sekali dan tidak ada yang error.
"Schema-per-tenant = isolasi." Isolasi namespace, bukan isolasi resource. Satu schema bisa menghabiskan shared_buffers, WAL, dan I/O yang sama.
"Kita selalu bisa migrasi nanti." Migrasi tenancy arahnya nyata dan mahal: pool β silo relatif mudah (salin data, switch routing); silo β pool sangat mahal (harus rekonsiliasi ID, dedupe, dan menyeragamkan skema yang sudah drift). Keputusan awal berat ke arah pool.
"Tenant isolation cukup di aplikasi." Satu jalur yang lupa β cron job, admin panel, analytics query, CLI support β sudah cukup untuk kebocoran. Isolasi harus di titik yang tidak bisa dilewati, bukan di titik yang paling sering ditulis.
β Cek Pemahaman
-
Kamu punya 300 tenant di shared DB + RLS, connection pool PgBouncer transaction-mode. Engineer baru menambahkan middleware yang memanggil
SET app.tenant_id = ...(bukanSET LOCAL) di awal request. Test fungsional semua hijau. Jelaskan skenario konkret kebocoran data yang mungkin terjadi, dan kenapa test fungsional tidak menangkapnya. -
Sebuah tenant enterprise meminta "restore data kami ke kondisi kemarin jam 14:00, tanpa menyentuh tenant lain". Kamu di shared DB + RLS. Sebutkan minimal tiga cara memenuhi ini, beserta biaya/risiko masing-masing, dan kapan jawabannya adalah "pindahkan tenant ini ke silo".
-
Kamu merancang sistem baru: 5.000 tenant, 40 di antaranya enterprise dengan kebutuhan data residency. Rancang model tenancy-nya (bukan "pilih salah satu"), lalu sebutkan tiga invariant yang harus diuji otomatis di CI agar model itu tidak bocor.
π Poin Kunci
- Multi-tenancy = keputusan tentang blast radius, bukan tentang hemat biaya.
- Shared DB aman jika isolasi ditegakkan di lapisan yang tidak bisa dilewati (RLS + role non-owner) dan diuji dengan skenario reuse koneksi.
SET LOCALdi dalam transaksi; session-levelSET+ pooler = kebocoran lintas tenant.- Schema-per-tenant sering worst-of-both: kompleksitas fan-out tanpa isolasi fisik.
- DB-per-tenant memindahkan masalah ke control plane: provisioning, migrasi fan-out, drift detection, pooling.
- Hybrid (pool + silo) adalah norma di produksi; kuncinya routing terisolasi di satu tempat, dan tidak ada query bisnis yang tahu mode tenancy.
- Jalur non-HTTP (background job, admin panel, analytics) adalah sumber kebocoran paling umum.
tenant_idwajib ada di payload job. - Migrasi arah poolβsilo murah; siloβpool mahal. Berat ke pool di awal.
- Setiap cache key dan identifier eksternal wajib membawa
tenant_id.
β‘οΈ Eksplorasi Lanjut
- PostgreSQL RLS: baca dokumentasi resmi soal
CREATE POLICY,FORCE ROW LEVEL SECURITY, dan perilakuBYPASSRLSβ terutama daftar role yang melewati policy. - PgBouncer pooling modes:
sessionvstransactionvsstatement, dan implikasinya pada prepared statements serta session state. - Control plane design: provisioning idempotent, migration fan-out yang resumable, drift detection (bandingkan
migration_versionper tenant terhadap versi deploy). - Noisy neighbor mitigation: per-tenant rate limiting, statement timeout, resource governor di level pool, dan kapan jawabannya adalah "pindahkan ke silo".
- Citus / distributed Postgres: sharding berbasis
tenant_idsebagai jalan tengah antara pool dan silo β pahami biaya rebalancing dan batasan cross-shard query sebelum mempertimbangkannya. - Data residency & right-to-erasure: bagaimana backup retention berkonflik dengan permintaan penghapusan data, dan kenapa ini sering memaksa silo.
- Lanjut ke topik terkait: sharding strategy (shard key =
tenant_id), connection pool sizing (Little's Law), dan idempotency untuk job migrasi fan-out.