Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді з PostgreSQL

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

116 питань

Пошуку PostgreSQL зазвичай достатньо, коли:

  • обсяг - тисячі чи мільйони документів, а не сотні мільйонів;
  • потрібен пошук за словами з базовим ранжуванням і фільтрами за звичайними колонками (категорія, дата, статус) - в одному запиті з транзакційними даними;
  • важлива узгодженість: щойно збережений запис одразу знаходиться, без затримки синхронізації;
  • не хочеться підтримувати ще один сервіс, його синхронізацію, бекапи й моніторинг.

Окремий рушій (Meilisearch, Typesense, Elasticsearch/OpenSearch) потрібен, коли:

  • Якість пошуку - частина продукту: опечатки, синоніми, морфологія мов без готових словників (як українська), пошук під час набору, підказки, «можливо, ви мали на увазі».
  • Фасетний пошук з підрахунком кількості для кожного значення фільтра на великих обсягах.
  • Складне ранжування: популярність, свіжість, персоналізація, бусти полів, налаштування релевантності без зміни коду.
  • Обсяг і навантаження: пошукові запити конкурують з основним навантаженням бази, і їх хочеться винести на окремі ресурси.
  • Аналітика логів і агрегати по величезних обсягах тексту (Elasticsearch/OpenSearch).

Ціна окремого рушія:

  • Синхронізація: дані потрапляють в індекс із затримкою; потрібна черга оновлень і переіндексація при зміні схеми. У Laravel це бере на себе Scout, але проблеми розсинхронізації все одно треба відстежувати.
  • Дві системи правди для пошуку й даних: фільтр за правами доступу треба дублювати в індексі.
  • Інфраструктура: пам'ять, бекапи, оновлення, моніторинг.

Поширений проміжний шлях: почати з PostgreSQL (повнотекстовий пошук + pg_trgm) і перейти на рушій, коли з'явиться конкретна вимога, якої база не закриває. Scout з драйвером database дозволяє почати з бази й пізніше змінити драйвер майже без змін у коді.

Докладніше в документації: Повнотекстовий пошук

Семантичний пошук знаходить документи за змістом, а не за збігом слів: запит «як не втратити завдання при падінні воркера» знайде статтю про повторні спроби в чергах, навіть без спільних слів.

Текст перетворюють на вектор-ембединг (масив з сотень чи тисяч чисел) за допомогою моделі (OpenAI, Voyage, локальні моделі). Схожі за змістом тексти мають близькі вектори.

Розширення pgvector додає тип vector і оператори відстані:

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    content text NOT NULL,
    embedding vector(1536)
);

-- 5 найближчих за косинусною відстанню
SELECT id, content
FROM documents
ORDER BY embedding <=> $1
LIMIT 5;

Оператори: <-> - евклідова відстань, <=> - косинусна, <#> - від'ємний скалярний добуток.

Індекси для наближеного пошуку (ANN) - точний пошук перебирає всі вектори, на мільйонах це повільно:

CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);
  • HNSW - швидкий і точний пошук, але повільніша побудова й більше пам'яті.
  • IVFFlat - швидша побудова, менше пам'яті, але потрібні дані для навчання (створювати після наповнення таблиці) і нижча точність.

Наближені індекси можуть пропустити частину справжніх сусідів - точність регулюється параметрами (hnsw.ef_search).

Переваги вектора в PostgreSQL:

  • Векторний пошук в одному запиті з фільтрами (WHERE tenant_id = ? AND published) і транзакційними даними.
  • Немає окремої векторної бази, її синхронізації й бекапів.

Практичні нюанси:

  • Гібридний пошук: поєднання векторного й повнотекстового пошуку (наприклад, через Reciprocal Rank Fusion) зазвичай дає кращі результати, ніж кожен окремо.
  • Фільтри з HNSW: при дуже вибірковому фільтрі індекс може повернути замало результатів - нові версії pgvector мають ітеративне сканування для цього випадку.
  • Ембединги прив'язані до моделі: змінили модель - перераховуйте всі вектори.
  • Розмір: вектор на 1536 значень - ~6 КБ на рядок, плюс індекс.

Докладніше в документації: pgvector

