Конкурентность и база данных

Перед началом: модули 6–7, 9, 13 (пулы, таймауты, очереди). Инструменты: Go 1.22+, PostgreSQL 15+, pgx, docker. Трудоёмкость: ~4 ч (теория 1.5 ч · практика 2 ч · квиз 0.5 ч).

🔥 Инцидент: распараллелили шесть запросов — и уронили пул

Страница профиля собирает данные из шести источников: профиль, заказы, рекомендации, корзина, уведомления, настройки. Плюс тесты, метрики, health — кажется, что «всё уже работает». По очереди страница отдаётся за 1,2 с, и продукт просит хотя бы 400 мс.

Инженер заменил цикл на шесть горутин — и на стенде страница стала быстрой. В проде под нагрузкой посыпались ошибки: пул соединений на инстанс — четыре, а каждый запрос к странице требовал шесть соединений одновременно. Часть горутин ждала свободное соединение, не дожидалась дедлайна ctx и возвращала ошибку context deadline exceeded. Соседние эндпоинты делили тот же пул и тоже начали падать.

Проблема не в горутинах, а в бюджете общего ресурса: параллелизм надо ограничивать размером пула и считать его на весь процесс, а не на один хендлер.

📖 Теория

14.1 Параллельные запросы: errgroup и лимит пула

errgroup.WithContext даёт три вещи: ожидание всех горутин, первую ошибку и отмену остальных. SetLimit(n) ограничивает число одновременных задач — это и есть мост между «хочу параллельно» и «пул всего четыре».

Правила:

  • лимит ≤ размер пула, иначе горутины встанут в очередь за соединением;
  • SetLimit должен учитывать соседние эндпоинты: пул общий;
  • результат пишется в свой индекс, а не append из горутин;
  • первая ошибка отменяет ctx — остальные запросы должны её увидеть, а не доработать впустую.
go
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(3) // не больше, чем соединений в пуле
for i, q := range queries {
    g.Go(func() error {
        rows, err := db.Query(ctx, q.SQL, q.Args...)
        if err != nil {
            return err
        }
        defer rows.Close()
        results[i] = scan(rows)
        return nil
    })
}
if err := g.Wait(); err != nil {
    return nil, err
}

14.2 N+1: один ANY, а не сто запросов

Классика: получить 50 заказов, затем для каждого сходить за пользователем — 51 round trip. При RTT 2 мс это 100 мс чистой сетевой задержки, при 20 мс — уже секунда.

Решение — один запрос с массивом идентификаторов:

sql
SELECT id, name FROM users WHERE id = ANY($1);

pgx принимает []int64 как один параметр. Дальше — собрать map[id]user и разложить по заказам. IN со сгенерированными плейсхолдерами тоже работает, но ANY даёт один параметр и стабильный план. Для очень больших списков режьте на чанки по 500–1000: запрос с десятью тысячами параметров сам становится проблемой.

14.3 Batch и COPY: меньше round trip

pgx.Batch отправляет несколько запросов одним пакетом и читает ответы по порядку — это экономит round trip, но не делает операции транзакцией: транзакцию оборачивайте явно. Batch хорош для разнородных мелких операций: обновить три таблицы, вставить десяток строк.

Для массовой загрузки есть COPY: pgx.CopyFrom передаёт строки потоком и на порядки быстрее, чем INSERT на строку. Правило простое: единичные операции — batch; однородный объём — COPY; сотни тысяч строк — COPY чанками в транзакциях, чтобы не держать гигантский WAL и не ловить OOM.

14.4 ctx и statement_timeout: отмена доезжает до сервера

QueryContext при отмене ctx посылает PostgreSQL cancel request — запрос на сервере действительно прерывается, а не просто перестаёт ждать в клиенте. Это важно: иначе «отменённый» запрос продолжил бы жечь CPU и держать блокировки.

Но ctx живёт в клиенте. Если клиент убит или сеть разорвалась, отменять некому — поэтому на сервере нужен страховочный statement_timeout (и idle_in_transaction_session_timeout). Клиентский дедлайн должен быть меньше серверного, чтобы приложение успело вернуть понятную ошибку.

Внутри транзакции отмена переводит её в aborted: нужен rollback, а соединение из пула лучше не переиспользовать до чистого состояния.

14.5 Размер пула: GOMAXPROCS × инстансы vs max_connections

database/sql и pgxpool держат пул на процесс. Формула простая:

инстансы × MaxConns ≤ max_connections − резерв.

PostgreSQL не любит сотни соединений: каждое — процесс и память. Ориентир для OLTP — MaxConns порядка GOMAXPROCS (иногда 2×), дальше растёт конкуренция, а не пропускная способность.

Если инстансов много, между приложением и БД ставят PgBouncer в transaction-режиме: он мультиплексирует соединения и позволяет держать max_connections небольшим. Плата — ограничения на session-состояние: prepared statements, advisory locks, SET сессии. Проверяйте, что клиентская библиотека это переживает.

14.6 Компромиссы

  • Параллелизм шире vs уже: шире — быстрее страница, но пул делится; уже — стабильнее под нагрузкой.
  • N+1 vs один большой join: join убирает round trip, но может размножить строки и усложнить план; ANY — компромисс.
  • Batch больше vs меньше: больше экономит round trip, но держит память и блокировки; меньше — предсказуемее.
  • Клиентский таймаут vs statement_timeout: только клиентский не остановит запрос при падении клиента; только серверный — поздно отвечает пользователю.
  • Пул больше vs PgBouncer: больше соединений проще, но упирается в память PostgreSQL; PgBouncer экономнее, но требует дисциплины session-состояния.

💻 Практика

  1. Воспроизведите N+1. Загрузите 100 заказов и для каждого отдельно пользователя; посчитайте число запросов и время. Перепишите на один WHERE id = ANY($1) и сравните.
  2. Ограничьте параллелизм. Шесть запросов через errgroup.SetLimit(3) при пуле 4. Затем поднимите лимит до 6 и покажите рост ожидания на соединение и число таймаутов.
  3. Сравните загрузку. 100 000 строк: построчный INSERT, pgx.Batch и CopyFrom. Замерьте время, память и размер WAL; объясните разрыв.
  4. Запустите сниппет модуля: он покажет 15 тиков последовательно, 5 тиков при пуле 3 (ускорение 3.0x), раскладку запросов по слотам и число отработавших запросов — 6.

🧩 Quiz

1. Зачем ограничивать параллельные запросы размером пула?
2. Как убрать N+1 при загрузке пользователей для списка заказов?
3. Зачем нужен statement_timeout, если есть отмена через ctx?
4. Чем pgx.Batch отличается от COPY?
5. Как связаны размер пула и max_connections?

✅ Итоги модуля

Студент должен:

  • Ограничивать параллельные запросы через errgroup.SetLimit
  • Считать бюджет пула на весь процесс, а не на хендлер
  • Убирать N+1 одним ANY и сборкой map
  • Выбирать batch или COPY под характер нагрузки
  • Понимать, как отмена ctx доходит до PostgreSQL
  • Ставить statement_timeout как серверную страховку
  • Считать размер пула и знать, зачем PgBouncer
  • Ответить на все вопросы Quiz

🔗 Мостик к Модулю 15

База перестала быть узким местом на отдельных запросах — но остался горячий ключ, который истекает в час пик и отправляет сотни одинаковых промахов прямо в неё. Дальше — кэш, singleflight и защита от thundering herd.