โ† Semua pembelajaran / Go Nol โ†’ Enterprise
Fase 11 ยท Enterprise & Capstone

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_id harus 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 bersamaSkema per tenantDatabase per tenant
IsolasiDi aplikasi (+ RLS)SedangKuat
Biaya per tenantTerendahSedangTertinggi
MigrasiSekaliPer skemaPer database
Tenant baruSatu barisCREATE SCHEMA + migrasiSediakan database
Kueri lintas tenant (analitik)MudahSulitSangat sulit
Cocok untukBanyak tenant kecilMenengahEnterprise, kepatuhan ketat
Risiko kebocoranTertinggi โ€” satu WHERE yang lupaRendahSangat 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

TitikKebocoranPerbaikan
Kunci cacheData tenant A disajikan ke tenant B{tenantID}:produk:{id} (Fase 9)
Nama objek S3Berkas terbaca lintas tenant{tenantID}/unggahan/... + presigned URL
Kunci rate limitSatu tenant menghabiskan kuota yang lainSertakan tenant di kunci
Antrean pekerjaanJob diproses dengan konteks tenant yang salahTenant masuk ke muatan job, diverifikasi saat konsumsi
Endpoint pencarianIndex pencarian bersama tanpa filterFilter tenant di mesin pencari juga
Panel adminStaf melihat data lintas tenant tanpa jejakPeran impersonasi eksplisit + audit log
Log dan traceData satu tenant terbaca tim yang salahSertakan 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.