Три основні моделі, від найпростішої до найізольованішої.

1. Спільні таблиці з колонкою tenant_id:

CREATE TABLE projects (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tenant_id bigint NOT NULL REFERENCES tenants (id),
    name text NOT NULL,
    UNIQUE (tenant_id, name)
);
CREATE INDEX projects_tenant_idx ON projects (tenant_id);
  • Плюси: одна схема, прості міграції, ефективне використання ресурсів, легко рахувати аналітику по всіх тенантах.
  • Мінуси: головний ризик - забутий фільтр WHERE tenant_id = ?, і один клієнт бачить дані іншого. Захист - глобальні скоупи в застосунку, Row-Level Security у PostgreSQL, тести на ізоляцію. «Галасливий» тенант навантажує всіх.

2. Схема на тенанта (tenant_42.projects):

  • Плюси: ізоляція на рівні об'єктів бази - запит без фільтра не побачить чужих даних; легко видалити чи вивантажити дані одного клієнта.
  • Мінуси: міграції треба застосовувати до сотень чи тисяч схем; тисячі схем × десятки таблиць - велике навантаження на системний каталог і на pg_dump; пул з'єднань ускладнюється (search_path на кожен запит).

3. Окрема база на тенанта:

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

Як обирати:

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

Незалежно від моделі: tenant_id у всіх унікальних обмеженнях і індексах; тести, що перевіряють ізоляцію для кожної сутності; продумана поведінка фонових задач і кешу (ключі кешу з ідентифікатором тенанта).

Докладніше в документації: Схеми

Категорії, оргструктура, коментарі з відповідями, меню - дерева. Є кілька моделей, кожна з компромісами між простотою запису й читання.

1. Список суміжності (adjacency list) - parent_id:

CREATE TABLE categories (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    parent_id bigint REFERENCES categories (id),
    name text NOT NULL
);
  • Найпростіше: вставка й переміщення - зміна одного parent_id, є зовнішній ключ.
  • Піддерево чи шлях до кореня - через WITH RECURSIVE. У PostgreSQL це працює добре, тож для більшості задач цього достатньо.

2. Матеріалізований шлях - шлях до вузла зберігається рядком: 1.5.12.

CREATE EXTENSION IF NOT EXISTS ltree;
ALTER TABLE categories ADD COLUMN path ltree;
CREATE INDEX categories_path_gist ON categories USING gist (path);

SELECT * FROM categories WHERE path <@ '1.5';     -- усе піддерево
SELECT * FROM categories WHERE path @> '1.5.12';  -- усі предки
  • Піддерево й предки - одним індексованим запитом без рекурсії; глибина - nlevel(path).
  • Переміщення вузла - оновлення шляхів усього піддерева.

3. Вкладені множини (nested sets) - кожен вузол має lft і rgt, піддерево - усе в проміжку між ними.

  • Дуже швидке читання піддерев і підрахунок нащадків.
  • Вставка чи переміщення перераховує межі для великої частини дерева - погано для дерев, що часто змінюються. У Laravel - пакет kalnoy/nestedset.

4. Таблиця замикань (closure table) - окрема таблиця всіх пар «предок - нащадок» з глибиною.

  • Будь-які запити до ієрархії - простими JOIN з індексами.
  • Таблиця зв'язків росте як O(n × глибина); вставка й переміщення - кілька запитів.

Як обирати:

  • Типова ієрархія (категорії, меню) у PostgreSQL - parent_id + WITH RECURSIVE, а за потреби в швидких запитах до піддерев - додати ltree.
  • Дерево рідко змінюється, а читається постійно - nested sets чи closure table.
  • Дуже глибокі дерева з частими переміщеннями - adjacency list.

Не забути: захист від циклів (вузол не може стати нащадком самого себе) і обмеження глибини для даних від користувачів.

Докладніше в документації: ltree

Події, логи, метрики, кліки мають особливий профіль: лише вставки, рідкісні оновлення, читання переважно свіжих даних, і постійне видалення старих. Звичайна таблиця з таким навантаженням за рік-два стає проблемою.

Принципи:

