Przejdź do treści
·30 min czytania

Projektowanie schematu bazy danych: Wzorce, które starzeją się dobrze

Reguły normalizacji, konwencje nazewnictwa, soft delete, ścieżki audytu, wzorce multi-tenancy, strategie wersjonowania i decyzje dotyczące schematu, których żałowałem. Skupione na PostgreSQL.

Udostępnij:X / TwitterLinkedIn

Schemat to kontrakt z twoim przyszłym ja. Każda kolumna, którą dodasz, każde ograniczenie, które pominiesz, każde „posprzątamy to później" — to wszystko się kumuluje. Pracowałem w systemach, gdzie jedna zła decyzja dotycząca schematu podjęta trzy lata temu kosztuje teraz zespół pełny sprint co kwartał na obejścia.

Baza danych przeżyje wszystko. Twój framework frontendowy się zmieni. Twoja warstwa API zostanie przepisana. Twoja strategia deploymentu się wyewoluuje. Ale dane? Dane zostają. A kształt, który im nadałeś pierwszego dnia, ciągnie się za tobą na zawsze, bo migracja tabeli z 200 milionami wierszy to nie to samo co refaktoryzacja komponentu React.

To jest to, czego nauczyłem się o podejmowaniu decyzji dotyczących schematu, które cię nie prześladują. Skupione na PostgreSQL, bo tego używam i temu ufam, ale większość tych wzorców stosuje się do każdej relacyjnej bazy danych.

Konwencje nazewnictwa: Nudna rzecz, która ma największe znaczenie#

Widziałem więcej kłótni o konwencje nazewnictwa niż o faktyczne decyzje architektoniczne. Oto na czym się ustabilizowałem po latach przeskakiwania między projektami:

Snake_case do wszystkiego. Tabele, kolumny, indeksy, ograniczenia. Żadnego camelCase, żadnego PascalCase. PostgreSQL i tak zamienia niecytowane identyfikatory na małe litery, więc createdAt staje się createdat, chyba że wszędzie je cudzysłowujesz. Nie walcz z bazą danych.

Nazwy tabel w liczbie mnogiej. Tabela zawiera wiele wierszy. users nie user. orders nie order. order_items nie order_item. Czyta się to naturalnie w zapytaniach: SELECT * FROM users WHERE ... — wybierasz z kolekcji.

Sufiks _id dla kluczy obcych. user_id, order_id, tenant_id. Klucz główny to po prostu id. To jest jednoznaczne. Gdy łączysz tabele, users.id = orders.user_id czyta się jak angielski.

Sufiks _at dla timestampów. created_at, updated_at, deleted_at, published_at, expires_at. Zawsze wiesz, że to punkt w czasie.

Prefiks is_ dla booleanów. is_active, is_verified, is_published. Niektórzy używają has_ dla booleanów posiadania (has_mfa_enabled), ale ja trzymam się prostoty i używam is_ do wszystkiego.

Sufiks _count dla zdenormalizowanych liczników. comment_count, follower_count. Jasno pokazuje, że to zcachowana liczba, nie obliczenie na żywo.

Oto o co chodzi: spójność bije perfekcję. Widziałem zespoły spędzające tygodnie na debatowaniu, czy powinno być email_address czy email czy email_addr. Po prostu wybierz jeden wzorzec i wymuszaj go wszędzie. Najgorsza konwencja nazewnictwa to ta, która jest niespójnie stosowana.

