vyvchy
    Теми розділу

    05 · Бази даних і SQL для QA

    Індекси і швидкодія запитів

    Зміст

    Той самий запит на локальній базі з тисячею рядків виконується за мілісекунди, а на проді, де рядків десятки мільйонів, — за півхвилини, і сторінка «висить». Автотест зелений, ручна перевірка на стейджі зелена, а користувач бачить спінер і йде. Різниця майже завжди в одному: чи є на потрібних колонках індекс (index) і чи вміє база ним скористатися. Ця тема — головна причина, чому «працює, але повільно» — це теж дефект, і чому питання про індекси й EXPLAIN — майже гарантований блок на співбесідах рівня middle.

    Якщо ти проходиш SQL уперше й тобі поки досить писати запити, цю главу можна відкласти й повернутися пізніше. Але це канонічний дім теми швидкодії запитів на сайті — інші глави (транзакції, ізоляція, тестування БД) посилаються сюди, коли заходить мова про план виконання чи «чому запит гальмує». Домовимось про демо-таблицю: orders з колонками id, user_id, email, status (new/paid/cancelled), amount, created_at. У ній 50 мільйонів рядків — саме той масштаб, на якому все стає видно.

    Що таке індекс і чому він швидкий

    Уяви телефонну книгу, відсортовану за прізвищем. Знайти «Петренка» — кілька секунд: відкрив приблизно посередині, звузив, звузив ще. А тепер уяви ту саму книгу, але записи в ній ідуть у випадковому порядку. Єдиний спосіб знайти Петренка — читати підряд усі сторінки. Це і є різниця між пошуком по індексу й послідовним скануванням (sequential scan) таблиці: у першому випадку дані впорядковані, у другому — ні.

    Індекс — це окрема допоміжна структура, яку база тримає поряд із таблицею й підтримує впорядкованою за значеннями однієї чи кількох колонок. Найпоширеніший тип індексу (і тип за замовчуванням у PostgreSQL, MySQL та більшості СУБД) — B-дерево (B-tree), збалансоване дерево пошуку. Слово «збалансоване» тут ключове: усі листки лежать на однаковій глибині, тож пошук будь-якого значення коштує однаково — приблизно log(n) кроків, а не n.

    до 40

    40 – 80

    понад 80

    Корінь
    40 · 80

    Вузол
    10 · 25

    Вузол
    55 · 70

    Вузол
    90 · 120

    Лист: покажчик
    на рядок 70

    до 40

    40 – 80

    понад 80

    Корінь
    40 · 80

    Вузол
    10 · 25

    Вузол
    55 · 70

    Вузол
    90 · 120

    Лист: покажчик
    на рядок 70

    Щоб знайти рядок зі значенням 70, база не читає 50 мільйонів рядків. Вона спускається деревом: у корені 70 потрапляє між 40 і 80, іде в середню гілку; там натрапляє на 70 і по покажчику стрибає прямо в потрібний рядок таблиці. Три-чотири порівняння замість мільйонів. На практиці дерево на десятки мільйонів рядків має глибину лише 3–5 рівнів.

    Друга суперсила B-дерева — воно тримає значення впорядкованими, тому пришвидшує не лише точний пошук (=), а й діапазони (<, >, BETWEEN), сортування (ORDER BY) і пошук за початком рядка (LIKE 'abc%'). Нюанс PostgreSQL: якщо база не в C-локалі, для LIKE-префіксів звичайний індекс не спрацює — його створюють з операторним класом text_pattern_ops. А от LIKE '%abc' — з провідним відсотком — B-дерево вже не рятує: почати спуск немає від чого.

    Індекси «безкоштовно»: PK і UNIQUE

    Хороша новина: частину індексів ти вже маєш, навіть якщо жодного не створював руками. Коли ти оголошуєш первинний ключ (primary key) або обмеження UNIQUE (див. главу про DDL і обмеження цілісності), база автоматично будує під нього унікальний індекс. Інакше й бути не може: щоб на кожному вставленому рядку перевіряти «а такого значення ще немає?», потрібен швидкий пошук — тобто той самий індекс. Тому запити з фільтром по id чи по унікальному email майже завжди швидкі за замовчуванням.

    А от зовнішній ключ (foreign key) — пастка. Тут поведінка різних СУБД розходиться, і це любить питати інтерв'юер:

    • У PostgreSQL оголошення FOREIGN KEY індекс не створює. Індексовано лише ту сторону, куди FK посилається (первинний ключ батьківської таблиці). Тому JOIN чи фільтр по FK-колонці (WHERE user_id = 42) може несподівано впертися в послідовне сканування, поки ти не додаси індекс руками.
    • У MySQL (InnoDB) індекс під FK створюється автоматично — рушій цього вимагає.

    Практичний висновок для QA: наявність зв'язку між таблицями (див. реляційну модель) ще не означає, що запити по цьому зв'язку швидкі. На PostgreSQL це треба перевіряти окремо.

    Clustered vs non-clustered

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

    Кластерний індекс (clustered index) визначає порядок, у якому рядки таблиці фізично лежать на диску. Тому він може бути лише один на таблицю — не можна відсортувати ті самі дані двома способами одночасно. Пошук по кластерному індексу приводить одразу до рядка: покажчик і є самі дані.

    Некластерний (non-clustered), або вторинний, індекс — окрема структура, впорядкована за своєю колонкою, у листках якої лежать покажчики на рядки. Таких може бути багато. Ціна — зайвий крок: спершу знаходимо покажчик у вторинному індексі, потім по ньому дістаємо сам рядок.

    Як це реалізовано, залежить від СУБД, і це варто знати точно:

    СУБДЯк зберігається таблицяНюанс
    MySQL (InnoDB)Уся таблиця — це кластерний індекс за первинним ключемВторинний індекс зберігає значення PK; пошук по ньому = два спуски (індекс → PK)
    SQL ServerPK за замовчуванням стає кластерним індексомМожна зробити некластерним явно
    PostgreSQLТаблиця — «купа» (heap), кластерних індексів у цьому сенсі немаєУсі індекси вторинні; команда CLUSTER робить разове фізичне впорядкування, яке далі не підтримується

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

    Складений індекс і лівий префікс

    Індекс можна побудувати не на одній колонці, а на кількох — це складений (composite) індекс. Його значення впорядковані так само, як слова в словнику: спершу за першою колонкою, за однакової першої — за другою, і так далі. Індекс на (user_id, created_at) лежить відсортований за user_id, а всередині кожного user_id — за created_at.

    Звідси випливає правило лівого префікса (leftmost prefix): складений індекс працює лише тоді, коли запит фільтрує за колонками зліва направо без пропусків. Індекс на (a, b, c) допоможе запиту з умовою по a; по a та b; по a, b і c. Але він не допоможе, якщо фільтрувати тільки по b чи тільки по c — почати спуск деревом, не знаючи a, неможливо. Це як шукати в словнику слово, знаючи лише його третю літеру.

    Тому порядок колонок в індексі — не косметика, а рішення. Загальне правило: колонки з умовою на рівність (=) ставлять ліворуч, колонку з діапазоном (<, >, BETWEEN) чи сортуванням — праворуч. Індекс на (user_id, created_at) ідеально лягає на типовий запит «останні замовлення користувача»:

    SELECT * FROM orders
    WHERE user_id = 42
    ORDER BY created_at DESC
    LIMIT 20;

    База спускається до гілки user_id = 42 й читає вже відсортовані по created_at рядки — сортувати окремо не треба.

    Коли індекс не рятує

    Індекс — не чарівна паличка. Ось найчастіші випадки, коли він є, а запит усе одно повзе (усе це видно в EXPLAIN):

    • Функція чи вираз над колонкою. WHERE lower(email) = 'a@b.com' не скористається звичайним індексом на email: індекс зберігає оригінальні значення, а не результат lower(). Рятує окремий функціональний індекс саме на lower(email).
    • Неявне приведення типів. Якщо phone — рядок, а в запиті WHERE phone = 380501234567 (число), база спершу перетворить типи й індекс може «відклеїтися». Порівнюй сумісні типи.
    • Провідний символ підстановки. LIKE '%abc' та LIKE '%abc%' — B-дерево тут безпорадне, бо невідомий початок значення.
    • Низька селективність (selectivity). Якщо WHERE status = 'active', а таких рядків 90% таблиці, планувальник свідомо обере послідовне сканування: прочитати всю таблицю дешевше, ніж мільйони разів стрибати «індекс → рядок → індекс → рядок». Індекс окупається, коли умова відсіює більшість рядків.
    • Заперечення. !=, NOT IN, IS NOT NULL часто не звужують вибірку достатньо, щоб індекс мав сенс.
    • Мала таблиця. На кількох сотнях рядків seq scan просто швидший, і це правильний вибір.

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

    Скільки коштує індекс

    Якщо індекси такі корисні, чому не навісити їх на кожну колонку? Бо кожен індекс — це борг, який доводиться обслуговувати на запис. Коли ти робиш INSERT, UPDATE чи DELETE (див. главу про зміну даних), база мусить оновити не лише рядок, а й кожен індекс, що його зачіпає. П'ять індексів на таблиці — п'ять додаткових структур для перебудови на кожній вставці.

    Ціна індексів складається з трьох частин:

    1. Повільніший запис. Що більше індексів, то дорожчі INSERT/UPDATE/DELETE. Для таблиць із інтенсивним записом це відчутно.
    2. Місце на диску. Індекс на велику таблицю сам може важити гігабайти.
    3. Робота планувальнику й обслуговування. Зайві, дубльовані чи невикористовувані індекси база все одно розглядає, зберігає й підтримує актуальними — чиста втрата без користі.

    Тому індекс — це завжди компроміс «швидше читання ↔ повільніший запис». Правильний набір індексів визначають за реальними запитами, а не «про запас».

    EXPLAIN: seq scan проти index scan

    Головний інструмент, щоб побачити, а не гадати, — команда EXPLAIN. Вона показує план виконання (query plan): як саме база збирається виконати запит, які сканування й з'єднання застосує. EXPLAIN лише оцінює план і нічого не виконує; EXPLAIN ANALYZE реально запускає запит і показує фактичний час і кількість рядків — саме його беруть, коли треба зрозуміти справжню швидкодію.

    Порівняй два плани того самого запиту. Без індексу на email:

    Seq Scan on orders  (cost=0.00..1043210.00 rows=1 width=64)
      Filter: (email = 'a@b.com')
    

    Seq Scan означає, що база читає всю таблицю рядок за рядком і залишає ті, що проходять Filter. На 50 мільйонах рядків це дорого. Тепер із індексом:

    Index Scan using orders_email_idx on orders  (cost=0.56..8.58 rows=1 width=64)
      Index Cond: (email = 'a@b.com')
    

    Index Scan каже: база спустилася деревом за Index Cond і дістала лише потрібне. Зверни увагу на числа cost: 1043210 проти 8.58. Це не мілісекунди, а абстрактні одиниці вартості, які планувальник використовує, щоб порівнювати плани між собою; для реального часу потрібен EXPLAIN ANALYZE. Але навіть за абстрактними одиницями видно різницю на п'ять порядків.

    Ні

    Так

    Так, збігів мало

    Ні, збігів багато

    Запит із WHERE

    Є придатний
    індекс?

    Seq Scan:
    читаємо всю таблицю

    Умова відсіює
    більшість рядків?

    Index Scan:
    точковий доступ

    Ні

    Так

    Так, збігів мало

    Ні, збігів багато

    Запит із WHERE

    Є придатний
    індекс?

    Seq Scan:
    читаємо всю таблицю

    Умова відсіює
    більшість рядків?

    Index Scan:
    точковий доступ

    Що бачити QA в плані:

    • Seq Scan на великій таблиці там, де ти очікував index scan — перший підозрюваний. Або індексу немає, або запит написаний так, що ним не скористатися (функція, приведення типів).
    • rows — оцінка, і вона розходиться з реальністю. Якщо в EXPLAIN ANALYZE планувальник чекав 10 рядків, а прийшло 2 мільйони, статистика застаріла — план обрано хибний.
    • Порядок JOIN і тип з'єднання (Nested Loop, Hash Join) — для повільних запитів із багатьма таблицями (див. JOIN) це часто і є вузьке місце.

    Повільний запит — це баг

    Швидкодія — не «приємний бонус», а нефункціональна вимога. Стандарт якості продукту ISO/IEC 25010 виносить продуктивність (performance efficiency, зокрема time behaviour) в окрему характеристику якості нарівні з функціональністю. Тобто запит, який повертає правильний результат за 30 секунд, — це дефект, і заводити його треба як баг, а не мовчки чекати.

    Для QA це означає конкретну дисципліну. По-перше, обсяг даних — частина умов відтворення. Найпідступніший клас таких багів невидимий на малій тестовій базі: на тисячі рядків seq scan миттєвий, і проблема вилазить лише на прод-масштабі. Тому перевірку швидкодії роблять на реалістичних обсягах, а не на десятку рядків із фікстури.

    По-друге, баг-репорт про повільний запит підсилюється планом виконання. Замість «сторінка замовлень довго вантажиться» — конкретика: сам запит, вивід EXPLAIN ANALYZE, видимий Seq Scan на мільйонах рядків, гіпотеза про відсутній індекс. Такий репорт розробник закриває швидко, бо причина вже на столі.

    Розуміння планів корисне й в автотестах: маючи підключення до бази поряд із Playwright (див. главу про тестові дані і БД в автотестах), можна поставити гард, який ловить регресію продуктивності — коли після зміни запиту план несподівано скочується в Seq Scan:

    // Гард: у плані критичного запиту не має з'явитися Seq Scan
    const { rows } = await client.query(
      'EXPLAIN (FORMAT JSON) SELECT * FROM orders WHERE user_id = $1',
      [userId],
    );
    // шукаємо по всьому дереву: зверху може стояти Limit, Gather тощо
    const hasSeqScan = (node: any): boolean =>
      node['Node Type'] === 'Seq Scan' || (node.Plans ?? []).some(hasSeqScan);
    expect(hasSeqScan(rows[0]['QUERY PLAN'][0].Plan)).toBe(false);

    Глибше навантаження й пошук вузьких місць під конкурентним трафіком — це вже окрема дисципліна (performance testing) з власними інструментами; тут ідеться про одиничний запит і його план.

    Типові помилки

    • Виглядає як «додав індекс, а швидше не стало» — а насправді індексом не можна скористатися: функція над колонкою (lower(col)), неявне приведення типів або надто неселективна умова. Дивись EXPLAIN: якщо там Seq Scan, індекс не задіяний.
    • Виглядає як «cost у EXPLAIN — це мілісекунди» — а насправді це абстрактні одиниці для порівняння планів. Реальний час дає лише EXPLAIN ANALYZE.
    • Виглядає як «на локальній базі швидко, отже продуктивність ок» — а насправді на кількох сотнях рядків seq scan і так миттєвий; відсутній індекс вистрілить тільки на прод-обсягах.
    • Виглядає як «FK автоматично має індекс» — а насправді у PostgreSQL зовнішній ключ індексу не створює (лише сторона, куди він посилається); у MySQL InnoDB — створює.
    • Виглядає як «індекс на (a, b) пришвидшить пошук по b» — а насправді працює лише лівий префікс: по a або по a та b, але не по самому b.
    • Виглядає як «більше індексів — завжди краще» — а насправді кожен індекс сповільнює запис і їсть місце; зайві індекси — чистий борг.

    Підсумок

    • Індекс — це впорядкована допоміжна структура (зазвичай B-дерево); вона пришвидшує читання ціною сповільнення запису й місця на диску. Це завжди компроміс.
    • PK і UNIQUE дають індекс автоматично; у PostgreSQL зовнішній ключ — ні, і його часто треба індексувати руками.
    • Складений індекс працює за правилом лівого префікса — порядок колонок в індексі вирішує, які запити він пришвидшить.
    • EXPLAIN показує оцінку плану, EXPLAIN ANALYZE — реальний час; Seq Scan на великій таблиці там, де очікувався index scan, — головний сигнал проблеми.
    • Індекс не рятує при функції над колонкою, приведенні типів, провідному % у LIKE й низькій селективності — база тоді свідомо обирає послідовне сканування.
    • Повільний запит — це дефект (нефункціональна вимога), а обсяг даних — частина умов його відтворення.

    Що питають на співбесіді

    • «Що таке індекс і як він працює?» — хочуть почути про B-дерево, впорядкованість, log(n) замість повного сканування, і обов'язково — про компроміс «швидше читання, повільніший запис». Відповідь без tradeoff читається як зубріння.
    • «Додали індекс, а запит не пришвидшився — чому?» — перевіряють, чи знаєш ти про функції над колонкою, приведення типів і селективність, і чи здогадаєшся подивитися в EXPLAIN.
    • «Чим clustered відрізняється від non-clustered?» — фізичний порядок рядків проти окремої структури з покажчиками; бонус — назвати, як це в InnoDB і PostgreSQL.
    • «Що таке лівий префікс складеного індексу?» — класика; хочуть приклад, чому індекс на (a, b) не працює для фільтра тільки по b.
    • «Як зрозуміти, чому запит повільний?» — очікують EXPLAIN ANALYZE, пошук Seq Scan на великій таблиці й гіпотезу про відсутній індекс.

    На що дивиться інтерв'юер: чи розумієш ти, що індекс — це компроміс, а не безкоштовне прискорення; чи вмієш читати план виконання; і чи сприймаєш повільний запит як баг, який треба завести з планом у баг-репорті, а не як «ну, трохи гальмує».

    Джерела

    • PostgreSQL Documentation — Indexes — типи індексів, багатоколонкові індекси, унікальність.
    • PostgreSQL Documentation — Using EXPLAIN — як читати план виконання, seq scan vs index scan, EXPLAIN ANALYZE.
    • MySQL Reference Manual — How MySQL Uses Indexes — як оптимізатор використовує індекси: лівий префікс складеного індексу, покривні (covering) індекси, коли seq scan вигідніший.
    • Use The Index, Luke (Markus Winand) — канонічний безкоштовний посібник з індексування й B-дерев для розробників і тестувальників.
    • Продуктивність як характеристика якості — модель ISO/IEC 25010 (performance efficiency: time behaviour).