Перейти до вмісту
·30 хв читання

Проєктування схеми бази даних: патерни, що витримують час

Правила нормалізації, конвенції іменування, м'яке видалення, аудиторські сліди, патерни мультитенантності, стратегії версіонування та рішення щодо схеми, про які я шкодував. З фокусом на PostgreSQL.

Поділитися:X / TwitterLinkedIn

Схема — це контракт із твоїм майбутнім «я». Кожна колонка, яку ти додаєш, кожне обмеження, яке пропускаєш, кожне «ми це виправимо пізніше» — все це накопичується. Я працював із системами, де одне невдале рішення щодо схеми, прийняте три роки тому, тепер коштує команді повного спринту кожен квартал на обхідні рішення.

База даних переживає все. Твій фронтенд-фреймворк зміниться. Твій API-рівень перепишуть. Стратегія розгортання еволюціонує. Але дані? Дані залишаються. І форма, яку ти їм надав першого дня, переслідує тебе вічно, бо мігрувати таблицю з 200 мільйонами рядків — це не те саме, що рефакторити React-компонент.

Ось що я засвоїв про прийняття рішень щодо схеми, які не переслідуватимуть тебе. З фокусом на PostgreSQL, бо саме його я використовую і якому довіряю, але більшість цих патернів стосуються будь-якої реляційної бази даних.

Конвенції іменування: нудна річ, яка важить найбільше#

Я бачив більше суперечок про конвенції іменування, ніж про реальні архітектурні рішення. Ось на чому я зупинився після років перемикання між проєктами:

Snake_case для всього. Таблиці, колонки, індекси, обмеження. Жодного camelCase, жодного PascalCase. PostgreSQL і так перетворює ідентифікатори без лапок у нижній регістр, тому createdAt стає createdat, якщо ти не огортаєш це в подвійні лапки всюди. Не борися з базою даних.

Назви таблиць у множині. Таблиця містить багато рядків. users, а не user. orders, а не order. order_items, а не order_item. Це природно читається в запитах: SELECT * FROM users WHERE ... — ти обираєш із колекції.

Суфікс _id для зовнішніх ключів. user_id, order_id, tenant_id. Первинний ключ — просто id. Це однозначно. Коли ти з'єднуєш таблиці, users.id = orders.user_id читається як природна мова.

Суфікс _at для часових міток. created_at, updated_at, deleted_at, published_at, expires_at. Ти завжди знаєш, що це момент часу.

Префікс is_ для булевих значень. is_active, is_verified, is_published. Деякі використовують has_ для булевих значень належності (has_mfa_enabled), але я тримаю все просто й використовую is_ для всього.

Суфікс _count для денормалізованих лічильників. comment_count, follower_count. Одразу зрозуміло, що це закешоване число, а не обчислення в реальному часі.

Ось у чому суть: послідовність перемагає досконалість. Я бачив команди, які витрачали тижні на обговорення того, чи має це бути email_address, чи email, чи email_addr. Просто обери один патерн і дотримуйся його всюди. Найгірша конвенція іменування — та, що застосовується непослідовно.

sql
-- Добре: послідовно, читабельно, без сюрпризів
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()
);
 
-- Погано: непослідовний регістр, змішані конвенції, неоднозначні назви
CREATE TABLE User (
    UserID          SERIAL PRIMARY KEY,
    EmailAddress    VARCHAR(255),
    name            VARCHAR(100),
    passwd          VARCHAR(255),
    active          BOOLEAN,
    verified        INT,             -- це 0/1? рівень верифікації?
    lastLogin       TIMESTAMP,       -- camelCase у snake_case світі
    created         TIMESTAMP        -- створено що? коли?
);

Ще одне: ніколи не використовуй зарезервовані слова як назви колонок. type, order, user, group, table — все це працює, якщо ти огортаєш у лапки, але це обпалить тебе в ORM-ах, конструкторах запитів та кожному інструменті генерації динамічного SQL. Використовуй kind замість type, sort_order замість order. Твоє майбутнє «я» подякує.

Стандартні колонки: що отримує кожна таблиця#

Кожна таблиця в моїх схемах починається з одного й того ж скелета. Без винятків.

