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

Питання на співбесіді: Продуктивність запитів

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

16 питань

EXPLAIN показує, як база збирається виконати запит: у якому порядку читати таблиці, чи використовувати індекси, як з'єднувати. Сам запит при цьому не виконується.

EXPLAIN SELECT * FROM orders WHERE user_id = 5;
Index Scan using orders_user_id_idx on orders  (cost=0.43..8.45 rows=3 width=64)
  Index Cond: (user_id = 5)

Що шукати новачку:

  • Seq Scan (PostgreSQL) / type: ALL (MySQL) на великій таблиці - повний перегляд таблиці. Часто ознака відсутнього індексу.
  • Index Scan / Index Only Scan (PostgreSQL), type: ref, range (MySQL) - використано індекс.
  • rows - скільки рядків база очікує. Якщо оцінка сильно відрізняється від реальності, план може бути поганим.
  • Sort з великою кількістю рядків чи Using filesort у MySQL - сортування, яке не взяли з індексу.

cost - умовні одиниці вартості, не мілісекунди. Перше число - вартість до першого рядка, друге - до останнього.

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

Наступний крок - EXPLAIN ANALYZE, який виконує запит і показує реальний час і кількість рядків.

Докладніше в документації: Використання EXPLAIN

SELECT * повертає всі колонки, навіть ті, що коду не потрібні. На маленьких таблицях різниці немає, але проблеми з'являються з ростом:

  • Зайві дані через мережу й у пам'ять. Колонка body TEXT чи payload JSON на сотні кілобайтів, вибрана для списку із 50 заголовків, множить обсяг у рази.
  • Не працює покривний індекс. Якщо запиту потрібні лише id і status, які є в індексі, база могла б не звертатися до таблиці. З * мусить.
  • Крихкість. Додали колонку - і запит раптом повертає більше даних; у INSERT ... SELECT * чи UNION зміна схеми ламає запит.
  • Читабельність. З коду не видно, які дані реально використовуються.
-- Для списку статей
SELECT id, title, published_at FROM posts ORDER BY published_at DESC LIMIT 20;

У Laravel Eloquent за замовчуванням робить select *. Для важких таблиць варто явно обмежувати колонки: Post::select(['id', 'title', 'published_at']), а для зв'язків - with('author:id,name').

Коли * нормальний: разові запити в консолі, EXISTS (SELECT * ...) (там колонки не читаються взагалі), і COUNT(*), який з вибором колонок узагалі не пов'язаний.

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

У 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(*). Laravel simplePaginate() чи 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).

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

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

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at LIMIT 20;
Limit  (actual time=812.4..812.5 rows=20 loops=1)
  ->  Sort  (actual time=812.4..812.4 rows=20 loops=1)
        Sort Method: top-N heapsort
        ->  Seq Scan on orders  (rows=1200 ... actual rows=950000 loops=1)
              Filter: (status = 'pending')
              Rows Removed by Filter: 4050000
Buffers: shared read=73000

На що дивитися:

  • Оцінка проти факту: rows=1200 очікувалося, actual rows=950000 вийшло. Велика розбіжність означає застарілу статистику (ANALYZE orders) або корельовані колонки - і планувальник обирає поганий план.
  • Rows Removed by Filter - база прочитала мільйони рядків, щоб відкинути більшість. Підказка до індексу.
  • loops - скільки разів виконувався вузол. Час у вузлі вказано за один прохід: 0.5 мс × 20 000 loops у Nested Loop - це 10 секунд.
  • BUFFERS - скільки сторінок прочитано з кешу (hit) і з диска (read).

Обережно: EXPLAIN ANALYZE справді виконує запит. Для UPDATE/DELETE загортайте його в транзакцію з ROLLBACK.

MySQL має EXPLAIN ANALYZE з версії 8.0.18 - вивід у вигляді дерева з фактичним часом.

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

LIMIT 20 OFFSET 100000 не «перескакує» на потрібне місце. База мусить знайти й відкинути перші 100 000 рядків і лише потім віддати 20. Чим далі сторінка, тим довший запит - навіть з індексом.

Друга проблема - зсув: якщо між запитами сторінок з'явилися нові записи, частина рядків повториться на наступній сторінці, а частина пропаде.

Keyset (cursor) пагінація - замість номера сторінки запам'ятовують значення останнього рядка й продовжують від нього:

