Senior: питання на співбесіді з теми «Індекси»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
6 питань
Частковий індекс (PostgreSQL) індексує лише рядки, що відповідають умові WHERE. Він менший, швидше оновлюється і краще тримається в пам'яті.
1. Запити завжди дивляться на малу частину таблиці:
-- 99% замовлень завершені, але черга обробки дивиться лише на pending
CREATE INDEX orders_pending_idx ON orders (created_at) WHERE status = 'pending';
Індекс займає частку від повного, і запит WHERE status = 'pending' ORDER BY created_at бере з нього кілька рядків.
2. Унікальність лише серед частини рядків - найкорисніше застосування:
-- Email унікальний лише серед не видалених (soft delete)
CREATE UNIQUE INDEX users_email_active_uniq ON users (email) WHERE deleted_at IS NULL;
-- Лише одна активна підписка на користувача
CREATE UNIQUE INDEX subscriptions_one_active ON subscriptions (user_id) WHERE active;
Без часткового індексу видалений користувач «займав» би email назавжди, і зареєструватися знову було б неможливо.
Обмеження:
- Планувальник використає індекс, лише якщо з умови запиту можна вивести умову індексу.
WHERE status = 'pending'- так;WHERE status = $1з параметром, невідомим на етапі планування, - не завжди. - MySQL часткових індексів не має. Альтернативи - згенерована колонка, яка
NULLдля «неактивних» рядків (унікальний індекс пропускаєNULL), або функціональний індекс.
Звичайний CREATE INDEX у PostgreSQL бере блокування, яке забороняє запис у таблицю на весь час побудови. На таблиці в сотні мільйонів рядків це хвилини чи години простою для INSERT/UPDATE.
CREATE INDEX CONCURRENTLY будує індекс без блокування запису:
CREATE INDEX CONCURRENTLY orders_created_at_idx ON orders (created_at);
Ціна й підводні камені:
- Працює довше: два проходи по таблиці й очікування завершення всіх транзакцій, що вже йшли.
- Не можна в транзакції. У Laravel-міграції для цього потрібно вимкнути транзакцію міграції (
public $withinTransaction = false;). - Якщо побудова впала (наприклад, порушено унікальність), лишається індекс у стані
INVALID. Він не використовується для читання, але сповільнює запис. Його треба знайти (pg_index.indisvalid = false), видалитиDROP INDEX CONCURRENTLYі створити знову. - Довга транзакція десь у системі змушує побудову чекати - перед операцією варто перевірити
pg_stat_activity.
MySQL (InnoDB) з версії 5.6 будує більшість індексів online (ALGORITHM=INPLACE, LOCK=NONE), але на початку й наприкінці все одно потрібне коротке метаданих-блокування. Для великих таблиць часто використовують gh-ost чи pt-online-schema-change.
Загальне правило: зміни схеми великих таблиць ганяють окремо від деплою коду, з lock_timeout, щоб операція краще впала, ніж повісила всю базу в черзі за блокуванням.
BRIN (Block Range INdex) зберігає не кожне значення, а підсумок для діапазону сторінок таблиці: мінімум і максимум значення колонки в кожних, скажімо, 128 сторінках.
Запит WHERE created_at >= '2026-10-01' перевіряє підсумки й читає лише ті діапазони сторінок, де значення можуть бути, - решту пропускає.
CREATE INDEX events_created_brin ON events USING brin (created_at);
CREATE INDEX events_created_brin ON events USING brin (created_at) WITH (pages_per_range = 32);
Головна перевага - розмір. На таблиці в сотні гігабайтів B-tree займає десятки гігабайтів, а BRIN - мегабайти. Він майже не впливає на швидкість вставки.
Коли BRIN працює: значення корелюють з фізичним розташуванням рядків на диску. Ідеальний випадок - журнали, події, метрики, де рядки лише дописуються в кінець, а час вставки зростає. Тоді кожен діапазон сторінок містить вузький проміжок часу.
Коли не працює:
- Значення розкидані по таблиці випадково (наприклад,
user_idу журналі подій) - кожен діапазон містить майже всі значення, і BRIN нічого не відсікає. - Часті
UPDATEчи видалення з подальшими вставками на звільнене місце руйнують кореляцію. - Потрібен точковий пошук одного рядка - BRIN поверне діапазони сторінок, які доведеться перечитати.
Як перевірити придатність: correlation у pg_stats для колонки - близько до 1 чи -1 означає, що BRIN підійде.
Обслуговування: нові сторінки підсумовуються вакуумом або автоматично (autosummarize = on), або вручну brin_summarize_new_values(). Поки діапазон не підсумовано, його доводиться читати завжди.
Типова архітектура: BRIN за часом на великій таблиці подій + партиціонування за місяцями + B-tree лише на тих колонках, за якими справді шукають окремі записи.
Через MVCC UPDATE у PostgreSQL створює нову версію рядка. Звичайно нова версія лягає в інше місце таблиці, і кожен індекс таблиці отримує новий запис, що на неї вказує, - навіть індекси на колонках, які не змінювалися.
HOT (Heap-Only Tuple) - оптимізація, коли оновлення не чіпає індекси взагалі. Нова версія кладеться на ту саму сторінку, а стара посилається на неї ланцюжком. Індекси продовжують вказувати на стару позицію й знаходять нову через ланцюжок.
Умови HOT:
- Жодна індексована колонка не змінилася.
- На тій самій сторінці є вільне місце для нової версії.
Чому зайвий індекс шкодить UPDATE: індекс на колонці updated_at чи views_count, яку змінює майже кожне оновлення, робить HOT неможливим для всіх таких оновлень. Кожен UPDATE ... SET views_count = views_count + 1 тепер пише в усі індекси таблиці й більше роздуває їх.
Як збільшити частку HOT:
- Не індексувати «гарячі» колонки, які часто змінюються, без явної потреби.
fillfactorменше 100 - залишати на сторінках вільне місце для нових версій:
ALTER TABLE counters SET (fillfactor = 80);
(діє для нових сторінок; для наявних - після VACUUM FULL чи перебудови).
- Винести часто змінювані лічильники в окрему вузьку таблицю.
Як виміряти:
SELECT relname, n_tup_upd, n_tup_hot_upd,
round(100.0 * n_tup_hot_upd / nullif(n_tup_upd, 0), 1) AS hot_percent
FROM pg_stat_user_tables
ORDER BY n_tup_upd DESC;
Низький відсоток HOT на таблиці з багатьма оновленнями - сигнал переглянути індекси й fillfactor.
Кожен індекс уповільнює запис, займає пам'ять і диск, подовжує бекапи й вакуум. З роками в базі накопичуються індекси, створені «про всяк випадок» чи для запитів, яких уже немає.
1. Невикористані:
SELECT s.relname AS table, s.indexrelname AS index, s.idx_scan,
pg_size_pretty(pg_relation_size(s.indexrelid)) AS size
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE s.idx_scan = 0
AND NOT i.indisunique -- унікальні тримають цілісність
ORDER BY pg_relation_size(s.indexrelid) DESC;
2. Дублікати - однакові колонки в тому самому порядку:
SELECT indrelid::regclass AS table, array_agg(indexrelid::regclass) AS indexes
FROM pg_index
GROUP BY indrelid, indkey, indexprs::text, indpred::text
HAVING count(*) > 1;
3. Надлишкові за лівим префіксом. Індекс (user_id) зайвий, якщо є (user_id, created_at): другий обслуговує ті самі запити. Виняток - коли перший унікальний чи значно менший і використовується для гарячого запиту.
Перш ніж видаляти:
- Переконатися, що статистика збиралася достатньо довго (щомісячні звіти, сезонні задачі).
- Перевірити всі репліки - статистика окрема на кожному сервері.
- Перевірити, чи індекс не підтримує зовнішній ключ (каскадне видалення без нього стане повним переглядом таблиці).
- Видаляти через
DROP INDEX CONCURRENTLYі мати скрипт для швидкого відновлення.
Інструменти: запити з PostgreSQL Wiki, pgstattuple, звіти в pganalyze, Percona Monitoring. Аналіз варто повторювати регулярно, а не раз на кілька років після проблем зі швидкістю.
Планувальник оцінює кількість рядків для умов окремо по кожній колонці й припускає, що колонки незалежні: вибірковість A AND B = вибірковість A × вибірковість B.
Проблема - корельовані колонки:
SELECT * FROM addresses WHERE city = 'Львів' AND region = 'Львівська';
Якщо city = 'Львів' дає 2% рядків, а region = 'Львівська' - 5%, планувальник очікує 0.1%. Насправді ж майже всі Львови - у Львівській області, і результат близький до 2% - у 20 разів більше. З такою оцінкою він обирає Nested Loop там, де потрібен Hash Join, і запит сповільнюється на порядки.
Розширена статистика (PostgreSQL 10+) дозволяє зібрати статистику по комбінації колонок:
CREATE STATISTICS addresses_city_region (dependencies, ndistinct, mcv)
ON city, region FROM addresses;
ANALYZE addresses;
Види:
dependencies- функціональні залежності (місто визначає область).ndistinct- кількість унікальних комбінацій значень; важливо для оцінкиGROUP BY city, region.mcv(PostgreSQL 12+) - найчастіші комбінації значень з частотами; найточніша для конкретних пар значень.
З PostgreSQL 14 статистику можна збирати й для виразів: CREATE STATISTICS ... ON (lower(email)) FROM users.
Як зрозуміти, що це потрібно: EXPLAIN ANALYZE показує оцінку rows, що в десятки разів відрізняється від actual rows, на умові з кількома колонками однієї таблиці, а статистика окремих колонок свіжа.
Обмеження: допомагає лише для колонок однієї таблиці і лише для умов рівності й групування. Статистика займає місце і збирається під час ANALYZE, тож створюють її точково - для проблемних запитів.