Wzorce baz danych z wieloma dzierżawcami z przykładami w Go

Kompletny przewodnik po wzorcach baz danych wielodostępowych

Page content

Multi-tenancy to fundamentalny wzorzec architektoniczny dla aplikacji SaaS, umożliwiający wielu klientom (najemcom) współdzielone korzystanie z tej samej infrastruktury aplikacji, przy jednoczesnym utrzymaniu izolacji danych.

Wybór odpowiedniego wzorca bazy danych jest kluczowy dla skalowalności, bezpieczeństwa i wydajności operacyjnej.

databases-scheme

Omówienie wzorców multi-tenancy

Podczas projektowania aplikacji multi-tenant, masz do wyboru trzy główne wzorce architektury bazy danych:

  1. Współdzielona baza danych, współdzielony schemat (najczęstszy)
  2. Współdzielona baza danych, osobny schemat
  3. Oddzielna baza danych dla każdego najemcy

Każdy wzorzec ma swoje charakterystyczne cechy, kompromisy i przypadki użycia. Przeanalizujmy je szczegółowo.

Wzorzec 1: Współdzielona baza danych, współdzielony schemat

To najbardziej powszechny wzorzec multi-tenancy, w którym wszyscy najemcy współdzielą tę samą bazę danych i schemat, z kolumną tenant_id używaną do odróżniania danych najemców.

Architektura

┌─────────────────────────────────────┐
│     Single Database                 │
│  ┌───────────────────────────────┐  │
│  │  Shared Schema                │  │
│  │  - users (tenant_id, ...)     │  │
│  │  - orders (tenant_id, ...)    │  │
│  │  - products (tenant_id, ...)  │  │
│  └───────────────────────────────┘  │
└─────────────────────────────────────┘

Przykład implementacji

Podczas implementacji wzorców multi-tenancy, zrozumienie podstaw SQL jest kluczowe. Dla kompleksowego odniesienia do poleceń i składni SQL, sprawdź nasz SQL Cheatsheet. Oto jak skonfigurować wzorzec współdzielonego schematu:

-- Tabela użytkowników z kolumną tenant_id
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    tenant_id INTEGER NOT NULL,
    email VARCHAR(255) NOT NULL,
    name VARCHAR(255),
    created_at TIMESTAMP DEFAULT NOW(),
    FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);

-- Indeks na kolumnie tenant_id dla wydajności
CREATE INDEX idx_users_tenant_id ON users(tenant_id);

-- Zabezpieczenie na poziomie wiersza (przykład dla PostgreSQL)
ALTER TABLE users ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON users
    FOR ALL
    USING (tenant_id = current_setting('app.current_tenant')::INTEGER);

Dla więcej informacji na temat funkcji i poleceń PostgreSQL, w tym zasad RLS, zarządzania schematami i optymalizacji wydajności, odwiedź nasz PostgreSQL Cheatsheet.

Filtrowanie na poziomie aplikacji

Podczas pracy z aplikacjami w języku Go, wybór odpowiedniego ORM może znacząco wpłynąć na implementację multi-tenancy. Przykłady poniżej używają GORM, ale dostępne są również inne świetne opcje. Dla szczegółowego porównania ORMów w języku Go, w tym GORM, Ent, Bun i sqlc, zobacz nasz kompleksowy przewodnik po ORMach w języku Go dla PostgreSQL.

// Przykład w Go z użyciem GORM
func GetUserByEmail(db *gorm.DB, tenantID uint, email string) (*User, error) {
    var user User
    err := db.Where("tenant_id = ? AND email = ?", tenantID, email).First(&user).Error
    return &user, err
}