sql
CREATE TABLE things (
    id          BIGSERIAL PRIMARY KEY,
    -- ... колонки предметної області ...
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Дебати UUID vs BIGSERIAL#

Це одне з тих рішень, яке генерує набагато більше суперечок, ніж заслуговує. Ось реальний компроміс:

BIGSERIAL (автоінкрементне ціле число):

  • 8 байт. Компактно. Швидке індексування та з'єднання.
  • Впорядковане — можна сортувати за id, щоб отримати порядок вставки.
  • Передбачуване — користувач може вгадати інші ID, інкрементуючи свій.
  • Не працює добре у розподілених системах (потребує координації).

UUID v4 (випадковий):

  • 16 байт. Більші індекси, повільніші з'єднання (але рідко стає вузьким місцем).
  • Непередбачуваний — немає витоку інформації.
  • Працює в розподілених системах без координації.
  • Жахлива локальність індексу — випадкові UUID фрагментують B-tree індекси.

UUID v7 (відсортований за часом, RFC 9562):

  • 16 байт, але впорядкований за часом, тому локальність B-tree чудова.
  • Достатньо непередбачуваний для зовнішнього використання.
  • Відносно новий, але PostgreSQL 17+ має gen_random_uuid(), і можна використовувати uuid_generate_v7() через розширення.

Моя поточна позиція: BIGSERIAL для внутрішніх таблиць, UUID v7 для всього, що виставляється назовні. Якщо ID коли-небудь з'являється в URL, відповіді API або вебхук-пейлоаді — використовуй UUID. Якщо це чисто з'єднувальна таблиця, яку користувачі ніколи не бачать — BIGSERIAL годиться.

sql
-- Для ресурсу, що виставляється через API
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()
);
 
-- Для внутрішньої таблиці зв'язків
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)
);

Часові мітки: завжди TIMESTAMPTZ#

Використовуй TIMESTAMPTZ, а не TIMESTAMP. Версія з «TZ» зберігає значення в UTC і конвертує при читанні на основі часової зони сесії. Версія без TZ зберігає те, що ти їй даєш, без контексту часової зони — що означає, що якщо два сервери в різних часових зонах пишуть в одну таблицю, ти отримуєш тихе пошкодження даних.

sql
-- Завжди так
created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
 
-- Ніколи так
created_at  TIMESTAMP NOT NULL DEFAULT NOW()

Тригер updated_at#

PostgreSQL не має MySQL-ового ON UPDATE CURRENT_TIMESTAMP. Тобі потрібен тригер:

sql
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
-- Застосуй до кожної таблиці, що має 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();

Так, тобі потрібен один тригер на таблицю. Так, це нудно. Напиши хелпер для міграцій, який створює тригер автоматично, коли ти додаєш таблицю. Воно того варте, бо альтернатива — пам'ятати встановлювати updated_at = NOW() у кожному UPDATE-запиті по всьому застосунку — і ти забудеш.

Нормалізація: коли порушувати правила#

Кожен курс інформатики вчить нормалізації аж до 3НФ (Третьої Нормальної Форми). Правила:

  • 1НФ: кожна колонка містить єдине атомарне значення. Жодних масивів, жодних списків через кому.
  • 2НФ: кожна неключова колонка залежить від усього первинного ключа (актуально для складених ключів).
  • 3НФ: жодних транзитивних залежностей. Якщо колонка A визначає колонку B, а B визначає C, тоді C не має бути в одній таблиці з A.

На практиці 3НФ — це оптимальна точка для транзакційних таблиць. Починай із неї і відхиляйся тільки тоді, коли маєш конкретну, вимірювану причину.

Ось правильно нормалізована система замовлень:

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()
);

Зверни увагу на unit_price_cents в order_items. Це навмисна денормалізація. Ми зберігаємо знімок ціни на момент замовлення, бо ціна товару може змінитися пізніше. Це одна з найпоширеніших і правильних причин денормалізувати.

Коли денормалізація — правильний вибір#

Звітні таблиці. Якщо твій дашборд аналітики потребує з'єднання 8 таблиць для рендерингу, створи денормалізовану звітну таблицю та наповнюй її фоновим завданням. Твоя транзакційна схема залишається чистою, а звітні запити — швидкими.

sql
-- Денормалізована звітна таблиця, наповнюється cron-завданням
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,        -- масив тут прийнятний
    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()
);

Матеріалізовані подання. PostgreSQL-ова MATERIALIZED VIEW недооцінена. Це денормалізований знімок, який ти оновлюєш на вимогу. Ідеально для дашбордів.

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 MATERIALIZED VIEW CONCURRENTLY monthly_revenue;

JSON-колонки для динамічних атрибутів. Коли різним рядкам потрібні різні поля — варіанти товарів, відправки форм, користувацькі налаштування — JSONB-колонка часто краща, ніж кошмарна EAV (Entity-Attribute-Value) таблиця. Про це детальніше далі.

Правило, яке я ніколи не порушую#

Ніколи не денормалізуй свої таблиці-джерела істини. Денормалізуй копії, знімки, звіти та кеші. Канонічні дані залишаються нормалізованими. Коли денормалізована копія стає застарілою або пошкодженою (а вона стане), ти перебудовуєш її з нормалізованого джерела.

Зовнішні ключі та обмеження: найкращий код, якого ти ніколи не напишеш#

