Dalam arsitektur multi-tenant SaaS, tidak ada satu model isolasi data yang selalu paling baik. Shared schema biasanya paling efisien dan mudah dioperasikan, schema per tenant memberi pemisahan logis lebih jelas, sedangkan database per tenant memudahkan isolasi, pemulihan, dan penyesuaian untuk pelanggan tertentu—dengan biaya operasional yang lebih tinggi.

Keputusan sebaiknya didasarkan pada bentuk beban, konsekuensi kebocoran data, kebutuhan pemulihan per tenant, kepatuhan, dan kapasitas tim operasi. Banyak SaaS akhirnya memakai model hibrida: mayoritas tenant berada di database bersama, sementara tenant besar atau teregulasi dipindahkan ke database khusus.

Tiga Model Isolasi Data Multi-Tenant

1. Shared schema dengan kolom tenant_id

Semua tenant memakai database, schema, dan tabel yang sama. Setiap tabel bisnis memiliki kolom tenant_id untuk menentukan pemilik data.

CREATE TABLE invoices (
    tenant_id UUID NOT NULL,
    invoice_id UUID NOT NULL,
    customer_id UUID NOT NULL,
    amount NUMERIC(18, 2) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (tenant_id, invoice_id)
);

CREATE INDEX invoices_tenant_created_idx
    ON invoices (tenant_id, created_at DESC);

Model ini cocok ketika jumlah tenant dapat terus bertambah, bentuk datanya seragam, dan efisiensi infrastruktur menjadi prioritas. Satu migration dapat memperbarui semua tenant dan satu connection pool dapat melayani seluruh aplikasi.

Risiko utamanya adalah kesalahan query yang lupa menambahkan filter tenant. Tanpa guardrail di tingkat database, query seperti SELECT * FROM invoices dapat membaca data seluruh pelanggan.

2. Schema per tenant

Semua tenant berada dalam satu database PostgreSQL, tetapi masing-masing menggunakan schema tersendiri, misalnya tenant_acme.invoices dan tenant_zenith.invoices. Tabel dengan struktur identik dibuat berulang di setiap schema.

Pemisahan namespace mengurangi kemungkinan query lintas tenant secara tidak sengaja. Namun, jumlah objek database bertambah seiring pertumbuhan tenant. Setiap migration harus dijalankan pada seluruh schema, dan kegagalan di tengah proses dapat menyebabkan versi schema antar-tenant berbeda.

Model ini sering terlihat sebagai kompromi, tetapi tidak otomatis menjadi titik tengah terbaik. Ia tetap berbagi CPU, memori, I/O, WAL, dan proses database, sementara beban migration serta katalog sistem dapat mendekati kompleksitas model database per tenant.

3. Database per tenant

Setiap tenant mempunyai database sendiri. Database dapat berada dalam instance PostgreSQL yang sama atau ditempatkan pada instance berbeda untuk isolasi infrastruktur yang lebih kuat.

Model ini memudahkan pemberian kredensial, konfigurasi, backup, restore, dan jadwal maintenance yang berbeda per tenant. Namun, database terpisah pada server yang sama masih berbagi CPU, memori, disk, dan jaringan. Karena itu, database per tenant bukan berarti isolasi fisik kecuali tenant juga dipisahkan ke instance atau cluster berbeda.

Tantangan utamanya adalah jumlah connection pool, penyebaran migration, pengelolaan secret, pemantauan banyak database, serta penanganan tenant yang tertinggal versinya.

Perbandingan Trade-off Teknis