// Środek weryfikacji do ustawienia kontekstu najemcy
func TenantMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        tenantID := extractTenantID(r) // Z poddomeny, nagłówka lub JWT
        ctx := context.WithValue(r.Context(), "tenant_id", tenantID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Zalety współdzielonego schematu

  • Najniższy koszt: Jedna instancja bazy danych, minimalna infrastruktura
  • Najprostsze operacje: Jedna baza danych do kopii zapasowej, monitorowania i utrzymania
  • Proste zmiany schematu: Migracje są stosowane do wszystkich najemców naraz
  • Najlepsze dla dużej liczby najemców: Efektywne wykorzystanie zasobów
  • Analiza między-najemcza: Łatwe agregowanie danych między najemcami

Wady współdzielonego schematu

  • Słabsza izolacja: Ryzyko wycieku danych, jeśli zapytania zapomną filtrować po tenant_id
  • Noisy neighbor: Ciężki obciążenie jednego najemcy może wpłynąć na innych
  • Ograniczona personalizacja: Wszyscy najemcy współdzielą ten sam schemat
  • Trudności w zgodności: Trudniej spełniać wymagania ścisłej izolacji danych
  • Złożoność kopii zapasowej: Trudno łatwo przywrócić dane jednego najemcy

Najlepsze do użycia współdzielonego schematu

  • Aplikacje SaaS z wieloma małymi i średnimi najemcami
  • Aplikacje, w których najemcy nie potrzebują personalizowanych schematów
  • Start-upy wrażliwe na koszty
  • Gdy liczba najemców jest wysoka (tysiące+)

Wzorzec 2: Współdzielona baza danych, osobny schemat

Każdy najemca otrzymuje własny schemat w ramach tej samej bazy danych, zapewniając lepszą izolację, jednocześnie współdzieląc infrastrukturę.

Architektura osobnego schematu

┌─────────────────────────────────────┐
│     Single Database                 │
│  ┌──────────┐  ┌──────────┐         │
│  │ Schema A │  │ Schema B │  ...    │
│  │ (Tenant1)│  │ (Tenant2)│         │
│  └──────────┘  └──────────┘         │
└─────────────────────────────────────┘

Implementacja osobnego schematu

Schematy PostgreSQL to potężna funkcja dla multi-tenancy. Dla szczegółowych informacji na temat zarządzania schematami PostgreSQL, łańcuchów połączeń i poleceń administracyjnych bazy danych, skorzystaj z naszego PostgreSQL Cheatsheet.

-- Utwórz schemat dla najemcy
CREATE SCHEMA tenant_123;

-- Ustaw ścieżkę wyszukiwania dla operacji najemcy
SET search_path TO tenant_123, public;

-- Utwórz tabele w schemacie najemcy
CREATE TABLE tenant_123.users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) NOT NULL,
    name VARCHAR(255),
    created_at TIMESTAMP DEFAULT NOW()
);

Zarządzanie połączeniami aplikacji

Efektywne zarządzanie połączeniami jest kluczowe dla aplikacji multi-tenant. Poniższy kod zarządzania połączeniami używa GORM, ale warto rozważyć inne opcje ORM. Dla szczegółowego porównania ORMów w języku Go, w tym zarządzania pulami połączeń, cech wydajnościowych i przypadków użycia, odwiedź nasz przewodnik po porównaniu ORMów w języku Go.

// Ciąg połączenia z ustawieniem ścieżki wyszukiwania
func GetTenantDB(tenantID uint) *gorm.DB {
    db := initializeDB()
    db.Exec(fmt.Sprintf("SET search_path TO tenant_%d, public", tenantID))
    return db
}

// Albo użyj łańcucha połączenia PostgreSQL
// postgresql://user:pass@host/db?search_path=tenant_123

Zalety osobnego schematu

  • Lepsza izolacja: Oddzielne schematy zmniejszają ryzyko wycieku danych
  • Personalizacja: Każdy najemca może mieć różne struktury tabel
  • Średni koszt: Nadal jedna instancja bazy danych
  • Łatwe kopie zapasowe dla najemców: Można tworzyć kopie zapasowe poszczególnych schematów
  • Lepsze dla zgodności: Silniejsze niż wzorzec współdzielonego schematu

Wady osobnego schematu

  • Złożoność zarządzania schematami: Migracje muszą być uruchamiane dla każdego najemcy
  • Nadmiarowe obciążenie połączeń: Musisz ustawić search_path dla każdego połączenia
  • Ograniczona skalowalność: Liczba schematów ma ograniczenia (PostgreSQL ~10k schematów)
  • Złożone zapytania między-najemcze: Wymagają dynamicznych odwołań do schematów
  • Ograniczenia zasobów: Nadal współdzielone zasoby bazy danych