-- Перша сторінка
SELECT id, title, created_at FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- Наступна: від останнього показаного рядка
SELECT id, title, created_at FROM posts
WHERE (created_at, id) < ('2026-10-01 12:00:00', 4821)
ORDER BY created_at DESC, id DESC
LIMIT 20;

З індексом (created_at, id) кожна сторінка - кілька кроків по індексу, хоч перша, хоч мільйонна. id додано для однозначності, коли created_at однаковий.

Обмеження keyset: немає переходу на довільну сторінку «47» і загальної кількості сторінок. Для стрічок, нескінченного скролу, API й експорту це не проблема.

У Laravel: cursorPaginate() реалізує саме keyset, paginate() - OFFSET плюс окремий COUNT(*), simplePaginate() - OFFSET без підрахунку.

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

PostgreSQL має три алгоритми з'єднання таблиць, і планувальник обирає один для кожного JOIN за оцінками кількості рядків.

Nested Loop - для кожного рядка зовнішньої таблиці шукати відповідні рядки у внутрішній.

  • Чудовий, коли зовнішніх рядків мало, а у внутрішній таблиці є індекс на ключі з'єднання: 20 замовлень → 20 пошуків по індексу користувачів.
  • Катастрофічний, коли зовнішніх рядків багато: мільйон × пошук = мільйон звернень.

Hash Join - побудувати хеш-таблицю з меншого набору, потім пройти більший і шукати збіги в хеш-таблиці.

  • Для великих наборів без потрібного порядку - типовий вибір для аналітики й звітів.
  • Лише для умов рівності (=).
  • Хеш-таблиця будується в пам'яті (work_mem × hash_mem_multiplier); якщо не вміщується - ділиться на частини на диску, і з'єднання сповільнюється.

Merge Join - обидва набори впорядковані за ключем з'єднання, і їх «зшивають» за один прохід, як дві відсортовані колоди.

  • Ефективний, коли обидва набори вже відсортовані (наприклад, читаються з індексів) або результат усе одно потрібен відсортованим.
  • Якщо сортування треба робити окремо, часто програє Hash Join.

Як читати в EXPLAIN ANALYZE:

Nested Loop  (actual rows=950000 loops=1)
  -> Seq Scan on orders (actual rows=950000)
  -> Index Scan using users_pkey on users (actual rows=1 loops=950000)

loops=950000 - сигнал: планувальник очікував кілька рядків зовні, а отримав майже мільйон. Майже завжди причина - помилкова оцінка кількості рядків (застаріла статистика, корельовані умови), а не сам алгоритм.

Виправляють не алгоритм, а оцінки: ANALYZE, розширена статистика, переписаний запит. Для перевірки гіпотези в сесії можна вимкнути алгоритм (SET enable_nestloop = off), але на проді так не залишають.

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

work_mem - скільки пам'яті може використати одна операція запиту: сортування (ORDER BY, DISTINCT, Merge Join), хеш-таблиця (Hash Join, GROUP BY через хеш). За замовчуванням - 4 МБ.

Якщо даних більше, операція переходить на тимчасові файли на диску - на порядки повільніше.

Як побачити в плані:

Sort Method: external merge  Disk: 285440kB     -- сортування на диску
Sort Method: quicksort  Memory: 3120kB          -- у пам'яті
Hash ... Batches: 16  Memory Usage: 4096kB      -- хеш поділено на частини через нестачу пам'яті

Ще - лог тимчасових файлів (log_temp_files = 0) і temp_files/temp_bytes у pg_stat_database.

Чому не поставити 1 ГБ - пастка множення. Ліміт діє на кожну операцію, а не на з'єднання:

  • один запит може мати кілька сортувань і хешів одночасно;
  • паралельний запит множить на кількість воркерів;
  • хеш-операції можуть брати work_mem × hash_mem_multiplier (за замовчуванням ×2);
  • і все це - на кожне з сотень з'єднань.

200 з'єднань × 3 операції × 256 МБ = 150 ГБ потенційно - сервер піде в OOM під навантаженням.

Як налаштовувати:

  • Глобально - помірне значення (16-64 МБ залежно від пам'яті й кількості з'єднань).
  • Точково більше - для важких звітів і аналітики:
