Конкурентность и база данных
Перед началом: модули 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— остальные запросы должны её увидеть, а не доработать впустую.
gog, 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 мс — уже секунда.
Решение — один запрос с массивом идентификаторов:
sqlSELECT 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-состояния.
💻 Практика
- Воспроизведите N+1. Загрузите 100 заказов и для каждого
отдельно пользователя; посчитайте число запросов и время.
Перепишите на один
WHERE id = ANY($1)и сравните. - Ограничьте параллелизм. Шесть запросов через
errgroup.SetLimit(3)при пуле 4. Затем поднимите лимит до 6 и покажите рост ожидания на соединение и число таймаутов. - Сравните загрузку. 100 000 строк: построчный
INSERT,pgx.BatchиCopyFrom. Замерьте время, память и размер WAL; объясните разрыв. - Запустите сниппет модуля: он покажет 15 тиков последовательно, 5 тиков при пуле 3 (ускорение 3.0x), раскладку запросов по слотам и число отработавших запросов — 6.
🧩 Quiz
✅ Итоги модуля
Студент должен:
- Ограничивать параллельные запросы через
errgroup.SetLimit - Считать бюджет пула на весь процесс, а не на хендлер
- Убирать N+1 одним
ANYи сборкойmap - Выбирать batch или
COPYпод характер нагрузки - Понимать, как отмена
ctxдоходит до PostgreSQL - Ставить
statement_timeoutкак серверную страховку - Считать размер пула и знать, зачем
PgBouncer - Ответить на все вопросы Quiz
🔗 Мостик к Модулю 15
База перестала быть узким местом на отдельных запросах — но
остался горячий ключ, который истекает в час пик и отправляет
сотни одинаковых промахов прямо в неё. Дальше — кэш, singleflight
и защита от thundering herd.