Я чув усі відмовки за пропуск зовнішніх ключів. «Вони сповільнюють запис.» «Ми забезпечуємо це в застосунку.» «Нам потрібна гнучкість.»

Все це неправда.

Зовнішні ключі — це найвпливовіша річ, яку ти можеш додати до схеми. Вони запобігають цілим категоріям багів, які жодна кількість коду застосунку не може зловити — гонки даних, часткові збої, осиротілі рядки від провалених транзакцій. Зовнішній ключ — це гарантія від самого двигуна бази даних, забезпечена на рівні сховища. Хук beforeDelete твоєї ORM — це побажання.

sql
-- Завжди роби так
ALTER TABLE orders
    ADD CONSTRAINT fk_orders_customer
    FOREIGN KEY (customer_id) REFERENCES customers(id);
 
-- Альтернатива «ми обробимо це в коді»:
-- Надія. Надія — це не стратегія.

Стратегії ON DELETE#

Тут все стає нюансованим. Що відбувається, коли ти видаляєш батьківський рядок?

RESTRICT (за замовчуванням): видалення провалюється, якщо існують дочірні рядки. Використовуй для більшості зв'язків. Ти не можеш видалити клієнта, який має замовлення — це бізнес-логіка, закодована в схемі.

sql
-- Клієнта не можна видалити, поки в нього є замовлення
ALTER TABLE orders
    ADD CONSTRAINT fk_orders_customer
    FOREIGN KEY (customer_id) REFERENCES customers(id)
    ON DELETE RESTRICT;

CASCADE: дочірні рядки автоматично видаляються. Використовуй обережно та свідомо. Добре для відносин «частина цілого», де дочірній рядок не має сенсу без батьківського.

sql
-- Видалення замовлення видаляє його позиції — вони безглузді самі по собі
ALTER TABLE order_items
    ADD CONSTRAINT fk_order_items_order
    FOREIGN KEY (order_id) REFERENCES orders(id)
    ON DELETE CASCADE;
 
-- Видалення проєкту видаляє його членства
ALTER TABLE project_members
    ADD CONSTRAINT fk_project_members_project
    FOREIGN KEY (project_id) REFERENCES projects(id)
    ON DELETE CASCADE;

SET NULL: колонка зовнішнього ключа встановлюється в NULL. Використовуй, коли зв'язок необов'язковий і дочірній рядок все ще має сенс сам по собі.

sql
-- Якщо менеджер звільняється, його підлеглі все ще існують — просто без призначення
ALTER TABLE employees
    ADD CONSTRAINT fk_employees_manager
    FOREIGN KEY (manager_id) REFERENCES employees(id)
    ON DELETE SET NULL;

Моє правило: за замовчуванням RESTRICT, CASCADE для композиційних зв'язків, SET NULL для необов'язкових асоціацій. Якщо не впевнений — RESTRICT завжди безпечний вибір — послабити обмеження простіше, ніж відновити видалені дані.

Check-обмеження: дешева страховка#

Check-обмеження практично нічого не коштують при запису і запобігають потраплянню сміттєвих даних у систему назавжди:

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()
);

Кожне check-обмеження, яке ти додаєш — це ще один баг валідації, який тобі ніколи не доведеться дебажити в продакшені. База даних — останній рубіж оборони. Використовуй його.

Унікальні обмеження та часткові унікальні індекси#

Унікальні обмеження прості для простих випадків:

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

Але вони стають цікавими з частковими індексами — унікальними обмеженнями, що застосовуються лише до певних рядків:

sql
-- Тільки одна активна підписка на користувача (але може бути багато скасованих)
CREATE UNIQUE INDEX uq_active_subscription
    ON subscriptions (user_id)
    WHERE status = 'active';
 
-- Тільки одна основна адреса на користувача
CREATE UNIQUE INDEX uq_primary_address
    ON addresses (user_id)
    WHERE is_primary = true;

Це одна з кілерних фіч PostgreSQL. Використовуй її.

М'яке видалення: патерн, який усі люблять ненавидіти#

М'яке видалення просте за концепцією: замість DELETE FROM users WHERE id = 42 ти робиш UPDATE users SET deleted_at = NOW() WHERE id = 42. Рядок залишається в базі даних, але фільтрується зі звичайних запитів.

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()
);
 
-- Частковий індекс: ефективно фільтрує видалені рядки
CREATE INDEX idx_users_active ON users (email) WHERE deleted_at IS NULL;

Кожен запит у твоєму застосунку тепер потребує WHERE deleted_at IS NULL. Кожен. Один. Запит. ORM допомагають із цим (Prisma має middleware, Drizzle має .where(isNull(deletedAt))), але це податок на кожну операцію читання. Пропусти один раз — і ти показуєш «видалені» дані користувачам.

Проблема унікального обмеження#