SET LOCAL work_mem = '512MB';   -- лише в поточній транзакції
ALTER ROLE reporting SET work_mem = '256MB';
  • Для обслуговування (CREATE INDEX, VACUUM) - окремий параметр maintenance_work_mem.

Але спершу - сам запит: часто сортування великого набору не потрібне взагалі - вистачить індексу, що віддає рядки в потрібному порядку, чи фільтра, що зменшує набір до сортування.

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

shared_buffers - власний кеш сторінок даних PostgreSQL у спільній пам'яті. Кожне читання спершу шукає сторінку тут; якщо її немає - читає з файлу.

Подвійне кешування. PostgreSQL читає файли через звичайну файлову систему, тож сторінки, яких немає в shared_buffers, часто вже лежать у кеші ОС (page cache). Тому:

  • shared_buffers не має забирати всю пам'ять - решту ефективно використовує ОС;
  • типова рекомендація документації - близько 25% оперативної пам'яті на виділеному сервері бази (більше дає менший виграш, бо дублюється з кешем ОС);
  • effective_cache_size - підказка планувальнику, скільки кешу всього (shared_buffers + кеш ОС), зазвичай 50-75% пам'яті. Пам'ять він не виділяє, лише робить індексні плани «дешевшими» в оцінках.
# сервер з 32 ГБ, лише під PostgreSQL
shared_buffers = 8GB
effective_cache_size = 24GB

Значення за замовчуванням - лише 128 МБ - розраховане на запуск будь-де, а не на продуктивність. На сервері для продакшену його майже завжди треба змінити (і перезапустити PostgreSQL).

Як оцінити, чи вистачає кешу:

SELECT round(100.0 * blks_hit / nullif(blks_hit + blks_read, 0), 2) AS cache_hit_percent
FROM pg_stat_database
WHERE datname = current_database();

Для OLTP-навантаження зазвичай очікують 99%+. Але blks_read рахує читання повз shared_buffers, а ці сторінки могли прийти з кешу ОС - низький відсоток не завжди означає читання з диска. Точніше - EXPLAIN (ANALYZE, BUFFERS) для конкретних запитів і метрики вводу-виводу ОС.

Ще пов'язані параметри: huge_pages (для великих shared_buffers зменшує накладні витрати пам'яті), wal_buffers. У керованих базах (RDS, Cloud SQL) розумні значення вже виставлені під розмір інстанса.

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

Найповільніший спосіб - окремий INSERT на кожен рядок в autocommit: кожен рядок - окрема транзакція з записом у WAL і синхронізацією з диском.

Від швидкого до найшвидшого:

1. Одна транзакція і масові INSERT:

BEGIN;
INSERT INTO events (user_id, type, created_at) VALUES
    (1, 'login', now()), (2, 'login', now()), /* ...тисяча рядків... */;
-- ще порції
COMMIT;

Порції по 500-5000 рядків в одному INSERT - у десятки разів швидше за поодинокі. У Laravel - DB::table('events')->insert($batch).

2. COPY - спеціальний протокол масового завантаження, найшвидший спосіб:

COPY events (user_id, type, created_at) FROM '/data/events.csv' WITH (FORMAT csv, HEADER true);

COPY ... FROM читає файл на сервері бази; з клієнта - \copy у psql чи Pdo\Pgsql::copyFromFile() / copyFromArray() у PHP 8.4+ (раніше - методи pgsqlCopyFrom* на самому PDO).

3. Для величезних одноразових завантажень:

  • Створити індекси й зовнішні ключі після завантаження - побудувати індекс один раз швидше, ніж оновлювати його мільйон разів.
  • Збільшити maintenance_work_mem для швидшої побудови індексів.
  • UNLOGGED-таблиця для проміжних даних - без запису в WAL, але не переживає збій і не реплікується.
  • Тимчасово збільшити max_wal_size, щоб рідше робилися контрольні точки.

Після завантаження - обов'язково ANALYZE, інакше планувальник працюватиме зі статистикою порожньої таблиці. А для таблиць, куди більше не писатимуть, - VACUUM (FREEZE, ANALYZE), щоб пізніше не було масового «заморожування».

Обережно на проді: масове завантаження генерує багато WAL - репліки відстають, а сервер з архівуванням WAL заповнює сховище. Великі імпорти ділять на порції з паузами й стежать за затримкою реплікації.

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

