Сценарий, где пайплайн спасает: сервис прогревает кэш после деплоя. Товаров тысяча, полей у товара пять — это пять тысяч команд Set, и по одной за рейс они превращаются в пять тысяч сетевых поездок. При латентности сети в полмиллисекунды даже параллельными горутинами прогрев размажется на секунды и забьёт пул соединений — его устройство подробно разбирает урок про пул соединений. Пайплайн сжимает эти пять тысяч рейсов в пятьсот батчей по десять команд, а при желании и в пятьдесят по сотне: сеть перестаёт быть узким местом, потому что один пакет уходит и один блок ответов возвращается. Та же механика работает в обратную сторону: хендлер, который читает профиль, настройки и счётчик тремя вызовами rdb.Get, платит тремя рейсами за то, что могло быть одним.
Пайплайн go-redis: что происходит на проводе#
Механика простая, но её полезно держать в голове. Без пайплайна каждая команда проходит полный цикл: клиент пишет запрос в сокет, ядро гонит пакет через сеть, Redis отвечает, ответ снова едет назад. Четыре переключения на команду, и почти всё это время процессор простаивает. С пайплайном клиент буферизует команды в памяти, отправляет их одним куском и ждёт один блок ответов: Redis выполняет команды по очереди — он однопоточный — и складывает ответы в том же порядке. go-redis возвращает срез объектов redis.Cmder, где результат каждой команды лежит на своём месте. Два свойства важно не перепутать. Первое: пайплайн ускоряет сеть, но не даёт атомарности — между командами вашего батча сервер спокойно выполняет команды других клиентов. Второе: порядок ответов строго совпадает с порядком запросов, поэтому результаты не подписаны именами ключей и читаются по позиции. Если нужна атомарность, это уже MULTI/EXEC — следующий урок курса.
rdb.Pipelined: планируем команды замыканием
GOvar 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: каждая команда — свой CmderPipelined принимает замыкание, в котором вы только планируете команды: 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 внутри батча отменит вам весь прогрев, хотя физически ничего не сломалось.
Прогрев кэша батчами по пятьсот
GOfunc 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, здесь — батчирование команд. Граница та же, что и с базой: если команды зависят друг от друга, батчирование не поможет, нужна другая модель. И честная оговорка: все примеры урока требуют локального Go и Redis в Docker — сетевые соединения песочница не открывает, а разницу в латентности видно только на живом сервере.
Запустите redis-cli MONITOR в отдельном окне и выполните пайплайн из пяти Set: команды появятся в выводе подряд почти с одной временной меткой — это и есть один сетевой кусок. Для разовых скриптов загрузки есть и внешний путь: redis-cli --pipe читает протокол из stdin и заливает файлы на сотни тысяч команд, не тратя память Go-процесса.
Проверка: рейсы по сети
Хендлер выполняет 40 команд через один rdb.Pipelined. Сколько сетевых рейсов между клиентом и Redis это займёт, не считая дозвона?
Проверка: упала одна команда из пяти
В пайплайне из пяти команд одна упала с WRONGTYPE, остальные корректны. Что произойдёт?
Что вернёт Pipelined при промахе?
Прочитайте код и выберите, что он выведет:
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)Соберите пайплайн со счётчиком
Допишите запуск пайплайна, инкремент внутри него и чтение результата после выполнения.
Ручной вариант: Pipeline и Exec
Расставьте строки по порядку: создание пайплайна, планирование двух команд, выполнение и проверка ошибки.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: пайплайны
Пайплайн встал на место: сетевые рейсы сжаты, результаты вытащены, батчи отмерены. Дальше по курсу две соседние темы: урок про транзакции MULTI/EXEC добавляет к сжатию сети атомарность — тот же буфер команд заворачивается в MULTI и EXEC, а пайплайн превращается в TxPipeline. Если после пайплайна часть команд всё равно падает с таймаутами — загляните в урок про ошибки go-redis, там разобраны ретраи и redis.Nil. Много мелких команд на запрос живёт и в сессиях: урок про сессии веб-приложения покажет, где батчирование читает профиль и настройки одним рейсом. Полный план остальных тем — на странице курса по Redis. Домашнее задание ниже прогреет кэш двумя размерами батча — замерьте время в терминале, разница между рейсами видна невооружённым глазом.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Пайплайн буферизует команды и отправляет их одним сетевым рейсом: 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 соединение рвётся. Сотни команд на батч — безопасная практика, которую ломать незачем.
Нужен ли пайплайн, если команды вызывают много горутин?
Параллельные горутины уменьшают ожидание, но каждая команда остаётся отдельным рейсом и занимает соединение из пула. Пайплайн решает другую часть проблемы — сам рейс. На высоком трафике дают эффект оба приёма, но пайплайн дешевле по памяти и соединениям.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Транзакции MULTI/EXEC и Watch
- Контекст и таймауты в go-redis
- Ошибки go-redis: Nil, таймауты и ретраи
- Пул соединений: настройка и мониторинг
- Streams: журнал событий вместо очереди
- Pub/Sub: рассылка событий в реальном времени
- Отсортированные множества: лидерборды
- Множества: SAdd, SInter и уникальность
- Все курсы по Go
Следующая ступень учебного пути
JWT-аутентификация в Go: golang-jwt и bcrypt
Разбираем JWT в Go: устройство токена, golang-jwt v5, подписи HMAC и RSA, refresh-токены, bcrypt, middleware авторизации и лучшие практики.
- Понимать анатомию JWT и почему payload не зашифрован
- Выпускать и проверять токены через golang-jwt v5
- Хешировать пароли bcrypt и сравнивать безопасно
- Писать middleware аутентификации для chi и Gin