Ось де м'яке видалення стає потворним. Якщо ти маєш UNIQUE (email) і користувач м'яко видаляє свій акаунт, його email все ще в таблиці. Він не може зареєструватися знову з тим самим email. Новий користувач із цим email теж не може зареєструватися.

Виправлення — частковий унікальний індекс:

sql
-- Email має бути унікальним, але тільки серед невидалених користувачів
CREATE UNIQUE INDEX uq_users_email_active
    ON users (email)
    WHERE deleted_at IS NULL;

Це працює, але тепер тобі потрібно пам'ятати цей патерн для кожної унікальної колонки на кожній таблиці з м'яким видаленням. Це керовано з дисципліною, але це складність, яку ти вибрав на себе взяти.

Проблема зовнішнього ключа#

М'яке видалення погано взаємодіє з зовнішніми ключами. Якщо orders.user_id посилається на users.id з ON DELETE RESTRICT, і ти м'яко видаляєш користувача... нічого не відбувається. FK не спрацьовує, бо ти насправді не видалив рядок. Користувач «зник» з точки зору застосунку, але все ще дуже присутній у базі даних.

Це означає, що код твого застосунку має обробляти випадок, коли посилана сутність м'яко видалена. Кожне з'єднання, кожен пошук, кожного разу, коли ти переходиш за зовнішнім ключем — тобі потрібно перевіряти deleted_at IS NULL і на посиланій таблиці. Або ти цього не робиш, і твій застосунок показує «Замовлення від [видалений користувач]», що або баг, або фіча — залежно від того, кого ти питаєш.

Мій підхід#

Я використовую м'яке видалення тільки для сутностей, видимих користувачами, де відновлення — це бізнес-вимога — акаунти користувачів, проєкти, документи. Речі, де агент підтримки може потребувати відновити видалене. Для всього іншого я використовую жорстке видалення з аудиторським слідом (про це далі).

sql
-- М'яке видалення: видиме користувачам, відновлюване
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;
 
-- Жорстке видалення: внутрішнє, не відновлюється з UI (але аудитується)
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()
);
-- Коли видалено — його немає. audit_log фіксує, що він існував.

Аудиторські сліди: знай, що змінилося і хто це зробив#

Кожен нетривіальний застосунок рано чи пізно потребує відповіді на питання «що сталося з цим записом?». Аудиторські сліди — це те, як ти надаєш цю відповідь, не копаючись у логах застосунку.

Патерн окремої таблиці аудиту#

Найпростіший підхід: єдина таблиця audit_log, яка записує кожну зміну.

sql
CREATE TABLE audit_log (
    id              BIGSERIAL PRIMARY KEY,
    table_name      TEXT NOT NULL,
    record_id       TEXT NOT NULL,          -- текст для обробки як UUID, так і BIGINT PK
    operation       TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
    old_values      JSONB,                  -- NULL для INSERT
    new_values      JSONB,                  -- NULL для DELETE
    changed_fields  TEXT[],                 -- які колонки змінилися (для 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);

Виклик полягає в заповненні. Ти можеш робити це в коді застосунку (явно, але легко забути) або тригерами (автоматично, але складніше передати контекст на кшталт changed_by).

Підхід на основі тригерів#

Тригери фіксують кожну зміну автоматично, навіть від сирого SQL чи операцій адміністратора бази даних:

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);
        -- Знайти змінені поля
        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;
        -- Логувати тільки якщо щось реально змінилося
        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;
 
-- Застосуй до таблиць, які хочеш аудитувати
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();

Недолік: тригери не знають, який користувач застосунку зробив зміну. Ти можеш обійти це за допомогою змінних сесії:

sql
-- У твоєму застосунку, перед запитом:
SET LOCAL app.current_user_id = 'uuid-of-current-user';
 
-- У функції тригера:
changed_by_id := current_setting('app.current_user_id', true)::UUID;

Це працює, але виглядає крихким. На практиці я використовую гібридний підхід: тригери для збору даних і код застосунку для встановлення контексту сесії.

Патерн таблиці історії#

Для таблиць, де тобі потрібна повна історія версій (не просто «що змінилося», а «який був стан у момент T»), виділена таблиця історії чистіша:

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)
);

Перед кожним оновленням documents скопіюй поточний стан у document_history та збільш версію. Тепер ти можеш реконструювати документ у будь-який момент часу, показати різницю між версіями та навіть відновити старі версії.

Компроміс — сховище. Якщо твоя колонка content велика і часто змінюється, таблиця історії може швидко рости. Для більшості застосунків це нормально — сховище дешеве, і ти можеш архівувати старі версії в холодне сховище за потреби.

Мультитенантність: три підходи, обери свій біль#

