---
title: "Пайплайны: много команд за один рейс"
description: "Пайплайн go-redis: Pipelined отправляет десятки команд за один сетевой рейс. Результаты Cmder, размер батча и отличие от транзакций MULTI/EXEC."
course: "Redis в Go: кэш, сессии и очереди на go-redis"
url: "https://golangmaster.ru/courses/go-redis/lessons/16-pipy-lineinye/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Пайплайны: много команд за один рейс

*Курс «Redis в Go: кэш, сессии и очереди на go-redis», урок 16 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-redis/lessons/16-pipy-lineinye/*

Пайплайн в go-redis — способ отправить серию команд Redis за один сетевой рейс вместо отдельного рейса на каждую команду. Типичная ситуация: прогрев кэша на тысячу ключей или десяток инкрементов в одном хендлере — каждая операция быстрая, но суммарно запросы ждут не сервер, а сеть. Соберём пайплайны через rdb.Pipelined, вытащим результаты отдельных команд, отмерим размер батча и проговорим, чем пайплайн отличается от транзакции из следующего урока.

Сценарий, где пайплайн спасает: сервис прогревает кэш после деплоя. Товаров тысяча, полей у товара пять — это пять тысяч команд Set, и по одной за рейс они превращаются в пять тысяч сетевых поездок. При латентности сети в полмиллисекунды даже параллельными горутинами прогрев размажется на секунды и забьёт пул соединений — его устройство подробно разбирает урок про [пул соединений](/courses/go-redis/lessons/20-conn-pool/). Пайплайн сжимает эти пять тысяч рейсов в пятьсот батчей по десять команд, а при желании и в пятьдесят по сотне: сеть перестаёт быть узким местом, потому что один пакет уходит и один блок ответов возвращается. Та же механика работает в обратную сторону: хендлер, который читает профиль, настройки и счётчик тремя вызовами rdb.Get, платит тремя рейсами за то, что могло быть одним.

### Пайплайн go-redis: что происходит на проводе

Механика простая, но её полезно держать в голове. Без пайплайна каждая команда проходит полный цикл: клиент пишет запрос в сокет, ядро гонит пакет через сеть, Redis отвечает, ответ снова едет назад. Четыре переключения на команду, и почти всё это время процессор простаивает. С пайплайном клиент буферизует команды в памяти, отправляет их одним куском и ждёт один блок ответов: Redis выполняет команды по очереди — он однопоточный — и складывает ответы в том же порядке. go-redis возвращает срез объектов redis.Cmder, где результат каждой команды лежит на своём месте. Два свойства важно не перепутать. Первое: пайплайн ускоряет сеть, но не даёт атомарности — между командами вашего батча сервер спокойно выполняет команды других клиентов. Второе: порядок ответов строго совпадает с порядком запросов, поэтому результаты не подписаны именами ключей и читаются по позиции. Если нужна атомарность, это уже MULTI/EXEC — следующий урок курса.

### rdb.Pipelined: планируем команды замыканием

```go
var hits *redis.IntCmd

cmds, err := rdb.Pipelined(ctx, func(pipe redis.Pipeliner) error {
        hits = pipe.Incr(ctx, "stats:views:42")
        pipe.Expire(ctx, "stats:views:42", 24*time.Hour)
        pipe.Set(ctx, "cache:user:42", "annie", time.Hour)
        return nil
})
if err != nil {
        return err
}

fmt.Println(hits.Val()) // результат конкретной команды
fmt.Println(len(cmds))  // 3: каждая команда — свой Cmder
```

Pipelined принимает замыкание, в котором вы только планируете команды: pipe.Incr и pipe.Set копируют запросы в буфер и возвращают типизированные команды — реальная отправка случится после выхода из замыкания. Результат Incr достаётся через hits.Val() после выполнения батча, Err() отдаёт ошибку конкретной команды. Переменную под команду объявляют снаружи замыкания — стандартный приём go-redis. Для запуска нужен локальный Go и Redis в Docker: песочница сайта сетевые соединения не открывает.

### Результаты команд: Cmder и приведение типов

Каждый элемент среза cmds — интерфейс redis.Cmder, и у него два слоя данных: общий Err() и конкретное значение через приведение к точному типу. StatusCmd у Set и Expire, IntCmd у Incr и Del, StringCmd у Get, SliceCmd у MGet. Приведение обычное, через type assertion: cmds[2].(*redis.StringCmd), дальше cmd.Val(). Когда команд десятки, чаще перебирают срез по индексам и приводят по позиции — главное держать порядок планирования и порядок чтения согласованным, потому что порядок ответов совпадает с порядком запросов один в один. Отдельно про ошибки: если одна команда упала, остальные всё равно выполнятся, а Pipelined вернёт первую ошибку из результатов. Проверять её нужно у каждой значимой команды, а не только на общем err — иначе промах кэша redis.Nil внутри батча отменит вам весь прогрев, хотя физически ничего не сломалось.

### Прогрев кэша батчами по пятьсот

```go
func warmCache(ctx context.Context, items []Item) error {
        const batchSize = 500

        for start := 0; start < len(items); start += batchSize {
                end := min(start+batchSize, len(items))

                _, err := rdb.Pipelined(ctx, func(pipe redis.Pipeliner) error {
                        for _, item := range items[start:end] {
                                key := "cache:item:" + strconv.Itoa(item.ID)
                                pipe.Set(ctx, key, item.JSON, 6*time.Hour)
                        }
                        return nil
                })
                if err != nil {
                        return err
                }
        }
        return nil
}
```

Пачка в пятьсот команд — рабочая точка для прогрева: батч занимает десятки килобайт в буферах клиента и сервера, а сбой сети между пачками не теряет весь прогрев целиком. Константу подбирают по размеру значения: при килобайтовых JSON уменьшайте до сотни, при коротких флагах можно и тысячу. Цикл внутри замыкания — нормальная практика: планирование ничего не отправляет, аллокации остаются на стороне клиента.

> **Пайплайн не транзакция: между вашими командами работает кто-то ещё.** Типичная ошибка: прочитать значение через Get в пайплайне и на его основе запланировать Set в том же батче, рассчитывая, что между ними ничего не случится. Случится: пайплайн только сжимает сеть, а Redis спокойно вклинит команды других клиентов между вашими Incr и Set. Для схемы «прочитал — решил — записал» нужны транзакции с WATCH из следующего урока или распределённая блокировка. Пайплайн держите для независимых команд, где атомарность не важна.

### Сколько команд класть в один батч

Верхнего предела в API нет, но в реальности он есть, и не в клиенте. Сервер копит ответы, пока клиент их читает: батч на миллион команд — это мегабайты выходного буфера Redis, и при превышении лимита client-output-buffer-limit сервер разорвёт соединение — вы потеряете всю пачку и получите переподключение. Память буферизуется и на клиенте: go-redis складывает команды в срез, и миллион команд перед отправкой — лишние аллокации в вашем процессе. Практичная граница — сотни команд на батч: сетевой выигрыш уже почти максимальный, потому что рейс один, а риски буферов исчезают. Если объём больше — режьте на пачки циклом, как в примере выше. Отдельный случай — интерактивные хендлеры: там пайплайн из двух-пяти команд уже даёт заметный выигрыш, потому что латентность сети доминирует над временем исполнения самих команд.

### Батчирование и его соседи

| Подход | Рейсов по сети | Атомарность | Когда выбирать |
| --- | --- | --- | --- |
| Команда за командой | N | Нет | Одна-две операции на запрос |
| Пайплайн (Pipelined) | 1 | Нет | Много независимых команд: прогрев, счётчики, мультиключевое чтение |
| TxPipeline (MULTI/EXEC) | 1 | Да | Группа, которая обязана примениться целиком |
| Lua-скрипт (EVAL) | 1 | Да | Чтение и ветвление с записью в одной атомарной единице |

### Пайплайн и пул соединений: кто кого держит

Пайплайн занимает одно соединение из пула на всё время батча: от первой планируемой команды до последнего прочитанного ответа. Пока батч летит, другие горутины ждут свободный коннект — и это нормально, пока батч короткий. Десятитысячный батч на медленной сети держит соединение секунды, и вся остальная очередь пула в это время стоит: латентность обычных запросов скачет, хотя сервер Redis здоров. Отсюда правило: массовые операции уводите в фоновые горутины с собственным темпом, а в хендлерах держите батчи из единиц команд. Второй нюанс — ретраи: если соединение оборвалось посреди батча, go-redis повторит всю пачку целиком, поэтому команды внутри должны переносить повторное выполнение. Set и Expire переносят спокойно, Incr — нет: после повтора счётчик уедет вверх дважды. Некоммутируемые операции в пайплайнах лучше заменять Set-ом абсолютного значения или Lua-скриптом.

Соседняя развилка, которую стоит видеть: родные мультиключевые команды. MGet читает десяток строковых ключей одним вызовом, MSet пишет их же — и часто этого хватает без всякого пайплайна. Разница в гибкости: MGet отдаёт только строки и один тип команды, а пайплайн смешивает что угодно — Get по профилю, HGetAll по настройкам, ZScore по рейтингу, плюс Expire и Del в том же батче. Практическое правило: однотипное чтение строк — MGet, разнородные операции или запись с TTL — пайплайн. Есть и цена у MGet: он бьёт по куче ключей одной командой, и в кластере Redis ключи из разных слотов одной такой командой не обслужить, а пайплайн рассыпается по нодам без проблем. Пока вы на одиночном сервере, разницы в ограничениях нет — выбирайте по читаемости кода.

Где пайплайны живут в реальных сервисах. Прогрев кэша после деплоя — тот самый warmCache. Метрики и счётчики: раз в секунду сервис отправляет пачку Incr вместо сотни отдельных вызовов, и нагрузка на пул падает в разы. Мультиключевое чтение в хендлере: профиль, настройки и лимиты пользователя лежат в трёх ключах — один пайплайн вместо трёх rdb.Get сокращает латентность ответа на два рейса, что на удалённом Redis означает единицы миллисекунд с каждого запроса. Похоже на проблему N+1 в базах: там её решает preload из курса по [GORM](/courses/gorm/lessons/14-preload/), здесь — батчирование команд. Граница та же, что и с базой: если команды зависят друг от друга, батчирование не поможет, нужна другая модель. И честная оговорка: все примеры урока требуют локального Go и Redis в Docker — сетевые соединения песочница не открывает, а разницу в латентности видно только на живом сервере.

> **MONITOR покажет батч вживую.** Запустите redis-cli MONITOR в отдельном окне и выполните пайплайн из пяти Set: команды появятся в выводе подряд почти с одной временной меткой — это и есть один сетевой кусок. Для разовых скриптов загрузки есть и внешний путь: redis-cli --pipe читает протокол из stdin и заливает файлы на сотни тысяч команд, не тратя память Go-процесса.

### Проверка: рейсы по сети

**Вопрос:** Хендлер выполняет 40 команд через один rdb.Pipelined. Сколько сетевых рейсов между клиентом и Redis это займёт, не считая дозвона?

- ✅ Один: команды едут одним куском, ответы — одним блоком
- Два: отдельно запросы, отдельно ответы
- 40: каждый рейс как раньше
- Зависит от PoolSize

Пайплайн буферизует все команды и отправляет их одним пакетом, Redis складывает ответы в том же порядке, и клиент читает их одним чтением. PoolSize на это не влияет: пайплайн занимает одно соединение из пула на всё время батча.

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

**Вопрос:** В пайплайне из пяти команд одна упала с WRONGTYPE, остальные корректны. Что произойдёт?

- ✅ Все пять выполнятся; Pipelined вернёт WRONGTYPE как первую ошибку, результаты остальных живы
- Сервер прервёт батч на упавшей команде, остальные не выполнятся
- Pipelined вернёт nil: ошибки внутри пайплайна игнорируются
- Соединение закроется

Redis выполняет команды пайплайна по очереди независимо: ошибка одной не отменяет остальных. err от Pipelined — первая ошибка из среза результатов, поэтому значимые команды проверяйте отдельно через Err() или приведение к типу.

### Что вернёт Pipelined при промахе?

```go
cmds, err := rdb.Pipelined(ctx, func(pipe redis.Pipeliner) error {
        pipe.Set(ctx, "flag", "on", 0)
        pipe.Get(ctx, "no-such-key")
        return nil
})

fmt.Println(cmds[0].Err() == nil, err == redis.Nil)
```

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

- ✅ true true
- true false
- false true
- false false

GET по отсутствующему ключу отвечает redis.Nil, и Exec пайплайна отдаёт первую ошибку из результатов наружу — err станет redis.Nil. При этом первая команда прошла успешно: cmds[0].Err() равен nil. Отсюда правило: redis.Nil внутри пайплайна — штатный промах, обрабатывайте его у конкретной команды, не превращая в падение всего батча.

### Соберите пайплайн со счётчиком

Допишите запуск пайплайна, инкремент внутри него и чтение результата после выполнения.

```go
var counter *redis.IntCmd

_, err := rdb.___(ctx, func(pipe redis.Pipeliner) error {
        counter = pipe.___(ctx, "stats:hits")
        pipe.Expire(ctx, "stats:hits", time.Hour)
        return nil
})
if err != nil {
        return err
}

fmt.Println(counter.___()) // значение после выполнения батча
```

Подсказка: Пайплайн запускает Pipelined, счётчик двигает Incr, а результат типизированной команды достаётся методом Val.

Правильные ответы в пропусках: `Pipelined`, `Incr`, `Val`

### Ручной вариант: Pipeline и Exec

Расставьте строки по порядку: создание пайплайна, планирование двух команд, выполнение и проверка ошибки.

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

1. `pipe := rdb.Pipeline()`
2. `pipe.Set(ctx, "name", "annie", 0)`
3. `pipe.Expire(ctx, "name", time.Hour)`
4. `cmds, err := pipe.Exec(ctx)`
5. `if err != nil {`
6. `    return err`

### Карточки: пайплайны

- **rdb.Pipelined** — Выполняет замыкание с планированием, отправляет буфер одним рейсом и возвращает срез Cmder. Одна сетевая поездка на любое число команд.
- **redis.Cmder** — Интерфейс результата команды в срезе: Err() общий, значение — через приведение к StatusCmd, IntCmd, StringCmd по позиции.
- **Пайплайн и атомарность** — Атомарности нет: между командами батча сервер выполняет команды других клиентов. Для «прочитал — решил — записал» нужны MULTI/EXEC или WATCH.
- **Размер батча** — Практичная граница — сотни команд. Миллионный батч раздувает выходной буфер Redis до разрыва соединения по client-output-buffer-limit.
- **Pipeline и Exec** — Ручная форма: rdb.Pipeline() отдаёт объект, команды планируются где угодно, Exec(ctx) выполняет. Удобно, когда планирование разбросано по коду.

Пайплайн встал на место: сетевые рейсы сжаты, результаты вытащены, батчи отмерены. Дальше по курсу две соседние темы: урок про [транзакции MULTI/EXEC](/courses/go-redis/lessons/17-transakcii/) добавляет к сжатию сети атомарность — тот же буфер команд заворачивается в MULTI и EXEC, а пайплайн превращается в TxPipeline. Если после пайплайна часть команд всё равно падает с таймаутами — загляните в урок про [ошибки go-redis](/courses/go-redis/lessons/19-oshibki-go-redis/), там разобраны ретраи и redis.Nil. Много мелких команд на запрос живёт и в сессиях: урок про [сессии веб-приложения](/courses/go-redis/lessons/21-sessii-web/) покажет, где батчирование читает профиль и настройки одним рейсом. Полный план остальных тем — на странице [курса по Redis](/courses/go-redis/). Домашнее задание ниже прогреет кэш двумя размерами батча — замерьте время в терминале, разница между рейсами видна невооружённым глазом.

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

- Пайплайн буферизует команды и отправляет их одним сетевым рейсом: N команд — один рейс вместо N.
- rdb.Pipelined принимает замыкание с планированием и возвращает срез Cmder; значение конкретной команды — через Val() типизированной переменной.
- Пайплайн не атомарен: между вашими командами сервер выполняет команды других клиентов; для «прочитал — решил — записал» нужны транзакции с WATCH.
- Батч держите в сотнях команд: миллионный батч раздувает выходной буфер Redis до разрыва соединения.
- err от Pipelined — первая ошибка из результатов; redis.Nil от Get внутри батча — штатный промах, а не авария.
- Прогрев кэша, пачки счётчиков и мультиключевые чтения — типовые места пайплайнов в Go-сервисах.

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

Соберите warmCache с батчами по 500 и по 50 и прогрейте тысячу тестовых элементов локальным Go с Redis в Docker: замерьте время каждого варианта таймером. Затем перепишите хендлер с тремя rdb.Get на один Pipelined и проверьте в redis-cli MONITOR, что команды приходят одним куском. В конце добавьте Get по отсутствующему ключу внутрь пайплайна и обработайте redis.Nil у конкретной команды, не топя весь батч.

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

### Чем Pipelined отличается от Pipeline?

Pipelined создаёт пайплайн, выполняет ваше замыкание и сразу ждёт результаты, возвращая срез Cmder. Pipeline отдаёт объект Pipeliner, которым вы наполняете команды в разных местах кода и потом вызываете Exec. Под капотом это один и тот же буфер и одно соединение из пула.

### Замедляет ли большой пайплайн Redis для других клиентов?

Само батчирование нет: команды те же. Следите за размером: гигантский батч раздувает выходной буфер сервера, и при срабатывании client-output-buffer-limit соединение рвётся. Сотни команд на батч — безопасная практика, которую ломать незачем.

### Нужен ли пайплайн, если команды вызывают много горутин?

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