Multi-tenancy
Multi-tenancy bukan fitur yang ditambahkan belakangan; ia keputusan yang memengaruhi setiap kueri, setiap kunci cache, dan setiap baris log. Kesalahan paling mahal di sini bukan performa, melainkan kebocoran.
Intisari
- Tiga pola: satu database bersama (kolom
tenant_id), skema per tenant, database per tenant. tenant_idharus jadi kolom pertama di hampir semua index.- Ambil tenant sekali di middleware, taruh di context, dan jangan pernah menerimanya dari body permintaan.
- Kunci cache, nama berkas S3, dan kunci rate limit semuanya wajib memuat ID tenant.
- Postgres Row Level Security memberi jaring pengaman di lapisan database, bukan hanya di aplikasi.
Tiga pola isolasi
| Database bersama | Skema per tenant | Database per tenant | |
|---|---|---|---|
| Isolasi | Di aplikasi (+ RLS) | Sedang | Kuat |
| Biaya per tenant | Terendah | Sedang | Tertinggi |
| Migrasi | Sekali | Per skema | Per database |
| Tenant baru | Satu baris | CREATE SCHEMA + migrasi | Sediakan database |
| Kueri lintas tenant (analitik) | Mudah | Sulit | Sangat sulit |
| Cocok untuk | Banyak tenant kecil | Menengah | Enterprise, kepatuhan ketat |
| Risiko kebocoran | Tertinggi โ satu WHERE yang lupa | Rendah | Sangat rendah |
Pola campuran sering yang paling masuk akal: database bersama untuk sebagian besar pelanggan, database terpisah untuk beberapa pelanggan enterprise yang menuntutnya secara kontraktual. Yang penting, kodenya sama โ perbedaannya cuma DSN mana yang dipakai untuk tenant itu.
Mengambil tenant sekali, di gerbang
func (m *Tenancy) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Dari subdomain, header, atau klaim di sesi โ TIDAK PERNAH dari body.
t, err := m.dariHost(r.Host)
if err != nil {
http.Error(w, "tenant tidak dikenal", http.StatusNotFound)
return
}
// Pengguna yang login harus MEMANG milik tenant ini.
u := auth.DariContext(r.Context())
if u.TenantID != t.ID {
http.Error(w, "tidak berhak", http.StatusForbidden)
return
}
ctx := DenganTenant(r.Context(), t)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
ID tenant tidak boleh datang dari body atau query. Kalau klien bisa mengirim
{"tenant_id": 42}, maka multi-tenancy-mu hanyalah saran. Ia harus diturunkan dari sesuatu
yang sudah diverifikasi: subdomain yang dipetakan ke tenant, atau klaim di dalam sesi/token yang
ditandatangani server (Fase 6).
Setiap kueri membawa tenant
// Repository mengambil tenant dari context โ bukan dari parameter,
// supaya tidak mungkin lupa mengopernya.
func (r *Repo) Ambil(ctx context.Context, id int64) (Produk, error) {
t := tenancy.DariContext(ctx)
if t.ID == 0 {
return Produk{}, ErrTenantTidakAda // gagal keras, jangan diam
}
var p Produk
err := r.db.QueryRow(ctx, `
SELECT id, nama, harga FROM produk
WHERE tenant_id = $1 AND id = $2`, t.ID, id,
).Scan(&p.ID, &p.Nama, &p.Harga)
...
}
t.ID == 0 harus jadi error, bukan diam-diam berarti "semua tenant". Ini pola yang
sama dengan pembatasan kepemilikan di Fase 6: yang bukan miliknya tidak pernah ditemukan. Bedanya, di
sini satu kelalaian membocorkan data pelanggan lain โ bukan pengguna lain.
Jaring pengaman: Row Level Security
ALTER TABLE produk ENABLE ROW LEVEL SECURITY;
CREATE POLICY isolasi_tenant ON produk
USING (tenant_id = current_setting('app.tenant_id')::bigint);
// Setel di setiap koneksi yang dipinjam dari pool
func (r *Repo) denganTenant(ctx context.Context, f func(pgx.Tx) error) error {
t := tenancy.DariContext(ctx)
tx, err := r.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, "SELECT set_config('app.tenant_id', $1, true)",
strconv.FormatInt(t.ID, 10)); err != nil {
return err
}
if err := f(tx); err != nil {
return err
}
return tx.Commit(ctx)
}
RLS adalah lapisan kedua, bukan pengganti WHERE tenant_id. Kueri yang tetap
menyertakan tenant_id bisa memakai index dengan benar; RLS menangkap yang lupa. Perhatikan
parameter true pada set_config โ itu membuatnya berlaku hanya sampai akhir
transaksi, sehingga koneksi yang kembali ke pool tidak membawa konteks tenant sebelumnya. Tanpa itu,
pooling koneksi justru menjadi jalur kebocoran.
Index dan performa
-- tenant_id SELALU kolom pertama (Fase 4)
CREATE INDEX idx_produk_tenant_kategori ON produk (tenant_id, kategori_id, harga);
CREATE UNIQUE INDEX idx_produk_tenant_slug ON produk (tenant_id, slug);
-- Untuk tenant sangat besar, pertimbangkan partisi
CREATE TABLE produk (...) PARTITION BY LIST (tenant_id);
Masalah "tetangga berisik" itu nyata. Satu tenant yang menjalankan laporan besar bisa menghabiskan CPU database untuk semua orang. Pertahanannya: rate limit per tenant (Fase 6), batas kueri per tenant, dan โ untuk yang paling besar โ pindahkan ke database sendiri.
Yang paling sering bocor
| Titik | Kebocoran | Perbaikan |
|---|---|---|
| Kunci cache | Data tenant A disajikan ke tenant B | {tenantID}:produk:{id} (Fase 9) |
| Nama objek S3 | Berkas terbaca lintas tenant | {tenantID}/unggahan/... + presigned URL |
| Kunci rate limit | Satu tenant menghabiskan kuota yang lain | Sertakan tenant di kunci |
| Antrean pekerjaan | Job diproses dengan konteks tenant yang salah | Tenant masuk ke muatan job, diverifikasi saat konsumsi |
| Endpoint pencarian | Index pencarian bersama tanpa filter | Filter tenant di mesin pencari juga |
| Panel admin | Staf melihat data lintas tenant tanpa jejak | Peran impersonasi eksplisit + audit log |
| Log dan trace | Data satu tenant terbaca tim yang salah | Sertakan tenant_id, batasi akses log |
Tes yang wajib ada
func TestIsolasiTenant(t *testing.T) {
a := buatTenant(t, "a")
b := buatTenant(t, "b")
p := buatProduk(t, a, "Rahasia A")
// Detail: tenant B tidak boleh menemukannya.
if _, err := repo.Ambil(ctxTenant(b), p.ID); !errors.Is(err, ErrTidakDitemukan) {
t.Fatal("tenant B bisa membaca produk tenant A")
}
// Daftar: yang paling sering terlewat.
daftar, _ := repo.Daftar(ctxTenant(b))
for _, x := range daftar {
if x.TenantID != b.ID {
t.Fatalf("daftar tenant B memuat data tenant %d", x.TenantID)
}
}
// Tanpa tenant di context: harus GAGAL, bukan mengembalikan semuanya.
if _, err := repo.Daftar(context.Background()); err == nil {
t.Fatal("kueri tanpa tenant seharusnya gagal")
}
}
Latihan: tambahkan tenant_id ke satu tabel, pasang middleware tenant, dan tulis
ketiga tes di atas. Lalu sengaja hapus tenant_id dari kunci cache endpoint itu dan
buktikan tenant B menerima data tenant A โ kebocoran yang tidak akan tertangkap satu pun tes
database.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.