Мультитенантність — це одна з тих речей, які легко додати на початку і майже неможливо додати пізніше. Якщо є хоч найменший шанс, що твій застосунок обслуговуватиме кілька організацій — вбудовуй це з першого дня.

Рядковий рівень: tenant_id на кожній таблиці#

Найпоширеніший підхід. Кожна таблиця має колонку tenant_id, і кожен запит фільтрує за нею.

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()
);
 
-- Кожен індекс має включати tenant_id для продуктивності запитів
CREATE INDEX idx_projects_tenant ON projects (tenant_id);
CREATE INDEX idx_tasks_tenant_project ON tasks (tenant_id, project_id);

Ризик — витік даних — один пропущений WHERE tenant_id = ..., і ти показуєш дані Орендаря А Орендарю Б. Row-Level Security (RLS) PostgreSQL усуває цей клас багів:

sql
-- Увімкни RLS на таблиці
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
 
-- Створи політику на основі змінної сесії
CREATE POLICY tenant_isolation ON projects
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Примусово застосуй RLS навіть для власників таблиці
ALTER TABLE projects FORCE ROW LEVEL SECURITY;

Тепер, навіть якщо код твого застосунку забуде WHERE tenant_id = ..., PostgreSQL додає це автоматично. Це захист в глибину, і це один із найсильніших аргументів на користь PostgreSQL у мультитенантних системах.

sql
-- У middleware підключення твого застосунку:
SET LOCAL app.current_tenant_id = 'tenant-uuid-here';
 
-- Тепер усі запити до RLS-таблиць автоматично фільтруються
SELECT * FROM projects;
-- PostgreSQL внутрішньо додає: WHERE tenant_id = 'tenant-uuid-here'

Плюси: єдина база даних, прості операції, ефективне використання ресурсів, легкі міжтенантні запити для адміна. Мінуси: вимагає дисципліни (або RLS), кожен запит торкається tenant_id, складніше дати орендарям власний бекап/відновлення.

Схема-на-орендаря#

Кожен орендар отримує свою PostgreSQL-схему. Всі схеми спільно використовують одну базу даних, але таблиці ізольовані за простором імен.

sql
-- Створи схему для кожного орендаря
CREATE SCHEMA tenant_acme;
CREATE SCHEMA tenant_globex;
 
-- Таблиці живуть у схемі орендаря
CREATE TABLE tenant_acme.projects (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Встанови search_path для поточного орендаря
SET search_path TO tenant_acme, public;
 
-- Тепер некваліфіковані запити потрапляють у схему орендаря
SELECT * FROM projects;  -- запитує tenant_acme.projects

Плюси: сильна ізоляція, немає ризику міжтенантного витоку даних, легкий бекап/відновлення на рівні орендаря, орендарі можуть мати варіації схеми. Мінуси: складність міграції схем (потрібно мігрувати N схем), управління пулом з'єднань, PostgreSQL має практичні обмеження близько ~10 000 схем.

База-на-орендаря#

Кожен орендар отримує свою базу даних. Максимальна ізоляція.

Плюси: повна ізоляція, незалежне масштабування, легкий бекап/відновлення, можна розмістити великих орендарів на виділеному залізі. Мінуси: кошмар управління з'єднаннями, міжтенантні запити неможливі, міграція має запускатися N разів, значне операційне навантаження.

Що обрати?#

Для більшості SaaS-застосунків: починай з рядкового рівня + RLS. Це найпростіше в операціях, і RLS дає тобі достатньо сильну ізоляцію для переважної більшості випадків використання. Переходь до схеми-на-орендаря тільки якщо маєш контрактні вимоги до ізоляції (корпоративні клієнти, регульовані індустрії). База-на-орендаря — коли абсолютно необхідно гарантувати фізичну ізоляцію — і навіть тоді розглянь керовані бази даних, де операційне навантаження обробляється за тебе.

JSON/JSONB колонки: аварійний вихід#

JSONB у PostgreSQL — це щось видатне. Він дає тобі гнучкість документної бази даних усередині реляційної системи. Але як будь-який потужний інструмент, ним легко зловживати.

Коли JSONB — правильний вибір#

Динамічні атрибути, визначені користувачем. Е-коммерс платформа, де кожна категорія товарів має різні атрибути — взуття має size і color, електроніка має voltage і wattage. Замість EAV-таблиці або колонки для кожного можливого атрибута:

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()
);
 
-- Приклади даних:
-- Взуття: {"size": "10", "color": "black", "material": "leather"}
-- Ноутбук: {"ram_gb": 16, "storage_gb": 512, "screen_inches": 14.0}

Налаштування та конфігурація застосунку. Користувацькі вподобання, фіча-флаги, налаштування сповіщень — речі, які часто змінюють форму і не потребують реляційної цілісності.

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()
);

