Клиника анти-паттернов приложения
База у Brew здоровая: индексы на месте, схема нормальная, железо не перегружено.
А дашборд всё равно мигает красным — ручка меню тормозит, поиск по email уходит в
секунды, листалка заказов на сороковой странице падает по таймауту. DBA смотрит в
pg_stat_statements и пожимает плечами: «запросы простые, ничего не сломано». И он
прав — сломана не база, сломан способ, которым приложение в неё ходит. Это
типичная картина: пять одинаковых смолов, которые легко завести в любом сервисе и
почти невозможно увидеть на синтетике из одной строки. Каждый — лечится, и лечится
не вкусовщиной, а измеримо: меньше round-trip'ов, меньше столбцов, другой план.
Разберём пять болезней подряд, с лекарством для каждой.
1. N+1 → батч
Сервис показывает список клиентов и для каждого дотягивает заказы. Наивный код берёт
клиентов одним запросом, а потом в цикле спрашивает заказы по запросу на клиента:
SELECT ... FROM orders WHERE customer_id = $1 — и так N раз. На наших данных это
1 + 3 = 4 round-trip'а до базы ради списка из трёх клиентов. Каждый запрос дешёвый,
но это четыре путешествия по сети туда-обратно; на тысяче клиентов их станет тысяча с
лишним, и тормозит не база, а латентность связи с ней.
Лечение — один батч-запрос на всех сразу:
WHERE customer_id = ANY($1::text[]). Один round-trip, тот же ответ — те же три
заказа. Мы считаем именно round-trip'ы, а не время: время зависит от железа и сети, а
число обращений к базе — это структурный факт кода, и его видно сразу.
N+1: список, затем запрос на каждого клиента
app ──①──► SELECT customers ──► [c1, c2, c3]
app ──②──► SELECT orders WHERE id = c1 ──► заказы c1
app ──③──► SELECT orders WHERE id = c2 ──► заказы c2
app ──④──► SELECT orders WHERE id = c3 ──► заказы c3
1 + N round-trip'ов до базы (здесь 1 + 3 = 4)
батч: один запрос на всех
app ──①──► SELECT orders WHERE customer_id = ANY([c1,c2,c3]) ──► все заказы
1 round-trip, тот же ответ2. SELECT * → явные столбцы
Ручке меню нужны два поля — название напитка и цена. А код пишет SELECT * FROM drinks и получает 9 столбцов вместо нужных двух (name, base_price). Семь
лишних столбцов едут по сети на каждый ряд каждого запроса — и это меньшая беда.
Большая — хрупкая привязка к схеме: добавили в drinks колонку, поменяли порядок
полей — и код, который сканирует «всё подряд» позиционно или тащит лишний payload,
тихо ломается или начинает возить мусор. Мы считаем столбцы напрямую — через field
descriptions ответа: 9 против 2.
SELECT name, base_price — это контракт: ровно то, что показывает меню, ни байтом
больше.
3. non-sargable → expression index
Поиск по email. WHERE email = '...' идёт по обычному индексу accounts_email_idx —
в плане Index Scan, всё хорошо. Но кто-то заворачивает колонку в функцию ради
регистронезависимости: WHERE lower(email) = '...'. Индекс построен по email, а не
по lower(email), поэтому планировщик не может им воспользоваться — план сваливается в
Seq Scan по всем 50k строк. Предикат стал non-sargable: функция на колонке
ослепляет индекс.
Лечение — индексировать ту же функцию, которой оборачиваешь колонку:
CREATE INDEX ... ON accounts_lab (lower(email)). После него тот же lower(email) = '...' снова едет Index Scan. Тип верхнего узла мы читаем прямо из EXPLAIN:
Index Scan → Seq Scan → снова Index Scan.
4. глубокий OFFSET → keyset
Постраничный вывод заказов через LIMIT/OFFSET. Чтобы отдать сорок первую страницу,
ORDER BY id LIMIT 10 OFFSET 40000 заставляет сканер прочитать и выбросить весь
префикс — 40010 строк ради десяти. Чем глубже листаешь, тем дороже страница: OFFSET
не «прыгает» к нужному месту, он честно прочитывает всё до него.
Keyset-листание помнит, где остановилось, и идёт от границы:
WHERE id > 40000 ORDER BY id LIMIT 10 читает ровно 10 строк на ту же страницу. Мы
меряем это не часами, а фактически прочитанными строками листового узла через EXPLAIN ANALYZE: 40010 против 10.
5. огромный IN → = ANY($1::bigint[])
Нужно достать тысячу строк по списку id. Код склеивает их прямо в текст запроса:
id IN (1,2,...,1000) — тысяча литералов в SQL-строке. Каждый такой запрос с новым
набором id — это новый, уникальный текст: его заново парсят и планируют, кэш планов
распухает, а при больших списках упираешься в лимит на число параметров.
= ANY($1::bigint[]) — это один параметр-массив на всю тысячу id. Текст запроса
один и тот же при любом наборе, парсится и планируется он однажды. Обе формы находят
те же 1000 строк — разница чисто в форме параметров, и она структурная.
Пять смолов одним взглядом
| Смол | Наивный код | Лечение | Что доказывает |
|---|---|---|---|
| N+1 | цикл WHERE customer_id = $1 × N | = ANY($1) (или JOIN/LATERAL) | round-trip'ы: 4 → 1 |
SELECT * | SELECT * — 9 столбцов | явный список полей | столбцы: 9 → 2 |
| non-sargable | WHERE lower(email) = … по индексу на email | индекс по (lower(email)) | план: Seq Scan → Index Scan |
| глубокий OFFSET | LIMIT 10 OFFSET 40000 | keyset WHERE id > 40000 | прочитано строк: 40010 → 10 |
| огромный IN | id IN (1,…,1000) литералами | = ANY($1::bigint[]) | один текст, один парс-план |
Каждая строка — болезнь, наивный код и лечение, и число, которое это доказывает. Чинятся все пять со стороны приложения, а не базы.
Что показывает наш код
cmd/demo/main.go — это сама клиника: пять функций, по одной на болезнь, каждая печатает
наивный вариант и его лечение рядом. setupLab строит два лабораторных стола по 50k
строк через generate_series (детерминированно, без random): events_lab с плотным
ключом id — для OFFSET/keyset и огромного IN; accounts_lab с email — для
non-sargable. N+1 крутится на базовых customers/orders из schema/brew.sql.
Перед замерами демо делает SET max_parallel_workers_per_gather = 0, чтобы планы были
воспроизводимы.
Почему raw-pgx (escape-hatch с go.mod, без sqlc): урок не про текст одного запроса,
а про то, как приложение разговаривает с базой — сколько round-trip'ов оно делает,
какой формы передаёт параметры, какой план получает. Это логика Go и чтение EXPLAIN, а
не один query.sql, поэтому sqlc здесь не у дел.
Запуск
docker compose up -d
make lecture L=10-use-cases/10-03-app-anti-patterns-clinic T=db-reset
make lecture L=10-use-cases/10-03-app-anti-patterns-clinicT=run — режим по умолчанию, его можно не писать. Изнутри каталога юнита короче:
make db-reset, затем make run. Это capstone, поэтому у юнита есть и make test — он
гоняет интеграционный тест, который ассертит ровно эти числа (4 round-trip'а, 9 vs 2
столбца, Seq→Index, 40010 vs 10, 1000 строк).
1) N+1 → батч (round-trip'ы до базы)
N+1: 3 клиентов → 4 запроса (1 список + 3 на заказы), заказов 3
батч: те же данные → 1 запрос (= ANY), заказов 3
2) SELECT * → явные столбцы (сколько данных тянем)
SELECT *: вернул 9 столбцов
SELECT name,...: вернул 2 столбца — ровно то, что показывает меню
3) non-sargable → expression index (план поиска по email)
email = ... → Index Scan (обычный индекс работает)
lower(email) = ... → Seq Scan (функция слепила индекс)
lower(email) = ... → Index Scan (после expression-индекса)
4) глубокий OFFSET → keyset (сколько строк реально прочитано)
OFFSET 40000 LIMIT 10: сканер прочитал 40010 строк ради 10
WHERE id > 40000 LIMIT 10: сканер прочитал 10 строк (та же страница)
5) огромный IN → = ANY($1::bigint[]) (форма параметров)
IN (1,2,...,1000): 1000 литералов в тексте запроса, нашли 1000 строк
= ANY($1::bigint[]): 1 параметр-массив на 1000 id, нашли 1000 строкВсе пять пар читаются одинаково: слева — что делает наивный код, справа — что меняет
лечение, и цифра, которая это доказывает. N+1 ужался с 4 запросов до одного; SELECT *
тянул 9 столбцов вместо 2; lower(email) уронил план в Seq Scan, а expression-индекс
вернул Index Scan; глубокий OFFSET прочитал 40010 строк ради десяти, keyset — ровно 10;
тысяча литералов в тексте схлопнулась в один параметр-массив, ответ при этом тот же —
1000 строк.
Заборчик
Это всё смолы на стороне приложения: база здорова, лечится код. Оговорки по каждому лечению:
- Keyset быстр под подходящим индексом, но ценой гибкости. Он не умеет прыгать на
произвольную страницу («покажи сразу 4000-ю») — идёт от границы предыдущей. Нужна
продукту нумерованная навигация по страницам — keyset не подойдёт; его механика
разобрана в 03-02, а индексы и чтение
EXPLAIN— в модуле 06. = ANY— не единственное лечение N+1. Тот же список с заказами часто естественнее собрать однимJOIN(модуль 04) илиLATERAL-подзапросом «топ-N на клиента» (08-05) — выбор зависит от того, что именно нужно вернуть.= ANY($1)чинит не только кэш планов. Один параметр-массив заодно обходит лимит на число параметров запроса, в который упирается гигантскийIN-список, и снимает повторный парс-план на каждом новом наборе id.- Non-sargable лечится строго одним правилом. Индексируй ту же функцию, которой
оборачиваешь колонку (
lower(email)в запросе →(lower(email))в индексе). Если в запросеlower, а индекс по сыромуemail— он бесполезен; подробнее в 06-03. SELECT *ломается не только сетью. Код, завязанный на «все столбцы», падает или начинает возить мусор, когда колонки добавили или поменяли местами. Явный список столбцов — это ещё и страховка от изменений схемы.
Что забрать с собой
Здоровую базу легко заставить болеть с пяти сторон — и все пять чинятся со стороны
приложения, измеримо, а не на вкус. N+1 — цикл из запросов вместо одного батча
(= ANY или JOIN/LATERAL); считаем round-trip'ы. SELECT * — лишние столбцы и
хрупкая привязка к схеме; перечисляй поля. Non-sargable — функция на колонке слепит
индекс; индексируй ту же функцию. Глубокий OFFSET — сканер читает весь префикс;
keyset читает только страницу. Огромный IN — тысяча литералов в тексте; один
параметр-массив = ANY($1). Общий знаменатель: смотри не на отдельный запрос, а на то,
как приложение в сумме разговаривает с базой — число обращений, форму параметров, план.
Дальше — последний capstone 10-04: пулинг соединений из приложения. Поговорим о том,
почему открывать соединение на запрос дорого, как pgxpool держит и переиспользует
коннекты, и где сервис незаметно исчерпывает пул.