AspekShared schemaSchema per tenantDatabase per tenant
Isolasi dataPaling bergantung pada filter tenant_id, RLS, dan disiplin aplikasiPemisahan namespace lebih jelas, tetapi masih dalam satu databasePemisahan logis paling kuat; dapat ditingkatkan menjadi isolasi instance
Noisy neighborRisiko tinggi karena tabel dan resource dipakai bersamaTabel terpisah, tetapi resource database tetap bersamaLebih mudah dibatasi; tetap ada jika database berada pada host yang sama
Performa queryEfisien jika indeks diawali tenant_id; distribusi tenant yang timpang dapat memengaruhi rencana queryStatistik dan indeks terpisah per schema, tetapi jumlah objek bertambahStatistik dan tuning dapat disesuaikan per tenant
Connection poolingSatu atau sedikit pool dapat melayani semua tenantDapat berbagi pool, tetapi konteks search_path harus amanPool per database dapat menghasilkan banyak koneksi idle
Migrasi schemaSatu migration untuk semua tenantMigration harus diulang dan dilacak per schemaMigration harus diorkestrasi pada armada database
Backup dan restore per tenantSulit; biasanya perlu memulihkan salinan lalu mengekstrak tenantEkspor logis per schema memungkinkan, tetapi pemulihan konsisten tetap perlu dirancangLebih mudah jika platform backup mendukung unit database tersebut
KepatuhanMemerlukan kontrol akses dan bukti isolasi yang kuatDapat membantu pemisahan logis, tetapi belum tentu memenuhi residensi dataLebih mudah ditempatkan di region atau instance khusus
ObservabilityMudah secara sistem, tetapi metrik per tenant dapat berisiko berkardinalitas tinggiSchema dapat menjadi dimensi diagnosisDiagnosis per tenant jelas, tetapi jumlah target monitoring meningkat
Biaya infrastrukturUmumnya paling efisienMasih berbagi compute, dengan overhead objek dan operasiBerpotensi paling mahal, terutama jika memakai instance khusus
Beban operasionalRendah pada deployment, tinggi pada guardrail dataTinggi pada migration dan pengelolaan objekTinggi pada provisioning, koneksi, backup, secret, dan upgrade

Performa query dan noisy neighbor

Pada shared schema, indeks sebaiknya mengikuti pola akses tenant. Untuk query yang selalu mencari invoice milik satu tenant berdasarkan waktu, indeks (tenant_id, created_at) lebih relevan daripada indeks created_at saja. Constraint unik juga biasanya perlu mencakup tenant.

CREATE UNIQUE INDEX invoices_tenant_external_ref_uidx
    ON invoices (tenant_id, external_reference);

Foreign key juga perlu mempertahankan ruang lingkup tenant. Jika hanya menggunakan customer_id, aplikasi dapat secara tidak sengaja menghubungkan invoice suatu tenant dengan customer tenant lain.

ALTER TABLE customers
    ADD CONSTRAINT customers_tenant_customer_uniq
    UNIQUE (tenant_id, customer_id);

ALTER TABLE invoices
    ADD CONSTRAINT invoices_customer_fk
    FOREIGN KEY (tenant_id, customer_id)
    REFERENCES customers (tenant_id, customer_id);

Noisy neighbor tidak hanya berasal dari query lambat. Bulk import, laporan analitik, pembuatan indeks, autovacuum, dan pertumbuhan tabel satu tenant juga dapat mengganggu tenant lain. Mitigasinya meliputi rate limit per tenant, antrean kerja terpisah, timeout query, pembatasan konkurensi, read replica untuk beban baca yang sesuai, dan pemindahan tenant berat ke placement khusus.

Connection pooling

Shared schema paling ramah terhadap connection pooling karena tenant tidak menentukan target database. Schema per tenant juga dapat memakai satu pool, tetapi aplikasi tidak boleh mengandalkan search_path yang tersimpan permanen pada sesi. Koneksi dari pool dapat digunakan kembali oleh tenant lain.

Database per tenant dapat menyebabkan ledakan koneksi jika setiap proses aplikasi membuat pool untuk setiap database. Gunakan registry placement dan pool yang dibatasi, tutup pool yang lama tidak dipakai, atau tempatkan pooler di depan PostgreSQL. Kapasitas harus dihitung dari jumlah instance aplikasi, jumlah database aktif, dan batas koneksi server—bukan hanya jumlah tenant terdaftar.

Dengan pooling berbasis transaksi, state sesi tidak boleh dianggap tetap. Atur konteks tenant menggunakan transaksi dan state lokal transaksi, lalu jalankan semua query terkait melalui koneksi yang sama.

Backup, restore, dan point-in-time recovery

Backup fisik PostgreSQL umumnya mencakup unit server atau cluster yang dikelola oleh platform. Pada shared schema, pemulihan satu tenant sering membutuhkan prosedur berikut: pulihkan backup ke lingkungan sementara, pilih data tenant, validasi relasi, lalu impor kembali ke produksi. Ini lebih lambat dan rawan konflik dibanding restore database khusus.

Ekspor logis berdasarkan tenant_id dapat membantu, tetapi bukan pengganti otomatis untuk point-in-time recovery yang konsisten. Data tenant mungkin tersebar di banyak tabel, object storage, search index, cache, dan sistem eksternal. Prosedur restore harus mencakup seluruh dependensi tersebut serta diuji secara berkala.

