<? phpukraine СПІВБЕСІДИ
Пошук по платформі

SQL для Middle: питання на співбесіду

5 питань рівня Middle з теми SQL з розгорнутими відповідями, порадами та перевіркою.

Тема
Рівень
5 питань
SQL
SQL·Middle ·віконні функції ·ROW_NUMBER ·LAG

Віконна функція рахує агрегат або позицію по групі рядків (вікні), не згортаючи їх у один рядок: ROW_NUMBER і RANK дають нумерацію всередині PARTITION BY, LAG бере значення попереднього рядка, SUM() OVER (ORDER BY …) дає running total — усе це замінює корельовані підзапити й самоджойни одним проходом.

Як вибрати три найдорожчі замовлення для кожного користувача одним запитом?
Чим ROW_NUMBER відрізняється від RANK і DENSE_RANK?
Як порахувати наростаючий підсумок без коду на PHP і без корельованого підзапиту?
Чому запит із ROW_NUMBER() у WHERE падає з помилкою?

Віконна функція обчислює значення по набору рядків, повʼязаних із поточним, і при цьому не згортає їх: на виході стільки ж рядків, скільки на вході, просто зʼявляється додаткова колонка. Набір задає речення OVER: PARTITION BY ділить результат на незалежні розділи (нумерація починається заново для кожного user_id), ORDER BY задає порядок усередині розділу, а рамка (ROWS/RANGE) визначає, які саме сусідні рядки потрапляють у розрахунок. Це і є принципова відмінність від GROUP BY, який із десяти замовлень користувача робить один рядок: вікно лишає всі десять і кожному дописує ранг, суму або значення сусіда.

Три функції покривають більшість практичних задач. ROW_NUMBER() дає суцільні унікальні номери 1, 2, 3 — навіть якщо значення однакові, порядок між ними база обере довільно. RANK() при ничиїй ставить однаковий ранг і пропускає наступні (1, 1, 3), DENSE_RANK() не пропускає (1, 1, 2); різниця важлива, коли треба «топ-3 з урахуванням рівних результатів». LAG(col, 1, default) бере значення з попереднього рядка розділу, LEAD — з наступного, і третій аргумент рятує від NULL на межі розділу. Раніше те саме писали корельованим підзапитом (SELECT count(*) FROM orders o2 WHERE o2.user_id = o.user_id AND o2.total > o.total) або самоджойном по n - 1: обидва читають таблицю багато разів, вікно — один раз із сортуванням.

Ключовий момент, на якому валяться на співбесіді, — момент обчислення. Віконні функції рахуються після FROM, WHERE, GROUP BY і HAVING, але до ORDER BY і LIMIT. Тому у WHERE їх писати не можна: WHERE row_number() OVER (...) <= 3 дає помилку і в MySQL, і в PostgreSQL. Канонічний шаблон top-N у групі — CTE або похідна таблиця, де вікно живе в SELECT, і зовнішній запит із WHERE rn <= 3. Наслідок із того самого правила корисний і в інший бік: усе, що відсіює WHERE, до вікна взагалі не доходить, тому WHERE status = 'paid' всередині CTE звужує розділ, а не фільтрує вже пронумеровані рядки.

Running total — це агрегат із рамкою: sum(total) OVER (PARTITION BY user_id ORDER BY created_at ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW). Явний ROWS тут не прикраса. Якщо рамку не вказати, стандарт наказує RANGE UNBOUNDED PRECEDING AND CURRENT ROW, а RANGE працює зі значеннями ORDER BY: усі рядки з однаковим created_at вважаються однією позицією й отримують однакову суму, що включає їх усіх. На секундній точності часу це майже непомітно, на даті — ламає звіт. Без ORDER BY рамка охоплює весь розділ, і sum(total) OVER (PARTITION BY user_id) дає загальну суму користувача в кожному його рядку — зручно, щоб порахувати частку замовлення від обороту без другого запиту.

Межі теж варто назвати. Віконні функції є в PostgreSQL із 8.4, у MySQL з 8.0, MariaDB з 10.2, SQLite з 3.25 — на легасі MySQL 5.7 доведеться повертатись до підзапитів. Продуктивність не безкоштовна: PARTITION BY user_id ORDER BY total DESC без індексу (user_id, total) дає в плані Sort над повним скануванням, і на великій таблиці це дорожче за альтернативу. Для «одного найкращого рядка на групу» майже завжди виграє DISTINCT ON у PostgreSQL або LATERAL із LIMIT 1 (MySQL 8.0.14+), бо вони читають по кілька рядків з індексу замість сортування всього розділу. Тож правило просте: вікно — коли потрібні всі рядки з додатковим контекстом, GROUP BY — коли потрібен один рядок на групу, а вибір між вікном і LATERAL вирішує EXPLAIN.

