2026-08-05 · 10 min
Multi-Tenant Database: Schema-per-Tenant vs Row-Level Isolation — Trade-off Nyata
Setiap kali membangun SaaS baru, pertanyaan yang sama selalu muncul di awal: gimana kita isolasi data antar-tenant? Dua jawaban paling umum adalah schema-per-tenant dan row-level security — dan keduanya benar, tergantung konteksnya. Di sini saya tulis apa yang benar-benar terjadi di production, bukan teori whitepaper.
Konteks: kapan masalah ini muncul
Masalah multi-tenancy mulai terasa begitu satu database dipakai bersama oleh lebih dari satu pelanggan (tenant). Kalau Anda hanya build internal tool untuk satu perusahaan, ini tidak relevan. Tapi begitu Anda build SaaS — apapun skalanya — desain isolasi data menentukan seberapa mudah Anda bisa scale, audit, dan debug di masa depan.
Dua pendekatan yang paling banyak dipakai di PostgreSQL:
- Schema-per-tenant: setiap tenant punya PostgreSQL schema sendiri (
tenant_a.orders,tenant_b.orders). Struktur tabel identik, tapi namespace terpisah. - Row-Level Security (RLS): semua tenant berbagi satu tabel (
public.orders), dengan kolomtenant_iddi setiap baris dan policy PostgreSQL yang memfilter akses secara otomatis.
Ada juga opsi ketiga — database-per-tenant — tapi itu biasanya hanya masuk akal kalau Anda jual produk on-premise atau managed dedicated, bukan SaaS multi-tenant murni.
Schema-per-tenant: setup dan cara kerjanya
Ide dasarnya sederhana. Saat tenant baru registrasi, Anda buat schema baru:
-- Provisioning tenant baru
CREATE SCHEMA tenant_a8f2c1;
-- Set search_path ke schema tenant tersebut
SET search_path = tenant_a8f2c1;
-- Jalankan DDL yang sama untuk semua tenant
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL,
total_amount NUMERIC(12, 2) NOT NULL,
status VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
Di lapisan aplikasi, setiap request harus mengeset search_path ke schema tenant yang tepat sebelum query dijalankan:
// middleware/tenantContext.ts
import { Pool } from 'pg';
export async function withTenantSchema<T>(
pool: Pool,
tenantSlug: string,
fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
const client = await pool.connect();
try {
// Sanitasi slug — jangan pernah interpolasi langsung dari user input
const schemaName = `tenant_${sanitizeSlug(tenantSlug)}`;
await client.query(`SET search_path = ${schemaName}, public`);
return await fn(client);
} finally {
// Reset search_path sebelum koneksi dikembalikan ke pool
await client.query(`SET search_path = public`);
client.release();
}
}
function sanitizeSlug(slug: string): string {
// Hanya izinkan huruf kecil, angka, underscore
if (!/^[a-z0-9_]{1,63}$/.test(slug)) {
throw new Error(`Slug tenant tidak valid: ${slug}`);
}
return slug;
}
Isolasi yang dihasilkan keras: bug query yang lupa filter tenant_id tidak akan bocorkan data tenant lain, karena tabelnya secara fisik berbeda.
Row-Level Security: setup dan cara kerjanya
RLS bekerja di satu tabel shared dengan policy yang transparan:
-- Tabel shared dengan kolom tenant_id
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
customer_id UUID NOT NULL,
total_amount NUMERIC(12, 2) NOT NULL,
status VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Index wajib ada, atau setiap query full scan
CREATE INDEX idx_orders_tenant_id ON orders (tenant_id);
-- Aktifkan RLS
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Policy: hanya baris dengan tenant_id yang cocok
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- Role aplikasi tidak boleh bypass RLS
CREATE ROLE app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO app_user;
-- Tanpa BYPASSRLS — ini default, tapi eksplisit lebih aman
Di sisi aplikasi, set session variable di setiap transaksi:
// middleware/tenantContext.ts
export async function withTenantRLS<T>(
pool: Pool,
tenantId: string,
fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Set di awal setiap transaksi — bukan saat koneksi dibuat
await client.query(
`SELECT set_config('app.tenant_id', $1, true)`,
[tenantId],
);
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
Parameter ketiga set_config(..., true) memastikan nilai hanya berlaku dalam transaksi saat ini — penting untuk keamanan saat connection pooling aktif.
Trade-off yang jujur
Ini bagian yang sering disugarcoat di blog arsitektur.
Schema-per-tenant: apa yang Anda bayar
Keuntungan:
- Isolasi data paling keras yang bisa Anda dapat tanpa database terpisah
- Bug query tidak bisa bocorkan data cross-tenant
- Migrasi per-tenant bisa dilakukan secara independen — kalau tenant A perlu upgrade duluan, bisa
- Mudah melakukan backup/restore data satu tenant tanpa touching tenant lain
- Cocok kalau tiap tenant butuh skema tabel yang sedikit berbeda
Ongkos:
- Migrasi DDL = migrasi ke N schema. Kalau Anda punya 500 tenant dan perlu
ALTER TABLE orders ADD COLUMN notes TEXT, itu 500 eksekusi DDL. Membutuhkan skrip migrasi yang robust dengan retry dan logging pg_catalogdaninformation_schemajadi besar — PostgreSQL menyimpan metadata per-schema, dan ribuan schema memperlambat operasi yang scan metadata- Dengan PgBouncer transaction pooling,
search_pathper-koneksi perlu di-reset eksplisit, atau ada risiko query jalan di schema yang salah - Tools migration seperti Flyway tidak punya first-class support untuk multi-schema — Anda biasanya harus custom
Di klien Jakarta yang saya tangani — platform B2B dengan 200-an tenant enterprise — kami pakai schema-per-tenant. Migrasi dijalankan lewat skrip Node.js yang baca daftar schema aktif dari tabel kontrol, lalu apply DDL satu per satu dengan circuit breaker: kalau lebih dari 5 schema gagal berturut-turut, proses berhenti dan alert dikirim. Itu bukan sesuatu yang bisa Anda ambil begitu saja dari library.
Row-Level Security: apa yang Anda bayar
Keuntungan:
- Satu set tabel untuk semua tenant — migrasi sekali jalan
- Operasi DBA jauh lebih sederhana: satu tabel untuk di-analyze, di-vacuum, di-index
- Onboarding tenant baru instan — tidak perlu buat schema, cukup insert ke tabel
tenants - Query planner bisa memanfaatkan statistik global yang lebih akurat karena data di satu tempat
Ongkos:
- Risiko bocor jika session variable tidak di-set. Ini bukan edge case — ini failure mode yang nyata. Kalau ada bug di middleware yang membiarkan request jalan tanpa set
app.tenant_id, policy USING tidak aktif dan query mengembalikan semua baris. Anda butuh test eksplisit untuk ini - Tenant “whale” dengan jutaan baris bisa mendominasi table bloat dan mempengaruhi performa tenant lain
- Audit trail per-tenant lebih sulit — semua data di tabel yang sama, butuh filter tambahan di setiap query audit
- Tidak ada cara native untuk “restore data tenant X ke titik waktu T” tanpa mengekstrak dulu data mereka
Di production kami untuk produk SaaS self-serve dengan 2.000+ tenant SMB, RLS bekerja baik. Hampir semua tenant punya volume data kecil, skema seragam, dan tidak ada SLA isolasi yang ketat. Yang lebih penting: tim kami bisa deploy schema change dalam sekali deployment tanpa skrip multi-schema.
Benchmark performa yang perlu Anda ketahui
Sebelum membuat keputusan berdasarkan kekhawatiran performa, ukur dulu. Beberapa angka dari test internal di PostgreSQL 17 dengan Supabase:
-- Simulasi 100k baris, 1.000 tenant, query dengan RLS aktif
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
WHERE status = 'pending'
ORDER BY created_at DESC
LIMIT 20;
Hasil: overhead RLS pada query sederhana sekitar 2-4% dibanding query langsung tanpa policy, dengan index (tenant_id, status, created_at) yang tepat. Ini tidak signifikan untuk hampir semua use case.
Yang lebih mempengaruhi performa adalah apakah index Anda dirancang untuk akses per-tenant:
-- Partial index untuk tenant dengan volume tinggi
CREATE INDEX idx_orders_tenant_pending
ON orders (tenant_id, created_at DESC)
WHERE status = 'pending';
-- Composite index untuk query umum
CREATE INDEX idx_orders_tenant_status
ON orders (tenant_id, status, created_at DESC);
Dengan schema-per-tenant, setiap schema punya index sendiri — ini bisa lebih efisien untuk tenant whale, tapi statistik per-schema kurang akurat untuk tenant kecil karena data sedikit.
Verdict: pilih berdasarkan model bisnis, bukan estetika
Saya capek melihat diskusi arsitektur yang dimulai dari “mana yang lebih benar” alih-alih “mana yang cocok untuk konteks kita.”
Pilih schema-per-tenant jika:
- Anda jual ke enterprise dengan kebutuhan isolasi ketat (GDPR, kepatuhan industri keuangan, healthcare)
- Tiap tenant punya potensi kebutuhan skema berbeda
- Jumlah tenant Anda di bawah 500-an dan pertumbuhannya bertahap
- Tim Anda mampu membangun dan merawat tooling migrasi multi-schema
Pilih row-level security jika:
- Model bisnis self-serve dengan onboarding otomatis dan ratusan hingga ribuan tenant
- Semua tenant punya struktur data yang seragam
- Tim kecil dan Anda perlu minimasi kompleksitas operasional
- Tidak ada klausul kontrak yang meminta bukti isolasi fisik
Hindari database-per-tenant kecuali Anda memang jual dedicated instance atau produk on-premise. Biayanya linear dengan jumlah tenant — tidak scalable untuk SaaS.
Satu hal yang tidak boleh Anda lakukan: pilih schema-per-tenant karena “terasa lebih aman” tanpa kemampuan tim untuk memaintain migrasi multi-schema dengan benar. RLS yang diimplementasi dengan disiplin (set session variable di setiap transaksi, test kebocoran eksplisit, role tanpa BYPASSRLS) lebih aman dari schema-per-tenant yang migrasinya setengah jalan karena terlalu rumit dikelola.
Arsitektur terbaik adalah yang tim Anda bisa operasikan dengan tenang pada Jumat malam.
Ditulis oleh Reza Pradipta