← semua materi
05:00 Arsitektur Β· 22 Oct 2026 Β· 05:01

πŸ“š 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:

  1. Memilih model isolasi tenant berdasarkan kebutuhan compliance, skala tenant, dan biaya operasional β€” bukan berdasarkan "yang lagi tren".
  2. Menjelaskan kenapa shared-DB bukan berarti tidak aman, dan kenapa DB-per-tenant bukan berarti bebas masalah.
  3. Mendesain tenant routing yang tidak bocor (leak) di level connection pool, cache, dan background job.
  4. 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)

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.

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)

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:

  1. SET LOCAL (bukan SET) + transaksi eksplisit.
  2. RLS aktif + role aplikasi tanpa BYPASSRLS.
  3. Test yang secara eksplisit reuse koneksi lintas tenant dan assert 0 rows.

βš–οΈ Trade-off & Kapan JANGAN Dipakai

Shared DB + RLS β€” jangan dipakai kalau:

Schema-per-tenant β€” jangan dipakai kalau:

DB-per-tenant β€” jangan dipakai kalau:

Kapan pilihan "salah" justru benar


🏭 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

  1. Kamu punya 300 tenant di shared DB + RLS, connection pool PgBouncer transaction-mode. Engineer baru menambahkan middleware yang memanggil SET app.tenant_id = ... (bukan SET LOCAL) di awal request. Test fungsional semua hijau. Jelaskan skenario konkret kebocoran data yang mungkin terjadi, dan kenapa test fungsional tidak menangkapnya.

  2. 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".

  3. 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


➑️ Eksplorasi Lanjut