Вебхук-пейлоади, API-відповіді, дані подій. Будь-що, де схема контролюється зовнішньою системою і може змінитися без попередження.

GIN-індекси на JSONB#

Без індексів запити всередині JSONB потребують повного сканування таблиці. GIN-індекси роблять це швидким:

sql
-- Індексуй всю JSONB-колонку (добре для запитів з оператором @>)
CREATE INDEX idx_products_attributes ON products USING GIN (attributes);
 
-- Запит: знайти всі товари з color = "black"
SELECT * FROM products
WHERE attributes @> '{"color": "black"}';
 
-- Запит: знайти товари з будь-яким із цих атрибутів
SELECT * FROM products
WHERE attributes ?| ARRAY['color', 'size'];

Для конкретних шляхів, які ти часто запитуєш, цільовий індекс ефективніший:

sql
-- Індекс на конкретний шлях
CREATE INDEX idx_products_color ON products ((attributes->>'color'));
 
-- Тепер це звичайний B-tree пошук
SELECT * FROM products
WHERE attributes->>'color' = 'black';

Пастка міграції#

Ось де JSONB тебе кусає: ти не можеш легко додати NOT NULL обмеження або значення за замовчуванням для полів всередині JSON. Якщо ти додаєш нове обов'язкове поле до атрибутів товару, тобі потрібно заповнити кожен існуючий рядок. Зі звичайною колонкою міграція обробляє це атомарно. З JSONB ти пишеш UPDATE, який торкається кожного рядка, і сподіваєшся, що код твого застосунку коректно обробляє відсутні поля, поки заповнення не завершиться.

sql
-- Додавання нової колонки: чисто, атомарно, один вираз
ALTER TABLE products ADD COLUMN weight_kg NUMERIC(10,3);
 
-- Додавання нового JSONB-поля: брудно, потребує заповнення
UPDATE products
SET attributes = attributes || '{"weight_kg": null}'
WHERE NOT (attributes ? 'weight_kg');
-- Плюс оновити код застосунку для обробки відсутнього weight_kg
-- Плюс оновити логіку валідації
-- Плюс оновити кожну API-відповідь, що включає attributes

Моє правило: якщо ти запитуєш JSONB-поле в WHERE-клаузі частіше двох разів на тиждень, це, мабуть, має бути колонкою. JSONB — чудовий аварійний вихід. Він жахливий за замовчуванням.

Міграції схеми: не ламай продакшен о 3 ночі#

Міграції схеми — це місце, де теорія зустрічається з реальністю. Твоя схема виглядає чудово на папері, але тепер тобі потрібно змінити таблицю з 50 мільйонами рядків у робочий час без жодного простою.

Інструменти міграції#

Я використовував більшість із них. Короткі враження:

Drizzle (TypeScript): мій поточний фаворит. Схема-як-код, типобезпечні запити, генеровані зі схеми, чистий SQL міграцій. Команда push для розробки швидка.

Prisma (TypeScript): чудовий DX для простих схем. Проблеми з просунутими функціями PostgreSQL (часткові індекси, кастомні типи, RLS). Движок міграцій може приймати неочікувані рішення.

Flyway (Java/CLI): надійний як скала, перевірений боями, SQL-first. Якщо ти пишеш сирі SQL-міграції, Flyway відстежує їх надійно. Жодної магії, жодних сюрпризів.

golang-migrate (Go/CLI): подібний до Flyway, але легший. Чудовий для Go-проєктів або коли тобі потрібен простий запускач up/down міграцій.

Проблема нульового простою#

Найнебезпечніші зміни схеми — ті, що блокують таблицю. У PostgreSQL ALTER TABLE ... ADD COLUMN зі значенням за замовчуванням раніше блокував усю таблицю на час перезапису. Починаючи з PostgreSQL 11, прості значення за замовчуванням (константи) — це тільки метадані, миттєво. Але інші операції все ще блокують:

sql
-- БЕЗПЕЧНО: тільки метадані, миттєво (PostgreSQL 11+)
ALTER TABLE users ADD COLUMN bio TEXT;
ALTER TABLE users ADD COLUMN is_premium BOOLEAN NOT NULL DEFAULT false;
 
-- НЕБЕЗПЕЧНО: перезаписує всю таблицю, повне блокування таблиці
ALTER TABLE users ALTER COLUMN email TYPE VARCHAR(500);
ALTER TABLE users ADD COLUMN score INTEGER NOT NULL DEFAULT compute_score();
 
-- НЕБЕЗПЕЧНО: сканує всю таблицю для валідації
ALTER TABLE orders ADD CONSTRAINT check_total CHECK (total_cents >= 0);

Безпечне додавання NOT NULL колонок#

Ти не можеш просто додати NOT NULL до існуючої таблиці з даними — це провалюється, бо існуючі рядки не мають значення. Наївний підхід:

