✻ Урок 11.3 · Тема 11: Поиск узких мест
База данных: медленный запрос, EXPLAIN, индекс, пул соединений
Содержание урока
Зачем это нужно
В уроке 11.1 ты снял базовую линию «Магазина» и записал гипотезу: на 30 визитах в секунду страдает база. Самый медленный маршрут: GET /api/orders (p95 3,1 с). Распродажа через неделю, тест при тройной нагрузке всё ещё встаёт. Сегодня ты гипотезу проверишь и починишь, а заодно увидишь, как узкое место переезжает: уберёшь одну причину, и вылезет следующая.
База данных чаще всего оказывается узким местом веб-сервиса. Приложение можно запустить в десяти копиях, а база обычно одна, и все копии стоят к ней в очереди. Болезни почти всегда те же. Запрос читает всю таблицу вместо одной строки (нет индекса). Один и тот же запрос повторяется сотни раз там, где хватило бы одного (проблема N+1). Все соединения заняты, и новые запросы ждут (мал пул). Лечатся они по-разному, а главное умение одно: сначала найти виновника по измерению, а не по догадке. Я сам в начале карьеры раздул пул «на всякий случай» и сделал хуже, об этом ты прочтёшь ниже.
Шаг проекта: в ~/perf-lab/11-bottlenecks/ ты по очереди найдёшь три причины (через pg_stat_statements, EXPLAIN ANALYZE и метрики пула) и исправишь каждую одним изменением. После каждого пересчитаешь предел системы и запишешь таблицу «что поменяли, что стало» в 03-database.md.
Что нужно знать
- Метод и скрипты
bn.js,set-env.sh,run.sh, базовая линия: урок 11.1. - SQL:
SELECT,WHERE,JOIN, индексы,EXPLAINна уровне «создавал и удалял»: урок 2.4. - Как бэкенд ходит в базу через пул соединений: урок 2.3; как работает пул под нагрузкой: урок 8.2.
- Метрики пула
shop_db_pool_*иshop_db_connection_wait_seconds: урок 7.2, дашборд урок 7.3. - Закон Литтла (сколько соединений нужно при данной нагрузке): урок 8.2.
- Стенд: PostgreSQL 18.6 с расширением
pg_stat_statementsиcpus: "1.0"у контейнера базы, пулDB_POOL_MAX=5на воркер.
Картина целиком
Библиотека с одним библиотекарем. Читатель просит «все мои заказы». Каталога по читателям нет, поэтому библиотекарь идёт вдоль всех 200 000 полок и смотрит каждую карточку: «Не ваша… не ваша… не ваша…». Это полный перебор (Seq Scan). С каталогом по читателям он сразу идёт к нужной полке: это индекс. Вторая беда: на каждый из 20 заказов читателя библиотекарь ещё раз ходит в хранилище за списком товаров. Принести все 20 за раз он не догадывается (N+1). Третья: окошек у входа всего пять, и если библиотекарь занят, люди стоят в очереди (пул соединений).
flowchart TD
A["Симптом<br>GET /api/orders p95 3,1 с"] --> B["pg_stat_statements<br>кто съел время БД"]
B --> C["EXPLAIN ANALYZE<br>почему запрос дорог"]
C --> D["Индекс<br>orders.user_id"]
D --> E["N+1<br>21 запрос → 2"]
E --> F["Пул<br>5 → 15 соединений"]
Сначала узнаём, какой запрос дорог, потом почему, чиним, и после каждого исправления снова меряем: узкое место сдвигается на следующий ресурс.
Теория
Как устроен запрос к базе и откуда берётся время
«База тормозит» остаётся словами, пока не знаешь, на что она тратит время. Давай разберём.
Это как заказ в ресторане. Время складывается из ожидания официанта (очередь к соединению), работы повара (процессор базы) и того, сколько продуктов надо достать с полок (чтение данных). Быстрее всего то блюдо, для которого всё лежит под рукой.
В базе то же самое по стадиям. Запрос принимает соединение (connection): в PostgreSQL это отдельный процесс на каждое соединение, и их число ограничено (у нас max_connections=100). Затем планировщик выбирает план: как искать строки (перебрать таблицу целиком или пойти по индексу) и в каком порядке соединять таблицы. Потом исполнитель читает данные. Таблица хранится страницами по 8 КБ. Страницы в памяти читаются быстро (hit), а с диска на порядок медленнее (read). В итоге время запроса это процессор на сравнение и сортировку, плюс чтение страниц, плюс ожидание блокировок.
Приложение берёт соединение из пула: набора заранее открытых соединений, которыми пользуются по очереди. Открывать соединение к PostgreSQL дорого (десятки миллисекунд), поэтому их держат и переиспользуют.
Вот запрос списка заказов из main.py:
SELECT id, status, total, created_at FROM orders
WHERE user_id = 42 ORDER BY created_at DESC, id DESC LIMIT 20
В orders 200 000 строк, примерно по 200 заказов на каждого из 1000 пользователей. Нужно найти 200 строк пользователя 42 и отсортировать по дате: чтобы взять 20 последних, надо знать, какие из 200 самые свежие. Без индекса база не знает, где они лежат, и читает все 200 000 (это 1575 страниц по 8 КБ, около 12 МБ), проверяя user_id = 42. Выходит около 20 мс процессорного времени на вызов (так мерили на стенде в одиночку, под нагрузкой чуть больше).
Прикинь сам: 20 мс на запрос это быстро? Один визит делает такой запрос один раз, а приходит 30 визитов в секунду. Сколько процессора базы уйдёт?
Под нагрузкой запрос стоит около 22 мс. Тридцать по 22 это 660 мс процессора базы в секунду, то есть две трети единственного ядра уходят на один запрос. Стоимость запроса надо умножать на частоту.
Главное: время запроса складывается из процессора, чтения страниц и ожиданий, а стоимость запроса надо умножать на то, как часто он вызывается.
Мы знаем, что запрос дорог. Но как среди сотен запросов найти тот, что съедает базу?
pg_stat_statements: кто съел время базы
Журнал медленных запросов показывает только те, что дольше порога (у нас 200 мс). Но самый вредный запрос может быть быстрым и частым. Нужен учёт по всем запросам. Вспомни банковскую выписку: важны не только крупные покупки, но и мелкие регулярные платежи, которые за месяц набегают в большую сумму.
Расширение pg_stat_statements (в стенде включено, track=all) и есть такая выписка. Оно ведёт таблицу, где каждому нормализованному запросу (значения заменены на $1, $2, чтобы запросы с разными параметрами считались одним) соответствует строка. В ней calls (сколько раз вызван), total_exec_time (суммарное время, мс), mean_exec_time (среднее, мс) и rows (сколько строк отдано). Счётчики копятся с запуска или с последнего pg_stat_statements_reset(), поэтому перед замером их сбрасывают, иначе в таблице исторический мусор. Сортировать для поиска узкого места надо по total_exec_time: именно суммарное время занимает базу.
После минуты теста при 30 визитах в секунду (до исправлений) такой запрос:
SELECT left(query, 60) AS query, calls, round(total_exec_time) AS total_ms,
round(mean_exec_time::numeric, 1) AS mean_ms, rows
FROM pg_stat_statements WHERE query NOT LIKE '%pg_stat%'
ORDER BY total_exec_time DESC LIMIT 5;
даёт примерно такое:
query | calls | total_ms | mean_ms | rows
--------------------------------------------------------------+-------+----------+---------+--------
SELECT id, status, total, created_at FROM orders WHERE user_ | 1612 | 51270 | 31.8 | 32240
SELECT oi.order_id, oi.product_id, p.name, oi.qty, oi.price | 32240 | 4590 | 0.1 | 64480
SELECT id, name, price, category_id, stock FROM products WHE | 1672 | 2210 | 1.3 | 1672
INSERT INTO orders (user_id, status, total) VALUES ($1, $2, | 1670 | 1650 | 1.0 | 1670
SELECT p.id, p.name, p.price, p.category_id, p.stock FROM pr | 1672 | 1480 | 0.9 | 33440
Смотри сначала на total_ms. Список заказов набрал 51 270 мс за минуту, больше всех остальных запросов вместе. Осторожно: total_ms это время выполнения вместе с ожиданием (ядра, диска, блокировок), а не чистый расход процессора. Поэтому сумма по нескольким соединениям может превысить 60 000 мс за минуту даже на одном ядре. Для поиска главного виновника это неважно: доля в общем времени показывает, кого чинить первым. Вызовов 1612 за минуту, около 27 в секунду, чуть меньше 30 из-за прогрева. Среднее у запроса 31,8 мс: это его 22 мс работы плюс ожидание ядра, когда запросов много. Так он в десятки раз дороже остальных.
Прикинь сам: второй запрос (
order_items) вызван 32 240 раз, первый 1 612. Что говорит их отношение?
32 240 / 1 612 = 20: на каждый вызов списка заказов приходится ровно двадцать запросов за товарами. Суммарно они съели мало (по 0,1 мс), но число вызовов это отпечаток N+1. Подсказка для следующего шага.
Осторожно: сортировка по mean_exec_time выберет «самый медленный» запрос, который вызван три раза. Сортировка по calls найдёт самый частый, который стоит микросекунды. Сначала total_exec_time, потом смотри, из чего он складывается: из цены (mean) или частоты (calls).
Проверь понимание: запрос A: 10 000 вызовов,
mean0,5 мс. Запрос B: 20 вызовов,mean120 мс. Какой сильнее нагрузит базу?Ответ
A: 10 000 × 0,5 = 5 000 мс. B: 20 × 120 = 2 400 мс. Базу сильнее нагружает A, хотя каждый его вызов быстрый. Но если B стоит на критичном маршруте, пользователь ощутит его сильнее. Для нагрузки на базу смотрят суммарное время, для задержки пользователя
meanи p95 маршрута.
Главное: виновника ищут по суммарному времени, а отношение
callsдвух запросов выдаёт N+1.
Виновник найден. А почему он дорог, показывает план.
EXPLAIN ANALYZE: почему запрос дорог
План похож на маршрутный лист курьера: сначала зайти сюда, потом туда. EXPLAIN показывает маршрут, который выбрал планировщик, а EXPLAIN (ANALYZE, BUFFERS) ещё и выполняет запрос, добавляя реальное время, число строк и страниц. Осторожно: на INSERT или UPDATE он реально изменит данные, оборачивай такие в транзакцию с откатом.
План это дерево узлов, читать его надо снизу вверх: внутренние узлы выполняются первыми. Два главных способа достать строки. Seq Scan читает всю таблицу и фильтрует: нормален для маленьких таблиц, плох, когда нужна малая доля большой. Index Scan и Bitmap Index Scan идут по индексу к нужным строкам. Bitmap сначала собирает адреса страниц, потом читает их по порядку: удобно, когда строк много, но не большинство. Из полей важнее всех Rows Removed by Filter (сколько строк прочитано и отброшено: главный признак напрасной работы), actual time (миллисекунды) и Buffers: shared hit=... read=... (страницы из памяти и с диска). cost это условные единицы оценки, их сравнивают только между планами одного запроса.
План списка заказов до индекса:
Limit (cost=4075.12..4075.17 rows=20 width=28) (actual time=19.84..19.85 rows=20 loops=1)
Buffers: shared hit=1575
-> Sort (cost=4075.12..4075.62 rows=200 width=28) (actual time=19.83..19.84 rows=20 loops=1)
Sort Key: created_at DESC, id DESC
Sort Method: top-N heapsort Memory: 27kB
-> Seq Scan on orders (cost=0.00..4070.00 rows=200 width=28) (actual time=0.03..19.62 rows=200 loops=1)
Filter: (user_id = 42)
Rows Removed by Filter: 199800
Buffers: shared hit=1575
Planning Time: 0.12 ms
Execution Time: 19.90 ms
Читаем снизу. Seq Scan on orders с фильтром user_id = 42: нашлось 200 строк, а Rows Removed by Filter: 199800 значит, что 199 800 строк прочитаны зря (99,9% работы). shared hit=1575 это 1575 страниц из памяти. Выше Sort (метод top-N heapsort держит только 20 лучших, а не упорядочивает все) берёт из 200 строк 20 лучших, Limit отдаёт их. Из 19,9 мс на Seq Scan ушло 19,62.
Прикинь сам: прочитано 200 000 строк, нужно 200. Какую долю работы составляет напрасная?
Нужные 200 из 200 000 это 0,1%, значит напрасных 99,9%. Нужна малая доля большой таблицы, а база читает всю. Лечение: индекс по user_id.
Осторожно: cost не время. Если оценка rows сильно расходится с фактом (оценка 1, реально 50 000), статистика устарела, и лечит это команда ANALYZE.
Главное: по плану ищи
Seq Scanс огромнымRows Removed by Filter: это работа, которую индекс сделал бы напрасной.
Лечение названо. Что такое индекс и сколько он стоит?
Индекс: каталог, который надо поддерживать
Без индекса поиск строки по значению это просмотр всей таблицы: чем больше таблица, тем дольше. Индекс превращает его в проход по дереву. Время почти не растёт с размером таблицы: таблица в тысячу раз больше добавляет лишь несколько шагов по дереву.
Представь предметный указатель в конце книги: слово и страницы. Вместо чтения всей книги ты открываешь указатель и идёшь на три нужные страницы. Цена: указатель надо поддерживать, при каждой новой странице обновлять, и он занимает место.
Индекс по user_id это отдельная структура (B-дерево: значения разложены по ветвям, как справочник по буквам), где значения отсортированы, а рядом лежат адреса строк. Поиск user_id = 42 проходит дерево за 3-4 шага и находит адреса 200 строк. У этого три цены. Каждый INSERT в orders теперь обновляет и индекс (на нашем масштабе доли миллисекунды, но «индексы на всякий случай» заметно замедляют запись). Индекс по integer на 200 000 строк занимает 4-5 МБ. А если нужна большая доля таблицы, планировщик всё равно выберет Seq Scan, и это правильно.
Команда создания: CREATE INDEX orders_user_id_idx ON orders(user_id);. На боевой таблице используют CREATE INDEX CONCURRENTLY: он не блокирует запись при построении, а обычный CREATE INDEX блокирует, и в разгар дня магазин встанет. После создания выполни ANALYZE orders; (команда обновляет сводку, по которой планировщик прикидывает, сколько строк найдётся), чтобы планировщик точнее оценивал строки.
Для любопытных: составной индекс
Наш запрос просит не просто строки пользователя, а 20 самых свежих: WHERE user_id = 42 ORDER BY created_at DESC, id DESC LIMIT 20. Простой индекс по user_id находит все 200 строк пользователя, но потом их приходится сортировать (в плане выше это узел Sort). Составной индекс (в нём два и больше столбцов) хранит строки одного пользователя уже упорядоченными по времени:
CREATE INDEX orders_user_created_idx ON orders (user_id, created_at DESC, id DESC);
ANALYZE orders;
Тогда планировщик идёт по индексу с начала и останавливается на двадцатой строке, а Sort исчезает. План примерно такой (числа у тебя будут другими, это иллюстрация):
Limit (cost=0.42..60.10 rows=20 width=28) (actual time=0.03..0.09 rows=20 loops=1)
-> Index Scan using orders_user_created_idx on orders (cost=0.42..600.00 rows=200 width=28) (actual time=0.03..0.08 rows=20 loops=1)
Index Cond: (user_id = 42)
Execution Time: 0.12 ms
Что здесь видно: узла Sort нет, прочитано 20 строк вместо 200, а время на порядок меньше. Для одного пользователя с 200 заказами выигрыш в миллисекундах, но у активного покупателя с тысячами заказов он станет заметным. Если делаешь этот опыт, удали простой индекс перед созданием составного (DROP INDEX orders_user_id_idx;), иначе планировщик может выбрать любой из двух, и это смажет сравнение. В уроке мы остаёмся на простом индексе user_id: он дешевле в записи и объясняет принцип.
План после индекса:
Limit (cost=219.30..219.35 rows=20 width=28) (actual time=0.61..0.62 rows=20 loops=1)
Buffers: shared hit=193
-> Sort (cost=219.30..219.80 rows=200 width=28) (actual time=0.61..0.61 rows=20 loops=1)
Sort Key: created_at DESC, id DESC
Sort Method: top-N heapsort Memory: 27kB
-> Bitmap Heap Scan on orders (cost=5.20..215.00 rows=200 width=28) (actual time=0.12..0.52 rows=200 loops=1)
Recheck Cond: (user_id = 42)
Heap Blocks: exact=190
Buffers: shared hit=193
-> Bitmap Index Scan on orders_user_id_idx (cost=0.00..5.15 rows=200 width=0) (actual time=0.08..0.08 rows=200 loops=1)
Index Cond: (user_id = 42)
Buffers: shared hit=3
Planning Time: 0.15 ms
Execution Time: 0.65 ms
Время упало с 19,9 до 0,65 мс (в 30 раз), страниц прочитано 193 вместо 1575 (в 8 раз), Rows Removed by Filter исчез. Bitmap Index Scan прошёл по индексу (3 страницы) и собрал список адресов 200 строк, как закладки. Bitmap Heap Scan прочитал эти страницы таблицы по порядку (Heap Blocks: exact=190 это число страниц, Recheck Cond перепроверка условия). Заказы пользователя лежат вперемешку с чужими, записаны по мере поступления, поэтому 200 строк раскиданы по 190 страницам. Всё равно это 193 против 1575.
Осторожно: «создам индекс на каждый столбец» замедляет запись и раздувает память. Индекс ставят под конкретный частый запрос, который показала статистика. Бывает и так, что индекс создан, а запрос медленный: планировщик его не использует (статистика устарела, тип в условии не совпадает, функция над столбцом вроде WHERE lower(email) = ...).
Главное: индекс меняет поиск по всей таблице на проход по дереву, но платит за это записью и местом, поэтому ставится под измеренный запрос.
Индекс убрал самую жирную часть. Но в pg_stat_statements остался второй запрос с тысячами вызовов.
N+1: сто маленьких вместо двух больших
Нужно купить 20 продуктов. N+1 это 20 отдельных походов в магазин по одному продукту плюс первый поход за списком. Правильно: один поход со списком из 20 пунктов.
В коде так: сначала один запрос возвращает N заказов, потом в цикле по каждому заказу идёт ещё запрос за его товарами. Получается 1 + N запросов вместо двух. У «Магазина» это включает настройка BUG_N_PLUS_ONE. По умолчанию она равна 1, ошибка включена, и код делает 1 запрос + 20 по заказам (21 запрос), при 0 один запрос заказов и один WHERE oi.order_id = ANY(...) сразу по всем (2 запроса). Каждый из двадцати быстр (0,1 мс), но у каждого есть накладные расходы: сетевой круг до базы, разбор, занятое соединение. Под нагрузкой они съедают процессор и долго держат соединение из пула.
| Вариант | Запросов к БД | Круги до базы | Соединение занято |
|---|---|---|---|
BUG_N_PLUS_ONE=1 |
21 | 21 | около 8-10 мс на пустой базе (21 круг по 0,3-0,4 мс) |
BUG_N_PLUS_ONE=0 |
2 | 2 | около 1 мс |
Прикинь сам: при 30 визитах в секунду сколько лишних запросов к базе делает N+1?
Двадцать лишних на визит, 30 × 20 = 600 запросов в секунду. На каждый уходит около 0,1-0,15 мс процессора: 60-90 мс в секунду, до 9% ядра, плюс время, пока соединение занято.
Осторожно: N+1 не «медленные запросы». Каждый из них быстрый, поэтому в журнал медленных он не попадёт, и его видно, только когда смотришь на число вызовов. И индекс тут не лечит: индекс по order_id уже есть, лечится сменой количества запросов.
Главное: N+1 это много быстрых запросов вместо одного, и узнают его по
callsвторого запроса, делённому наcallsпервого.
Запросов стало меньше. Но каждый всё равно берёт соединение из пула. Сколько таких «окошек» нужно?
Пул соединений: сколько окошек и как долго они заняты
Каждое соединение к PostgreSQL это процесс на сервере (несколько мегабайт памяти и лишняя работа операционной системы по переключению между ними). Тысяча соединений тормозит базу сама по себе, поэтому приложение держит небольшой пул. Но маленький пул сам становится узким местом: пока все заняты, новые запросы ждут.
Это пять кассовых окон в банке и сколько угодно клиентов. Допустим, визит держит окошко в среднем 115 мс (так на стенде при N+1: 21 запрос и остальная работа). Тогда окна в сумме успевают 5 / 0,115 = 43 клиента в секунду, остальные ждут в зале. Если ждут слишком долго (таймаут), часть уходит с отказом.
В «Магазине» пул настраивают DB_POOL_MIN=1, DB_POOL_MAX=5 (на каждый воркер) и DB_POOL_TIMEOUT=5 секунд. Запрос берёт соединение (если свободного нет, ждёт), работает и возвращает. Прождал больше пяти секунд, получает 503 «database pool timeout». Метрики: shop_db_pool_size (сколько соединений открыто), shop_db_pool_available (сколько свободно), shop_db_pool_waiting (сколько запросов сейчас ждут), shop_db_connection_wait_seconds (гистограмма: сколько ждали). Как в кафе: два посетителя в минуту по три минуты за столиком занимают в среднем шесть столиков. По закону Литтла из урока 8.2 среднее число занятых соединений равно частоте запросов, умноженной на время удержания. Если на 40 итераций в секунду приходится 115 мс удержания, занято в среднем 40 × 0,115 = 4,6 соединения из 5. Запас 0,4, и любой всплеск превращается в очередь.
Пять соединений освобождаются реже, чем приходят запросы: максимум около 43 в секунду, а приходит 50. Поэтому очередь растёт и не рассосётся, пока нагрузка выше предела. Те, кто прождал дольше таймаута, получают отказ. Поставь размер 15, и тот же поток пройдёт без очереди.
Прикинь сам: база упёрлась в процессор на 100%. Запросы выполняются дольше, соединения держатся дольше. Что станет с очередью у пула из 5 соединений, и что даст раздутие пула до 100?
Время удержания растёт, пул из 5 насыщается при меньшей нагрузке, поэтому очередь в пуле часто следствие медленной базы, а не причина. Раздутие до 100 сделает хуже: сто конкурирующих запросов на одном ядре базы идут все медленнее, а не быстрее. Я так и сделал когда-то: очередь у окошек исчезла, зато вся очередь переехала внутрь базы, а p95 стал хуже. Правило: прежде чем увеличивать пул, проверь процессор базы.
Есть и верхняя граница: max_connections=100. Пул у каждого воркера свой, поэтому WEB_CONCURRENCY × DB_POOL_MAX не должно превышать сотню минус запас на мониторинг и администрирование. 15 соединений на один воркер нормально, а 15 на 8 воркеров (120) уже нет.
Осторожно: shop_db_pool_waiting показывает очередь сейчас, а shop_db_connection_wait_seconds показывает, как долго ждали. Это разные вопросы.
Главное: размер пула подбирают по закону Литтла и по процессору базы, а очередь у пула часто лишь следствие медленной базы.
Три исправления готовы. Что они вместе делают с пределом системы?
Узкое место переезжает
Каждое исправление сдвигает предел ступенью. Предел ресурса это его мощность, делённая на цену одного визита. Процессор базы: 1000 мс в секунду / 32 мс на визит это около 31. Это грубая оценка: мы считаем, что почти всё время запроса база работает процессором, а ожидание диска и блокировок мало (при упёртом в 1,00 CPU postgres так и есть). После индекса запрос списка стоит 0,65 мс, и на весь визит база тратит около 6 мс: так показывает pg_stat_statements ниже (четыре главных запроса набрали 12 740 мс за 60 секунд, это около 21% ядра при 40 визитах в секунду). Предел процессора базы около 165, он уже не главный. Пул: 5 окошек / 0,115 с это около 43. Процессор приложения: около 15 мс на визит, это около 67. Пределы в визитах в секунду (визит это одна итерация сценария mix):
| Состояние | Предел CPU БД | Предел пула (5) | Предел CPU shop | Предел системы |
|---|---|---|---|---|
| Исходно | около 31 | около 43 | около 67 | 31 (CPU БД) |
+ индекс orders.user_id |
около 165 | около 43 | около 67 | около 43 (пул) |
+ BUG_N_PLUS_ONE=0 |
около 280 | около 60 | около 67 | около 60 (пул) |
+ DB_POOL_MAX=15 |
около 280 | около 180 | около 67 | около 67 (CPU shop) |
Виджет считает те же ступени и рисует загрузку трёх ресурсов. Включи по очереди индекс и исправление N+1 и подвинь пул:
Линия, которая первой достигает 100%, и есть узкое место. При нагрузке 40 исходная система давно за пределом (процессор базы), после индекса запас скромный (первым кончается пул), а после остальных правок ограничивать начинает уже что-то другое. Поднимешь пул, не починив процессор базы, и предел не сдвинется.
Главное: после каждого исправления снова меряй, потому что узкое место переезжает, а поднимать пул, пока процессор базы упёрт, бесполезно.
Вернёмся к распродаже: индекс, N+1 и пул подняли предел с 31 до 60-67 визитов в секунду. Тройная нагрузка уже не кладёт магазин, а следующий предел будет искать сам процессор приложения.
Практика
Стенд с мониторингом, BCRYPT_ROUNDS=4 после прошлого урока (вход нам не мешает) и прогретые пользователи. Остальные настройки по умолчанию: DB_POOL_MAX=5, BUG_N_PLUS_ONE=1, индекса orders_user_id_idx нет. Проверь:
cd ~/learning/load-tester/project/shop
grep -E '^(DB_POOL_MAX|BUG_N_PLUS_ONE|BCRYPT_ROUNDS)=' .env
docker compose exec -T postgres psql -U shop -d shop -c "SELECT indexname FROM pg_indexes WHERE tablename='orders'"
DB_POOL_MAX=5
BUG_N_PLUS_ONE=1
BCRYPT_ROUNDS=4
indexname
-----------------
orders_pkey
(1 row)
Если в списке есть orders_user_id_idx, удали его: docker compose exec -T postgres psql -U shop -d shop -c "DROP INDEX orders_user_id_idx". Удобно сделать псевдоним для работы с базой:
alias psqlshop='docker compose -f ~/learning/load-tester/project/shop/compose.yaml exec -T postgres psql -U shop -d shop'
1. Базовая линия на 40 визитах
Сегодня удобнее вести расследование на нагрузке 40: она выше предела, симптом заметен. Прогон и база сразу после него:
cd ~/perf-lab/11-bottlenecks
psqlshop -c "SELECT pg_stat_statements_reset();"
./run.sh db-base SCENARIO=mix RATE=40 DURATION=60s
pg_stat_statements_reset() обнуляет учёт, поэтому цифры после него относятся только к нашему прогону. Результат ориентировочно: p95 6,2 с, выполнено 26,9 визита в секунду, ошибок 7% (503 «database pool timeout» через 5 с).
2. Кто съел время базы
psqlshop -c "SELECT left(query, 60) AS query, calls, round(total_exec_time) AS total_ms, round(mean_exec_time::numeric,1) AS mean_ms FROM pg_stat_statements WHERE query NOT LIKE '%pg_stat%' ORDER BY total_exec_time DESC LIMIT 5;"
Вывод аналогичен показанному в теории. Как читать вывод: верхний запрос это SELECT ... FROM orders WHERE user_id = ..., он забрал большую часть суммарного времени; второй SELECT oi.order_id ... FROM order_items имеет число вызовов в 20 раз больше. Запиши обе подсказки: «orders: дорогой запрос» и «order_items: N+1».
Затем проверь процессор и пул:
P=~/perf-lab/scripts/promq.sh; S='container_label_com_docker_compose_service'
$P "sum(rate(container_cpu_usage_seconds_total{$S=\"postgres\"}[30s]))"
$P "shop_db_pool_waiting"
$P "histogram_quantile(0.95, sum by (le) (rate(shop_db_connection_wait_seconds_bucket[30s])))"
Эти запросы нужно выполнять во время прогона, поэтому запусти прогон на 90 секунд в одном терминале и снимай показания в другом. Типичные значения: процессор postgres 1,00, pool waiting 3-4, p95 ожидания соединения около 0,4 с на 30 визитах и около 4,8 с на 40 (почти до таймаута). По дереву из 11.1: процессор shop не упёрт, ждут соединения, процессор базы упёрт: запросы к БД.
3. EXPLAIN ANALYZE: почему
Возьми пользователя с заказами и посмотри план:
psqlshop -c "EXPLAIN (ANALYZE, BUFFERS) SELECT id, status, total, created_at FROM orders WHERE user_id = 42 ORDER BY created_at DESC, id DESC LIMIT 20;"
Разбор: EXPLAIN (ANALYZE, BUFFERS) выполняет запрос и печатает план с реальными временем и страницами (запрос только читает, поэтому ничего не меняет). Ожидаемый вывод это план из теории: Seq Scan on orders, Rows Removed by Filter: 199800, Execution Time около 20 мс.
Как читать вывод: сначала самый глубокий узел (с наибольшим отступом): Seq Scan. В нём Rows Removed by Filter: 199800 значит, что работа почти вся напрасная. Время самого запроса (Execution Time) умножь на частоту: 40 визитов/с × 20 мс = 800 мс процессора базы в секунду, а у неё в секунде 1000 мс.
Типичные ошибки: ERROR: relation "orders" does not exist значит, что подключился не к базе shop (проверь -d shop); план показывает Index Scan значит, что индекс уже создан (удали и повтори); time в плане вдвое больше на первый раз значит, что страницы читались с диска, повтори запрос.
Запиши гипотезу: «Если причина в полном переборе orders, то индекс по user_id снизит время запроса с 20 мс до 1 мс, а p95 mix на 40/с упадёт с 6,2 с ниже 2 с. Если p95 останется выше 4 с, гипотеза неверна.»
4. Исправление 1: индекс
psqlshop -c "CREATE INDEX orders_user_id_idx ON orders(user_id);"
psqlshop -c "ANALYZE orders;"
psqlshop -c "EXPLAIN (ANALYZE, BUFFERS) SELECT id, status, total, created_at FROM orders WHERE user_id = 42 ORDER BY created_at DESC, id DESC LIMIT 20;"
Разбор: CREATE INDEX строит индекс (на 200 000 строк около секунды), ANALYZE обновляет статистику, третья команда это тот же EXPLAIN. Теперь план содержит Bitmap Index Scan on orders_user_id_idx, Execution Time около 0,65 мс, Buffers: shared hit=193. Первое изменение сделано одной командой. Запиши его в журнал руками, раз это не set-env.sh: echo "$(date '+%F %T') CREATE INDEX orders_user_id_idx" >> ~/perf-lab/11-bottlenecks/experiments.log.
Теперь прогон при тех же условиях (сначала сброс учёта):
psqlshop -c "SELECT pg_stat_statements_reset();"
./run.sh db-index SCENARIO=mix RATE=40 DURATION=60s
Ожидаемо: выполнено около 39,5 визита в секунду, p95 около 1,3 с, ошибок около 0,3%. Гипотеза подтверждена по заранее записанному порогу (ниже 2 с). Узкое место сдвинулось: процессор базы почти свободен (около 20-25% ядра), зато пул (5 окон по 115 мс, занято в среднем 4,5) почти на пределе.
Типичные ошибки: прогон показал то же, что до индекса: индекс не создан в той базе, к которой подключается стенд (psqlshop -c "\d orders" покажет индекс); p95 стал хуже на первых секундах: страницы индекса ещё не в кэше, выбрось прогрев или повтори.
5. Исправление 2: N+1
Снова смотрим pg_stat_statements после прогона db-index:
psqlshop -c "SELECT left(query, 60) AS query, calls, round(total_exec_time) AS total_ms, round(mean_exec_time::numeric,2) AS mean_ms FROM pg_stat_statements WHERE query NOT LIKE '%pg_stat%' ORDER BY total_exec_time DESC LIMIT 4;"
query | calls | total_ms | mean_ms
--------------------------------------------------------------+-------+----------+---------
SELECT oi.order_id, oi.product_id, p.name, oi.qty, oi.price | 47320 | 5870 | 0.12
SELECT id, status, total, created_at FROM orders WHERE user_ | 2366 | 1540 | 0.65
SELECT id, name, price, category_id, stock FROM products WHE | 2366 | 3010 | 1.27
INSERT INTO orders (user_id, status, total) VALUES ($1, $2, | 2364 | 2320 | 0.98
Как читать вывод: четыре строки вместе дают 12 740 мс за 60 с, это около 21% ядра: процессор базы уже не упёрт. Самый дорогой запрос теперь order_items (47 320 вызовов, ровно 20 на каждый вызов списка заказов, 2366 × 20). Каждый вызов быстр (0,12 мс), но их множество. Это отпечаток N+1. Теория твердит, что лечится сменой числа запросов, а не индексом. В стенде это переключатель BUG_N_PLUS_ONE:
./set-env.sh BUG_N_PLUS_ONE=0
psqlshop -c "SELECT pg_stat_statements_reset();"
./run.sh db-nplus1 SCENARIO=mix RATE=40 DURATION=60s
Ожидаемо: p95 около 0,35 с, ошибок нет, выполнено 40 в секунду. В pg_stat_statements число вызовов order_items стало равно числу вызовов списка заказов (2 запроса на вызов вместо 21). Предел системы теперь около 60 визитов в секунду (5 окон по 83 мс), его задаёт пул.
Параллельно проверь число запросов к базе на вызов: psqlshop -c "SELECT calls FROM pg_stat_statements WHERE query LIKE '%order_items oi%'" сравни с числом вызовов GET /api/orders из отчёта k6 (число итераций).
6. Исправление 3: пул, но осознанно
Сейчас на 40 визитах пул не мешает. Увидеть его влияние можно, подняв нагрузку, где пул уже важен: 55 визитов в секунду.
./run.sh db-nplus1-55 SCENARIO=mix RATE=55 DURATION=60s
Снимай во время прогона shop_db_pool_waiting и процессор postgres. Типичные значения: waiting 1-2, процессор postgres около 20%, занято в среднем 55 × 0,083 ≈ 4,6 соединения из 5. По дереву: база свободна, соединения заняты, значит, теперь, когда база починена, узкое место сам пул. Гипотеза на пул: «Если мал пул, то 15 соединений сократят ожидание соединения (p95 с 0,25 до ниже 0,05 с) и общий p95. Если не изменится, пул не был узким».
./set-env.sh DB_POOL_MAX=15
./run.sh db-pool15-55 SCENARIO=mix RATE=55 DURATION=60s
Результат: ожидание соединения упало ниже 0,02 с, и общий p95 упал с 0,5 до 0,3 с. Предел системы вырос с 60 до 67, и теперь его задаёт процессор приложения, так что больший пул ничего не изменит. Это правильный вывод: до индекса очередь у пула была следствием упёртой базы, а после починки базы пул стал настоящим пределом. Раздутый пул всё равно не принёс бы ничего хорошего. Проверь max_connections: psqlshop -c "SHOW max_connections" покажет 100, при WEB_CONCURRENCY=1 пул 15 безопасен.
Смотри: две первые правки дают почти весь эффект, третья (пул) при 40 визитах почти ничего: до предела пула (около 60) ещё далеко. Пул начинает мешать ближе к пределу, на 55 визитах, и там пул 15 снизил p95 с 0,5 до 0,3 с. Поэтому изменения проверяют по одному и на той нагрузке, где ресурс близок к пределу: иначе ты бы решил, что пул 15 бесполезен.
7. Запиши вывод и закоммить
Допиши ~/perf-lab/11-bottlenecks/03-database.md таблицу и выводы:
# 11.3. База данных
## Симптом
mix 40 визитов/с: p95 6,2 с, выполнено 26,9/с, 7% ошибок (503 пул). GET /api/orders самый медленный.
## Показания
CPU postgres 1,00 (упёрт), pool waiting 3-4, wait p95 около 4,8 с. pg_stat_statements: запрос orders 85% времени БД, order_items в 20 раз чаще orders.
## Что менял по одному (mix 40/с, 60 с)
| изменение | p95, мс | выполнено/с |
| исходно | 6200 | 26,9 |
| индекс orders_user_id_idx | 1300 | 39,5 |
| BUG_N_PLUS_ONE=0 | 350 | 40,0 |
| DB_POOL_MAX=15 (при 55/с) | 300 | 55 |
## Вывод
Главное: индекс (Seq Scan 20 мс → 0,65 мс). Затем N+1 (21 запрос → 2). До индекса пул был следствием, после починки базы стал пределом (~60 визитов/с); с пулом 15 предел ~67, дальше упирается CPU shop.
cd ~/perf-lab
git add 11-bottlenecks results/11-db-*.txt
git commit -m "11.3: БД: индекс, N+1, пул, расследование"
git push
Оставь индекс, BUG_N_PLUS_ONE=0 и DB_POOL_MAX=15: следующие уроки опираются на исправленную базу.
Сломай и почини
Поломка: раздуй пул «на всякий случай» и посмотри, что получится. Верни медленный запрос и поставь пул 90:
psqlshop -c "DROP INDEX orders_user_id_idx;"
./set-env.sh DB_POOL_MAX=90
./run.sh db-bigpool SCENARIO=mix RATE=40 DURATION=60s
Что произойдёт: ошибок «database pool timeout» нет (соединений много), но p95 не лучше, а часто хуже исходного: все 90 запросов лезут в базу одновременно, процессор делится на всех, и каждый запрос выполняется дольше. Очередь переехала из приложения внутрь базы, где её хуже видно. К тому же 90 соединений занимают больше памяти базы, а второй воркер уже не поместился бы в max_connections=100.
Диагностика: psqlshop -c "SELECT state, count(*) FROM pg_stat_activity WHERE datname='shop' GROUP BY state" показывает десятки соединений в состоянии active одновременно; метрика shop_db_pool_waiting равна нулю (очереди в приложении нет), а процессор postgres 100%. Вывод: насыщена база, пул лишь прятал очередь.
Решение: создай индекс заново (CREATE INDEX orders_user_id_idx ON orders(user_id); ANALYZE orders;), верни DB_POOL_MAX=15, повтори прогон и сравни. Правило на запас: размер пула подбирают по закону Литтла (частота × время удержания × запас 2) и по числу ядер базы, а не «чтобы не было ожидания».
Сначала пройди диагностику сам:
pg_stat_activity,shop_db_pool_waiting, процессор postgres. Потом спроси нейросеть, согласна ли она с выводом «насыщена база, пул прятал очередь», и проверь повторным прогоном сDB_POOL_MAX=15и индексом.
ИИ в помощь
Нейросеть неплохо читает план EXPLAIN ANALYZE и подсказывает индекс, но не знает твоих данных и объёмов. Общие правила: ИИ-помощник.
Задача: разобрать план запроса.
PostgreSQL 18, таблица orders (около 200 000 строк), запрос: <вставь SELECT ... WHERE user_id = ...>.
Вот вывод EXPLAIN (ANALYZE, BUFFERS): <вставь план целиком>
Объясни построчно, что значит каждый узел, где тратится время (actual time, rows, loops) и почему
выбран такой план. Предложи индекс, объясни, почему именно он, и скажи, как мне проверить, что
он помог. Не угадывай объёмы данных, которых нет в плане.
Проверь ответ: создай индекс на копии стенда, снова выполни EXPLAIN ANALYZE и сравни Execution Time и тип узла (Seq Scan должен смениться на Index Scan). Типичные ошибки: индекс не на тот столбец, совет ставить индекс на каждый столбец (замедлит запись), забытый ANALYZE после создания.
Задача: найти N+1 по картине запросов.
В pg_stat_statements стенда «Магазин» самый частый запрос: <вставь query и calls, mean_exec_time>.
За один прогон из 40 визитов он выполнен <число> раз. Объясни, как отличить N+1 от обычной нагрузки
и как посчитать, сколько запросов приходится на один HTTP-запрос. Покажи, как исправить: один запрос
с JOIN или IN вместо цикла. Объясни, как проверить результат метрикой.
Проверь ответ: отношение calls к числу HTTP-запросов на маршруте должно сойтись с твоей оценкой; после правки оно упадёт до единиц. Типичная ошибка: исправление, которое грузит в память лишние данные, или совет поднять пул вместо устранения лишних запросов.
Данные клиентов и пароли из таблиц в чат не копируй: оставляй только структуру и числа.
Словарик урока
| Термин | Простыми словами |
|---|---|
| Соединение (connection) | Канал между приложением и базой; в PostgreSQL это отдельный процесс на сервере |
| Пул соединений | Набор заранее открытых соединений, которые раздают запросам по очереди |
pg_stat_statements |
Расширение PostgreSQL: учёт всех запросов, числа вызовов и суммарного времени |
| Нормализованный запрос | Запрос, где значения заменены на $1, $2, чтобы похожие считались одним |
| План выполнения (plan) | Способ, которым база решила выполнить запрос |
EXPLAIN ANALYZE |
Показывает план и реально выполняет запрос, замеряя время и страницы |
| Seq Scan | Чтение всей таблицы подряд с фильтрацией |
| Index Scan, Bitmap Index Scan | Поиск строк через индекс |
| Rows Removed by Filter | Сколько строк прочитано и отброшено: признак напрасной работы |
| Индекс | Дополнительная структура для быстрого поиска, замедляет запись, занимает место |
CREATE INDEX CONCURRENTLY |
Построение индекса без блокировки записи, на боевых таблицах |
| N+1 | Один запрос списка и ещё N запросов по каждому элементу вместо двух |
shop_db_pool_waiting |
Сколько запросов прямо сейчас ждут свободное соединение |
max_connections |
Максимум одновременных соединений к PostgreSQL |
Статистика (ANALYZE) |
Данные о таблице, по которым планировщик оценивает стоимость плана |
Вопросы с собеседований
Раздел для повторения: ответь вслух, потом открой ответ.
1. [junior] [часто] Как найти самый вредный запрос к базе?
Ответ
Использовать pg_stat_statements: сбросить статистику, прогнать нагрузку, отсортировать по total_exec_time. Смотреть, из чего складывается время: из дороговизны одного вызова (mean_exec_time) или из их количества (calls). Затем разобрать план EXPLAIN (ANALYZE, BUFFERS).
Что хотят услышать: учёт, сортировка по суммарному времени, сброс перед замером, EXPLAIN.
Красный флаг: искать только по логу медленных запросов или по mean.
2. [junior] [часто] Что показывает EXPLAIN ANALYZE и на что смотреть первым?
Ответ
План выполнения с реальным временем, строками и страницами. Сначала на самый глубокий узел: Seq Scan на большой таблице, Rows Removed by Filter (сколько строк прочитано напрасно), Buffers (сколько страниц), разницу оценки и реального числа строк.
Что хотят услышать: Seq Scan против Index Scan, Rows Removed by Filter, предупреждение, что ANALYZE выполняет запрос.
Красный флаг: путают cost с миллисекундами.
3. [junior] [часто] Когда индекс помогает и когда вредит?
Ответ
Помогает, когда запрос выбирает малую долю большой таблицы по этому столбцу. Вредит, когда их слишком много: замедляют запись, занимают место и память. Если нужна большая часть таблицы, планировщик всё равно выберет полный перебор. Индекс создают под конкретный частый запрос, проверяя планом.
Что хотят услышать: оба направления, цена записи, избирательность.
Красный флаг: «индекс на каждый столбец».
4. [junior] Что такое N+1 и как его заметить?
Ответ
Один запрос списка и N запросов по каждому элементу. Заметить по pg_stat_statements: у быстрого запроса огромное число вызовов, кратное числу вызовов родительского. Лечится одним запросом WHERE id = ANY(...) или JOIN.
Что хотят услышать: признак по числу вызовов, лечение сменой числа запросов.
Красный флаг: «каждый запрос быстрый, значит, проблем нет».
5. [junior] Что делает пул соединений и почему нельзя поставить его размер в сто?
Ответ
Пул держит открытые соединения и раздаёт их запросам, чтобы не платить за открытие. Большой пул не ускоряет, если упёрся процессор базы: все запросы идут одновременно и делят то же ядро. Растёт память, и общий лимит max_connections быстро кончается, особенно при нескольких воркерах.
Что хотят услышать: пул на воркер, лимит базы, очередь переезжает в базу.
Красный флаг: «поставлю пул побольше, чтобы не ждали».
6. [middle] Как понять, что пул причина, а не следствие?
Ответ
Смотреть процессор базы и время запросов: если процессор базы упёрт, соединения заняты дольше из-за него, и пул следствие. Если процессор базы низкий, запросы быстрые, а pool waiting высок, то пул мал или соединения удерживаются зря (например, транзакция держит соединение и ждёт внешний сервис, это тема урока 11.5). Проверка: расширить пул и повторить прогон.
Что хотят услышать: порядок «сначала процессор базы», проверка изменением.
Красный флаг: лечат оба симптома сразу.
7. [middle] Как создать индекс на боевой таблице безопасно?
Ответ
CREATE INDEX CONCURRENTLY: не блокирует запись, но дольше строится и не может идти внутри транзакции. Перед этим убедиться, что хватит места и нагрузки, после выполнить ANALYZE, проверить планом, что индекс используется, и следить за временем записи.
Что хотят услышать: CONCURRENTLY, проверка планом, цена записи.
Красный флаг: обычный CREATE INDEX на горячей таблице в рабочее время.
8. [middle] Индекс создан, а запрос всё равно медленный. Почему?
Ответ
Планировщик его не выбрал: устаревшая статистика (нужен ANALYZE), условие не совпадает с индексом (функция над столбцом, несовпадение типов, LIKE '%x'), нужна большая доля таблицы, составной индекс в другом порядке столбцов. Бывает и так, что индекс используется, а запрос ждёт блокировку или чтение с диска. Проверка: EXPLAIN и смотреть узел.
Что хотят услышать: несколько причин и инструмент проверки.
Красный флаг: «значит, индекс не работает, создам ещё один».
9. [middle] Сколько соединений нужно сервису при 100 запросов в секунду и удержании 50 мс?
Ответ
По закону Литтла: 100 × 0,05 = 5 соединений занято в среднем. С запасом на всплески 10-15. Но удержание зависит от процессора базы: если он насыщен, удержание растёт, и цифры устаревают, поэтому проверяют под нагрузкой.
Что хотят услышать: формулу и оговорку про зависимость времени удержания.
Красный флаг: «ставлю 100, это безопасно».
10. [на скорость] Как называется чтение всей таблицы в плане PostgreSQL?
Ответ
Seq Scan (последовательное чтение).
11. [на скорость] Как сбросить статистику запросов перед замером?
Ответ
SELECT pg_stat_statements_reset();
12. [на скорость] Как по pg_stat_statements понять, что есть N+1?
Ответ
У быстрого запроса число вызовов в N раз больше, чем у родительского запроса.
Проверено на версиях
PostgreSQL 18.6 с pg_stat_statements (track=all), планы записаны в формате PostgreSQL 18 (названия узлов те же, что в 14-17), psycopg-pool в образе shop, Prometheus 3.15, k6 2.3. Числа в таблицах рассчитаны моделью по коду стенда и могут отличаться на твоём железе; последовательность «индекс, N+1, пул» и форма изменений должны совпасть. Октябрь 2026.
Итог урока: ты умеешь
- Найти самый вредный запрос по
pg_stat_statementsс учётом суммарного времени и числа вызовов. - Прочитать
EXPLAIN (ANALYZE, BUFFERS): Seq Scan, Rows Removed by Filter, страницы, время. - Создать индекс под конкретный запрос и подтвердить эффект планом и нагрузочным прогоном.
- Узнать N+1 по числу вызовов и убрать его.
- Прочитать метрики пула и отличить пул-причину от пула-следствия.
- Применять по одному изменению и видеть, как узкое место переезжает.
- Записать расследование таблицей «изменение, p95, выполнено» с выводом.
Дальше: урок 11.4. Память и утечки: тест на выносливость, где база уже здорова, но приложение медленно пожирает память.
Проверь себя
Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.
Тест работает с включённым JavaScript.