Najlepsze do użycia osobnego schematu

  • SaaS o średniej skali (dziesiątki do setek najemców)
  • Gdy najemcy potrzebują personalizacji schematu
  • Aplikacje potrzebujące lepszej izolacji niż współdzielony schemat
  • Gdy wymagania zgodności są średnie

Wzorzec 3: Oddzielna baza danych dla każdego najemcy

Każdy najemca otrzymuje własną pełną instancję bazy danych, zapewniając maksymalną izolację.

Architektura osobnej bazy danych

┌──────────────┐  ┌──────────────┐  ┌──────────────┐
│  Database 1  │  │  Database 2  │  │  Database 3  │
│  (Tenant A)  │  │  (Tenant B)  │  │  (Tenant C)  │
└──────────────┘  └──────────────┘  └──────────────┘

Implementacja osobnej bazy danych

-- Utwórz bazę danych dla najemcy
CREATE DATABASE tenant_enterprise_corp;

-- Połącz się z bazą danych najemcy
\c tenant_enterprise_corp

-- Utwórz tabele (nie potrzebny tenant_id!)
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) NOT NULL,
    name VARCHAR(255),
    created_at TIMESTAMP DEFAULT NOW()
);

Dynamiczne zarządzanie połączeniami

// Menedżer puli połączeń
type TenantDBManager struct {
    pools map[uint]*gorm.DB
    mu    sync.RWMutex
}

func (m *TenantDBManager) GetDB(tenantID uint) (*gorm.DB, error) {
    m.mu.RLock()
    if db, exists := m.pools[tenantID]; exists {
        m.mu.RUnlock()
        return db, nil
    }
    m.mu.RUnlock()

    m.mu.Lock()
    defer m.mu.Unlock()

    // Sprawdź ponownie po uzyskaniu bloku zapisu
    if db, exists := m.pools[tenantID]; exists {
        return db, nil
    }

    // Utwórz nowe połączenie
    db, err := gorm.Open(postgres.Open(fmt.Sprintf(
        "host=localhost user=dbuser password=dbpass dbname=tenant_%d sslmode=disable",
        tenantID,
    )), &gorm.Config{})
    
    if err != nil {
        return nil, err
    }

    m.pools[tenantID] = db
    return db, nil
}

Zalety osobnej bazy danych

  • Maksymalna izolacja: Pełne oddzielenie danych
  • Najlepsze bezpieczeństwo: Brak ryzyka dostępu do danych między najemcami
  • Pełna personalizacja: Każdy najemca może mieć zupełnie inne schematy
  • Indywidualne skalowanie: Możliwość skalowania baz danych najemców osobno
  • Łatwe zgodność: Spełnia najbardziej rygorystyczne wymagania izolacji danych
  • Kopie zapasowe dla najemców: Proste, niezależne kopie zapasowe i przywracanie
  • Brak noisy neighbor: Obciążenie jednego najemcy nie wpływa na innych

Wady osobnej bazy danych

  • Najwyższy koszt: Wiele instancji baz danych wymaga więcej zasobów
  • Złożoność operacyjna: Zarządzanie wieloma bazami danych (kopie zapasowe, monitorowanie, migracje)
  • Ograniczenia połączeń: Każda instancja bazy danych ma ograniczenia liczby połączeń
  • Analiza między-najemcza: Wymaga federacji danych lub ETL
  • Złożoność migracji: Musisz uruchomić migracje na wszystkich bazach danych
  • Nadmiarowe obciążenie zasobów: Wymaga więcej pamięci, procesora i miejsca na dysku

Najlepsze do użycia osobnej bazy danych

  • SaaS dla przedsiębiorstw z wysokowartościowymi klientami
  • Ścisze wymagania zgodności (HIPAA, GDPR, SOC 2)
  • Gdy najemcy potrzebują znacznej personalizacji
  • Niski do średniego poziomu najemców (dziesiątki do niskich setek)
  • Gdy najemcy mają bardzo różne modele danych

Aspekty bezpieczeństwa

Niezależnie od wybranego wzorca, bezpieczeństwo jest kluczowe:

1. Zabezpieczenie na poziomie wiersza (RLS)