sql
-- Це блокує таблицю і перезаписує її. Не роби цього на великій таблиці.
ALTER TABLE users ADD COLUMN avatar_url TEXT NOT NULL DEFAULT 'https://example.com/default.png';

Починаючи з PostgreSQL 11, це насправді безпечно для константних значень за замовчуванням — це тільки метадані. Але якщо твоє значення за замовчуванням — функція або тобі потрібно заповнити обчисленими значеннями, використовуй патерн розширення-скорочення.

Патерн розширення-скорочення#

Це золотий стандарт для змін схеми без простою. Три фази:

Фаза 1: Розширення — Додай нову колонку як nullable. Задеплой код застосунку, що пише в обидві колонки — стару і нову.

sql
-- Міграція 1: Додай nullable колонку
ALTER TABLE users ADD COLUMN normalized_email TEXT;

Фаза 2: Міграція — Заповни існуючі рядки батчами. Твій застосунок вже пише в нову колонку для нових даних.

sql
-- Міграція 2: Заповни батчами (не роби це одним виразом для великих таблиць)
UPDATE users SET normalized_email = LOWER(TRIM(email))
WHERE normalized_email IS NULL
LIMIT 10000;
-- Повторюй, поки всі рядки не будуть заповнені

Фаза 3: Скорочення — Коли всі рядки заповнені, додай NOT NULL обмеження і видали стару колонку (якщо потрібно).

sql
-- Міграція 3: Додай обмеження (використай NOT VALID, щоб уникнути повного сканування таблиці, потім валідуй окремо)
ALTER TABLE users ADD CONSTRAINT users_normalized_email_not_null
    CHECK (normalized_email IS NOT NULL) NOT VALID;
 
-- Міграція 4: Валідуй обмеження (бере ShareUpdateExclusiveLock, а не AccessExclusiveLock)
ALTER TABLE users VALIDATE CONSTRAINT users_normalized_email_not_null;

Трюк з NOT VALID + VALIDATE CONSTRAINT критичний. Додавання check-обмеження зазвичай сканує всю таблицю, тримаючи важке блокування. NOT VALID додає обмеження без сканування (воно застосовується тільки до нових записів), а VALIDATE CONSTRAINT сканує з легшим блокуванням, що не блокує читання чи запис.

Створення індексів#

Створення індексів на великих таблицях за замовчуванням блокує запис. Завжди використовуй CONCURRENTLY:

sql
-- БЛОКУЄ ЗАПИС: не роби це на живій таблиці
CREATE INDEX idx_users_email ON users (email);
 
-- НЕ БЛОКУЄ: використовуй це замість того
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);

CONCURRENTLY займає довше і не може запускатися всередині транзакції, але не блокує таблицю. Цей компроміс завжди виправданий у продакшені.

Рішення, про які я шкодував#

У кожної схеми є жалі. Ось мої.

Використання SERIAL замість UUID для зовнішніх ID#

На початку проєкту я використовував SERIAL первинні ключі і виставляв їх напряму в URL: /users/42, /orders/1337. Це витікало інформацію (конкуренти могли вгадати кількість наших користувачів), робило пагінацію вгадуваною і зламалося, коли нам знадобилося об'єднати бази даних з двох регіонів. Перехід на UUID для зовнішніх ID зайняв міграцію тривалістю в кілька місяців.

Урок: використовуй UUID для всього, що виставляється за межі твоєї бази даних. Використовуй SERIAL/BIGSERIAL для внутрішніх з'єднувальних таблиць, якщо хочеш, але ніколи не дозволяй автоінкрементному цілому числу з'являтися в URL.

Без обмежень («ми додамо їх пізніше»)#

Ми запустили таблицю без check-обмежень, бо «ми рухаємося швидко і додамо їх пізніше». Протягом двох тижнів дані містили від'ємні ціни, порожні імена і значення order_status рівне "oof", яке хтось вбив під час тестування і ніколи не почистив.

Додавання обмежень постфактум вимагало:

  1. Знайти всі невалідні дані
  2. Вирішити, що з ними робити (виправити, видалити або залишити як є)
  3. Написати міграцію, яка виправляє/заповнює дані І додає обмеження

Це зайняло більше часу, ніж додавання обмеження в перший день. Починай з обмежень. Послаблюй, якщо потрібно. Ніколи навпаки.

Рядки через кому замість масивів або з'єднувальних таблиць#

Одного разу я зберігав теги як рядок через кому: "javascript,react,nextjs". Запитувати було кошмарно:

sql
-- Ось як ти запитуєш значення через кому. Не роби цього.
SELECT * FROM posts WHERE tags LIKE '%react%';
-- Це також матчить "react-native" і "preact"
 
-- Що я мав зробити:
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)
);

