16. Batch запросы
Представьте строителя, которому необходимо перевезти тысячу кирпичей со склада на стройплощадку. Если он погрузит в багажник легкового автомобиля ровно один кирпич, проедет через весь город, выгрузит его и вернется обратно — и так тысячу раз, — стройка затянется на месяцы, бензин сожжет весь бюджет, а водитель сойдет с ума. Любой здравомыслящий человек наймет грузовик, погрузит всю тысячу кирпичей сразу и сделает одну-единственную поездку.
В сетевом взаимодействии микросервисов с базами данных этот фундаментальный здравый смысл называется Batch-запросами (пакетными операциями).
Вместо того чтобы отправлять сотни и тысячи одиночных команд SELECT, INSERT, UPDATE или DELETE, заставляя операционную систему совершать тысячи сетевых пересылок туда и обратно, приложение формирует один уплотненный пакет данных. Для Go-разработчика, проектирующего высоконагруженные API, освоение пакетных операций — это кратчайший путь к десятикратному росту пропускной способности (throughput) и снижению задержек (latency).
Почему round-trip — враг
Даже в самом современном дата-центре скорость света в оптоволокне и кремниевых кристаллах конечна ($c \approx 200\,000 \text{ км/с}$). Время прохождения сетевого пакета от сервера приложений до сервера СУБД и обратно (Round-Trip Time, RTT) даже в пределах одной локальной сети составляет от 0.3 до 1.5 миллисекунд.
Каждый одиночный вызов db.Exec или db.Query совершает полный цикл:
- Go-приложение: сериализация аргументов в память буфера, системный вызов
sendto()ядра Linux. - Сетевой стек: фрагментация TCP/IP, передача через виртуальный коммутатор и физическую сетевую карту (NIC).
- Сервер СУБД: прерывание ядра, системный вызов
recvfrom(), парсер PostgreSQL, построение плана, выполнение и отправка ответа. - Возврат: чтение байтов из сокета, десериализация в Go-структуры.
Если вам нужно вставить 1 000 строк по одной, суммарное время составит:
$$\text{Время ожидания} = 1\,000 \times 1 \text{ мс} = 1 \text{ секунда!}$$
При этом сам процессор базы данных выполнял полезную работу лишь 2 миллисекунды. Остальные 998 миллисекунд система бессмысленно ждала подтверждения сетевых пакетов.
Пакетный запрос (Batch) уничтожает этот оверхед, упаковывая 1 000 операций в одну сетевую транзакцию.
Типы пакетных операций
1. Множественная вставка (Multi-row INSERT)
Вместо череды отдельных INSERT операторов данные объединяются в один SQL-запрос с множеством кортежей:
INSERT INTO events (user_id, type, payload) VALUES
($1, $2, $3),
($4, $5, $6),
($7, $8, $9);
База данных парсит синтаксическое дерево один раз и вставляет все строки в рамках одной атомарной операции.
2. Пакетная выборка по ключам (WHERE IN)
Вместо одиночных запросов вида SELECT ... WHERE id = 1 для каждого идентификатора, приложение формирует один запрос с предикатом WHERE id IN (...):
SELECT id, name FROM users WHERE id IN ($1, $2, $3, ...);
Это радикально сокращает число сетевых вызовов и позволяет оптимизатору применить Index Scan один раз по всему массиву ключей.
3. Пакетное обновление (Bulk UPDATE)
Обновление множества строк разными значениями без циклов можно выполнить с помощью выражения CASE или элегантно через виртуальную таблицу значений UPDATE ... FROM VALUES:
UPDATE users u SET
name = v.name,
email = v.email
FROM (VALUES
(1, 'Alice', 'alice@example.com'),
(2, 'Bob', 'bob@example.com')
) AS v(id, name, email)
WHERE u.id = v.id;
Это один запрос вместо N отдельных UPDATE. Аналогично для массового удаления используется конструкция DELETE FROM table WHERE id IN (...).
4. Множественные SQL-команды через разделитель ;
Протокол PostgreSQL позволяет отправить несколько разнородных команд в одном текстовом сообщении, разделяя их точкой с запятой ;. Сервер выполнит их последовательно без возврата управления в сеть между командами.
Реализация в Go: pgx.SendBatch
Для асинхронной пакетной отправки в Go драйвер pgx предоставляет мощнейший API — структуру pgx.Batch и метод conn.SendBatch:
package main
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
)
type Event struct {
UserID int64
Type string
Payload []byte
}
func InsertEventsBatch(ctx context.Context, conn *pgx.Conn, events []Event) error {
batch := &pgx.Batch{}
// Наполняем очередь батча операциями
for _, e := range events {
batch.Queue(
"INSERT INTO events(user_id, type, payload) VALUES($1, $2, $3)",
e.UserID, e.Type, e.Payload,
)
}
// Отправляем все команды единым TCP-пакетом по протоколу конвейеризации
br := conn.SendBatch(ctx, batch)
defer br.Close()
// Считываем результаты выполнения каждой операции из сетевого буфера
for i := 0; i < len(events); i++ {
_, err := br.Exec()
if err != nil {
return fmt.Errorf("batch insert failed at index %d: %w", i, err)
}
}
return br.Close()
}
А для пакетной выборки с динамическим числом аргументов в экосистеме Go традиционно используется библиотека sqlx.In:
package main
import (
"context"
"github.com/jmoiron/sqlx"
)
func GetUsersByIDs(ctx context.Context, db *sqlx.DB, ids []int64) ([]User, error) {
// sqlx.In автоматически разворачивает срез в плейсхолдеры: ($1, $2, $3...)
query, args, err := sqlx.In(
"SELECT id, name FROM users WHERE id IN (?);", ids,
)
if err != nil {
return nil, err
}
// Rebind преобразует '?' в диалект PostgreSQL ($1, $2, $3...)
query = db.Rebind(query)
var users []User
err = db.SelectContext(ctx, &users, query, args...)
return users, err
}
COPY: максимальная скорость вставки
Когда речь заходит о загрузке сотен тысяч или миллионов записей (ETL, аналитические дампы, миграции), даже INSERT ... VALUES уступает специализированному протоколу COPY.
Протокол COPY работает в обход стандартного SQL-парсера и планировщика запросов. Данные передаются напрямую в бинарном потоке, а ядро PostgreSQL потоком раскладывает их по страницам таблицы:
package main
import (
"context"
"github.com/jackc/pgx/v5"
)
func StreamEventsCopy(ctx context.Context, conn *pgx.Conn, events []Event) (int64, error) {
// CopyFrom использует бинарный протокол COPY PostgreSQL
return conn.CopyFrom(
ctx,
pgx.Identifier{"events"},
[]string{"user_id", "type", "payload"},
pgx.CopyFromSlice(len(events), func(i int) ([]any, error) {
e := events[i]
return []any{e.UserID, e.Type, e.Payload}, nil
}),
)
}
Метод CopyFrom на порядки превосходит INSERT: скорость вставки возрастает со скромных 2 000 строк/сек до впечатляющих 100 000 – 300 000 строк в секунду.
Потенциальные проблемы и ловушки
Mechanical Sympathy: что происходит под капотом
Давайте заглянем внутрь сетевого кабеля и ядра операционной системы, когда Go-сервис вызывает conn.SendBatch:
- Упаковка фреймов протокола:
В простом протоколе каждый одиночный SQL-запрос передается отдельным сообщением типа'Q'(Query). В пакетном же режиме драйвер задействует расширенный протокол PostgreSQL и конвейеризирует сообщения без ожидания:- Сообщение
'P'(Parse) — компиляция подготовленного выражения; - Сообщение
'B'(Bind) — привязка бинарных параметров; - Сообщение
'E'(Execute) — запуск узла исполнения; - И лишь в самом конце — одиночное сообщение
'S'(Sync).
- Сообщение
- Сетевой стек TCP (Pipelining):
Все эти команды укладываются в один или несколько непрерывных TCP-сегментов. Ядро Linux отправляет их через системный вызовwrite. - Работа бэкенда PostgreSQL:
Обслуживающий процесс PostgreSQL считывает данные из сокета черезrecvи выполняет команды непрерывной серией. Он не переключает контекст и не ждет сетевых подтверждений от клиента. - Сжатие WAL:
Вся пачка строк фиксируется единой записью фиксации транзакции (Commit Record) в журнале WAL. Вместо 1 000 операций сброса диска (fsync) сервер выполняет ровно одинfsync!
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph GoProcess["Go-приложение"]
Queue["Очередь операций в pgx.Batch"]:::data
SocketWrite["1 системный вызов: write() в сокет"]
Queue --> SocketWrite
end
subgraph Network["Сетевой стек TCP/IP"]
Packet["Единый сетевой пакет (Pipeline)<br>Сообщения: P + B + E + ... + Sync"]
SocketWrite --> Packet
end
subgraph PostgresBackend["Процесс PostgreSQL"]
SocketRead["1 системный вызов: recv() из сокета"]
ExecEngine["Непрерывное исполнение пачки команд<br>без сетевых пауз (Pipeline Mode)"]
SingleWAL["Единый fsync журнала WAL на диск!"]:::data
Packet --> SocketRead
SocketRead --> ExecEngine
ExecEngine --> SingleWAL
end
Когда batch не нужен
Пакетная обработка — инструмент коллективный. Если ваш сервис получает независимые единичные запросы от пользователей (например, одиночный клик в веб-интерфейсе), искусственное накопление батча во временном буфере задерживает ответ клиенту, увеличивая задержку (Latency Penalty).
Используйте батчи там, где данные уже имеют пакетную природу:
- Очереди сообщений (Kafka, RabbitMQ), вычитываемые пачками по 500 сообщений.
- Фоновые воркеры синхронизации и сбора метрик.
- Обработка входящих вебхуков от платежных систем.
Конкурентные batch-запросы и блокировки
При выполнении объемных пакетных модификаций UPDATE ... FROM (VALUES ...) база данных захватывает эксклюзивные блокировки строк таблицы (Row-level Locks).
Если две параллельные горутины обновляют одни и те же строки в разном порядке:
- Горутина 1 обновляет пользователя 10, затем пользователя 20.
- Горутина 2 обновляет пользователя 20, затем пользователя 10.
База данных мгновенно свалится в взаимоблокировку (Deadlock) (6. Deadlock и их предотвращение).
Правило предотвращения дедлоков в батчах:
Перед формированием батча на запись или обновление всегда сортируйте массив идентификаторов по возрастанию ключа (sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })). Если все транзакции захватывают блокировки строк в строго одинаковом порядке, возникновение дедлока математически невозможно!
Итог
Пакетные запросы — это финальный аккорд в архитектуре высокопроизводительного взаимодействия с базой данных. Они объединяют аппаратное понимание дискового кэша, законов распространения сетевых пакетов и легковесных возможностей Go-рантайма.
На этом мы триумфально завершаем подраздел «04. Индексы и производительность». За 16 фундаментальных лекций мы прошли грандиозный путь:
- Изучили кремниевую физику B-Tree, Hash и Bitmap индексов.
- Освоили искусство составных, покрывающих и частичных индексов.
- Научились индексировать полуструктурированные JSONB и массивы через GIN/GiST.
- Разобрали вредные индексы, научились читать мысли CBO через
EXPLAINи управлять кардинальностью. - Искоренили проблему N+1, оптимизировали
JOINиSELECTи обуздали сетевой ввод-вывод черезBatchиCOPY.
Вооружившись этим глубоким арсеналом знаний о производительности, мы готовы перейти к следующему краеугольному камню реляционной инженерии — надежности, атомарности и изоляции данных. В следующем подразделе мы открываем фундаментальную тему транзакций: 1. ACID. Основы.