RLS w PostgreSQL automatycznie filtrowa zapytania według najemcy, zapewniając warstwę bezpieczeństwa na poziomie bazy danych. Ta funkcja jest szczególnie potężna dla aplikacji multi-tenant. Dla więcej informacji na temat RLS w PostgreSQL, zasad bezpieczeństwa i innych zaawansowanych funkcji PostgreSQL, zobacz nasz PostgreSQL Cheatsheet.

-- Włącz RLS
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- Zasada izolacji według najemcy
CREATE POLICY tenant_isolation ON orders
    FOR ALL
    USING (tenant_id = current_setting('app.current_tenant')::INTEGER);

-- Aplikacja ustawia kontekst najemcy
SET app.current_tenant = '123';

2. Filtrowanie na poziomie aplikacji

Zawsze filtruj według tenant_id w kodzie aplikacji. Przykłady poniżej używają GORM, ale różne ORM-y mają swoje podejścia do budowania zapytań. Dla wskazówek dotyczących wyboru odpowiedniego ORM-a dla aplikacji multi-tenant, sprawdź nasz porównanie ORMów w języku Go.

// ❌ ZŁE - Brak filtra według najemcy
db.Where("email = ?", email).First(&user)

// ✅ DOBRE - Zawsze dodaj filtr według najemcy
db.Where("tenant_id = ? AND email = ?", tenantID, email).First(&user)

// ✅ Lepsze - Użyj zakresów lub middleware
db.Scopes(TenantScope(tenantID)).Where("email = ?", email).First(&user)

3. Pula połączeń

Używaj pulerów połączeń obsługujących kontekst najemcy:

// PgBouncer z pulą transakcji
// Albo użyj routingu połączeń na poziomie aplikacji

4. Rejestrowanie audytu

Śledź wszystkie dostęp do danych najemców:

type AuditLog struct {
    ID        uint
    TenantID  uint
    UserID    uint
    Action    string
    Table     string
    RecordID  uint
    Timestamp time.Time
    IPAddress string
}

Optymalizacja wydajności

Strategia indeksowania

Poprawne indeksowanie jest kluczowe dla wydajności baz danych multi-tenant. Zrozumienie strategii indeksowania SQL, w tym indeksów złożonych i częściowych, jest niezbędne. Dla kompleksowego odniesienia do poleceń SQL, w tym CREATE INDEX i optymalizacji zapytań, zobacz nasz SQL Cheatsheet. Dla funkcji indeksowania i optymalizacji wydajności specyficznych dla PostgreSQL, odwiedź nasz PostgreSQL Cheatsheet.

-- Złożone indeksy dla zapytań najemców
CREATE INDEX idx_orders_tenant_created ON orders(tenant_id, created_at DESC);
CREATE INDEX idx_orders_tenant_status ON orders(tenant_id, status);

-- Częściowe indeksy dla typowych zapytań specyficznych dla najemców
CREATE INDEX idx_orders_active_tenant ON orders(tenant_id, created_at)
WHERE status = 'active';

Optymalizacja zapytań

// Użyj przygotowanych zapytań dla zapytań najemców
stmt := db.Prepare("SELECT * FROM users WHERE tenant_id = $1 AND email = $2")

// Operacje wsadowe dla każdego najemcy
db.Where("tenant_id = ?", tenantID).Find(&users)

// Użyj puli połączeń dla każdego najemcy (dla wzorca osobnej bazy danych)

Monitorowanie

Efektywne narzędzia zarządzania bazą danych są niezbędne do monitorowania aplikacji multi-tenant. Musisz śledzić wydajność zapytań, wykorzystanie zasobów i stan bazy danych dla wszystkich najemców. Dla porównania narzędzi zarządzania bazą danych, które mogą pomóc w tym, sprawdź nasz porównanie DBeaver vs Beekeeper. Oba narzędzia oferują świetne funkcje do zarządzania i monitorowania baz danych PostgreSQL w środowiskach multi-tenant.

Monitoruj metryki dla każdego najemcy:

  • Wydajność zapytań dla każdego najemcy
  • Wykorzystanie zasobów dla każdego najemcy
  • Liczba połączeń dla każdego najemcy
  • Rozmiar bazy danych dla każdego najemcy

Strategia migracji

Wzorzec współdzielonego schematu