-- Топ-3 замовлення кожного користувача + наростаючий підсумок + дельта до попереднього
WITH ranked AS (
    SELECT
        o.id,
        o.user_id,
        o.total,
        o.created_at,
        -- унікальні номери 1,2,3… всередині кожного user_id
        row_number() OVER (PARTITION BY o.user_id ORDER BY o.total DESC) AS rn,
        -- при однакових сумах дає однаковий ранг і розрив: 1,1,3
        rank()       OVER (PARTITION BY o.user_id ORDER BY o.total DESC) AS rnk,
        -- сума всіх попередніх замовлень користувача; ROWS, а не дефолтний RANGE,
        -- інакше рядки з однаковим created_at отримають однакову суму
        sum(o.total) OVER (
            PARTITION BY o.user_id ORDER BY o.created_at
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS running_total,
        -- сума попереднього замовлення; 0 для першого рядка користувача
        lag(o.total, 1, 0) OVER (PARTITION BY o.user_id ORDER BY o.created_at) AS prev_total
    FROM orders o
    WHERE o.status = 'paid'          -- WHERE відпрацьовує ДО обчислення вікна
)
SELECT id, user_id, total, running_total, total - prev_total AS diff
FROM ranked
WHERE rn <= 3                        -- фільтр по вікну можливий лише в зовнішньому запиті
ORDER BY user_id, rn;

-- Індекс, який прибирає Sort із плану WindowAgg
CREATE INDEX orders_user_total ON orders (user_id, total DESC);

-- Для топ-1 у групі дешевше: PostgreSQL читає по одному рядку на користувача
SELECT DISTINCT ON (user_id) user_id, id, total
FROM orders
WHERE status = 'paid'
ORDER BY user_id, total DESC;
Що віконна функція не зменшує кількість рядків, на відміну від GROUP BY: кожен вхідний рядок лишається і отримує додаткову колонку.
Різницю ROW_NUMBER (завжди унікальні 1, 2, 3), RANK (при ничиїй однакове число і розрив: 1, 1, 3) і DENSE_RANK (1, 1, 2).
Що вікно рахується після WHERE, GROUP BY і HAVING, тому фільтрувати по ROW_NUMBER можна лише в зовнішньому запиті чи CTE — це і є типовий шаблон top-N у групі.
Що SUM() OVER (ORDER BY …) дає running total, і що за замовчуванням рамка RANGE UNBOUNDED PRECEDING включає всі рядки-однолітки з тим самим значенням ORDER BY, тому при дублікатах треба явно писати ROWS.
Що LAG/LEAD зі значеннями попереднього й наступного рядка замінюють самоджойн по `n - 1` і мають третій аргумент — значення за замовчуванням.
Версії: MySQL 8.0, MariaDB 10.2, PostgreSQL 8.4+, SQLite 3.25 — у MySQL 5.7 віконних функцій немає, і це впливає на вибір рішення для легасі-проєкту.
Писати `WHERE row_number() OVER (...) <= 3` — віконні функції у WHERE і HAVING заборонені, потрібен підзапит або CTE.
Плутати ROW_NUMBER і RANK: брати RANK для пагінації й отримувати дірки в нумерації, або ROW_NUMBER для «топ-3 з урахуванням ничиїх» і випадково відрізати один із рівних результатів.
Забувати PARTITION BY і отримувати наскрізну нумерацію по всій таблиці замість нумерації всередині користувача.
Писати `SUM(amount) OVER (ORDER BY created_at)` для running total на даних із однаковими датами й дивуватися, що кілька рядків мають однакову суму: дефолтна рамка RANGE, а не ROWS.
Вважати, що віконна функція завжди швидша: PARTITION BY і ORDER BY без відповідного індексу дають Sort у плані, і для top-1 у групі LATERAL або DISTINCT ON часто дешевші.
Замінювати GROUP BY віконним агрегатом і дивуватися дублікатам: вікно рядки не згортає, потрібен ще DISTINCT або зовнішній фільтр.
ПОРАДА

Скажіть одним реченням: «GROUP BY згортає рядки, вікно лишає їх на місці й додає колонку». Далі назвіть три робочі шаблони — ROW_NUMBER + PARTITION BY для top-N, SUM() OVER для наростаючого підсумку, LAG для дельти між сусідніми рядками — і одразу згадайте, що фільтрувати по вікну можна тільки зовні.

Сторінка питання →
SQL
SQL·Middle ·EXPLAIN ·план запиту

Дивіться на тип доступу до таблиці, оцінку рядків проти фактичної кількості і найдорожчі вузли плану; розбіжність оцінки й факту вказує на застарілу статистику.

Чим EXPLAIN ANALYZE відрізняється від EXPLAIN?
Що означає Seq Scan на великій таблиці?
Запит швидкий локально й повільний у продакшені: як зрозуміти чому?

EXPLAIN показує, як оптимізатор збирається виконати запит: дерево вузлів, для кожного оцінка вартості й очікувана кількість рядків. EXPLAIN ANALYZE виконує запит насправді й додає фактичний час і рядки на кожному вузлі. Саме порівняння оцінки з фактом дає найбільше: якщо оптимізатор очікував тисячу рядків, а отримав двісті тисяч, він побудував план під неправильні припущення, і лікується це ANALYZE або розширеною статистикою.

План читається від найглибших вузлів до кореня, і шукати треба той вузол, де витрачається час, а не загальний cost. Перше, на що дивляться, — тип доступу. Seq Scan або ALL у MySQL означає повне читання таблиці; на малій таблиці це нормально, на великій із селективною умовою сигнал, що індекс відсутній або незастосовний через функцію в умові. Index Scan читає індекс і таблицю, Index Only Scan лише індекс. Далі шукають сортування без індексу: Sort із великою кількістю рядків або Using filesort і Using temporary у MySQL. У PostgreSQL важливо множити на loops: вузол із rows=1 і loops=100000 виконався сто тисяч разів.

План залежить від даних. На порожній локальній базі оптимізатор обирає Seq Scan і Nested Loop, які в продакшені стають катастрофою, тому аналізувати треба на реалістичному обсязі, а повільні запити в продакшені ловити через auto_explain або slow query log.

EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, u.email
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = 'paid' AND o.created_at > now() - interval '7 days'
ORDER BY o.created_at DESC
LIMIT 50;

-- Проблемний план (PostgreSQL): читаємо всю таблицю й сортуємо в памʼяті
-- Limit (actual time=812.4..812.5 rows=50 loops=1)
--   -> Sort (actual time=812.3..812.4 rows=50 loops=1)
--        Sort Method: top-N heapsort
--        -> Hash Join (actual rows=184213 loops=1)
--             -> Seq Scan on orders o (rows=184213 loops=1)        <- повний скан
--                  Filter: (status = 'paid' AND created_at > ...)
--                  Rows Removed by Filter: 4815787                  <- 96 % рядків відкинуто
--             -> Hash -> Seq Scan on users u

-- Ознаки проблеми: Seq Scan з великим Rows Removed by Filter, Sort без індексу,
-- estimated rows=1200 проти actual rows=184213 (стара статистика)

CREATE INDEX orders_status_created ON orders (status, created_at DESC);
ANALYZE orders;

-- Після: Index Scan по orders_status_created, Sort зник, actual time ~2 ms
Що EXPLAIN показує план оптимізатора з оцінками, а EXPLAIN ANALYZE реально виконує запит і додає фактичний час і кількість рядків на кожному вузлі.
Що план читається від найглибших вузлів до кореня, і шукати треба вузол, де витрачається час, а не загальний cost.
Типи доступу: Seq Scan або ALL це повне читання таблиці, Index Scan пошук по індексу з читанням таблиці, Index Only Scan без таблиці, Bitmap Scan для великих діапазонів.
Що велика різниця між rows estimated і rows actual означає застарілу статистику або скорельовані колонки, і тоді оптимізатор обирає неправильний JOIN або порядок.
Що план залежить від даних: на порожній локальній базі Seq Scan нормальний, тому аналізувати треба на копії продакшн-обсягів.
Дивитись лише на загальний cost і вважати, що EXPLAIN показує час.
Панікувати від Seq Scan на таблиці з тисячею рядків: для малих таблиць це найшвидший спосіб.
Запускати EXPLAIN ANALYZE на UPDATE або DELETE у продакшені без транзакції з ROLLBACK.
Не знати, що loops у PostgreSQL множить час і рядки вузла: actual rows=1 loops=100000 це сто тисяч виконань.
Ігнорувати Using filesort і Using temporary у MySQL або Sort з Disk у PostgreSQL, які показують сортування без індексу.
ПОРАДА

Скажіть, що читаєте EXPLAIN ANALYZE на копії продакшн-даних: на порожній локальній базі план буде іншим. Згадайте BUFFERS і auto_explain для пошуку повільних запитів у продакшені.

Сторінка питання →
SQL
SQL·Middle ·пагінація ·keyset ·OFFSET

OFFSET не вміє «стрибати»: база читає й відкидає всі пропущені рядки, тому час зростає лінійно з номером сторінки. Keyset (seek method) замість номера сторінки передає ключ останнього прочитаного рядка й читає рівно LIMIT записів за постійний час.

Чому 1-ша сторінка списку відкривається миттєво, а 5000-та — три секунди?
Що таке seek method або курсорна пагінація і чим вона краща за LIMIT/OFFSET?
Чому користувач бачить той самий запис двічі, гортаючи сторінки?
Індекс на created_at є, EXPLAIN показує Index Scan — чому запит із OFFSET 200000 усе одно повільний?

OFFSET — це не пошук, а відлік. Індекс дає базі впорядкований список записів, але не дає адресації «дай мені сотий тисячний елемент»: щоб дійти до потрібної позиції, планувальник читає й викидає всі рядки до неї. Тому ORDER BY created_at DESC LIMIT 20 OFFSET 100000 коштує 100 020 прочитаних записів заради двадцяти віддданих, і час зростає лінійно з номером сторінки. У плані це видно як Limit над Index Scan із великим rows removed, і саме тому перша сторінка списку відкривається за одиниці мілісекунд, а п'ятитисячна — за секунди. Якщо ще й індексу під сортування немає, до відліку додається повне сортування всієї вибірки.

Keyset-пагінація (вона ж seek method, вона ж курсорна) прибирає відлік: замість номера сторінки клієнт повертає значення ключа останнього прочитаного рядка, а запит формулюється як діапазон — «усе, що йде строго після цієї позиції». Для сортування created_at DESC, id DESC умова записується рядковим порівнянням (created_at, id) < (?, ?), і база через індекс (user_id, created_at, id) одразу стрибає в потрібну точку дерева та читає рівно двадцять записів. Вартість сторінки перестає залежати від її глибини. Ключовий момент — унікальність ключа сортування: created_at сам по собі не унікальний, тож без tie-breaker у вигляді id рядки з однаковою міткою часу розподіляються між сторінками недетерміновано. Так само не можна розписувати умову як created_at <= ? AND id < ?: це відкидає всі рядки з меншим created_at, але більшим id. Або рядкове порівняння, або явне a < ? OR (a = ? AND b < ?) — у MySQL 8.0 обидві форми оптимізуються в range scan, у старіших версіях надійніша друга.

Другий, часто важливіший за швидкість, аргумент — консистентність. Між запитом сторінки 3 і сторінки 4 хтось вставив запис на початок списку: вікно OFFSET зсувається, і користувач бачить той самий елемент двічі; при видаленні — навпаки, пропускає. Keyset прив'язаний до значення, а не до позиції, тому вставки та видалення на попередніх сторінках його не зсувають. Той самий принцип лежить в основі chunkById() і lazyById() в Laravel — вони обходять таблицю через WHERE id > ? саме тому, що chunk() з OFFSET ламається, коли обробка змінює рядки. Для HTTP-пагінації Laravel з версії 8.27 дає cursorPaginate(): курсор — це base64-JSON зі значеннями колонок з orderBy, який фреймворк розгортає в keyset-умову.

Ціна в keyset теж є, і на співбесіді її треба назвати першим. Немає переходу на довільну сторінку — позиція описується ключем, а не номером. Немає загальної кількості сторінок (COUNT(*) на великій таблиці й сам по собі коштує сканування, тому його або кешують, або замінюють на оцінку з reltuples у PostgreSQL чи rows з EXPLAIN у MySQL). Сортування має бути детермінованим, а колонки сортування — без NULL або з явним NULLS LAST і окремою гілкою курсора. Зміна сортування користувачем означає новий індекс під кожен варіант. Звідси практичний поділ: стрічки, нескінченний скрол, експорт і API з next_cursor — keyset; адмінка з номерами сторінок і глибиною в десятки сторінок — звичайний paginate(), він там ніколи не стане вузьким місцем. І в обох випадках рішення підтверджують через EXPLAIN (ANALYZE, BUFFERS), а не на око.

-- OFFSET: щоб віддати 20 рядків, база читає й відкидає 100 000
SELECT id, created_at, total
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;

-- Keyset: передаємо не номер сторінки, а ключ останнього прочитаного рядка.
-- Рядкове порівняння читається як «усе, що йде строго після цієї позиції»
SELECT id, created_at, total
FROM orders
WHERE user_id = 42
  AND (created_at, id) < ('2026-02-01 10:15:00', 918273)
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- Індекс під keyset: рівність попереду, обидві колонки сортування далі
CREATE INDEX orders_user_created_id
    ON orders (user_id, created_at DESC, id DESC);

-- ПОМИЛКА: так пропадуть рядки з іншим created_at, але меншим id
-- WHERE created_at <= '2026-02-01 10:15:00' AND id < 918273

-- Еквівалент рядкового порівняння вручну:
-- для MySQL до 8.0 і для змішаних напрямків сортування
SELECT id, created_at, total
FROM orders
WHERE user_id = 42
  AND (created_at < '2026-02-01 10:15:00'
       OR (created_at = '2026-02-01 10:15:00' AND id < 918273))
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- Перевірка: у плані має бути range/Index Scan і ~20 прочитаних рядків,
-- а не сотні тисяч, як у варіанті з OFFSET
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM orders
WHERE user_id = 42 AND (created_at, id) < ('2026-02-01 10:15:00', 918273)
ORDER BY created_at DESC, id DESC LIMIT 20;
Що OFFSET не є операцією пошуку: індекс дає впорядкованість, але позицію N база отримує лише прочитавши N рядків, тому OFFSET 100000 LIMIT 20 коштує 100020 прочитаних рядків замість 20.
Що keyset формулює умову через значення останнього рядка попередньої сторінки, а не через його порядковий номер, і завдяки цьому кожна сторінка коштує однаково.
Що ключ сортування має бути унікальним: до created_at додають id як tie-breaker, інакше рядки з однаковим часом губляться або дублюються на межі сторінок.
Що умову треба писати рядковим порівнянням `(created_at, id) < (?, ?)`, а не `created_at <= ? AND id < ?` — друге відкидає валідні рядки.
Що OFFSET дає ще й неконсистентність: вставка або видалення між запитами зсуває вікно, і користувач бачить дублі або пропуски, а keyset до цього стійкий.
Що керуються компроміси усвідомлено: keyset не вміє переходу на довільну сторінку й не дає загальної кількості, тому підходить для стрічок і нескінченного скролу, а не для адмінки з номерами сторінок.
Вважати, що індекс на колонці сортування «лікує» OFFSET: індекс прибирає Sort, але пропущені рядки все одно читаються один за одним.
Писати keyset-умову як `created_at <= ? AND id < ?` — рядки з іншим created_at і більшим id зникнуть зі стрічки.
Сортувати лише за created_at без унікального tie-breaker і дивуватися дублям на межі сторінок при однакових мітках часу.
Плутати `simplePaginate()` з keyset: він лише прибирає `COUNT(*)`, але запит лишається `LIMIT ... OFFSET ...`.
Робити keyset по колонці, яка може бути NULL, без явного `NULLS LAST` і окремої гілки умови — порівняння з NULL дає UNKNOWN і рядок не потрапляє в жодну сторінку.
Лишати `SELECT COUNT(*)` на кожен запит: на великій таблиці він сам по собі коштує повного сканування й з'їдає весь виграш.
ПОРАДА

Скажіть коротко: «OFFSET — це не пошук, це відлік; keyset перетворює пагінацію на звичайний range scan по індексу». І одразу назвіть ціну: немає стрибка на сторінку 500 і немає загальної кількості — тому в адмінці лишаємо OFFSET, у стрічці й API ставимо keyset.

Сторінка питання →
SQL
SQL·Middle ·індекси ·EXPLAIN

Складений індекс працює за принципом лівого префікса: індекс (a, b, c) допомагає умовам по a, по a і b, по a, b і c, але не по b чи c окремо.

Чи спрацює індекс (a, b) для умови лише по b?
Яку колонку ставити першою в індексі?
Коли краще два окремих індекси замість одного складеного?

Складений індекс — це B-tree, у якому записи відсортовані спершу по першій колонці, всередині рівних значень по другій, і так далі. Звідси правило лівого префікса: індекс (a, b, c) дає швидкий пошук по a, по a, b і по a, b, c, але не по b окремо, бо значення b розкидані по всьому дереву. Телефонний довідник відсортований за прізвищем, потім за імʼям; знайти всіх Олен у ньому неможливо без повного перегляду.

Порядок колонок визначається запитами, які індекс має обслуговувати. Колонки з умовою рівності йдуть першими, колонка з діапазоном або та, по якій потрібне сортування, останньою. Після діапазону решта індексу для звуження пошуку вже не працює. Для типового WHERE user_id = ? AND status = ? ORDER BY created_at DESC LIMIT 20 правильний індекс (user_id, status, created_at): база стрибає в потрібне місце й читає двадцять уже відсортованих записів.

Індекс, який містить усі колонки запиту, стає покривним: таблиця не читається взагалі, у PostgreSQL це Index Only Scan із INCLUDE для додаткових колонок. Ціна кожного індексу — місце й повільніший запис, тому один складений індекс, спроєктований під кілька запитів, зазвичай кращий за набір вузьких. Після створення індексу його використання перевіряють через EXPLAIN, а не припускають.

-- Запит, під який проєктуємо індекс
SELECT id, total
FROM orders
WHERE user_id = 42 AND status = 'paid'
ORDER BY created_at DESC
LIMIT 20;

-- Правильно: рівності попереду, сортування останнім
CREATE INDEX orders_user_status_created ON orders (user_id, status, created_at DESC);

-- Неправильно: після діапазону або сортування решта індексу не використовується для пошуку
CREATE INDEX orders_created_user ON orders (created_at, user_id, status);

-- Покривний: total у INCLUDE, таблицю читати не треба (PostgreSQL)
CREATE INDEX orders_user_status_created_cov
    ON orders (user_id, status, created_at DESC) INCLUDE (total);

-- Перевірка: має бути Index Scan / Index Only Scan, а не Seq Scan + Sort
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, total FROM orders
WHERE user_id = 42 AND status = 'paid'
ORDER BY created_at DESC LIMIT 20;
Правило лівого префікса з поясненням через структуру B-tree: записи відсортовані спершу по a, всередині рівних a по b, тому без a стрибнути до потрібного b неможливо.
Що колонки з умовою рівності йдуть першими, а колонка з діапазоном або сортуванням останньою: після діапазону решта індексу для пошуку не використовується.
Що селективність важлива, але порядок визначає перш за все набір запитів, які індекс має покривати.
Що покривний індекс, який містить усі потрібні колонки, дозволяє не читати таблицю взагалі: Index Only Scan у PostgreSQL, Using index у MySQL.
Що кожен індекс коштує на запис і памʼять, тому один складений індекс під кілька запитів часто кращий за кілька вузьких.
Казати, що порядок не має значення й індекс працює для будь-якої комбінації.
Створювати індекс (created_at, user_id) для запиту WHERE user_id = ? ORDER BY created_at: діапазон або сортування мають бути в кінці.
Створювати окремі індекси по кожній колонці й очікувати, що база обʼєднає їх так само ефективно, як складений.
Ставити першою колонку з двома значеннями на кшталт is_active лише тому, що вона є в кожному запиті, не перевіривши альтернативи.
Не дивитись EXPLAIN після створення індексу й вірити, що він використовується.
ПОРАДА

Хороша ілюстрація — телефонний довідник: за прізвищем шукати легко, за одним лише імʼям — ні. І назвіть правило: рівність, потім діапазон або сортування.

Сторінка питання →
SQL
SQL·Middle ·JSON ·jsonb ·GIN

У PostgreSQL зберігайте jsonb і індексуйте його GIN для пошуку по довільних ключах або B-tree на виразі `(data->>'key')` для конкретного поля; у MySQL індекс на JSON можливий лише через згенеровану колонку, функціональний індекс (8.0.13+) або multi-valued index (8.0.17+). Помилка — тримати в JSON поля, які є в кожному рядку і за якими фільтрують чи джойнять: там потрібні звичайні колонки з типом, NOT NULL і зовнішнім ключем.

Чим json відрізняється від jsonb і що брати за замовчуванням?
Як прискорити `WHERE data->>'status' = 'paid'`, якщо в таблиці мільйон рядків?
Чому в MySQL не можна просто повісити індекс на колонку типу JSON?
Ми тримаємо всі атрибути товару в JSON-полі — що з цим не так?

У PostgreSQL є два типи: json зберігає текст документа дослівно — з пробілами, порядком і навіть дублікатами ключів, — і парсить його наново при кожному зверненні; jsonb розбирає документ один раз на запис у бінарне подання, де ключі відсортовані й унікальні. Практично завжди потрібен jsonb: тільки він підтримує оператори @>, ?, ?|, ?& і тільки його можна проіндексувати GIN. Тип json виправданий хіба що для сирого логування, де важливо зберегти байти як прийшли. У MySQL тип один — JSON (з 5.7.8), він теж бінарний і теж нормалізує документ, а перевірка валідності відбувається на вставці.

Індексація йде двома різними шляхами, і сильна відповідь називає обидва. GIN по jsonb індексує вміст документа й відповідає на питання «чи містить документ ось цей фрагмент» (meta @> '{"source":"webhook"}') та «чи є такий ключ» (meta ? 'source'); з PG 12 туди ж потрапляють jsonpath-оператори @? і @@. Клас операторів jsonb_path_ops індексує хеші повних шляхів: індекс менший і швидший, але вміє лише containment. Якщо ж запит завжди звертається до одного відомого поля, GIN зайвий — потрібен звичайний B-tree на виразі: CREATE INDEX ON orders ((meta->>'utm_source')). Такий індекс, на відміну від GIN, дає ще й статистику по виразу після ANALYZE, тому планувальник перестає вгадувати кардинальність.

MySQL прямий індекс на JSON-колонці забороняє взагалі. Класичний шлях — згенерована колонка: ADD COLUMN utm_source VARCHAR(64) AS (meta->>'$.utm_source') STORED плюс індекс на ній; VIRTUAL теж індексується і не займає місця в рядку. З 8.0.13 те саме можна записати функціональним індексом з обовʼязковим CAST, але всередині MySQL усе одно створює приховану віртуальну колонку. Для масивів з 8.0.17 є multi-valued index — єдиний випадок, коли один рядок дає кілька записів в індексі; він працює з MEMBER OF, JSON_CONTAINS і JSON_OVERLAPS, але не годиться для сортування, унікальності й первинного ключа. Головна пастка в обох варіантах — типи й колація: вираз в індексі має збігатися з виразом у WHERE посимвольно, інакше EXPLAIN мовчки покаже ALL.

Ціна JSON платиться на записі й на читанні великих документів. У PostgreSQL будь-який UPDATE через MVCC створює нову версію рядка цілком — часткового оновлення поля всередині jsonb не існує; документ більший за пару кілобайтів їде в TOAST у стиснутому вигляді, і щоб дістати один ключ, його треба прочитати й розтиснути повністю. GIN додає помітну вартість вставки й має pending list, через який щойно записані рядки шукаються повільніше, доки не відпрацює чистка. У MySQL оновлення на місці можливе, але лише для JSON_SET, JSON_REPLACE і JSON_REMOVE і лише якщо документ не зростає; будь-яка інша зміна переписує значення повністю.

Помилка починається там, де JSON заміняє схему. Якщо поле є в кожному рядку, має тип, за ним фільтрують, сортують чи джойнять — це колонка, а не ключ у документі: інакше ви втрачаєте NOT NULL, зовнішній ключ, нормальну статистику, а помилка в назві ключа не викликає помилки взагалі, meta->>'statuss' тихо повертає NULL. Обмеження частково рятують — CHECK (jsonb_typeof(meta->'items') = 'array'), CHECK (meta ? 'version'), унікальний індекс на виразі, у MySQL CHECK на JSON-функціях з 8.0.16, — але вони не замінять зовнішній ключ. Розумна межа проста: у колонки виносимо все обовʼязкове й запитуване, у JSON лишаємо розріджені атрибути, payload зовнішніх систем, снапшоти й налаштування, форма яких змінюється швидше, ніж ви готові писати міграції.

-- PostgreSQL: тільки jsonb, json індексувати не можна
CREATE TABLE orders (
    id      bigserial PRIMARY KEY,
    user_id bigint NOT NULL REFERENCES users(id),  -- завжди є → звичайна колонка
    status  text   NOT NULL,                       -- фільтруємо → звичайна колонка
    meta    jsonb  NOT NULL DEFAULT '{}'           -- сюди тільки змінне
);

-- GIN: пошук по довільному ключу через containment @> і наявність ключа ?
CREATE INDEX orders_meta_gin ON orders USING gin (meta);
SELECT id FROM orders WHERE meta @> '{"source":"webhook"}';

-- jsonb_path_ops: менший і швидший, але лише @> (без оператора ?)
CREATE INDEX orders_meta_path ON orders USING gin (meta jsonb_path_ops);

-- B-tree на виразі: під рівність/діапазон/сортування по одному полю
-- (дає ще й статистику для планувальника після ANALYZE)
CREATE INDEX orders_meta_utm ON orders ((meta->>'utm_source'));
SELECT id FROM orders WHERE meta->>'utm_source' = 'google';

-- Число з JSON порівнюємо після приведення, не як рядок
CREATE INDEX orders_meta_amount ON orders (((meta->>'amount')::numeric));

-- MySQL 8: прямий індекс на JSON заборонений, потрібна згенерована колонка
ALTER TABLE orders
    ADD COLUMN utm_source VARCHAR(64)
        AS (meta->>'$.utm_source') STORED,
    ADD INDEX idx_utm (utm_source);

-- Або функціональний індекс (8.0.13+): CAST обовʼязковий
CREATE INDEX idx_utm_fn ON orders ((CAST(meta->>'$.utm_source' AS CHAR(64))));

-- Multi-valued index для масиву (8.0.17+): працює з MEMBER OF / JSON_CONTAINS
ALTER TABLE orders ADD INDEX idx_tags ((CAST(meta->'$.tags' AS CHAR(32) ARRAY)));
SELECT id FROM orders WHERE 'urgent' MEMBER OF(meta->'$.tags');
Різницю json і jsonb: json зберігає текст як є (пробіли, порядок і дублікати ключів), jsonb — розібране бінарне подання, ключі відсортовані й унікальні, парсинг на запис, а не на читання; індексувати можна лише jsonb.
Що GIN індексує вміст документа й обслуговує оператори `@>`, `?`, `?|`, `?&` (і `@?`/`@@` з jsonpath у PG 12+), а B-tree на виразі `(data->>'key')` — це звичайний індекс під рівність, діапазон і сортування по одному полю.
Що в MySQL індекс на колонці JSON створити не можна: потрібна STORED/VIRTUAL generated column з індексом, функціональний індекс з обовʼязковим CAST (8.0.13+) або multi-valued index для масивів під `MEMBER OF`, `JSON_CONTAINS`, `JSON_OVERLAPS` (8.0.17+).
Що JSON коштує на запис: у PostgreSQL UPDATE переписує весь рядок через MVCC, великий jsonb їде в TOAST і читання одного ключа розтискає весь документ; у MySQL часткове оновлення на місці працює лише для JSON_SET/JSON_REPLACE/JSON_REMOVE і лише якщо документ не зростає.
Критерій вибору: JSON — для розріджених, різнорідних або зовнішніх даних (payload вебхука, налаштування, снапшот); звичайні колонки — для того, що є завжди, має тип, обмеження, зовнішній ключ і бере участь у фільтрах та джойнах.
Що планувальник погано оцінює селективність по JSON: для виразу `data->>'key'` статистики немає, поки не створено індекс на цьому ж виразі (PG) або згенеровану колонку (MySQL), тому оцінка кардинальності буває на порядки хибною.
Обрати тип `json` замість `jsonb` у PostgreSQL «бо коротша назва»: по `json` не можна побудувати GIN і кожне читання ключа заново парсить текст.
У MySQL написати `CREATE INDEX ... ON t (data)` для JSON-колонки й здивуватися помилці: прямий індекс на JSON заборонений (як і на BLOB/TEXT без довжини префікса).
Створити GIN-індекс і чекати, що він прискорить `data->>'status' = 'paid'`: GIN обслуговує `@>` і `?`, а не `->>`; або запит переписують на `data @> '{"status":"paid"}'`, або будують B-tree на виразі.
У MySQL зробити функціональний індекс без CAST і без узгодженої колації: `((data->>'$.email'))` не приймається, потрібен `CAST(data->>'$.email' AS CHAR(191))` і той самий COLLATE, що й у запиті, інакше індекс мовчки не використається.
Класти в JSON `user_id`, `status`, `created_at` — поля, які є в кожному рядку: втрачаються NOT NULL, зовнішній ключ, тип і нормальна статистика, а кожен запит обростає кастами.
Порівнювати числа з JSON як рядки: `data->>'price' > '100'` — це лексикографічне порівняння, потрібен явний `(data->>'price')::numeric` у PG або CAST у MySQL.
ПОРАДА

Сформулюйте правило одним реченням: «JSON — для того, чого ми не знаємо заздалегідь; колонки — для того, за чим фільтруємо». А далі покажіть, що знаєте обидва шляхи індексації: GIN для пошуку по довільному ключу, B-tree на виразі — для конкретного, і що в MySQL це завжди generated column під капотом.

Сторінка питання →
Прогрес карток і тестів зберігається у профілі. Створити профіль·Увійти