Database per tenant menyederhanakan batas pemulihan, tetapi kemampuan restore tetap bergantung pada layanan PostgreSQL yang digunakan. Pastikan unit backup, retensi WAL, enkripsi, region penyimpanan, dan proses restore benar-benar mendukung target pemulihan per tenant.

Pola Implementasi Aman di PostgreSQL

Row-Level Security untuk shared schema

PostgreSQL Row-Level Security atau RLS dapat menjadikan konteks tenant sebagai kebijakan database, bukan sekadar konvensi di lapisan aplikasi.

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;

CREATE POLICY invoices_tenant_isolation ON invoices
USING (
    tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::UUID
)
WITH CHECK (
    tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::UUID
);

Aplikasi mengatur tenant pada awal transaksi menggunakan nilai yang berasal dari identitas terautentikasi atau registry internal.

BEGIN;

SELECT set_config('app.tenant_id', $1, true);

SELECT invoice_id, amount, created_at
FROM invoices
WHERE created_at >= $2
ORDER BY created_at DESC;

COMMIT;

Argumen true pada set_config membatasi nilai pada transaksi saat ini. Semua statement harus dijalankan melalui koneksi yang sama sampai commit atau rollback.

RLS bukan perlindungan mutlak jika role aplikasi adalah superuser, memiliki atribut BYPASSRLS, atau menggunakan role pemilik tabel tanpa konfigurasi yang tepat. Praktik yang lebih aman adalah memisahkan role pemilik migration dari role runtime, mengaktifkan FORCE ROW LEVEL SECURITY jika sesuai, serta memastikan role aplikasi tidak memiliki hak bypass.

search_path untuk schema per tenant

Nama schema harus berasal dari registry internal, bukan langsung dari header atau parameter pengguna. Identifier SQL tidak dapat diperlakukan seperti parameter nilai biasa, sehingga perlu validasi dan quoting yang benar.

BEGIN;

SELECT set_config(
    'search_path',
    quote_ident($1) || ', public',
    true
);

SELECT invoice_id, amount
FROM invoices
ORDER BY created_at DESC;

COMMIT;

Gunakan transaksi agar search_path tidak bocor ketika koneksi dikembalikan ke pool. Untuk operasi sensitif, nama tabel yang dilengkapi schema secara eksplisit lebih mudah diaudit daripada bergantung pada resolusi search_path.

Cabut izin pembuatan objek yang tidak diperlukan pada schema bersama. Jika tenant atau role yang tidak tepercaya dapat membuat fungsi atau tabel pada schema yang lebih dahulu dicari, object shadowing dapat mengarahkan query ke objek yang salah.

Routing untuk database per tenant

Gunakan tenant placement registry sebagai control plane. Registry memetakan tenant ke model penyimpanan dan lokasi data, misalnya:

tenant_id       storage_model    placement_key
acme            shared           shard-eu-1
zenith          database         db-zenith-eu
internal-demo   schema           shared-dev-2:tenant_demo

Alur request yang aman adalah:

  1. Autentikasi pengguna dan tentukan tenant yang boleh diakses.
  2. Ambil placement tenant dari registry server-side.
  3. Pilih pool database atau shard berdasarkan placement.
  4. Mulai transaksi dan pasang konteks RLS atau schema jika diperlukan.
  5. Jalankan query, commit atau rollback, lalu kembalikan koneksi.

Jangan mempercayai tenant_id dari header hanya karena formatnya valid. Header dapat menjadi petunjuk pemilihan tenant, tetapi harus diverifikasi terhadap session, token, keanggotaan pengguna, atau kredensial service-to-service.

Guardrail Pencegah Kebocoran Data

Isolasi tenant perlu diterapkan berlapis. Mengandalkan satu repository method atau middleware saja membuat keamanan bergantung pada tidak adanya kesalahan pemrograman.

  • Identitas: turunkan tenant dari principal terautentikasi dan verifikasi membership atau scope.
  • Database: gunakan RLS, schema terpisah, atau kredensial database khusus sesuai model.
  • Model data: masukkan tenant_id dalam primary key, unique constraint, dan foreign key yang relevan.
  • Transaksi: pasang konteks tenant secara lokal pada setiap transaksi, bukan sebagai state sesi permanen.
  • API: cegah mass assignment terhadap tenant_id; server harus mengisinya dari konteks otorisasi.
  • Cache: sertakan tenant dalam cache key, idempotency key, lock, dan namespace object storage.
  • Background job: simpan tenant yang telah divalidasi dalam payload dan bangun ulang konteks sebelum memproses job.
  • Pengujian: buat negative test yang memastikan pengguna tenant A tidak dapat membaca, mengubah, atau menghapus data tenant B.
  • Logging: catat tenant placement dan request ID, tetapi hindari data sensitif dan label metrik berkardinalitas tak terkendali.