Звичайне подання (view) - збережений запит: щоразу при зверненні він виконується заново.

Матеріалізоване подання зберігає результат запиту як таблицю. Читати його так само швидко, як таблицю, - але дані оновлюються лише за командою.

CREATE MATERIALIZED VIEW sales_by_month AS
SELECT date_trunc('month', created_at) AS month,
       product_id,
       sum(total) AS revenue,
       count(*) AS orders
FROM orders
WHERE status = 'paid'
GROUP BY 1, 2;

CREATE UNIQUE INDEX sales_by_month_key ON sales_by_month (month, product_id);

Оновлення:

REFRESH MATERIALIZED VIEW sales_by_month;                -- блокує читання на весь час оновлення
REFRESH MATERIALIZED VIEW CONCURRENTLY sales_by_month;   -- читання не блокується

CONCURRENTLY будує новий результат поруч, порівнює зі старим і застосовує різницю. Вимагає унікального індексу на поданні й працює довше, зате дашборди, що читають подання, не зависають.

Коли доречно:

  • Важкі агрегати для дашбордів і звітів, де допустимі дані «станом на кілька хвилин чи годин тому».
  • Денормалізовані дані для пошуку чи API, які дорого збирати з багатьох таблиць на кожен запит.

Обмеження:

  • Немає автоматичного оновлення - лише REFRESH за розкладом (cron, планувальник Laravel) чи за подією.
  • Оновлюється цілком: навіть при зміні одного рядка запит перераховується повністю. Для дуже великих агрегатів - інкрементальні підходи (окрема таблиця з оновленням за тригером чи пакетними задачами, розширення на кшталт pg_ivm).
  • Займає місце, як звичайна таблиця, і потребує індексів під свої запити.

Альтернатива на рівні застосунку - кеш результату запиту (Redis). Матеріалізоване подання краще, коли результат великий, його фільтрують і з'єднують з іншими таблицями прямо в SQL.

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

Повільний запит - не лише той, що виконується 5 секунд один раз. Часто більше шкодить запит на 20 мс, який виконується мільйон разів на день. Тому дивляться на сумарний час.

PostgreSQL - pg_stat_statements. Розширення збирає статистику по кожному нормалізованому запиту (параметри замінено на $1): кількість викликів, сумарний і середній час, прочитані блоки.

-- shared_preload_libraries = 'pg_stat_statements' у postgresql.conf, потім:
CREATE EXTENSION pg_stat_statements;

SELECT calls, round(total_exec_time) AS total_ms, round(mean_exec_time, 1) AS mean_ms, query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

Доповнення:

  • log_min_duration_statement = 500 - записувати в лог усі запити довші за 500 мс, з параметрами.
  • auto_explain - автоматично логувати плани повільних запитів. Без цього план на проді, де дані й статистика інші, ніж локально, часто не відтворити.
  • pg_stat_activity - що виконується просто зараз, хто кого чекає.

MySQL: slow query log (long_query_time), performance_schema і sys.statement_analysis - аналог pg_stat_statements.

На рівні застосунку APM (Sentry Performance, New Relic, Telescope локально) пов'язує запит з місцем у коді, яке його породило. Часто проблема - не сам запит, а N+1: сто швидких запитів там, де мав бути один.

Порядок дій: знайти топ за сумарним часом → EXPLAIN (ANALYZE, BUFFERS) на реальних параметрах → індекс, переписаний запит чи кеш → порівняти статистику після змін.

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

Кожне з'єднання з PostgreSQL - це окремий процес сервера з власною пам'яттю. Сотні з'єднань споживають багато ресурсів, а встановлення нового з'єднання помітно повільне. Тим часом PHP-FPM з 200 воркерами на кількох серверах легко відкриває тисячу з'єднань, більшість з яких простоює.

PgBouncer стоїть між застосунком і базою: тисячі клієнтських з'єднань він обслуговує невеликим пулом серверних.

Режими:

  • Session pooling - серверне з'єднання видається клієнту на всю сесію. Безпечно, але виграш малий.
  • Transaction pooling - з'єднання видається лише на час транзакції (або окремого запиту поза транзакцією). Головна економія - і головні сюрпризи.
  • Statement pooling - на окремий запит; транзакції з кількох запитів неможливі.