1. Append-only. Рядки не оновлюються - лише додаються. Немає мертвих версій, менше роботи для вакууму, таблиця залишається компактною. Якщо подію треба «виправити» - нова подія, а не UPDATE.

2. Партиціонування за часом:

CREATE TABLE events (
    id bigint GENERATED ALWAYS AS IDENTITY,
    occurred_at timestamptz NOT NULL,
    type text NOT NULL,
    user_id bigint,
    payload jsonb,
    PRIMARY KEY (id, occurred_at)
) PARTITION BY RANGE (occurred_at);
  • Видалення старих даних - DROP чи DETACH партиції, миттєво й без навантаження.
  • Запити за останні дні читають лише свіжі партиції.
  • Партиції створюються наперед (pg_partman чи заплановане завдання).

3. Мінімум індексів. Кожен індекс - ціна кожної вставки. Часто достатньо BRIN за часом і B-tree лише на тих полях, за якими справді шукають окремі записи.

4. Пакетний запис. Не INSERT на кожну подію з веб-запиту, а накопичення в черзі чи буфері й вставка порціями (або COPY). Це в рази зменшує навантаження.

5. Вузькі рядки. Типи з мінімальним розміром, порядок колонок з урахуванням вирівнювання, довідники замість повторюваних рядків, payload у jsonb лише для справді змінних даних.

6. Агрегати окремо. Дашборди читають не сирі події, а попередньо агреговані таблиці (за годину, день), що оновлюються пакетно чи матеріалізованими поданнями.

7. Політика зберігання. Сирі події - 30-90 днів, агрегати - роками, архів - в об'єктному сховищі (Parquet в S3).

Коли PostgreSQL уже не той інструмент: мільярди подій на день і аналітичні запити по всьому обсягу - тут колоночні сховища (ClickHouse, BigQuery) чи розширення на кшталт TimescaleDB дають на порядки кращу швидкість і стиснення.

Докладніше в документації: Партиціонування таблиць

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

DB::transaction(function () use ($order) {
    $order->save();
});
$broker->publish(new OrderPlaced($order));   // впало тут - подію втрачено назавжди

Якщо поміняти місцями - можна опублікувати подію про замовлення, транзакція якого потім відкотиться.

Transactional outbox: подія записується в ту саму базу й ту саму транзакцію, що й зміна даних, - у спеціальну таблицю:

CREATE TABLE outbox (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    aggregate_type text NOT NULL,
    aggregate_id bigint NOT NULL,
    event_type text NOT NULL,
    payload jsonb NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now(),
    published_at timestamptz
);
DB::transaction(function () use ($order) {
    $order->save();
    Outbox::create(['event_type' => 'OrderPlaced', 'payload' => [...]]);
});

Тепер або є і замовлення, і подія, або немає нічого.

Окремий процес-ретранслятор читає неопубліковані записи й надсилає їх у брокер:

SELECT * FROM outbox WHERE published_at IS NULL ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED;

Після успішної відправки - позначає published_at (або видаляє запис). Альтернатива опитуванню - читати зміни таблиці з WAL через Change Data Capture (Debezium).

Що з цього випливає:

  • Доставка «щонайменше раз»: ретранслятор міг надіслати подію й упасти, не позначивши її. Отримувачі мають бути ідемпотентними (ID події + перевірка, чи вже оброблено).
  • Порядок подій зберігається в межах одного агрегата, якщо ретранслятор обробляє їх за id.
  • Таблицю outbox треба чистити - вона росте з кожною подією.

У Laravel частково цю задачу вирішують afterCommit для завдань і подій (відправити в чергу лише після успішного коміту), але це не захищає від падіння процесу між комітом і відправкою - для справжньої гарантії потрібен саме outbox.

Докладніше в документації: Pattern: Transactional outbox

У PostgreSQL у WITH можна використовувати не лише SELECT, а й INSERT, UPDATE, DELETE з RETURNING. Результат такого CTE доступний основному запиту.

Архівування: перенести старі замовлення в архів одним запитом:

WITH moved AS (
    DELETE FROM orders
    WHERE created_at < now() - interval '2 years'
    RETURNING *
)
INSERT INTO orders_archive
SELECT * FROM moved;