sql
-- Good: consistent, readable, no surprises
CREATE TABLE users (
    id              BIGSERIAL PRIMARY KEY,
    email           TEXT NOT NULL,
    display_name    TEXT NOT NULL,
    password_hash   TEXT NOT NULL,
    is_active       BOOLEAN NOT NULL DEFAULT true,
    is_verified     BOOLEAN NOT NULL DEFAULT false,
    login_count     INTEGER NOT NULL DEFAULT 0,
    last_login_at   TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Bad: inconsistent casing, mixed conventions, ambiguous names
CREATE TABLE User (
    UserID          SERIAL PRIMARY KEY,
    EmailAddress    VARCHAR(255),
    name            VARCHAR(100),
    passwd          VARCHAR(255),
    active          BOOLEAN,
    verified        INT,             -- is this 0/1? a verification level?
    lastLogin       TIMESTAMP,       -- camelCase in a snake_case world
    created         TIMESTAMP        -- created what? when?
);

Jeszcze jedno: nigdy nie używaj słów zarezerwowanych jako nazw kolumn. type, order, user, group, table — wszystkie działają, jeśli je zacytujesz, ale spalą cię w ORM-ach, query builderach i każdym narzędziu do dynamicznego generowania SQL. Użyj kind zamiast type, sort_order zamiast order. Twoje przyszłe ja ci podziękuje.

Standardowe kolumny: Co dostaje każda tabela#

Każda tabela w moich schematach zaczyna się od tego samego szkieletu. Bez wyjątków.

sql
CREATE TABLE things (
    id          BIGSERIAL PRIMARY KEY,
    -- ... domain columns ...
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Debata UUID vs BIGSERIAL#

To jedna z tych decyzji, która generuje dużo więcej emocji niż światła. Oto faktyczny kompromis:

BIGSERIAL (auto-inkrementujący integer):

  • 8 bajtów. Kompaktowy. Szybki do indeksowania i łączenia.
  • Uporządkowany — możesz sortować po id, żeby uzyskać kolejność wstawiania.
  • Przewidywalny — użytkownik może odgadnąć inne ID, inkrementując swoje.
  • Nie działa dobrze w systemach rozproszonych (wymaga koordynacji).

UUID v4 (losowy):

  • 16 bajtów. Większe indeksy, wolniejsze joiny (ale rzadko wąskie gardło).
  • Nieprzewidywalny — brak wycieku informacji.
  • Działa w systemach rozproszonych bez koordynacji.
  • Fatalna lokalność indeksu — losowe UUID fragmentują indeksy B-tree.

UUID v7 (posortowany czasowo, RFC 9562):

  • 16 bajtów, ale posortowany czasowo, więc lokalność B-tree jest doskonała.
  • Wystarczająco nieprzewidywalny do użytku zewnętrznego.
  • Stosunkowo nowy, ale PostgreSQL 17+ ma gen_random_uuid() i możesz użyć uuid_generate_v7() z rozszerzeniami.

Moje obecne stanowisko: BIGSERIAL dla tabel wewnętrznych, UUID v7 dla wszystkiego wystawionego na zewnątrz. Jeśli ID kiedykolwiek pojawia się w URL, odpowiedzi API lub payloadzie webhooka, używaj UUID. Jeśli to czysta tabela łącząca, której użytkownicy nigdy nie widzą, BIGSERIAL jest w porządku.

sql
-- For an API-facing resource
CREATE TABLE projects (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT NOT NULL,
    owner_id    UUID NOT NULL REFERENCES users(id),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- For an internal mapping table
CREATE TABLE project_members (
    id          BIGSERIAL PRIMARY KEY,
    project_id  UUID NOT NULL REFERENCES projects(id),
    user_id     UUID NOT NULL REFERENCES users(id),
    role        TEXT NOT NULL DEFAULT 'member',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (project_id, user_id)
);

Timestampy: Zawsze TIMESTAMPTZ#

Używaj TIMESTAMPTZ, nie TIMESTAMP. Wersja „TZ" przechowuje wartość w UTC i konwertuje przy odczycie na podstawie strefy czasowej sesji. Wersja bez TZ przechowuje cokolwiek jej podasz bez kontekstu strefy czasowej — co oznacza, że jeśli dwa serwery w różnych strefach czasowych piszą do tej samej tabeli, masz ciche uszkodzenie danych.

sql
-- Always this
created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
 
-- Never this
created_at  TIMESTAMP NOT NULL DEFAULT NOW()

Trigger updated_at#

PostgreSQL nie ma ON UPDATE CURRENT_TIMESTAMP jak MySQL. Potrzebujesz triggera:

sql
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
-- Apply to every table that has updated_at
CREATE TRIGGER set_updated_at
    BEFORE UPDATE ON users
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();
 
CREATE TRIGGER set_updated_at
    BEFORE UPDATE ON projects
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

Tak, potrzebujesz jednego triggera na tabelę. Tak, to żmudne. Napisz helper migracji, który tworzy trigger automatycznie, gdy dodajesz tabelę. Warto, bo alternatywą jest pamiętanie o ustawianiu updated_at = NOW() w każdym zapytaniu UPDATE w całej aplikacji — a zapomnisz.

Normalizacja: Kiedy łamać reguły#

Każdy kurs informatyki uczy normalizacji do 3NF (Trzeciej Postaci Normalnej). Reguły to:

  • 1NF: Każda kolumna zawiera jedną atomową wartość. Żadnych tablic, żadnych list rozdzielonych przecinkami.
  • 2NF: Każda kolumna nie będąca kluczem zależy od całego klucza głównego (istotne dla kluczy złożonych).
  • 3NF: Żadnych zależności przechodnich. Jeśli kolumna A determinuje kolumnę B, a B determinuje C, to C nie powinno być w tej samej tabeli co A.

W praktyce 3NF to sweet spot dla tabel transakcyjnych. Powinieneś od tego zacząć i odchodzić tylko wtedy, gdy masz konkretny, mierzalny powód.

Oto poprawnie znormalizowany system zamówień:

sql
CREATE TABLE customers (
    id              BIGSERIAL PRIMARY KEY,
    email           TEXT NOT NULL UNIQUE,
    display_name    TEXT NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE products (
    id              BIGSERIAL PRIMARY KEY,
    sku             TEXT NOT NULL UNIQUE,
    name            TEXT NOT NULL,
    price_cents     INTEGER NOT NULL,
    is_active       BOOLEAN NOT NULL DEFAULT true,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE orders (
    id              BIGSERIAL PRIMARY KEY,
    customer_id     BIGINT NOT NULL REFERENCES customers(id),
    status          TEXT NOT NULL DEFAULT 'pending'
                    CHECK (status IN ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled')),
    total_cents     INTEGER NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE order_items (
    id              BIGSERIAL PRIMARY KEY,
    order_id        BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    product_id      BIGINT NOT NULL REFERENCES products(id),
    quantity        INTEGER NOT NULL CHECK (quantity > 0),
    unit_price_cents INTEGER NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Zwróć uwagę na unit_price_cents w order_items. To celowa denormalizacja. Zapisujemy cenę w momencie zamówienia, bo cena produktu może się później zmienić. To jeden z najczęstszych i poprawnych powodów denormalizacji.

Kiedy denormalizacja jest właściwą decyzją#

Tabele raportowe. Jeśli twój dashboard analityczny musi łączyć 8 tabel, żeby się wyrenderować, utwórz zdenormalizowaną tabelę raportową i wypełniaj ją zadaniem w tle. Twój schemat transakcyjny zostaje czysty, a zapytania raportowe szybkie.

sql
-- Denormalized reporting table, populated by a cron job
CREATE TABLE order_reports (
    id                  BIGSERIAL PRIMARY KEY,
    order_id            BIGINT NOT NULL,
    customer_email      TEXT NOT NULL,
    customer_name       TEXT NOT NULL,
    product_names       TEXT[] NOT NULL,        -- array is fine here
    item_count          INTEGER NOT NULL,
    total_cents         INTEGER NOT NULL,
    order_status        TEXT NOT NULL,
    ordered_at          TIMESTAMPTZ NOT NULL,
    report_generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Zmaterializowane widoki. MATERIALIZED VIEW PostgreSQL jest niedoceniany. To zdenormalizowany snapshot, który odświeżasz na żądanie. Idealny do dashboardów.

sql
CREATE MATERIALIZED VIEW monthly_revenue AS
SELECT
    DATE_TRUNC('month', o.created_at) AS month,
    COUNT(DISTINCT o.id) AS order_count,
    SUM(o.total_cents) AS revenue_cents,
    COUNT(DISTINCT o.customer_id) AS unique_customers
FROM orders o
WHERE o.status != 'cancelled'
GROUP BY DATE_TRUNC('month', o.created_at)
ORDER BY month DESC;
 
-- Refresh it nightly
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_revenue;

Kolumny JSON dla dynamicznych atrybutów. Gdy różne wiersze potrzebują różnych pól — warianty produktów, zgłoszenia formularzy, preferencje użytkowników — kolumna JSONB jest często lepsza niż koszmarny stół EAV (Entity-Attribute-Value). Więcej o tym za chwilę.

Reguła, której nigdy nie łamię#

Nigdy nie denormalizuj swoich tabel źródła prawdy. Denormalizuj kopie, snapshoty, raporty i cache'e. Kanoniczne dane zostają znormalizowane. Kiedy zdenormalizowana kopia się zdezaktualizuje lub uszkodzi (a stanie się to), odbudujesz ją ze znormalizowanego źródła.

Klucze obce i ograniczenia: Najlepszy kod, którego nigdy nie napiszesz#

Słyszałem każdą wymówkę za pomijaniem kluczy obcych. „Spowalniają zapisy." „Wymuszamy to w aplikacji." „Potrzebujemy elastyczności."

Wszystkie są błędne.

Klucze obce to pojedyncza najbardziej wpływowa rzecz, jaką możesz dodać do schematu. Zapobiegają całym kategoriom bugów, których żaden kod aplikacji nie może złapać — wyścigi, częściowe awarie, osierocone wiersze z nieudanych transakcji. Klucz obcy to gwarancja od samego silnika bazy danych, wymuszana na poziomie przechowywania. Hook beforeDelete twojego ORM-a to sugestia.

sql
-- Always do this
ALTER TABLE orders
    ADD CONSTRAINT fk_orders_customer
    FOREIGN KEY (customer_id) REFERENCES customers(id);
 
-- The "we'll handle it in code" alternative:
-- Hope. Hope is not a strategy.

Strategie ON DELETE#

Tu zaczyna się niuansowanie. Co się dzieje, gdy usuwasz wiersz nadrzędny?

RESTRICT (domyślne): Usunięcie się nie powiedzie, jeśli istnieją wiersze potomne. Używaj tego dla większości relacji. Nie możesz usunąć klienta, który ma zamówienia — to logika biznesowa zakodowana w schemacie.

sql
-- Customer can't be deleted while they have orders
ALTER TABLE orders
    ADD CONSTRAINT fk_orders_customer
    FOREIGN KEY (customer_id) REFERENCES customers(id)
    ON DELETE RESTRICT;

CASCADE: Wiersze potomne są automatycznie usuwane. Używaj oszczędnie i celowo. Dobre dla relacji „część czegoś", gdzie potomek nie ma sensu bez rodzica.

sql
-- Deleting an order deletes its line items — they're meaningless alone
ALTER TABLE order_items
    ADD CONSTRAINT fk_order_items_order
    FOREIGN KEY (order_id) REFERENCES orders(id)
    ON DELETE CASCADE;
 
-- Deleting a project deletes its memberships
ALTER TABLE project_members
    ADD CONSTRAINT fk_project_members_project
    FOREIGN KEY (project_id) REFERENCES projects(id)
    ON DELETE CASCADE;

SET NULL: Kolumna klucza obcego jest ustawiana na NULL. Używaj, gdy relacja jest opcjonalna i wiersz potomny nadal ma sens sam w sobie.

sql
-- If a manager leaves, their reports still exist — just unassigned
ALTER TABLE employees
    ADD CONSTRAINT fk_employees_manager
    FOREIGN KEY (manager_id) REFERENCES employees(id)
    ON DELETE SET NULL;

Moja zasada kciuka: domyślnie RESTRICT, CASCADE dla relacji kompozycji, SET NULL dla opcjonalnych asocjacji. Jeśli nie jesteś pewien, RESTRICT jest zawsze bezpiecznym wyborem — łatwiej jest poluzować ograniczenie niż odzyskać usunięte dane.

Ograniczenia CHECK: Tanie ubezpieczenie#

Ograniczenia CHECK kosztują prawie nic przy zapisie i zapobiegają śmieciowym danym na zawsze:

sql
CREATE TABLE products (
    id              BIGSERIAL PRIMARY KEY,
    name            TEXT NOT NULL CHECK (LENGTH(name) > 0),
    price_cents     INTEGER NOT NULL CHECK (price_cents >= 0),
    discount_pct    NUMERIC(5,2) CHECK (discount_pct BETWEEN 0 AND 100),
    weight_kg       NUMERIC(10,3) CHECK (weight_kg > 0),
    status          TEXT NOT NULL CHECK (status IN ('draft', 'active', 'archived')),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Każde dodane ograniczenie CHECK to jeden mniej bug walidacji, którego kiedykolwiek będziesz debugować na produkcji. Baza danych to ostatnia linia obrony. Używaj jej.

Unikalne ograniczenia i częściowe indeksy unikalne#

Ograniczenia unikalne są proste dla prostych przypadków:

sql
ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE (email);

Ale stają się interesujące z częściowymi indeksami — ograniczeniami unikalnymi, które stosują się tylko do pewnych wierszy:

sql
-- Only one active subscription per user (but they can have many cancelled ones)
CREATE UNIQUE INDEX uq_active_subscription
    ON subscriptions (user_id)
    WHERE status = 'active';
 
-- Only one primary address per user
CREATE UNIQUE INDEX uq_primary_address
    ON addresses (user_id)
    WHERE is_primary = true;

To jedna z killer features PostgreSQL. Używaj tego.

Soft delete: Wzorzec, który wszyscy kochają nienawidzić#

Soft delete to proste koncepcyjnie: zamiast DELETE FROM users WHERE id = 42, robisz UPDATE users SET deleted_at = NOW() WHERE id = 42. Wiersz zostaje w bazie, ale jest filtrowany z normalnych zapytań.

sql
CREATE TABLE users (
    id          BIGSERIAL PRIMARY KEY,
    email       TEXT NOT NULL,
    display_name TEXT NOT NULL,
    is_active   BOOLEAN NOT NULL DEFAULT true,
    deleted_at  TIMESTAMPTZ,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- The partial index: filter out deleted rows efficiently
CREATE INDEX idx_users_active ON users (email) WHERE deleted_at IS NULL;

Każde zapytanie w twojej aplikacji potrzebuje teraz WHERE deleted_at IS NULL. Każde. Jedno. Zapytanie. ORM-y pomagają z tym (Prisma ma middleware, Drizzle ma .where(isNull(deletedAt))), ale to podatek na każdej operacji odczytu. Przegap to raz i pokazujesz „usunięte" dane użytkownikom.

Problem unikalnych ograniczeń#

Tu soft delete robi się brzydkie. Jeśli masz UNIQUE (email) i użytkownik soft-deletuje konto, jego email wciąż jest w tabeli. Nie może się zarejestrować ponownie z tym samym emailem. Nowy użytkownik z tym emailem też nie może się zarejestrować.

Rozwiązanie to częściowy indeks unikalny:

sql
-- Email must be unique, but only among non-deleted users
CREATE UNIQUE INDEX uq_users_email_active
    ON users (email)
    WHERE deleted_at IS NULL;

To działa, ale teraz musisz pamiętać o tym wzorcu dla każdej unikalnej kolumny w każdej tabeli z soft delete. Jest to zarządzalne z dyscypliną, ale to złożoność, którą świadomie na siebie bierzesz.

Problem kluczy obcych#

Soft delete źle współgra z kluczami obcymi. Jeśli orders.user_id odwołuje się do users.id z ON DELETE RESTRICT, a ty soft-deletujesz użytkownika... nic się nie dzieje. FK nie odpala, bo tak naprawdę nie usunąłeś wiersza. Użytkownik jest „usunięty" z perspektywy aplikacji, ale wciąż bardzo obecny w bazie danych.

To oznacza, że twój kod aplikacji musi obsłużyć przypadek, gdy odwoływana encja jest soft-deletowana. Każdy join, każde wyszukiwanie, za każdym razem gdy podążasz za kluczem obcym — musisz sprawdzić deleted_at IS NULL na odwoływanej tabeli. Albo nie sprawdzasz, a twoja aplikacja pokazuje „Zamówienie od [usunięty użytkownik]", co jest albo bugiem, albo funkcją, w zależności od tego, kogo zapytasz.

Moje podejście#

Używam soft delete tylko dla encji widocznych dla użytkownika, gdzie odzyskanie jest wymaganiem biznesowym — konta użytkowników, projekty, dokumenty. Rzeczy, gdzie agent supportu może potrzebować przywrócić usunięcie. Dla wszystkiego innego używam twardego usuwania ze ścieżką audytu (więcej o tym za chwilę).

sql
-- Soft delete: user-facing, recoverable
CREATE TABLE documents (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title       TEXT NOT NULL,
    content     TEXT NOT NULL,
    owner_id    UUID NOT NULL REFERENCES users(id),
    deleted_at  TIMESTAMPTZ,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_documents_active ON documents (owner_id) WHERE deleted_at IS NULL;
 
-- Hard delete: internal, not recoverable from UI (but audited)
CREATE TABLE api_keys (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    key_hash    TEXT NOT NULL,
    last_used_at TIMESTAMPTZ,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- When deleted, it's gone. The audit_log records that it existed.

Ścieżki audytu: Wiedz, co się zmieniło i kto to zrobił#

Każda nietrywialna aplikacja w pewnym momencie potrzebuje odpowiedzi na „co się stało z tym rekordem?". Ścieżki audytu to sposób na dostarczenie tej odpowiedzi bez przekopywania się przez logi aplikacji.

Wzorzec osobnej tabeli audytu#

Najprostsze podejście: jedna tabela audit_log rejestrująca każdą zmianę.

sql
CREATE TABLE audit_log (
    id              BIGSERIAL PRIMARY KEY,
    table_name      TEXT NOT NULL,
    record_id       TEXT NOT NULL,          -- text to handle both UUID and BIGINT PKs
    operation       TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
    old_values      JSONB,                  -- NULL for INSERT
    new_values      JSONB,                  -- NULL for DELETE
    changed_fields  TEXT[],                 -- which columns changed (for UPDATE)
    changed_by      UUID REFERENCES users(id),
    changed_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    ip_address      INET,
    user_agent      TEXT
);
 
CREATE INDEX idx_audit_log_table_record ON audit_log (table_name, record_id);
CREATE INDEX idx_audit_log_changed_by ON audit_log (changed_by);
CREATE INDEX idx_audit_log_changed_at ON audit_log (changed_at);

Wyzwaniem jest wypełnianie tej tabeli. Możesz to robić w kodzie aplikacji (jawne, ale łatwo zapomnieć) lub triggerami (automatyczne, ale trudniej przekazać kontekst jak changed_by).

Podejście oparte na triggerach#

Triggery przechwytują każdą zmianę automatycznie, nawet z surowego SQL czy operacji admina bazy danych:

sql
CREATE OR REPLACE FUNCTION audit_trigger_function()
RETURNS TRIGGER AS $$
DECLARE
    old_data JSONB;
    new_data JSONB;
    changed  TEXT[];
    col      TEXT;
BEGIN
    IF TG_OP = 'DELETE' THEN
        old_data := to_jsonb(OLD);
        INSERT INTO audit_log (table_name, record_id, operation, old_values, changed_at)
        VALUES (TG_TABLE_NAME, OLD.id::TEXT, 'DELETE', old_data, NOW());
        RETURN OLD;
    ELSIF TG_OP = 'UPDATE' THEN
        old_data := to_jsonb(OLD);
        new_data := to_jsonb(NEW);
        -- Find changed fields
        FOR col IN SELECT key FROM jsonb_each(new_data)
        LOOP
            IF old_data->col IS DISTINCT FROM new_data->col THEN
                changed := array_append(changed, col);
            END IF;
        END LOOP;
        -- Only log if something actually changed
        IF array_length(changed, 1) > 0 THEN
            INSERT INTO audit_log (table_name, record_id, operation, old_values, new_values, changed_fields, changed_at)
            VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'UPDATE', old_data, new_data, changed, NOW());
        END IF;
        RETURN NEW;
    ELSIF TG_OP = 'INSERT' THEN
        new_data := to_jsonb(NEW);
        INSERT INTO audit_log (table_name, record_id, operation, new_values, changed_at)
        VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'INSERT', new_data, NOW());
        RETURN NEW;
    END IF;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;
 
-- Apply to tables you want audited
CREATE TRIGGER audit_users
    AFTER INSERT OR UPDATE OR DELETE ON users
    FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();
 
CREATE TRIGGER audit_orders
    AFTER INSERT OR UPDATE OR DELETE ON orders
    FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();

Wada: triggery nie wiedzą, który użytkownik aplikacji dokonał zmiany. Możesz to obejść zmiennymi sesji:

sql
-- In your application, before the query:
SET LOCAL app.current_user_id = 'uuid-of-current-user';
 
-- In the trigger function:
changed_by_id := current_setting('app.current_user_id', true)::UUID;

To działa, ale wydaje się kruche. W praktyce używam podejścia hybrydowego: triggery do przechwytywania danych, a kod aplikacji do ustawiania kontekstu sesji.

Wzorzec tabeli historii#

Dla tabel, gdzie potrzebujesz pełnej historii wersji (nie tylko „co się zmieniło" ale „jaki był stan w czasie T"), dedykowana tabela historii jest czystsza:

sql
CREATE TABLE documents (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title           TEXT NOT NULL,
    content         TEXT NOT NULL,
    version         INTEGER NOT NULL DEFAULT 1,
    owner_id        UUID NOT NULL REFERENCES users(id),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE document_history (
    id              BIGSERIAL PRIMARY KEY,
    document_id     UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
    title           TEXT NOT NULL,
    content         TEXT NOT NULL,
    version         INTEGER NOT NULL,
    changed_by      UUID REFERENCES users(id),
    changed_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (document_id, version)
);

Przed każdą aktualizacją documents kopiuj bieżący stan do document_history i inkrementuj wersję. Teraz możesz odtworzyć dokument w dowolnym punkcie czasu, pokazywać różnice między wersjami, a nawet przywracać stare wersje.

Kompromis to przechowywanie. Jeśli twoja kolumna content jest duża i często się zmienia, tabela historii może szybko rosnąć. Dla większości aplikacji to w porządku — przechowywanie jest tanie i możesz archiwizować stare wersje do zimnego storage'u, jeśli trzeba.

Multi-tenancy: Trzy podejścia, wybierz swój ból#

Multi-tenancy to jedna z tych rzeczy, które łatwo dodać na początku i prawie niemożliwe dodać później. Jeśli jest jakakolwiek szansa, że twoja aplikacja będzie obsługiwać wiele organizacji, wbuduj to od pierwszego dnia.

Na poziomie wierszy: tenant_id w każdej tabeli#

Najczęstsze podejście. Każda tabela ma kolumnę tenant_id, a każde zapytanie filtruje po niej.

sql
CREATE TABLE tenants (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT NOT NULL,
    slug        TEXT NOT NULL UNIQUE,
    plan        TEXT NOT NULL DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'enterprise')),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE projects (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id   UUID NOT NULL REFERENCES tenants(id),
    name        TEXT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE tasks (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id   UUID NOT NULL REFERENCES tenants(id),
    project_id  UUID NOT NULL REFERENCES projects(id),
    title       TEXT NOT NULL,
    status      TEXT NOT NULL DEFAULT 'todo',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Every index should include tenant_id for query performance
CREATE INDEX idx_projects_tenant ON projects (tenant_id);
CREATE INDEX idx_tasks_tenant_project ON tasks (tenant_id, project_id);

Ryzyko to wyciek danych — jedno pominięte WHERE tenant_id = ... i pokazujesz dane Tenanta A Tenantowi B. Row-Level Security (RLS) PostgreSQL eliminuje tę klasę błędów:

sql
-- Enable RLS on the table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
 
-- Create a policy based on a session variable
CREATE POLICY tenant_isolation ON projects
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Force RLS even for table owners
ALTER TABLE projects FORCE ROW LEVEL SECURITY;

Teraz, nawet jeśli twój kod aplikacji zapomni o klauzuli WHERE tenant_id = ..., PostgreSQL dodaje ją automatycznie. To obrona w głąb i jeden z najsilniejszych argumentów za PostgreSQL w systemach multi-tenant.

sql
-- In your application's connection middleware:
SET LOCAL app.current_tenant_id = 'tenant-uuid-here';
 
-- Now all queries on RLS-enabled tables are automatically filtered
SELECT * FROM projects;
-- PostgreSQL internally adds: WHERE tenant_id = 'tenant-uuid-here'

Zalety: Jedna baza danych, prosty ops, efektywne wykorzystanie zasobów, łatwe zapytania cross-tenant dla admina. Wady: Wymaga dyscypliny (lub RLS), każde zapytanie dotyka tenant_id, trudniejszy backup/restore per tenant.

Schemat na tenanta#

Każdy tenant dostaje własny schemat PostgreSQL. Wszystkie schematy współdzielą tę samą bazę danych, ale tabele są izolowane przestrzenią nazw.

sql
-- Create a schema for each tenant
CREATE SCHEMA tenant_acme;
CREATE SCHEMA tenant_globex;
 
-- Tables live in the tenant's schema
CREATE TABLE tenant_acme.projects (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Set the search_path for the current tenant
SET search_path TO tenant_acme, public;
 
-- Now unqualified queries hit the tenant's schema
SELECT * FROM projects;  -- queries tenant_acme.projects

Zalety: Silna izolacja, brak ryzyka wycieku danych cross-tenant, łatwy backup/restore per tenant, tenanci mogą mieć warianty schematu. Wady: Złożoność migracji schematu (musisz migrować N schematów), zarządzanie pulą połączeń, PostgreSQL ma praktyczne limity około ~10 000 schematów.

Baza danych na tenanta#

Każdy tenant dostaje własną bazę danych. Maksymalna izolacja.

Zalety: Pełna izolacja, niezależne skalowanie, łatwy backup/restore, możliwość umieszczenia dużych tenantów na dedykowanym sprzęcie. Wady: Koszmar zarządzania połączeniami, zapytania cross-tenant niemożliwe, migracje muszą być uruchomione N razy, znaczny narzut operacyjny.

Które wybrać?#

Dla większości aplikacji SaaS: zacznij od poziomu wierszy + RLS. To najprostsze w utrzymaniu, a RLS daje wystarczająco silną izolację dla zdecydowanej większości przypadków użycia. Przejdź na schemat-na-tenanta tylko jeśli masz kontraktowe wymagania izolacji (klienci enterprise, branże regulowane). Baza-na-tenanta jest na sytuacje, gdy absolutnie musisz zagwarantować fizyczną izolację — i nawet wtedy rozważ zarządzane bazy danych, gdzie ciężar operacyjny jest obsługiwany za ciebie.

Kolumny JSON/JSONB: Wyjście awaryjne#

JSONB PostgreSQL jest niezwykły. Daje ci elastyczność bazy dokumentowej wewnątrz systemu relacyjnego. Ale jak każde potężne narzędzie, łatwo go nadużyć.

Kiedy JSONB to właściwy wybór#

Dynamiczne atrybuty definiowane przez użytkownika. Platforma e-commerce, gdzie każda kategoria produktów ma inne atrybuty — buty mają size i color, elektronika ma voltage i wattage. Zamiast tabeli EAV czy kolumny dla każdego możliwego atrybutu:

sql
CREATE TABLE products (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name            TEXT NOT NULL,
    category_id     UUID NOT NULL REFERENCES categories(id),
    price_cents     INTEGER NOT NULL CHECK (price_cents >= 0),
    attributes      JSONB NOT NULL DEFAULT '{}',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Example data:
-- Shoe: {"size": "10", "color": "black", "material": "leather"}
-- Laptop: {"ram_gb": 16, "storage_gb": 512, "screen_inches": 14.0}

Ustawienia aplikacji i konfiguracja. Preferencje użytkownika, flagi funkcji, ustawienia powiadomień — rzeczy, które często zmieniają kształt i nie potrzebują integralności relacyjnej.

sql
CREATE TABLE user_settings (
    user_id     UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
    preferences JSONB NOT NULL DEFAULT '{
        "theme": "system",
        "notifications": {"email": true, "push": false},
        "locale": "en",
        "timezone": "UTC"
    }',
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Payloady webhooków, odpowiedzi API, dane zdarzeń. Wszystko, gdzie schemat jest kontrolowany przez zewnętrzny system i może się zmienić bez uprzedzenia.

Indeksy GIN na JSONB#

Bez indeksów odpytywanie wewnątrz JSONB wymaga pełnego skanu tabeli. Indeksy GIN przyspieszają to:

sql
-- Index the entire JSONB column (good for @> containment queries)
CREATE INDEX idx_products_attributes ON products USING GIN (attributes);
 
-- Query: find all products with color = "black"
SELECT * FROM products
WHERE attributes @> '{"color": "black"}';
 
-- Query: find products with any of these attributes
SELECT * FROM products
WHERE attributes ?| ARRAY['color', 'size'];

Dla konkretnych ścieżek, po których często odpytujesz, celowany indeks jest bardziej efektywny:

sql
-- Index a specific path
CREATE INDEX idx_products_color ON products ((attributes->>'color'));
 
-- Now this is a regular B-tree lookup
SELECT * FROM products
WHERE attributes->>'color' = 'black';

Pułapka migracji#

Tu JSONB cię gryzie: nie możesz łatwo dodać ograniczeń NOT NULL ani wartości domyślnych do pól wewnątrz JSON. Jeśli dodajesz nowe wymagane pole do atrybutów produktu, musisz uzupełnić każdy istniejący wiersz. Przy zwykłej kolumnie migracja obsługuje to atomowo. Z JSONB piszesz UPDATE, który dotyka każdego wiersza i masz nadzieję, że twój kod aplikacji elegancko obsługuje brakujące pola do czasu zakończenia uzupełniania.

sql
-- Adding a new column: clean, atomic, one statement
ALTER TABLE products ADD COLUMN weight_kg NUMERIC(10,3);
 
-- Adding a new JSONB field: messy, requires a backfill
UPDATE products
SET attributes = attributes || '{"weight_kg": null}'
WHERE NOT (attributes ? 'weight_kg');
-- Plus update application code to handle missing weight_kg
-- Plus update validation logic
-- Plus update every API response that includes attributes

Moja zasada: jeśli odpytujesz pole JSONB w klauzuli WHERE częściej niż dwa razy w tygodniu, prawdopodobnie powinno być kolumną. JSONB to świetne wyjście awaryjne. To kiepski domyślny wybór.

Migracje schematu: Nie zepsuj produkcji o 3 w nocy#

Migracje schematu to miejsce, gdzie teoria spotyka rzeczywistość. Twój schemat wygląda świetnie na papierze, ale teraz musisz zmienić tabelę z 50 milionami wierszy w godzinach pracy bez żadnego przestoju.

Narzędzia do migracji#

Używałem większości z nich. Krótkie opinie:

Drizzle (TypeScript): Mój obecny faworyt. Schemat jako kod, typowane zapytania generowane ze schematu, czyste SQL migracji. Komenda push do developmentu jest szybka.

Prisma (TypeScript): Świetne DX dla prostych schematów. Ma problemy z zaawansowanymi funkcjami PostgreSQL (częściowe indeksy, niestandardowe typy, RLS). Silnik migracji potrafi podejmować zaskakujące decyzje.

Flyway (Java/CLI): Solidny jak skała, sprawdzony w boju, SQL-first. Jeśli piszesz surowe migracje SQL, Flyway śledzi je niezawodnie. Żadnej magii, żadnych niespodzianek.

golang-migrate (Go/CLI): Podobny do Flyway, ale lżejszy. Świetny do projektów Go lub gdy po prostu chcesz prosty runner migracji up/down.

Problem zerowego przestoju#

Najniebezpieczniejsze zmiany schematu to te, które blokują tabelę. W PostgreSQL ALTER TABLE ... ADD COLUMN z wartością domyślną blokował kiedyś całą tabelę na czas przepisywania. Od PostgreSQL 11 proste wartości domyślne (stałe) to tylko metadane i są natychmiastowe. Ale inne operacje wciąż blokują:

sql
-- SAFE: metadata-only, instant (PostgreSQL 11+)
ALTER TABLE users ADD COLUMN bio TEXT;
ALTER TABLE users ADD COLUMN is_premium BOOLEAN NOT NULL DEFAULT false;
 
-- DANGEROUS: rewrites the entire table, full table lock
ALTER TABLE users ALTER COLUMN email TYPE VARCHAR(500);
ALTER TABLE users ADD COLUMN score INTEGER NOT NULL DEFAULT compute_score();
 
-- DANGEROUS: scans the entire table to validate
ALTER TABLE orders ADD CONSTRAINT check_total CHECK (total_cents >= 0);

Bezpieczne dodawanie kolumn NOT NULL#

Nie możesz po prostu dodać NOT NULL do istniejącej tabeli z danymi — zawiedzie, bo istniejące wiersze nie mają wartości. Naiwne podejście:

sql
-- This locks the table and rewrites it. Don't do this on a big table.
ALTER TABLE users ADD COLUMN avatar_url TEXT NOT NULL DEFAULT 'https://example.com/default.png';

Od PostgreSQL 11 to jest faktycznie bezpieczne dla stałych wartości domyślnych — to tylko metadane. Ale jeśli twoja wartość domyślna jest funkcją lub musisz uzupełnić obliczonymi wartościami, użyj wzorca expand-contract.

Wzorzec Expand-Contract#

To złoty standard dla zmian schematu bez przestoju. Trzy fazy:

Faza 1: Rozszerzenie — Dodaj nową kolumnę jako nullable. Wdróż kod aplikacji, który pisze zarówno do starej, jak i nowej kolumny.

sql
-- Migration 1: Add the nullable column
ALTER TABLE users ADD COLUMN normalized_email TEXT;

Faza 2: Migracja — Uzupełnij istniejące wiersze partiami. Twoja aplikacja już pisze do nowej kolumny dla nowych danych.

sql
-- Migration 2: Backfill in batches (don't do this in one statement for large tables)
UPDATE users SET normalized_email = LOWER(TRIM(email))
WHERE normalized_email IS NULL
LIMIT 10000;
-- Repeat until all rows are backfilled

Faza 3: Kontraktacja — Gdy wszystkie wiersze są uzupełnione, dodaj ograniczenie NOT NULL i usuń starą kolumnę (jeśli dotyczy).

sql
-- Migration 3: Add constraint (use NOT VALID to avoid full table scan, then validate separately)
ALTER TABLE users ADD CONSTRAINT users_normalized_email_not_null
    CHECK (normalized_email IS NOT NULL) NOT VALID;
 
-- Migration 4: Validate the constraint (takes a ShareUpdateExclusiveLock, not an AccessExclusiveLock)
ALTER TABLE users VALIDATE CONSTRAINT users_normalized_email_not_null;

Sztuczka NOT VALID + VALIDATE CONSTRAINT jest kluczowa. Dodanie ograniczenia CHECK normalnie skanuje całą tabelę trzymając ciężki lock. NOT VALID dodaje ograniczenie bez skanowania (dotyczy tylko nowych zapisów), a VALIDATE CONSTRAINT skanuje z lżejszym lockiem, który nie blokuje odczytów ani zapisów.

Tworzenie indeksów#

Tworzenie indeksów na dużych tabelach domyślnie blokuje zapisy. Zawsze używaj CONCURRENTLY:

sql
-- BLOCKS WRITES: don't do this on a live table
CREATE INDEX idx_users_email ON users (email);
 
-- NON-BLOCKING: use this instead
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);

CONCURRENTLY trwa dłużej i nie może działać wewnątrz transakcji, ale nie blokuje tabeli. Ten kompromis jest zawsze wart podjęcia na produkcji.

Decyzje, których żałowałem#

Każdy schemat ma żale. Oto moje.

Użycie SERIAL zamiast UUID dla zewnętrznych ID#

Na początku projektu używałem kluczy głównych SERIAL i wystawiałem je bezpośrednio w URL-ach: /users/42, /orders/1337. To wyciekało informacje (konkurenci mogli zgadnąć naszą liczbę użytkowników), paginacja była odgadywalna i się zepsuło, gdy musieliśmy połączyć bazy danych z dwóch regionów. Przejście na UUID dla zewnętrznych ID wymagało wielomiesięcznej migracji.

Lekcja: używaj UUID dla wszystkiego wystawionego poza bazą danych. Używaj SERIAL/BIGSERIAL dla wewnętrznych tabel łączących, jeśli chcesz, ale nigdy nie pozwól, by auto-inkrementujący integer pojawił się w URL.

Brak ograniczeń („Dodamy je później")#

Uruchomiliśmy tabelę bez ograniczeń CHECK, bo „idziemy szybko i dodamy je później". W ciągu dwóch tygodni dane miały ujemne ceny, puste nazwy i wartość order_status równą "oof", którą ktoś wpisał podczas testów i nigdy nie posprzątał.

Dodanie ograniczeń po fakcie wymagało:

  1. Znalezienia wszystkich nieprawidłowych danych
  2. Zdecydowania, co z nimi zrobić (naprawić, usunąć czy zachować w obecnym stanie)
  3. Napisania migracji, która uzupełnia/naprawia dane I dodaje ograniczenie

To zajęło więcej czasu niż dodanie ograniczenia pierwszego dnia. Zacznij od ograniczeń. Poluzuj je, jeśli musisz. Nigdy odwrotnie.

Łańcuchy rozdzielone przecinkami zamiast tablic lub tabel łączących#

Kiedyś przechowywałem tagi jako łańcuch rozdzielony przecinkami: "javascript,react,nextjs". Odpytywanie było koszmarem:

sql
-- This is how you query comma-separated values. Don't do this.
SELECT * FROM posts WHERE tags LIKE '%react%';
-- This also matches "react-native" and "preact"
 
-- What I should have done:
CREATE TABLE post_tags (
    post_id     UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
    tag_id      UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
    PRIMARY KEY (post_id, tag_id)
);

Albo przynajmniej użyj natywnego typu tablicowego PostgreSQL:

sql
CREATE TABLE posts (
    id      UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title   TEXT NOT NULL,
    tags    TEXT[] NOT NULL DEFAULT '{}',
    -- ...
);
 
CREATE INDEX idx_posts_tags ON posts USING GIN (tags);
 
-- Clean queries
SELECT * FROM posts WHERE 'react' = ANY(tags);
SELECT * FROM posts WHERE tags @> ARRAY['react', 'nextjs'];

Tablice są akceptowalne dla prostych list, które nie potrzebują własnych atrybutów. W momencie, gdy potrzebujesz metadanych o relacji (jak „kto dodał ten tag" albo „kiedy został dodany"), potrzebujesz tabeli łączącej.

Używanie „type" jako nazwy kolumny#

sql
-- Seemed fine at the time
CREATE TABLE notifications (
    id      BIGSERIAL PRIMARY KEY,
    type    TEXT NOT NULL,  -- "email", "sms", "push"
    -- ...
);
 
-- Then you try to query it in literally any ORM:
-- notification.type  ← conflicts with every language's type system
-- "type" is a reserved word in most SQL dialects
-- You end up quoting it everywhere: SELECT "type" FROM notifications

Użyj kind, category lub notification_type. Cokolwiek oprócz type. Podobnie unikaj order (użyj sort_order lub position), user (użyj account lub dodaj prefiks) i group (użyj team lub group_name).

Brak created_at od samego początku#

Jedna tabela nie dostała created_at, bo „nie potrzebujemy tego". Trzy miesiące później musieliśmy zdebugować problem i nie mieliśmy pojęcia, kiedy rekordy zostały utworzone. Dodanie tego retroaktywnie oznaczało, że wszystkie istniejące wiersze dostały ten sam timestamp (timestamp migracji), czyniąc dane historyczne bezużytecznymi.

Każda tabela dostaje created_at. Bez wyjątków. Koszt to jedna kolumna. Koszt braku jest niepoznawalny, dopóki nie będziesz tego potrzebować.

Umieszczanie logiki biznesowej w widokach bazy danych#

Kiedyś stworzyłem łańcuch widoków — active_users filtrował users, premium_active_users filtrował active_users, a widok raportowy łączył je wszystkie. Działało świetnie, dopóki ktoś nie zmienił tabeli users i wszystkie trzy widoki się po cichu zepsuły. Planista zapytań też miał problemy z optymalizacją przez wiele warstw widoków.

Widoki są świetne dla wygody i wzorców dostępu tylko do odczytu. Są kiepskie jako miejsce do kodowania logiki biznesowej, która się zmienia. Trzymaj reguły biznesowe w kodzie aplikacji, gdzie są wersjonowane, testowane i wdrażalne. Używaj widoków jako skrótów raportowych, nie jako bloków architektonicznych.

Składając to wszystko razem#

Oto jak wygląda dobrze zaprojektowany schemat, gdy połączysz te wzorce. Uproszczony system zarządzania projektami:

sql
-- Extensions
CREATE EXTENSION IF NOT EXISTS "pgcrypto";  -- for gen_random_uuid()
 
-- Updated_at trigger function (reusable)
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
-- Tenants
CREATE TABLE tenants (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name            TEXT NOT NULL CHECK (LENGTH(name) > 0),
    slug            TEXT NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9-]+$'),
    plan            TEXT NOT NULL DEFAULT 'free'
                    CHECK (plan IN ('free', 'pro', 'enterprise')),
    settings        JSONB NOT NULL DEFAULT '{}',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON tenants
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
-- Users
CREATE TABLE users (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    email           TEXT NOT NULL,
    display_name    TEXT NOT NULL CHECK (LENGTH(display_name) > 0),
    password_hash   TEXT NOT NULL,
    is_active       BOOLEAN NOT NULL DEFAULT true,
    is_verified     BOOLEAN NOT NULL DEFAULT false,
    last_login_at   TIMESTAMPTZ,
    deleted_at      TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE UNIQUE INDEX uq_users_email_per_tenant
    ON users (tenant_id, email)
    WHERE deleted_at IS NULL;
 
CREATE INDEX idx_users_tenant ON users (tenant_id) WHERE deleted_at IS NULL;
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON users
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
-- Enable RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON users
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Projects
CREATE TABLE projects (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    name            TEXT NOT NULL CHECK (LENGTH(name) > 0),
    description     TEXT NOT NULL DEFAULT '',
    status          TEXT NOT NULL DEFAULT 'active'
                    CHECK (status IN ('active', 'archived', 'deleted')),
    owner_id        UUID NOT NULL REFERENCES users(id),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_projects_tenant ON projects (tenant_id);
CREATE INDEX idx_projects_owner ON projects (owner_id);
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON projects
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Tasks
CREATE TABLE tasks (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    project_id      UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    title           TEXT NOT NULL CHECK (LENGTH(title) > 0),
    description     TEXT NOT NULL DEFAULT '',
    status          TEXT NOT NULL DEFAULT 'todo'
                    CHECK (status IN ('todo', 'in_progress', 'review', 'done')),
    priority        INTEGER NOT NULL DEFAULT 0 CHECK (priority BETWEEN 0 AND 4),
    assignee_id     UUID REFERENCES users(id) ON DELETE SET NULL,
    due_at          TIMESTAMPTZ,
    completed_at    TIMESTAMPTZ,
    sort_order      INTEGER NOT NULL DEFAULT 0,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_tasks_tenant_project ON tasks (tenant_id, project_id);
CREATE INDEX idx_tasks_assignee ON tasks (assignee_id) WHERE assignee_id IS NOT NULL;
CREATE INDEX idx_tasks_status ON tasks (tenant_id, status) WHERE status != 'done';
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON tasks
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON tasks
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Task comments
CREATE TABLE task_comments (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    task_id         UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
    author_id       UUID NOT NULL REFERENCES users(id),
    body            TEXT NOT NULL CHECK (LENGTH(body) > 0),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_task_comments_task ON task_comments (task_id);
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON task_comments
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
ALTER TABLE task_comments ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON task_comments
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Audit log (no RLS — admin-only table)
CREATE TABLE audit_log (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    table_name      TEXT NOT NULL,
    record_id       TEXT NOT NULL,
    operation       TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
    old_values      JSONB,
    new_values      JSONB,
    changed_fields  TEXT[],
    changed_by      UUID REFERENCES users(id),
    changed_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_audit_log_tenant_table ON audit_log (tenant_id, table_name, record_id);
CREATE INDEX idx_audit_log_changed_at ON audit_log (changed_at);

Zwróć uwagę na wzorce:

  • Każda tabela ma tenant_id i włączony RLS (oprócz tenants i audit_log).
  • Każda tabela ma created_at i updated_at z triggerami.
  • UUID dla wszystkich kluczy głównych (encje wystawione na zewnątrz).
  • Ograniczenia CHECK na enumach statusów, długościach i zakresach.
  • Klucze obce z odpowiednim zachowaniem ON DELETE.
  • Indeksy zaprojektowane pod wzorce dostępu (tenant + project, tenant + status).
  • Soft delete tylko na users (gdzie potrzebne jest odzyskanie konta), archiwizacja oparta na statusie na projects.

Końcowe przemyślenia#

Projektowanie schematu nie jest efektowne. Nikt nigdy nie wygłosił wykładu konferencyjnego pt. „Dodałem ograniczenia CHECK do każdej tabeli i zaoszczędziło nam to sześć miesięcy debugowania". Ale dokładnie to robi dobre projektowanie schematu — zapobiega problemom tak cicho, że nigdy nawet nie wiesz, że by się pojawiły.

Wzorce w tym wpisie nie są nowatorskie. To wynik lat pisania migracji, debugowania uszkodzeń danych i refaktoryzacji schematów pod obciążeniem produkcyjnym. Każdy z nich istnieje, bo ja, lub ktoś z kim pracowałem, najpierw zrobił to inaczej i zapłacił cenę.

Zacznij od ograniczeń. Używaj kluczy obcych. Dodawaj created_at do wszystkiego. Wybierz konwencję nazewnictwa i wymuszaj ją bezwzględnie. Używaj RLS dla multi-tenancy. Bądź ostrożny z JSONB. Testuj migracje na danych o rozmiarze produkcyjnym przed ich wdrożeniem.

Baza danych to fundament. Zrób to dobrze, a wszystko zbudowane na wierzchu staje się prostsze. Zrób to źle, a żaden sprytny kod aplikacji cię nie uratuje.

Powiązane wpisy