PostgreSQL CookbookUse casesКлиника анти-паттернов приложения
0 / 63 (0%)

Клиника анти-паттернов приложения

База у 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'ы, а не время: время зависит от железа и сети, а число обращений к базе — это структурный факт кода, и его видно сразу.

plaintext
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-sargableWHERE lower(email) = … по индексу на emailиндекс по (lower(email))план: Seq Scan → Index Scan
глубокий OFFSETLIMIT 10 OFFSET 40000keyset WHERE id > 40000прочитано строк: 40010 → 10
огромный INid 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 здесь не у дел.

Запуск

sh
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-clinic

T=run — режим по умолчанию, его можно не писать. Изнутри каталога юнита короче: make db-reset, затем make run. Это capstone, поэтому у юнита есть и make test — он гоняет интеграционный тест, который ассертит ровно эти числа (4 round-trip'а, 9 vs 2 столбца, Seq→Index, 40010 vs 10, 1000 строк).

plaintext
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 держит и переиспользует коннекты, и где сервис незаметно исчерпывает пул.

·Модуль 11

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Use cases / Клиника анти-паттернов приложения