Або як мінімум використовуй рідний масивний тип 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);
 
-- Чисті запити
SELECT * FROM posts WHERE 'react' = ANY(tags);
SELECT * FROM posts WHERE tags @> ARRAY['react', 'nextjs'];

Масиви прийнятні для простих списків, яким не потрібні власні атрибути. Щойно тобі потрібні метадані на зв'язку (як «хто додав цей тег» або «коли його додали»), тобі потрібна з'єднувальна таблиця.

Використання «type» як назви колонки#

sql
-- Виглядало нормально тоді
CREATE TABLE notifications (
    id      BIGSERIAL PRIMARY KEY,
    type    TEXT NOT NULL,  -- "email", "sms", "push"
    -- ...
);
 
-- Потім ти пробуєш запитати це в буквально будь-якій ORM:
-- notification.type  ← конфліктує з типовою системою кожної мови
-- "type" — зарезервоване слово в більшості діалектів SQL
-- Ти починаєш огортати його в лапки всюди: SELECT "type" FROM notifications

Використовуй kind, category або notification_type. Будь-що, тільки не type. Аналогічно, уникай order (використовуй sort_order або position), user (використовуй account або додай префікс) і group (використовуй team або group_name).

Не додав created_at від початку#

Одна таблиця не отримала created_at, бо «нам це не потрібно». Три місяці потому нам знадобилося зробити дебаг проблеми, і ми не мали жодного уявлення, коли записи були створені. Додавання його ретроактивно означало, що всі існуючі рядки отримали однакову часову мітку (часову мітку міграції), що зробило історичні дані марними.

Кожна таблиця отримує created_at. Без винятків. Вартість — одна колонка. Вартість не мати її — невідома, поки вона тобі не знадобиться.

Бізнес-логіка в подання бази даних#

Одного разу я створив ланцюжок подань — active_users фільтрував users, premium_active_users фільтрував active_users, і звітне подання з'єднувало їх усі. Працювало чудово, поки хтось не змінив таблицю users і всі три подання тихо зламалися. Планувальник запитів теж мав проблеми з оптимізацією через кілька шарів подань.

Подання чудові для зручності та патернів доступу тільки для читання. Вони жахливі як місце для кодування бізнес-логіки, що змінюється. Тримай бізнес-правила в коді застосунку, де вони версіонуються, тестуються та деплояться. Використовуй подання для звітних ярликів, а не як архітектурні будівельні блоки.

Збираємо все разом#

Ось як виглядає добре спроєктована схема, коли ти комбінуєш ці патерни. Спрощена система управління проєктами:

sql
-- Розширення
CREATE EXTENSION IF NOT EXISTS "pgcrypto";  -- для gen_random_uuid()
 
-- Функція тригера updated_at (повторно використовувана)
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
-- Орендарі
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();
 
-- Користувачі
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();
 
-- Увімкни RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON users
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Проєкти
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);
 
-- Задачі
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);
 
-- Коментарі до задач
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);
 
-- Лог аудиту (без RLS — таблиця тільки для адмінів)
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);

Зверни увагу на патерни:

  • Кожна таблиця має tenant_id і увімкнений RLS (крім tenants і audit_log).
  • Кожна таблиця має created_at і updated_at з тригерами.
  • UUID для всіх первинних ключів (сутності, що виставляються назовні).
  • Check-обмеження на статусні перерахування, довжини та діапазони.
  • Зовнішні ключі з відповідною поведінкою ON DELETE.
  • Індекси, спроєктовані під патерни доступу (tenant + project, tenant + status).
  • М'яке видалення тільки на users (де потрібне відновлення акаунту), архівація на основі статусу на projects.

Підсумкові думки#

Проєктування схеми не гламурне. Ніхто ніколи не робив конференційну доповідь під назвою «Я додав check-обмеження до кожної таблиці і це зекономило нам шість місяців дебагу». Але саме це і робить хороше проєктування схеми — воно запобігає проблемам настільки тихо, що ти навіть не дізнаєшся, що вони могли б існувати.

Патерни в цій статті не нові. Вони — результат років написання міграцій, дебагу пошкодження даних і рефакторингу схем під навантаженням продакшену. Кожен з них існує тому, що я або хтось, з ким я працював, спочатку зробив інакше — і заплатив за це.

Починай з обмежень. Використовуй зовнішні ключі. Додавай created_at до всього. Обери конвенцію іменування і дотримуйся її нещадно. Використовуй RLS для мультитенантності. Будь обережний з JSONB. Тестуй свої міграції на даних продакшен-розміру перед деплоєм.

База даних — це фундамент. Зроби це правильно, і все, побудоване зверху, стає простішим. Зроби це неправильно, і жодна кількість розумного коду застосунку тебе не врятує.

Схожі записи