Kesalahan umum adalah menerapkan filter tenant pada endpoint daftar, tetapi melupakannya pada endpoint detail, update, ekspor, atau background worker. Uji semua jalur akses, termasuk administrative tools dan script maintenance.

Tabel Keputusan Berdasarkan Bentuk Beban

Tidak ada ambang jumlah tenant, ukuran database, atau volume transaksi yang universal. Batas nyata bergantung pada hardware, pola query, target pemulihan, kemampuan platform, dan kapasitas tim. Gunakan tabel berikut sebagai arah keputusan, lalu validasi dengan pengukuran produksi dan load test yang menyerupai distribusi tenant sebenarnya.

KondisiPilihan awal yang masuk akalAlasan dan catatan
Banyak tenant dengan beban relatif kecil dan struktur data seragamShared schemaEfisien untuk koneksi, migration, dan utilisasi resource; wajib memiliki RLS serta indeks tenant-aware
Beban mayoritas seragam, tetapi ada beberapa tenant jauh lebih besarShared schema dengan jalur ke model hibridaTempatkan tenant umum bersama dan pindahkan outlier ke database atau shard khusus
Tenant memerlukan namespace objek atau extension logis yang berbedaSchema per tenantCocok jika variasi masih dapat dikelola; perhitungkan orkestrasi migration dan pertumbuhan katalog
Restore per tenant merupakan kebutuhan utamaDatabase per tenant atau kelompok database kecilBatas backup lebih jelas, tetapi harus diverifikasi terhadap kemampuan layanan PostgreSQL
Kebutuhan residensi data berbeda antar-pelangganDatabase per tenant atau shard regionalPlacement registry dapat memetakan tenant ke region yang diizinkan
Tenant tertentu memerlukan maintenance window atau versi khususDatabase khususMemberi fleksibilitas, tetapi meningkatkan risiko version drift dan biaya dukungan
Tim operasi kecil dan perubahan schema seringShared schemaMengurangi jumlah target migration dan komponen yang perlu dipantau
Beban analitik tenant besar mengganggu transaksi tenant lainDatabase khusus, shard, atau jalur analitik terpisahSchema per tenant saja tidak menghilangkan persaingan resource pada database yang sama
Konsekuensi kebocoran lintas tenant sangat tinggiDatabase atau instance khususPilih batas isolasi yang sesuai threat model; arsitektur tetap harus disertai IAM, audit, dan enkripsi

Observability dan Operasi Harian

Shared schema memerlukan visibilitas terhadap distribusi beban. Pantau query lambat, ukuran data, jumlah row, penggunaan cache, pekerjaan antrean, dan error berdasarkan kelas tenant. Namun, menjadikan setiap tenant_id sebagai label permanen pada semua metrik dapat menciptakan cardinality tinggi.

Gunakan kombinasi berikut:

  • Metrik agregat berdasarkan shard, region, tier, atau kelas workload.
  • Tracing dengan tenant yang disamarkan atau dibatasi sesuai kebijakan data.
  • Log terstruktur untuk investigasi tenant tertentu.
  • Daftar tenant terberat yang dihitung secara periodik, bukan label untuk seluruh tenant.
  • Dashboard migration yang menunjukkan versi schema, kegagalan, durasi, dan tenant tertinggal.

Untuk schema atau database per tenant, control plane perlu melacak status provisioning, versi migration, backup terakhir, hasil uji restore, region, kredensial, dan lifecycle tenant. Tanpa registry yang konsisten, otomatisasi akan bergantung pada penamaan resource dan mudah menghasilkan orphan database.

Jalur Migrasi Bertahap Menuju Model Hibrida