Видалення й вставка - один оператор, одна атомарна операція: рядок не може зникнути з orders, не з'явившись в архіві.

Створити пов'язані записи з отриманим id:

WITH new_user AS (
    INSERT INTO users (email) VALUES ('olena@example.com')
    RETURNING id
)
INSERT INTO profiles (user_id, locale)
SELECT id, 'uk' FROM new_user;

Журнал змін разом з оновленням:

WITH changed AS (
    UPDATE products SET price = price * 1.1
    WHERE category_id = 3
    RETURNING id, price
)
INSERT INTO price_history (product_id, price, changed_at)
SELECT id, price, now() FROM changed;

Важливі правила виконання:

  • усі частини бачать один знімок даних - знімок на момент початку оператора. Основний запит не бачить змін, зроблених у CTE, через звичайне читання таблиці - лише через RETURNING;
  • не можна змінювати той самий рядок двічі в одному операторі (в CTE й в основному запиті) - результат непередбачуваний;
  • CTE зі зміною даних виконується завжди повністю, навіть якщо основний запит не читає всіх його рядків;
  • порядок виконання частин не гарантований - не можна покладатися на те, що один CTE спрацює раніше за інший, якщо вони не пов'язані через RETURNING.

Переваги перед кількома запитами в транзакції:

  • один мережевий обмін замість кількох;
  • немає проміжного стану в застосунку - не треба передавати тисячі id з бази в PHP і назад.

Обмеження й ризики:

  • великі обсяги: перенесення мільйонів рядків одним оператором - довга транзакція, роздування таблиць і блокування. Для архівування великих таблиць - порціями (... WHERE id IN (SELECT id ... LIMIT 10000)) у циклі;
  • читабельність: складний ланцюжок CTE важко рев'ювати - коментарі й тести на ці запити обов'язкові;
  • переносимість: MySQL такого не підтримує.

У Laravel - сирий запит через DB::statement() чи DB::select() (якщо потрібен результат RETURNING).

Докладніше в документації: WITH: оператори зміни даних

WITH RECURSIVE складається з двох частин, об'єднаних UNION ALL:

  • початкова - стартові рядки;
  • рекурсивна - посилається на сам CTE і додає наступний рівень, доки не поверне порожній результат.

Усі підкатегорії категорії з глибиною:

WITH RECURSIVE tree AS (
    SELECT id, parent_id, name, 1 AS depth
    FROM categories
    WHERE id = 10

    UNION ALL

    SELECT c.id, c.parent_id, c.name, t.depth + 1
    FROM categories c
    JOIN tree t ON c.parent_id = t.id
)
SELECT * FROM tree;

Шлях від вузла до кореня (хлібні крихти) - те саме в зворотному напрямку: JOIN tree t ON c.id = t.parent_id.

Проблема циклів. У дереві циклів немає, але в графі (рекомендації, залежності, «хто кого запросив») чи в зіпсованих даних (категорія стала батьком свого предка) рекурсія ніколи не завершиться - запит працюватиме до вичерпання пам'яті чи тайм-ауту.

PostgreSQL 14+: CYCLE - вбудоване виявлення циклів:

WITH RECURSIVE graph AS (
    SELECT id, parent_id FROM categories WHERE id = 10
    UNION ALL
    SELECT c.id, c.parent_id FROM categories c JOIN graph g ON c.parent_id = g.id
) CYCLE id SET is_cycle USING path
SELECT * FROM graph WHERE NOT is_cycle;

PostgreSQL сам відстежує шлях і зупиняє гілку, що повертається до вже відвіданого вузла.

SEARCH задає порядок обходу:

) SEARCH DEPTH FIRST BY id SET ordercol
SELECT * FROM tree ORDER BY ordercol;

DEPTH FIRST дає порядок, у якому зручно виводити дерево з відступами; BREADTH FIRST - рівень за рівнем.

До PG 14 - вручну: накопичувати масив відвіданих id і перевіряти NOT c.id = ANY(path).