Що ламається в transaction pooling: усе, що прив'язане до сесії, бо наступний запит може піти іншим серверним з'єднанням:

  • SET (часовий пояс, search_path, statement_timeout) без LOCAL - налаштування «перетече» до іншого клієнта.
  • Сесійні advisory-блокування, LISTEN/NOTIFY, тимчасові таблиці.
  • Підготовлені запити (prepared statements) - PgBouncer підтримує їх на рівні протоколу лише з версії 1.21 і з налаштуванням max_prepared_statements. До того PDO з емуляцією вимкненою отримував помилки на кшталт prepared statement does not exist.

Альтернативи й доповнення: керовані пули (RDS Proxy, Supavisor у Supabase), довгоживучі процеси (Octane), що тримають сталі з'єднання, і просто обмеження кількості PHP-воркерів під реальну ємність бази.

Докладніше в документації: Можливості PgBouncer

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

Finalize Aggregate
  -> Gather  (Workers Planned: 2, Workers Launched: 2)
       -> Partial Aggregate
            -> Parallel Seq Scan on orders

Що вміє працювати паралельно: послідовне й індексне сканування, Hash Join і Nested Loop, агрегати, сортування з об'єднанням (Gather Merge), створення B-tree індексів.

Ключові налаштування:

  • max_parallel_workers_per_gather (за замовчуванням 2) - воркерів на один вузол запиту;
  • max_parallel_workers і max_worker_processes - загальні ліміти на сервер;
  • min_parallel_table_scan_size - менші таблиці не розпаралелюються;
  • parallel_setup_cost, parallel_tuple_cost - вартість запуску й передачі рядків, з якою планувальник порівнює виграш.

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

Коли не допомагає чи шкодить:

  • OLTP-запити (знайти користувача за ID) - запуск воркерів коштує більше, ніж сам запит; планувальник їх і не розпаралелить.
  • Високе паралельне навантаження: якщо сервер і так зайнятий сотнями запитів, воркери конкурують з ними за ядра. Сумарна пропускна здатність може впасти.
  • Запити, що змінюють дані (UPDATE, DELETE), майже не розпаралелюються, а INSERT ... SELECT - лише частково.
  • Функції, позначені PARALLEL UNSAFE (за замовчуванням для власних функцій), забороняють паралельний план.

Як діагностувати: Workers Planned проти Workers Launched у EXPLAIN ANALYZE - якщо запущено менше запланованого, на сервері забракло вільних воркерів.

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

Підготовлений запит (prepared statement) розбирається один раз, а виконується багато разів з різними параметрами. PDO і Laravel використовують їх для прив'язки параметрів.

PREPARE find_orders (text) AS SELECT * FROM orders WHERE status = $1;
EXECUTE find_orders('pending');

Для виконання PostgreSQL може використати два види плану:

  • Custom plan - будується для конкретних значень параметрів. Точний, але планування на кожне виконання коштує час.
  • Generic plan - один план для будь-яких значень, без урахування конкретних. Будується раз і перевикористовується.

Як PostgreSQL обирає (plan_cache_mode = auto): перші п'ять виконань - custom-плани. Потім він порівнює їхню середню вартість з вартістю generic-плану і, якщо generic не набагато гірший, переходить на нього.

Де це ламається - перекошені дані:

-- status = 'pending'   - 0.1% рядків → ідеальний Index Scan
-- status = 'completed' - 95% рядків → потрібен Seq Scan

Generic-план не знає, яке значення прийде, і обирає «середній» варіант. Запит, що спершу виконувався за мілісекунди, після п'ятого разу раптом стає повільним для частини значень. Класична загадка «запит повільний лише в застосунку, а в psql - швидкий» (у psql ви виконуєте його з конкретним значенням - custom план).

Що робити:

SET plan_cache_mode = force_custom_plan;   -- для сесії чи ролі

Або точково - для конкретних запитів з перекошеними даними.

Як діагностувати: EXPLAIN EXECUTE find_orders('completed') після шостого виконання показує, чи план уже generic (параметри в ньому виглядають як $1); auto_explain у логах продакшену.

Нюанс PHP: PDO з PDO::ATTR_EMULATE_PREPARES = true підставляє параметри сам і надсилає готовий текст - тоді на сервері підготовлених запитів немає взагалі, і проблеми generic-планів теж. Laravel за замовчуванням для PostgreSQL використовує справжні підготовлені запити.

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