Perubahan model isolasi tidak harus dilakukan sebagai migrasi serentak. Tambahkan abstraksi placement sejak awal agar aplikasi tidak mengasumsikan seluruh tenant selalu berada pada satu database.

  1. Inventarisasi akses data. Pastikan semua tabel, object storage, cache, search index, dan job memiliki kepemilikan tenant yang jelas.
  2. Perkenalkan registry placement. Simpan lokasi tenant pada control plane dan gunakan resolver tunggal dalam aplikasi.
  3. Tentukan unit migrasi. Daftar semua tabel dan dependensi yang harus dipindahkan secara konsisten.
  4. Siapkan target. Provision database, jalankan migration schema, buat role, dan konfigurasikan backup serta monitoring.
  5. Salin snapshot awal. Ekspor hanya data tenant tujuan sambil mempertahankan primary key dan relasi.
  6. Tangkap perubahan berjalan. Gunakan CDC yang mendukung kebutuhan filtering atau outbox aplikasi. Hindari dual-write tanpa mekanisme idempotensi dan rekonsiliasi.
  7. Validasi. Bandingkan jumlah row, checksum yang sesuai, total bisnis, foreign key, dan sampel query.
  8. Cutover. Hentikan sementara penulisan atau tunggu replikasi mengejar, ubah registry secara atomik, lalu arahkan traffic ke target.
  9. Pertahankan rollback window. Jangan langsung menghapus data sumber. Tetapkan sumber kebenaran tunggal agar penulisan tidak bercabang.
  10. Bersihkan sumber. Setelah periode validasi, hapus data lama sesuai kebijakan retensi dan buktikan bahwa cache maupun job tertunda tidak lagi menunjuk placement lama.

Model hibrida yang umum adalah shared schema untuk tenant standar, beberapa shard bersama untuk distribusi beban, dan database khusus untuk tenant besar atau teregulasi. Routing tetap seragam karena aplikasi bertanya kepada registry, bukan menebak lokasi dari ID tenant.

Dual-write langsung ke sumber dan target terlihat sederhana, tetapi kegagalan parsial dapat membuat kedua sisi berbeda. Jika dual-write tidak dapat dihindari, gunakan idempotency key, pencatatan status, retry terkontrol, dan proses rekonsiliasi.

Checklist Keputusan dan Tanda Model Perlu Diubah

Checklist sebelum memilih model

  • Seberapa besar dampak bisnis dan hukum jika terjadi akses lintas tenant?
  • Apakah ada kebutuhan residensi data, kunci enkripsi, atau region khusus?
  • Apakah backup dan restore harus dapat dilakukan untuk satu tenant?
  • Apakah distribusi beban relatif merata atau memiliki beberapa outlier besar?
  • Berapa banyak koneksi yang dapat ditanggung oleh aplikasi, pooler, dan PostgreSQL?
  • Seberapa sering schema berubah dan bagaimana migration akan dilacak?
  • Apakah tenant memerlukan jadwal maintenance atau konfigurasi berbeda?
  • Bagaimana tenant diidentifikasi pada API, worker, cache, log, dan object storage?
  • Apakah tim memiliki otomatisasi provisioning, secret rotation, backup, restore, dan observability?
  • Apakah jalur pemindahan tenant telah diuji sebelum menjadi kebutuhan darurat?

Tanda model saat ini perlu diubah

  • Satu tenant berulang kali menyebabkan latensi atau antrean bagi tenant lain.
  • Maintenance tabel bersama semakin sering mengganggu layanan.
  • Restore satu tenant membutuhkan prosedur manual yang tidak memenuhi target pemulihan.
  • Kebutuhan kepatuhan atau residensi data tidak dapat dipenuhi oleh placement saat ini.
  • Connection pool tumbuh tidak terkendali setelah menerapkan database per tenant.
  • Migration schema per tenant sering gagal, tertinggal, atau menghasilkan version drift.
  • Biaya database khusus untuk tenant kecil lebih besar daripada manfaat isolasinya.
  • Query plan memburuk akibat distribusi ukuran tenant yang sangat timpang.
  • Tim tidak dapat mengetahui dengan cepat lokasi, versi schema, dan status backup suatu tenant.
  • Insiden kebocoran konteks tenant muncul pada cache, job, ekspor, atau administrative tooling.

Untuk kebanyakan SaaS, shared schema dengan RLS, constraint tenant-aware, dan abstraksi placement adalah titik awal yang praktis. Namun, desain tersebut sebaiknya tidak mengunci seluruh tenant pada satu lokasi selamanya. Registry routing dan prosedur migrasi yang diuji memungkinkan sistem berkembang menjadi hibrida saat kebutuhan isolasi, performa, atau kepatuhan berubah.