Додаткові запобіжники:

  • обмеження глибини (WHERE t.depth < 20) - навіть з CYCLE захищає від неочікувано глибоких структур;
  • statement_timeout для таких запитів;
  • UNION замість UNION ALL прибирає дублікати рядків і теж обриває прості цикли, але дорожчий і не завжди достатній.

Продуктивність: індекс на parent_id обов'язковий - кожен рівень рекурсії шукає дітей за ним.

Альтернативи рекурсії для дерев, які часто читають і рідко змінюють: матеріалізований шлях (ltree), closure table, nested sets - вони дають піддерево одним простим запитом.

У Laravel рекурсивні CTE пишуть сирим SQL або через пакет staudenmeir/laravel-adjacency-list, що додає зв'язки descendants() і ancestors() на основі WITH RECURSIVE.

Докладніше в документації: WITH: виявлення циклів

Схема - простір імен усередині бази. Таблиці billing.invoices і public.invoices - різні об'єкти. За замовчуванням усе створюється в схемі public.

CREATE SCHEMA billing;
CREATE TABLE billing.invoices (...);

search_path - список схем, у яких PostgreSQL шукає об'єкт, названий без схеми:

SHOW search_path;           -- "$user", public
SET search_path = billing, public;
SELECT * FROM invoices;     -- billing.invoices

"$user" - схема з ім'ям поточного користувача, якщо вона існує.

Навіщо схеми:

  • модулі застосунку: billing, analytics, audit - окремі права й зрозуміла структура;
  • мультитенантність «схема на тенанта»: однакові таблиці в tenant_42, tenant_43, перемикання через search_path. Ізоляція краща за колонку tenant_id, але тисячі схем ускладнюють міграції й навантажують каталог;
  • розширення окремо від даних (CREATE EXTENSION pg_trgm SCHEMA extensions);
  • права: GRANT USAGE ON SCHEMA analytics TO bi_reader - доступ до всієї групи таблиць.

Безпека - головне про search_path:

1. Підміна об'єктів. Якщо користувач може створювати об'єкти в схемі, що стоїть у search_path раніше за потрібну, він може «перехопити» ім'я таблиці чи функції. Саме тому з PostgreSQL 15 звичайні користувачі не можуть створювати об'єкти в public за замовчуванням (раніше могли всі).

2. Функції з SECURITY DEFINER виконуються з правами власника. Якщо в них не зафіксовано search_path, зловмисник створює функцію з тим самим ім'ям у своїй схемі - і вона виконується з правами власника:

CREATE FUNCTION billing.close_period() RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = billing, pg_temp
AS $$ ... $$;

pg_temp останнім - щоб тимчасові об'єкти сесії не могли нічого підмінити.

У Laravel:

  • 'search_path' => 'public' у конфігурації з'єднання pgsql (ключ search_path);
  • міграції й моделі можуть звертатися до схеми явно: protected $table = 'billing.invoices';
  • PgBouncer у режимі transaction pooling: SET search_path на рівні сесії «протікає» між клієнтами, що ділять з'єднання. Безпечніше задавати search_path для ролі (ALTER ROLE app SET search_path = ...) чи використовувати повні імена.

Правило: у продакшен-базі користувач застосунку не повинен мати права CREATE у схемах, які є в search_path інших ролей.

Докладніше в документації: Схеми: шлях пошуку

Мінливість (volatility) - обіцянка, яку функція дає планувальнику про свою поведінку. Від неї залежить, які оптимізації PostgreSQL може застосувати.

Категорія Обіцянка Приклади
IMMUTABLE для тих самих аргументів завжди той самий результат, нічого не читає з бази lower(text), abs(), математика
STABLE той самий результат у межах одного оператора, може читати базу now(), функції, що залежать від налаштувань сесії
VOLATILE (за замовчуванням) результат може змінюватися навіть між рядками, можливі побічні ефекти random(), nextval(), clock_timestamp()

Що від цього залежить:

1. Індекси за виразом дозволені лише для IMMUTABLE-функцій:

CREATE INDEX ON users (lower(email));           -- працює
CREATE INDEX ON events ((created_at::date));    -- помилка для timestamptz
ERROR: functions in index expression must be marked IMMUTABLE