Podczas implementacji migracji bazy danych, wybór ORM-a wpływa na to, jak radzisz sobie z zmianami schematu. Przykłady poniżej używają funkcji AutoMigrate w GORM, ale różne ORM-y mają różne strategie migracji. Dla szczegółowych informacji na temat tego, jak różne ORM-y w języku Go radzą sobie z migracjami i zarządzaniem schematami, zobacz nasz porównanie ORMów w języku Go.

// Migracje są stosowane automatycznie dla wszystkich najemców
func Migrate(db *gorm.DB) error {
    return db.AutoMigrate(&User{}, &Order{}, &Product{})
}

Wzorzec osobnego schematu/bazy danych

// Migracje muszą być uruchamiane dla każdego najemcy
func MigrateAllTenants(tenantIDs []uint) error {
    for _, tenantID := range tenantIDs {
        db := GetTenantDB(tenantID)
        if err := db.AutoMigrate(&User{}, &Order{}); err != nil {
            return fmt.Errorf("tenant %d: %w", tenantID, err)
        }
    }
    return nil
}

Macierz decyzyjna

Czynnik Współdzielony schemat Osobny schemat Osobna baza danych
Izolacja Niska Średnia Wysoka
Koszt Niski Średni Wysoki
Skalowalność Wysoka Średnia Niska-Średnia
Personalizacja Brak Średnia Wysoka
Złożoność operacyjna Niska Średnia Wysoka
Zgodność Ograniczona Dobra Wspaniała
Najlepsza liczba najemców 1000+ 10-1000 1-100

Hybrydowy podejście

Możesz połączyć wzorce dla różnych poziomów najemców:

// Małe najemcy: Współdzielony schemat
if tenant.Tier == "standard" {
    return GetSharedDB(tenant.ID)
}

// Najemcy enterprise: Osobna baza danych
if tenant.Tier == "enterprise" {
    return GetTenantDB(tenant.ID)
}

Praktyczne wskazówki

  1. Zawsze filtruj według najemcy: Nie zaufaj tylko kodowi aplikacji; użyj RLS, jeśli to możliwe. Zrozumienie podstaw SQL pomaga zapewnić poprawne konstrukcje zapytań — odwiedź nasz SQL Cheatsheet dla najlepszych praktyk w zakresie zapytań.
  2. Monitoruj wykorzystanie zasobów przez najemców: Identyfikuj i ogranicz najemców generujących szum. Użyj narzędzi zarządzania bazą danych, takich jak te porównane w naszym przewodniku DBeaver vs Beekeeper, aby śledzić metryki wydajności.
  3. Zaimplementuj middleware do kontekstu najemcy: Centralizuj ekstrakcję i walidację najemcy. Wybór ORM-a wpływa na to, jak to zaimplementujesz — zobacz nasz porównanie ORMów w języku Go dla różnych podejść.
  4. Użyj puli połączeń: Efektywnie zarządzaj połączeniami do bazy danych. Strategie puli połączeń specyficzne dla PostgreSQL są omawiane w naszym PostgreSQL Cheatsheet.
  5. Planuj migrację najemców: Możliwość przenoszenia najemców między wzorcami
  6. Zaimplementuj miękkie usuwanie: Użyj deleted_at zamiast twardego usuwania danych najemców
  7. Rejestruj wszystko: Loguj wszystkie dostęp do danych najemców dla zgodności
  8. Testuj izolację: Regularne audyty bezpieczeństwa, aby zapobiec wyciekom danych między najemcami

Podsumowanie

Wybór odpowiedniego wzorca bazy danych multi-tenancy zależy od Twoich konkretnych wymagań dotyczących izolacji, kosztów, skalowalności i złożoności operacyjnej. Wzorzec Współdzielona baza danych, współdzielony schemat działa dobrze dla większości aplikacji SaaS, podczas gdy osobna baza danych dla każdego najemcy jest konieczna dla klientów enterprise z rygorystycznymi wymaganiami zgodności.

Zacznij od najprostszego wzorca, który spełnia Twoje wymagania, a planuj migrację do bardziej izolowanego wzorca, gdy Twoje potrzeby ewoluują. Zawsze priorytetyzuj bezpieczeństwo i izolację danych, niezależnie od wybranego wzorca.

Przydatne linki