---
title: "Batch: сотни запросов за один рейс"
description: "pgx.Batch: Queue, SendBatch и BatchResults, порядок результатов, неявная транзакция пачки, цена забытого Close, ловушка DDL. Массовые запросы из Go."
course: "PostgreSQL в Go: драйвер pgx с нуля до продакшена"
url: "https://golangmaster.ru/courses/pgx/lessons/18-batch-zaprosy/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Batch: сотни запросов за один рейс

*Курс «PostgreSQL в Go: драйвер pgx с нуля до продакшена», урок 18 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/pgx/lessons/18-batch-zaprosy/*

Массовые запросы из Go к Postgres по умолчанию платят сетевым рейсом за каждую команду, и pgx.Batch убирает этот налог: Queue складывает сотни команд в очередь, SendBatch отправляет их одной поездкой, а BatchResults отдаёт результаты строго в порядке постановки. Разберём механику пачки, неявную транзакцию, цену забытого Close, ловушку DDL и границу, где batch уступает COPY.

Импорт справочника на пятьсот записей через цикл Exec — это пятьсот отдельных сетевых рейсов: каждый запрос уезжает на сервер, ждёт ответа и только потом уступает место следующему. При локальной базе с задержкой в полмиллисекунды дорога съедает четверть секунды, при удалённой с двадцатью миллисекундами на рейс — десять секунд чистого ожидания, хотя сам сервер тратит на команду доли миллисекунды. pgx.Batch закрывает эту проблему: команды складываются в очередь, отправляются одним пакетом, и сервер обрабатывает их без пауз на дорогу — работает конвейер расширенного протокола, где запросы и ответы идут потоком, а не ходят парами запрос-ответ. API из трёх частей: batch := &pgx.Batch{} создаёт пачку, batch.Queue(sql, args...) ставит запрос в очередь вместе с аргументами, conn.SendBatch(ctx, batch) или pool.SendBatch возвращает BatchResults — ридер результатов. Метод Len() показывает число постановок и заслуживает места в каждом логе массовой операции. За экономию рейсов платят дисциплиной: результаты читаются строго в порядке очереди, Close обязателен, а поведение при сбое посреди пачки подчиняется правилам неявной транзакции. Терминологическая граница пригодится в разговорах с коллегами: транзакция из тринадцатого урока — про границу фиксации, пачка — про транспорт, и совпадают они только внешне. Порядок урока: механика очереди, порядок чтения, судьба пачки при ошибке, ловушка DDL и граница, где пачка уступает COPY из следующего урока.

### Механика: Queue, SendBatch, BatchResults

Очередь строится без сети: Queue только запоминает SQL и аргументы и возвращает *QueuedQuery — указатель на постановку. SendBatch отправляет всё скопом и отдаёт BatchResults, дальше контракты совпадают с обычными вызовами: br.Exec() читает результат следующей команды, как обычный Exec — CommandTag и ошибку; br.Query() открывает курсор следующего SELECT со знакомым циклом rows.Next из пятого урока; br.QueryRow() даёт Row со Scan и ErrNoRows. Единственное правило — порядок: первый вызов читает результат первой постановки, второй — второй, и перепрыгнуть нельзя. Пачка живёт на одном коннекте: у *pgx.Conn соединение занято с момента SendBatch до Close — документация формулирует это буквально, ридер обязан быть закрыт до следующего использования коннекта. Пул в pool.SendBatch берёт коннект из стока, а возвращает ровно в Close — в исходниках pgxpool Release вызывается именно там. Отсюда рефлекс: defer br.Close() пишется сразу после SendBatch, и между ними не должно быть ни одного return. Переиспользовать Batch нельзя: комментарий в исходниках гласит, что пачка отправляется один раз — новая операция собирает новый объект. Len перед отправкой — дешёвая проверка, что цикл поставил столько команд, сколько ожидалось: пустая пачка уедет и вернёт пустой набор, а лишний Exec на исчерпанной очереди вернёт ошибку no more results in batch — увидим её в интерактиве ниже.

### Пятьсот платежей одной пачкой

```go
func savePayments(ctx context.Context, pool *pgxpool.Pool,
        payments []Payment) error {

        batch := &pgx.Batch{}
        for _, p := range payments {
                batch.Queue(
                        "INSERT INTO payments (order_id, amount) VALUES ($1, $2)",
                        p.OrderID, p.Amount)
        }
        log.Printf("в пачке %d команд", batch.Len())

        br := pool.SendBatch(ctx, batch)
        defer br.Close()

        for range payments {
                if _, err := br.Exec(); err != nil {
                        return err
                }
        }
        return nil
}
```

Очередь собирается в памяти без сети, затем одна SendBatch увозит все INSERT за одну поездку. Чтение идёт циклом по числу постановок: br.Exec() выдаёт CommandTag каждой вставки по очереди. defer br.Close() стоит сразу после SendBatch — с пулом Close возвращает коннект в сток, без него сток истощается под нагрузкой. Лог batch.Len() перед отправкой ловит рассинхрон цикла и ожиданий ещё до поездки.

### Неявная транзакция: пачка падает целиком

Документация SendBatch формулирует жёстко: все запросы пачки выполняются в неявной транзакции, если вы сами не послали команды управления транзакциями. Практическое следствие: сбой посреди пачки откатывает уже выполненные команды и рвёт остаток — четвёртый INSERT упал, значит первые три не фиксируются, а пятый и дальше вернут ошибку. Пачка по умолчанию — атомарный блок: либо всё, либо ничего, как транзакция из тринадцатого урока, только без явных BEGIN и COMMIT. Нужен частичный успех — режьте операцию на несколько независимых пачек по бизнес-группам: упавшая группа не тянет остальные и повторяется отдельно. Формулировка «если не послали команды управления» — это не формальность: BEGIN и COMMIT можно положить в очередь обычными элементами пачки, и границы сместятся, но такой стиль быстро становится нечитаемым — явная tx.SendBatch из четырнадцатого урока честнее. Ошибка в середине — обычная PgError из шестнадцатого урока: br.Exec() вернёт её на соответствующем месте очереди, 23505 от дубликата валит пачку целиком, а все последующие чтения возвращают ту же ошибку — конвейер уже оборван. Отсюда правило идемпотентности: пачку после сбоя можно отправить заново целиком, неявный откат гарантирует, что дубликатов внутри не останется. Тонкость для продакшена: журналировать стоит только факт провала группы и её размер — собирать «сколько успело» бессмысленно, успело ноль.

### Проверка: порядок результатов

**Вопрос:** В пачку поставили UPDATE, затем SELECT, затем ещё UPDATE. Как читать результаты?

- Сначала все Exec, потом Query — по типам команд
- ✅ br.Exec(), br.Query(), br.Exec() — строго в порядке постановки
- В любом порядке: результаты помечены индексами
- Query нельзя вызывать на BatchResults

BatchResults — ридер по очереди: каждый вызов выдаёт результат следующей постановки, порядок Queue обязателен. Тип вызова должен соответствовать типу команды в очереди. Индексов у результатов нет — только позиция в очереди.

### Close и коннект: цена забытого ридера

BatchResults.Close читает все непрочитанные результаты, выполняет зарегистрированные колбэки, завершает операцию и освобождает коннект — на уровне пула это буквальный Release. Забытый Close означает коннект, который не вернулся в сток: при MaxConns из четвёртого урока достаточно нескольких утечек в минуту, чтобы сервис замер на Acquire без единой ошибки в логе — просто медленно, потом наглухо. Рецепт двумя строками: defer br.Close() сразу после SendBatch, повторный вызов безопасен — исходники гарантируют, что второй Close вернёт ту же ошибку без побочных эффектов. Сам Close возвращает агрегированную ошибку пачки: если нечитанные результаты содержали сбой, вы узнаете здесь — в сценариях с неполным чтением ошибку Close стоит логировать отдельно, она последняя, кто видел проблему. Вторая грань — параллельность: пачка выполняется на одном коннекте последовательно, распараллелить её внутренности между соединениями нельзя; масштабирование достигается несколькими SendBatch из разных горутин, каждая со своей пачкой, и потолок задаёт размер пула. Диагностика свободна: pool.Stat() в срезе метрик показывает занятые и ожидающие Acquire — ступенька вниз на графике свободных коннектов сразу указывает на утечку ридеров. Третья грань — память: очередь живёт в приложении до отправки, миллион Queue с аргументами съедает сотни мегабайт, поэтому большие объёмы режьте на пачки по несколько тысяч команд или переходите на COPY.

### Разнородная пачка: вставка, выборка, обновление

```go
batch := &pgx.Batch{}
batch.Queue("INSERT INTO notes (title) VALUES ($1)", "черновик")
batch.Queue("SELECT count(*) FROM notes")
batch.Queue("UPDATE notes SET archived = true WHERE title = $1",
        "черновик")

br := pool.SendBatch(ctx, batch)
defer br.Close()

if _, err := br.Exec(); err != nil {
        log.Fatal(err)
}
var n int
if err := br.QueryRow().Scan(&n); err != nil {
        log.Fatal(err)
}
if _, err := br.Exec(); err != nil {
        log.Fatal(err)
}
fmt.Println("заметок:", n)
```

Три разных контракта в одной пачке: Exec, QueryRow, снова Exec — вызовы идут ровно в порядке постановки. QueryRow внутри пачки ведёт себя как обычный: Scan и ErrNoRows на пустой выборке. Пачка уезжает одним пакетом, хотя команды разнородные: сетевая экономия не зависит от типа команд. Сбой порядка чтения тихо сместит результаты — третий вызов прочитает чужой ответ, и код получит не то, что ожидал.

### Что вернёт третий Exec?

```go
batch := &pgx.Batch{}
batch.Queue("SELECT 1")
batch.Queue("SELECT 2")

br := pool.SendBatch(ctx, batch)
defer br.Close()

_, _ = br.Exec()
_, _ = br.Exec()
_, err := br.Exec()
fmt.Println(err)
```

**Что выведет программа?**

- nil — лишний Exec просто ничего не делает
- ✅ ошибка no more results in batch — очередь исчерпана
- паника на нулевом результате
- nil, но CommandTag повторит последний

Результатов ровно столько, сколько постановок: два SELECT прочитаны, третий Exec упирается в пустую очередь и получает ошибку no more results in batch. После Close на том же ридере ошибка другая — batch already closed.

### Выбор инструмента для множества команд

| Инструмент | Что посылает | Объём | Сценарий |
| --- | --- | --- | --- |
| Цикл Exec | по одному рейсу на команду | единицы | разовые правки, скрипты обслуживания |
| pgx.Batch | все команды одним пакетом | десятки–тысячи | разнородные команды, справочники, пачки вставок |
| Batch внутри tx | пачка под явным BEGIN/COMMIT | десятки–тысячи | нужен свой контур фиксации относительно других операций |
| COPY FROM | бинарный поток строк | десятки тысяч+ | однотипная вставка без конфликтов — следующий урок |

### Ловушка DDL и подготовленных стейтментов

Дефолтный режим выполнения pgx — QueryExecModeCacheStatement: перед выполнением пачки новые запросы готовятся все сразу, одним конвейером описаний. Документация предупреждает о следствии прямо: если в одной пачке сначала CREATE TABLE, а потом INSERT в неё, вставка может упасть — на момент подготовки стейтмента таблицы ещё нет, хотя CREATE формально уже выполнен. Ловушка расставляет границы: DDL в пачку не кладётся — схема меняется отдельной поездкой через Exec, как в пятом уроке, а пачка работает только с данными. Вторая тонкость: постановка выполняется в дефолтном режиме коннекта, поэтому поведение пачки зависит от QueryExecMode — на простом протоколе пачка склеивается в одну строку через точку с запятой, и правила игры другие; тесты обязаны гоняться на том же режиме, что и продакшен, иначе проверяется чужой драйвер. Аргументы каждой постановки едут через расширенный протокол отдельно, значит правила типизации из восьмого урока действуют на каждую команду в отдельности — nil против пустой строки и прочие сюрпризы не растворяются в массовой отправке. Сверхдлинный SQL внутри Queue — сигнал, что логика живёт не там: пачка экономит дорогу, а не чинит кривой план.

> **Живое место: SendBatch без Close.** История из практики: после рефакторинга хендлер начал возвращать ошибку до чтения всех результатов, и через час под нагрузкой сервис замер — коннекты пула прицеплены к ридерам пачек, Acquire стоит вечно, health-check красный. Если BatchResults не закрыт, то коннект пула не возвращается, даже когда все данные уже прочитаны. Лекарство мгновенное: defer br.Close() сразу после SendBatch, а ошибку Close логировать отдельно. После починки графики pool.Stat() показывают ровный пилообразный пул вместо ступеньки вниз, которая и выдала утечку.

### Правила работы с Batch

- Queue строит очередь без сети, SendBatch отправляет одним пакетом
- Результаты читаются строго в порядке постановки — перепрыгнуть нельзя
- defer br.Close() сразу после SendBatch: с пулом Close возвращает коннект
- Пачка отправляется один раз; новая операция — новый Batch
- По умолчанию пачка — неявная транзакция: сбой откатывает всё выполненное
- Частичный успех — несколько пачек по независимым группам
- DDL из пачек выносится: подготовка стейтментов опережает выполнение
- Миллионы однотипных строк — не batch, а COPY из следующего урока

### Проверка: сбой в середине пачки

**Вопрос:** Пачка из пяти INSERT: третий падает с ошибкой. Что стало с первыми двумя?

- Закоммитились — каждый INSERT сам по себе
- ✅ Откатились: пачка по умолчанию выполняется в неявной транзакции
- Остались в подвешенном состоянии до Close
- Закоммитились, но только при DO NOTHING

Документация SendBatch: запросы пачки идут в неявной транзакции, если не посланы явные команды управления. Ошибка откатывает выполненное и рвёт остаток. Именно поэтому упавшую пачку можно отправить заново целиком без риска дубликатов.

### Соберите пачку

Восстановите каркас: очередь, отправка, закрытие, чтение.

```go
batch := &pgx.___{}
batch.Queue("INSERT INTO tags (name) VALUES ($1)", "go")

br := pool.___(ctx, batch)
defer br.___()

if _, err := br.___(); err != nil {
        return err
}
```

Подсказка: Создаётся Batch, отправляется SendBatch, закрывается Close, чтение пачки — Exec.

Правильные ответы в пропусках: `Batch`, `SendBatch`, `Close`, `Exec`

### Соберите разнородную пачку

Расставьте строки: очередь из двух команд, отправка, чтение по порядку.

Правильный порядок строк:

1. `batch := &pgx.Batch{}`
2. `batch.Queue("INSERT INTO tags (name) VALUES ($1)", "go")`
3. `batch.Queue("SELECT count(*) FROM tags")`
4. `br := pool.SendBatch(ctx, batch)`
5. `defer br.Close()`
6. `if _, err := br.Exec(); err != nil { return err }`
7. `var n int`
8. `if err := br.QueryRow().Scan(&n); err != nil { return err }`
9. `log.Print(n)`

### Карточки: pgx.Batch

- **pgx.Batch** — Очередь запросов для отправки одним пакетом. Queue ставит команду, Len считает, объект отправляется один раз.
- **SendBatch** — Отправляет пачку и возвращает BatchResults. На *Conn занимает коннект до Close; у пула берёт коннект из стока.
- **BatchResults** — Ридер результатов: Exec, Query, QueryRow — строго в порядке Queue. Close читает остаток и освобождает коннект.
- **Неявная транзакция пачки** — Сбой посреди пачки откатывает выполненное и рвёт остаток. Частичный успех — несколько независимых пачек.
- **Забытый Close** — Коннект пула не возвращается: пул истощается без ошибок в логе. Лечение — defer br.Close() сразу после SendBatch.
- **DDL в пачке** — В дефолтном режиме стейтменты готовятся заранее: CREATE TABLE и INSERT в неё в одной пачке могут не ужиться. DDL — отдельно.

> **Живое место: колбэки на QueuedQuery.** Queue возвращает *QueuedQuery с методами Exec, Query и QueryRow, принимающими колбэк: qq := batch.Queue(sql, args); qq.Exec(func(ct pgconn.CommandTag) error { ... }). Колбэки выполняются в Close по нечитанным постановкам — удобно для мелких пачек без ручного счётчика. Грабля с условием: если вы прочитали постановку вручную через br.Exec(), то её колбэк уже не выполнится — Close гонит функции только по остатку очереди; а если один колбэк вернул ошибку, то последующие не вызываются вовсе, и ошибка выезжает из Close.

### Когда batch, а когда COPY

Граница проходит по однородности и объёму. Пачка — про разнородность: вставить справочник, обновить счётчики, посчитать итоги — всё за одну поездку и с разными типами результатов. COPY — про объём: тысячи однотипных INSERT в одну таблицу через пачку всё равно готовят стейтмент на каждую постановку и тащат каждый результат, а COPY гонит бинарный поток строк без SQL вообще — механизм следующего урока. Между ними лежит ступенька мульти-VALUES: INSERT ... VALUES (...), (...) собирает много строк в один запрос, но упирается в потолок расширенного протокола — 65535 параметров на оператор, значит максимум строк в пачке равен 65535, делённому на число колонок; для широких таблиц потолок низкий, а длинные VALUES тяжки для планировщика. Ориентиры: единицы команд — обычный Exec, десятки-сотни — batch, тысячи — уже вопрос, десятки тысяч — точно COPY; финальное слово за замером на вашей базе. Атомарность тоже различается: пачка откатывается целиком при сбое, COPY — тоже, но конфликты уникальности он разрешать не умеет вовсе. Следующий урок покажет бенчмарк на глазах: одна и та же загрузка через цикл и через CopyFrom, цифры рядом.

Пачки сберегают рейсы и требуют дисциплины взамен: порядок чтения, Close, помните о неявной транзакции. Песочница урока исполняет чистый Go без сети, а pgx.Batch живёт только рядом с настоящим Postgres — гоняйте примеры локально или в Docker, подняв контейнер из первого урока. Дальше по курсу: в [девятнадцатом](/courses/pgx/lessons/19-copy-iz-zapisi/) — COPY FROM и миллионы строк; в [шестнадцатом](/courses/pgx/lessons/16-oshibki-pgx/) — pgconn.PgError, который приедет из середины пачки; в [тринадцатом](/courses/pgx/lessons/13-tranzakcii/) — явные транзакции, рядом с которыми пачка получает предсказуемый контур фиксации; в [пятнадцатом](/courses/pgx/lessons/15-kontekst-tajmauty/) — дедлайн, который стоит надеть на любую большую отправку. Домашнее задание превращает тройку Queue, SendBatch, Close в рефлекс.

## Коротко: что запомнить

- pgx.Batch собирает очередь команд: Queue ставит, Len считает, SendBatch отправляет одним пакетом.
- Результаты читаются строго в порядке постановки: Exec, Query или QueryRow на каждый элемент очереди.
- defer br.Close() сразу после SendBatch — с пулом Close возвращает коннект, без него сток истощается.
- Пачка по умолчанию — неявная транзакция: сбой откатывает выполненное и рвёт остаток.
- Batch отправляется один раз; конкурентность — несколько пачек, а не переиспользование.
- DDL в пачку не кладётся: подготовка стейтментов опережает CREATE TABLE.
- Память очереди живёт в приложении: большие объёмы режутся на пачки или уходят в COPY.
- Колбэки QueuedQuery выполняются в Close по нечитанным постановкам.

## Домашнее задание

Напишите загрузку пятисот тегов двумя способами: циклом Exec и через pgx.Batch, замерьте time.Since обоих на локальной базе и запишите разницу в комментарий. Сломайте третий INSERT пачки дубликатом и проверьте, что первые два откатились, а пятый вернул ошибку. Добавьте defer br.Close() и уберите его — посмотрите через pool.Stat(), как меняется число свободных коннектов. Соберите разнородную пачку из INSERT, SELECT и UPDATE и убедитесь, что чтение результатов по порядку — единственный работающий вариант.

## Вопросы и ответы по уроку

### Batch и транзакция — это одно и то же?

Нет. Транзакция — граница фиксации данных, batch — транспорт: экономия сетевых рейсов. Пачка по умолчанию выполняется в неявной транзакции, поэтому внешне похожа на атомарную операцию, но назначение у неё другое. Для контроля границ оборачивайте пачку в явную tx.SendBatch — тогда фиксация согласована с остальными операциями вашей транзакции.

### Сколько команд ставить в одну пачку?

Ориентир — от сотен до пары тысяч: очередь живёт в памяти приложения, а каждый стейтмент занимает место на сервере при подготовке. Сверх этого режьте на блоки и отправляйте последовательно. Точная граница зависит от размера аргументов и памяти — измеряйте на своих данных, а не по чужим цифрам.

### Почему результаты нельзя читать в произвольном порядке?

Потому что BatchResults — ридер потока: сервер присылает результаты в порядке постановки, и каждый вызов выдаёт следующий. Индексов и ключей у результатов нет. Если нужен произвольный доступ, разложите результаты по переменным в порядке чтения или разбейте операцию на несколько пачек.