Приведення timestamptz до date залежить від часового поясу сесії, тож результат не незмінний. Рішення - зафіксувати пояс: ((created_at AT TIME ZONE 'UTC')::date).

2. Обчислення один раз: STABLE чи IMMUTABLE функцію з константними аргументами в WHERE планувальник може обчислити один раз і використати індекс. VOLATILE - викликається для кожного рядка, і індекс за нею не використати.

3. Згенеровані колонки й умови партицій теж вимагають IMMUTABLE.

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

CREATE FUNCTION tax_rate(country text) RETURNS numeric
LANGUAGE sql IMMUTABLE    -- неправда: читає таблицю
AS $$ SELECT rate FROM tax_rates WHERE code = country $$;

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

Правило: позначайте функцію найсуворішою категорією, яка справді правдива. Сумніви - STABLE чи VOLATILE. Вигода від неправдивого IMMUTABLE не варта пошкоджених даних.

Пов'язане - PARALLEL SAFE: чи можна виконувати функцію в паралельних воркерах. Власні функції за замовчуванням PARALLEL UNSAFE - це вимикає паралельні плани для запитів з ними.

У Laravel такі функції й індекси за виразами створюють у міграціях через DB::statement(), а запити мають використовувати точно той самий вираз, що в індексі (whereRaw('lower(email) = ?', [...])), інакше індекс не підхопиться.

Докладніше в документації: Категорії мінливості функцій

Звичайні обмеження (NOT NULL, CHECK, UNIQUE, FOREIGN KEY) перевіряються одразу після кожного оператора. Але деякі правила за природою тимчасово порушуються посеред транзакції.

Приклад 1 - обмін позиціями. Колонка position унікальна, і треба поміняти місцями два записи:

UPDATE steps SET position = 2 WHERE id = 1;   -- конфлікт: позиція 2 вже зайнята
UPDATE steps SET position = 1 WHERE id = 2;

Відкладене обмеження перевіряється в момент коміту:

ALTER TABLE steps ADD CONSTRAINT steps_position_unique
UNIQUE (list_id, position) DEFERRABLE INITIALLY IMMEDIATE;

BEGIN;
SET CONSTRAINTS steps_position_unique DEFERRED;
UPDATE steps SET position = 2 WHERE id = 1;
UPDATE steps SET position = 1 WHERE id = 2;
COMMIT;   -- перевірка тут

DEFERRABLE можна задати для UNIQUE, PRIMARY KEY, FOREIGN KEY і EXCLUDE, але не для CHECK і NOT NULL.

Приклад 2 - правило між рядками. «Сума часток власників компанії дорівнює 100%» - CHECK бачить лише один рядок. Тут допомагає constraint-тригер: тригер AFTER, який можна відкласти до коміту:

CREATE CONSTRAINT TRIGGER shares_total_check
AFTER INSERT OR UPDATE OR DELETE ON shares
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION check_shares_total();

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

Приклад 3 - заборона перетину інтервалів: EXCLUDE USING gist (room_id WITH =, during WITH &&) - вбудоване рішення без тригерів.

Ризики правил у тригерах:

  • конкуренція: дві паралельні транзакції можуть кожна побачити суму 75% без змін іншої й обидві закомітитися - разом порушивши правило. Потрібне блокування батьківського рядка (SELECT ... FROM companies WHERE id = ? FOR UPDATE) чи рівень SERIALIZABLE;
  • продуктивність: відкладені рядкові тригери накопичуються в пам'яті до коміту - масова операція може стати дуже дорогою;
  • повідомлення про помилку приходить при коміті, а не на рядку, що порушив правило, - застосунку важче показати користувачу зрозумілу причину.

Розподіл відповідальності з Laravel: валідація в застосунку дає зрозумілі повідомлення користувачу, обмеження в базі - гарантію, що некоректні дані не потраплять у таблицю навіть з іншого коду. Обидва рівні доповнюють один одного, а не замінюють.

Докладніше в документації: CREATE TRIGGER

Питання з реальних технічних співбесід - 116 питань у 9 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 30 Middle 47 Senior 39

Готуєтесь до співбесіди не просто так: зараз на сайті 146 відкритих вакансій Laravel і PHP. Переглянути вакансії