Питання на співбесіді з PostgreSQL
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
116 питань
У PostgreSQL немає збереженого лічильника рядків таблиці. Через MVCC різні транзакції одночасно бачать різну кількість рядків, тож SELECT count(*) FROM orders мусить переглянути рядки (або індекс) і перевірити видимість кожного. На сотнях мільйонів рядків це секунди чи хвилини.
Що можна зробити:
1. Приблизна кількість зі статистики - миттєво:
SELECT reltuples::bigint AS estimate
FROM pg_class
WHERE oid = 'public.orders'::regclass;
Значення оновлює ANALYZE/autovacuum. Для лічильника «близько 2,4 млн записів» на сторінці адмінки - цілком достатньо.
2. Оцінка для запиту з умовою - з плану:
EXPLAIN SELECT * FROM orders WHERE status = 'paid';
-- rows=183000 у плані - оцінка планувальника
3. Точна кількість швидше: index-only scan по невеликому індексу, якщо visibility map свіжа (після вакууму), - помітно швидше за читання всієї таблиці.
4. Лічильник, що ведеться окремо: таблиця-лічильник, оновлювана тригером чи застосунком, або кеш на кілька хвилин. Точно й миттєво, але з ціною на кожен запис (і точкою конкуренції, якщо всі пишуть в один рядок лічильника).
Практичні висновки:
- Пагінація з «сторінка 1 з 48 213» - дорога на великих таблицях: кожна сторінка - ще й
count(*). LaravelsimplePaginate()чиcursorPaginate()обходяться без підрахунку. - «Чи є хоч один рядок» - не
count(*) > 0, аEXISTS (SELECT 1 ... ): зупиняється на першому знайденому. count(column)не швидший заcount(*)- він ще й перевіряєNULL.
ANALYZE збирає статистику про дані таблиці: кількість рядків, частку NULL, кількість унікальних значень, найчастіші значення, гістограму розподілу. Планувальник використовує її, щоб оцінити, скільки рядків поверне кожна частина запиту, - і обрати план: індекс чи повний перегляд, який вид JOIN, у якому порядку з'єднувати таблиці.
ANALYZE orders; -- одна таблиця
ANALYZE orders (status, user_id); -- окремі колонки
ANALYZE; -- уся база
ANALYZE читає не всю таблицю, а вибірку рядків, тож виконується швидко навіть на великих таблицях.
Чому після імпорту все повільно: статистика стала застарілою. Таблиця, у якій учора було 1000 рядків, сьогодні має 10 мільйонів, а планувальник досі думає, що їх тисяча, - і обирає план, оптимальний для маленької таблиці (наприклад, Nested Loop там, де потрібен Hash Join).
Autovacuum запускає ANALYZE автоматично, коли змінилася помітна частка таблиці, але не миттєво. Тому після масових операцій - імпорту, видалення великої частини даних, відновлення з дампу, pg_upgrade - ANALYZE варто запустити вручну одразу.
Як помітити проблему: у EXPLAIN ANALYZE оцінка rows сильно розходиться з actual rows. Коли статистика застаріла - розходження величезне; ANALYZE усе виправляє.
Коли статистики недостатньо навіть свіжої:
- Перекошений розподіл значень - збільшити деталізацію:
ALTER TABLE ... ALTER COLUMN ... SET STATISTICS 1000. - Корельовані колонки - розширена статистика (
CREATE STATISTICS).
Час останнього аналізу видно в pg_stat_user_tables (last_analyze, last_autoanalyze).
psql - консольний клієнт PostgreSQL. Окрім SQL, у ньому є метакоманди з \, що швидко показують структуру бази.
Підключення:
psql -h localhost -U app -d app_production
psql "postgresql://app@localhost:5432/app_production"
Структура бази:
\l список баз
\c app_test підключитися до іншої бази
\dt таблиці
\d orders структура таблиці: колонки, індекси, обмеження, тригери
\di індекси
\dn схеми
\du ролі
\df функції
\dx встановлені розширення
\dt+ таблиці з розміром
Робота із запитами:
\x auto розгорнутий вивід для широких рядків
\timing on показувати час виконання кожного запиту
\e редагувати останній запит у $EDITOR
\i file.sql виконати файл
\copy orders TO 'orders.csv' CSV HEADER експорт з клієнтського боку
\watch 2 повторювати запит кожні 2 секунди
\q вийти
Корисні прийоми:
\set ON_ERROR_STOP onу скриптах - зупинитися на першій помилці замість виконання решти.BEGIN;перед ризикованими змінами на проді - подивитися результат і вирішити:COMMITчиROLLBACK.\set AUTOCOMMIT off- кожна команда автоматично в транзакції, доки не зробитиCOMMIT.~/.psqlrc- налаштування за замовчуванням (\timing,\x auto, кольоровий промпт для продакшену).~/.pgpass- паролі для підключень, щоб не вводити їх щоразу й не передавати в командному рядку.
На проді - обережно: psql на продакшн-базі - повний доступ. Безпечніше підключатися до репліки для читання і мати окрему роль лише з правами читання для розслідувань.
Поширена практика «застосунок підключається як postgres» означає, що SQL-ін'єкція чи помилка в коді може видалити будь-яку таблицю, прочитати будь-які дані, змінити налаштування сервера. Принцип найменших привілеїв: кожна роль має лише ті права, які їй потрібні.
Типова схема ролей:
-- Власник схеми: від його імені ганяють міграції
CREATE ROLE app_owner LOGIN PASSWORD '...';
CREATE SCHEMA app AUTHORIZATION app_owner;
-- Застосунок: лише робота з даними
CREATE ROLE app_user LOGIN PASSWORD '...';
GRANT USAGE ON SCHEMA app TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO app_user;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA app TO app_user;
-- Права на таблиці, які створять пізніше міграції
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
GRANT USAGE ON SEQUENCES TO app_user;
-- Аналітика й розслідування: лише читання
CREATE ROLE readonly LOGIN PASSWORD '...';
GRANT pg_read_all_data TO readonly; -- PostgreSQL 14+
Що це дає:
- Застосунок не може
DROP TABLE,ALTER, створювати ролі чи читати системні налаштування. - Міграції виконуються окремою роллю - її пароль не живе в змінних середовища вебсерверів.
- Аналітики й розробники на проді працюють від ролі лише для читання.
Деталі, про які забувають:
- Права за замовчуванням (
ALTER DEFAULT PRIVILEGES) - без них нова таблиця з міграції буде недоступна застосунку. - Схема
public: з PostgreSQL 15 звичайні ролі за замовчуванням не можуть створювати в ній об'єкти - на старіших версіях це право варто забрати (REVOKE CREATE ON SCHEMA public FROM PUBLIC). - Послідовності потребують окремого
USAGE, інакше вставка з автоінкрементом впаде. - Підключення обмежують ще й на рівні
pg_hba.conf: з яких адрес, до яких баз, яким методом автентифікації (scram-sha-256).
Мінорні оновлення (17.4 → 17.6) - лише виправлення помилок: досить оновити пакет і перезапустити сервер. Формат даних не змінюється.
Мажорні (16 → 17 → 18) змінюють внутрішній формат зберігання, тож файли даних старої версії нова прочитати не може. Є три способи:
1. pg_dump + відновлення - найпростіше й найнадійніше:
pg_dump -Fc -d app > app.dump
# новий сервер
pg_restore -d app app.dump
Простій - на весь час дампу й відновлення з перебудовою індексів. Для бази в кілька гігабайтів - нормально, для терабайтів - години.
2. pg_upgrade - перетворює каталог даних «на місці»:
pg_upgrade --old-datadir ... --new-datadir ... --old-bindir ... --new-bindir ... --link
З --link файли даних не копіюються, а використовуються жорсткі посилання - оновлення займає хвилини навіть для великих баз. Обов'язково спершу --check (сухий прогін). Після оновлення потрібна статистика для планувальника (vacuumdb --analyze-in-stages); з PostgreSQL 18 pg_upgrade уміє переносити її сам.
3. Логічна реплікація - майже без простою: новий сервер на новій версії підписується на зміни старого, наздоганяє його, і застосунок перемикається за секунди. Найскладніше в налаштуванні: послідовності й зміни схеми не реплікуються, їх переносять окремо.
Як підготуватися:
- Прочитати release notes усіх мажорних версій між поточною й цільовою - розділи про несумісності.
- Перевірити, що розширення (PostGIS, pgvector тощо) доступні для нової версії.
- Прогнати тести застосунку на новій версії, відрепетирувати оновлення на копії продакшн-даних, виміряти час.
- Мати свіжий бекап і план відкату.
На керованих сервісах (RDS, Cloud SQL) мажорне оновлення - кнопка, але простій і перевірки сумісності ті самі - про них варто подбати заздалегідь.
-- settings = '{"theme": "dark", "notify": {"email": true}, "tags": ["php", "sql"]}'
settings -> 'notify' -- {"email": true} (результат - jsonb)
settings ->> 'theme' -- dark (результат - text)
settings -> 'notify' ->> 'email' -- true
settings #>> '{notify,email}' -- true (шлях масивом)
settings -> 'tags' -> 0 -- "php" (елемент масиву за індексом з нуля)
settings @> '{"theme": "dark"}' -- містить цю пару
settings ? 'theme' -- є ключ верхнього рівня
settings ?| array['theme', 'lang'] -- є хоча б один з ключів
settings ?& array['theme', 'lang'] -- є всі ключі
Головна різниця, на якій помиляються: -> повертає jsonb, ->> - text. Для порівняння з рядком чи числом потрібен ->> і приведення типу:
WHERE (data ->> 'price')::numeric > 100
WHERE data ->> 'status' = 'active'
@> (містить) - найкорисніший оператор для фільтрів: його прискорює GIN-індекс, на відміну від порівняння через ->>.
Пастка в PHP: оператори ?, ?|, ?& конфліктують із заповнювачами параметрів PDO - драйвер прийме ? за параметр запиту. Варіанти:
- функції-аналоги:
jsonb_exists(settings, 'theme'),jsonb_exists_any(),jsonb_exists_all(); - подвоєний знак
??(PDO з PHP 7.4 розуміє його як буквальний?); - оператор
@>там, де він підходить за змістом.
У Laravel запити до JSON-полів пишуть через стрілки в імені колонки - Query Builder перетворює їх на оператори PostgreSQL:
User::where('settings->theme', 'dark')->get();
User::whereJsonContains('settings->tags', 'php')->get();
Значення jsonb - одне ціле: «змінити поле на місці» неможливо, будь-яка зміна створює новий документ. Але PostgreSQL має функції й оператори, які збирають новий документ за вас:
-- встановити чи замінити значення за шляхом
UPDATE users SET settings = jsonb_set(settings, '{notify,email}', 'false')
WHERE id = 1;
-- jsonb_set створює лише останній ключ шляху: якщо об'єкта ui ще немає,
-- документ повернеться без змін. Проміжний об'єкт збирають явно:
UPDATE users SET settings = jsonb_set(
settings, '{ui}', coalesce(settings -> 'ui', '{}') || '{"sidebar": "collapsed"}'
);
-- злити об'єкти: ключі правого перезапишуть лівий
UPDATE users SET settings = settings || '{"theme": "light", "lang": "uk"}';
-- видалити ключ / шлях / елемент масиву
UPDATE users SET settings = settings - 'beta_features';
UPDATE users SET settings = settings #- '{notify,sms}';
-- вставити в масив
UPDATE users SET settings = jsonb_insert(settings, '{tags,0}', '"laravel"');
Нюанси:
- Значення в
jsonb_set- теж JSON: рядок пишуть з подвійними лапками всередині ('"collapsed"'), число йtrue/false- без. jsonb_setзNULLяк новим значенням (не JSON-null, а SQLNULL) повернеNULLдля всього документа - і стерте налаштування. Для безпечної роботи зNULLєjsonb_set_lax()(PostgreSQL 13+): за замовчуванням він запише JSON-null.||зливає лише верхній рівень: вкладений об'єкт праворуч повністю замінить вкладений об'єкт ліворуч, а не зіллється з ним.- Конкурентні оновлення: два запити, що одночасно змінюють різні ключі одного документа через
jsonb_set, не конфліктують - кожен перечитує актуальне значення вUPDATE. А от «прочитати весь JSON у застосунок → змінити → записати назад» дає загублене оновлення.
У Laravel:
$user->update(['settings->notify->email' => false]); // перетвориться на jsonb_set
Сигнал до зміни схеми: якщо якесь поле JSON постійно оновлюється й фільтрується окремо, - йому, ймовірно, місце в окремій колонці.
Нормалізація - розкладання даних по таблицях так, щоб кожен факт зберігався в одному місці. Мета - уникнути аномалій: коли зміну доводиться вносити в кількох місцях і одне з них забувають.
Приклад проблеми:
orders: id | customer_name | customer_email | product | price
1 | Оля | olia@x.com | Книга | 300
2 | Оля | olia@x.com | Ручка | 50
Оля змінила email - треба оновити всі її замовлення. Пропустили одне - дані суперечать одне одному.
Нормальні форми (основні три):
- 1НФ - у кожній клітинці одне атомарне значення; немає повторюваних груп («product1, product2, product3» чи список через кому в одній колонці).
- 2НФ - 1НФ, і кожна неключова колонка залежить від усього складеного ключа, а не від частини. У таблиці
(order_id, product_id, product_name, qty)назва товару залежить лише відproduct_id- її місце в таблиці товарів. - 3НФ - 2НФ, і неключові колонки не залежать одна від одної (немає транзитивних залежностей).
orders (id, customer_id, customer_email)- email залежить від клієнта, а не від замовлення.
Нормалізована схема:
customers: id | name | email
products: id | name | price
orders: id | customer_id | created_at
order_items: order_id | product_id | quantity | price_at_purchase
Зверніть увагу на price_at_purchase: ціна в момент покупки - окремий факт, а не дублювання. Якщо завтра ціна товару зміниться, старе замовлення не має змінитися.
Практичне правило: проєктувати в 3НФ за замовчуванням і свідомо денормалізувати там, де виміряно, що це потрібно для швидкодії. Вищі форми (BCNF, 4НФ, 5НФ) у прикладних застосунках потрібні рідко.
Колонка не може посилатися на кілька рядків іншої таблиці, тож зв'язок «багато-до-багатьох» (статті ↔ теги, студенти ↔ курси) реалізують проміжною таблицею з двома зовнішніми ключами.
CREATE TABLE posts (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, title text NOT NULL);
CREATE TABLE tags (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE);
CREATE TABLE post_tag (
post_id bigint NOT NULL REFERENCES posts (id) ON DELETE CASCADE,
tag_id bigint NOT NULL REFERENCES tags (id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
CREATE INDEX post_tag_tag_id_idx ON post_tag (tag_id);
Ключові деталі:
- Складений первинний ключ
(post_id, tag_id)- не дає прив'язати той самий тег двічі і водночас є індексом для «теги статті». - Окремий індекс на
tag_id- для зворотного напрямку «статті з тегом». Складений ключ починається зpost_id, тож для пошуку заtag_idвін не допоможе. ON DELETE CASCADE- видалили статтю, зник і зв'язок. Без цього видалення статті падатиме на зовнішньому ключі.
Атрибути зв'язку. Якщо в самого зв'язку є дані - коли тег додано, хто додав, роль користувача в команді, - вони живуть у проміжній таблиці:
CREATE TABLE team_user (
team_id bigint REFERENCES teams (id),
user_id bigint REFERENCES users (id),
role text NOT NULL DEFAULT 'member',
joined_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (team_id, user_id)
);
У Laravel: belongsToMany з таблицею post_tag (назви моделей в однині, за абеткою), withPivot('role') і withTimestamps() для атрибутів, attach/detach/sync для зміни зв'язків.
Коли проміжна таблиця стає сутністю: якщо в неї з'явилися власна поведінка й багато полів (членство з історією й статусами), варто зробити з неї повноцінну модель з власним id.
- Природний ключ - значення, що вже унікальне в реальному світі: email, ІПН, код валюти
UAH, ISBN, номер телефону. - Сурогатний ключ - штучний ідентифікатор без бізнес-змісту:
idз послідовності чи UUID.
Проблеми природних ключів:
- Вони змінюються. Email змінюють, телефони переносять, номери документів виправляють. Зміна первинного ключа - каскадне оновлення всіх таблиць, що на нього посилаються.
- Унікальність, яка виявляється не зовсім унікальною: один номер телефону в сім'ї, повторно використаний email після видалення облікового запису, дублі ІПН через помилки введення.
- Розмір і швидкість: довгий рядок як ключ і в кожному зовнішньому ключі займає більше місця, ніж
bigint. - Розкриття даних: email в URL і логах.
Тому типова практика:
- Сурогатний первинний ключ для зв'язків між таблицями;
- Унікальне обмеження на природний ключ, щоб правило бізнесу все одно перевірялося базою:
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL,
CONSTRAINT users_email_unique UNIQUE (email)
);
Коли природний ключ доречний:
- Довідники зі стабільними стандартними кодами: валюти (
UAH), країни (UA), мови (uk). Код зручно читати в даних, і він не змінюється. - Проміжні таблиці «багато-до-багатьох» - складений ключ з двох зовнішніх ключів природний за своєю суттю.
Сурогатний ключ не замінює унікальність: таблиця з лише id і без унікального обмеження на бізнес-ключ дозволить створити двох однакових клієнтів, і це найпоширеніша причина дублікатів у даних.
Upsert - «вставити, а якщо такий запис уже є, - оновити». У PostgreSQL це один атомарний запит:
INSERT INTO product_prices (product_id, currency, amount)
VALUES (42, 'UAH', 1999)
ON CONFLICT (product_id, currency)
DO UPDATE SET amount = EXCLUDED.amount, updated_at = now();
ON CONFLICT (колонки)- на якому унікальному обмеженні чи індексі визначати конфлікт;EXCLUDED- рядок, який намагалися вставити: з нього беруть нові значення;DO NOTHING- просто пропустити дублікат.
INSERT INTO subscriptions (user_id, list_id)
VALUES (7, 3)
ON CONFLICT DO NOTHING;
Обов'язкова умова: на колонках з ON CONFLICT має бути унікальний індекс чи обмеження. Інакше:
ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification
Умовне оновлення - не перезаписувати свіжіші дані старішими:
INSERT INTO stock (sku, qty, synced_at) VALUES ('A-1', 10, '2026-10-04 09:00')
ON CONFLICT (sku) DO UPDATE
SET qty = EXCLUDED.qty, synced_at = EXCLUDED.synced_at
WHERE stock.synced_at < EXCLUDED.synced_at;
Лічильник одним запитом:
INSERT INTO page_views (page_id, day, views) VALUES (5, current_date, 1)
ON CONFLICT (page_id, day) DO UPDATE SET views = page_views.views + 1;
Чому не «SELECT, потім INSERT або UPDATE»: між перевіркою й записом інший запит може вставити той самий рядок - і один з двох отримає помилку унікальності чи перезапише чужі дані. ON CONFLICT розв'язує конфлікт атомарно всередині бази.
У Laravel Model::upsert($rows, uniqueBy: [...], update: [...]) генерує саме INSERT ... ON CONFLICT ... DO UPDATE. Події моделі при цьому не спрацьовують.
Нюанси:
- одним запитом не можна оновити той самий рядок двічі: якщо в пакеті вставки два рядки з однаковим ключем - помилка
ON CONFLICT DO UPDATE command cannot affect row a second time. Дублікати треба прибрати до запиту; - послідовність identity/serial витрачає значення навіть при конфлікті - у нумерації з'являються пропуски, і це нормально.
RETURNING повертає дані рядків, які щойно вставлено, оновлено чи видалено, - тим самим запитом, без додаткового SELECT.
INSERT INTO orders (user_id, total) VALUES (7, 1999)
RETURNING id, created_at;
Типова потреба: дізнатися згенерований id, значення за замовчуванням (created_at, uuid), результат тригера.
З UPDATE і DELETE:
UPDATE accounts SET balance = balance - 500
WHERE id = 7 AND balance >= 500
RETURNING balance;
-- 0 рядків у відповіді - коштів не вистачило
DELETE FROM sessions WHERE last_activity < now() - interval '30 days'
RETURNING user_id;
PostgreSQL 18: старі й нові значення в одному запиті:
UPDATE products SET price = price * 1.1 WHERE category_id = 3
RETURNING id, old.price AS old_price, new.price AS new_price;
Раніше для «було - стало» потрібен був окремий запит до оновлення чи тригер.
З ON CONFLICT - дізнатися, чи рядок вставлено, чи оновлено:
INSERT INTO prices (sku, amount) VALUES ('A-1', 100)
ON CONFLICT (sku) DO UPDATE SET amount = EXCLUDED.amount
RETURNING id, (xmax = 0) AS inserted;
(xmax = 0 - поширений прийом, що спирається на внутрішню деталь MVCC, а не на гарантований інтерфейс.)
Чому це важливо:
- на один запит менше - менше затримки, особливо коли база на іншому сервері;
- атомарність: окремий
SELECTпісляUPDATEможе побачити вже змінений іншим запитом рядок, аRETURNINGповертає саме те, що записав ваш запит; - черга задач:
UPDATE ... WHERE id = (SELECT ... FOR UPDATE SKIP LOCKED) RETURNING *- взяти задачу й позначити її зайнятою одним запитом.
У Laravel: при Model::create() на PostgreSQL Eloquent отримує id саме через INSERT ... RETURNING "id". Для решти сценаріїв - сирий запит:
$rows = DB::select('UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ? RETURNING balance', [500, 7, 500]);
Докладніше в документації: Повернення даних зі змінених рядків
DISTINCT ON (вирази) - розширення PostgreSQL: з кожної групи рядків з однаковими значеннями виразів лишається перший рядок за порядком ORDER BY.
Задача: останнє замовлення кожного користувача.
SELECT DISTINCT ON (user_id) user_id, id, total, created_at
FROM orders
ORDER BY user_id, created_at DESC;
- групи визначає
user_id; - усередині групи рядки впорядковані за
created_at DESC; - лишається перший - тобто найновіший.
Правило: вирази з DISTINCT ON мають бути на початку ORDER BY. Інакше:
ERROR: SELECT DISTINCT ON expressions must match initial ORDER BY expressions
Без додаткового сортування всередині групи «перший» рядок випадковий - завжди вказуйте, який саме потрібен.
Порівняння з альтернативами:
| Спосіб | Особливості |
|---|---|
DISTINCT ON |
найкоротший запис, лише PostgreSQL |
віконна функція row_number() |
стандартний SQL, працює й у MySQL 8, можна взяти N рядків на групу |
LATERAL з LIMIT 1 |
швидкий, коли груп мало, а рядків у групі багато, і є індекс |
-- те саме через віконну функцію
SELECT * FROM (
SELECT o.*, row_number() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM orders o
) t WHERE rn = 1;
Продуктивність: індекс (user_id, created_at DESC) дозволяє виконати DISTINCT ON без окремого сортування всієї таблиці. Без індексу PostgreSQL сортує всі рядки - на великій таблиці це повільно.
У Laravel є прямий метод:
Order::query()
->distinct('user_id')
->orderBy('user_id')
->latest()
->get();
distinct('user_id') на PostgreSQL генерує DISTINCT ON ("user_id"). Для зв'язку «останнє замовлення» в Eloquent є latestOfMany() - він будує підзапит, що працює в усіх базах.
Типова помилка - очікувати від DISTINCT ON агрегацію: він не рахує суми й кількості, лише вибирає один рядок з групи. Для підсумків потрібен GROUP BY.
Представлення (view) - збережений запит з іменем. Його можна читати як таблицю, але дані не зберігаються: при кожному зверненні PostgreSQL виконує запит, що лежить в основі.
CREATE VIEW active_customers AS
SELECT u.id, u.email, count(o.id) AS orders_count, max(o.created_at) AS last_order_at
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.deleted_at IS NULL
GROUP BY u.id, u.email;
SELECT * FROM active_customers WHERE orders_count > 5;
Планувальник підставляє запит представлення в зовнішній запит і оптимізує їх разом - умова orders_count > 5 не означає, що спершу рахуються всі клієнти.
Навіщо представлення:
- повторно використовувати складний запит - один
JOINз агрегатами замість копій у кількох місцях; - стабільний інтерфейс для звітів, BI-інструментів, сторонніх систем: таблиці можна змінювати, а представлення лишити сумісним;
- обмеження доступу: надати роль лише на представлення без чутливих колонок, а не на всю таблицю.
Звичайне проти матеріалізованого:
VIEW |
MATERIALIZED VIEW |
|
|---|---|---|
| зберігає дані | ні | так, знімок на момент оновлення |
| актуальність | завжди | до REFRESH |
| швидкість читання | як у запиту | як у таблиці, можна індексувати |
Оновлювані представлення: просте представлення з однієї таблиці без агрегатів PostgreSQL дозволяє оновлювати через INSERT/UPDATE/DELETE. WITH CHECK OPTION забороняє вставку рядків, які не пройдуть умову WHERE представлення.
У Laravel представлення створюють у міграції через DB::statement('CREATE VIEW ...'), а читають звичайною моделлю:
class ActiveCustomer extends Model
{
protected $table = 'active_customers';
}
Підводні камені:
- зміна таблиць: видалити колонку, яку використовує представлення, не можна без
CASCADE- аCASCADEвидалить і представлення. Міграції мають перестворювати представлення; SELECT *у визначенні фіксує список колонок на момент створення - нові колонки таблиці в представленні не з'являться;- вкладені представлення (представлення над представленням над представленням) ховають складність і погіршують плани запитів.
Розширення додають у базу нові типи, функції, оператори й індекси без зміни самого PostgreSQL. Встановлюються командою в конкретній базі:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
SELECT extname, extversion FROM pg_extension; -- що вже встановлено
SELECT name, default_version FROM pg_available_extensions; -- що доступно на сервері
Корисні розширення:
| Розширення | Для чого |
|---|---|
pg_stat_statements |
статистика всіх запитів: які найповільніші й найчастіші - основний інструмент пошуку проблем |
pg_trgm |
пошук за схожістю й LIKE '%текст%' з індексом |
unaccent |
пошук без урахування діакритичних знаків |
citext |
рядки без урахування регістру (email) |
pgcrypto |
хешування й шифрування в SQL |
btree_gist |
обмеження на кшталт «бронювання не перетинаються» з рівністю й діапазоном |
pgvector |
вектори для семантичного пошуку й RAG |
postgis |
географічні дані: відстані, полігони, «найближчі точки» |
pg_partman |
автоматичне створення партицій |
pg_stat_statements - особливий: його потрібно завантажити при старті сервера:
shared_preload_libraries = 'pg_stat_statements'
SELECT calls, round(mean_exec_time::numeric, 1) AS avg_ms, query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
У Laravel розширення вмикають у міграції:
public function up(): void
{
DB::statement('CREATE EXTENSION IF NOT EXISTS pg_trgm');
}
Що врахувати:
- права: створювати більшість розширень може лише суперкористувач чи власник бази; користувач застосунку з мінімальними правами міграцію не виконає - розширення встановлює адміністратор;
- керовані бази (RDS, Cloud SQL, Neon, Supabase) дозволяють лише розширення зі свого списку - перевірте до того, як будувати на ньому архітектуру;
- тестова база теж має мати розширення, інакше міграції в CI впадуть;
- оновлення PostgreSQL: розширення мають бути доступні й у новій версії, а
ALTER EXTENSION ... UPDATE- частина плану оновлення.
Питання з реальних технічних співбесід - 116 питань у 9 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 146 відкритих вакансій Laravel і PHP. Переглянути вакансії