Содержание урока · 8 разделов
Задача, которая звучит почти в каждом проекте: при регистрации отправить письмо, при загрузке файла сжать картинку, при оплате вызвать вебхук. Делать это в HTTP-хендлере нельзя — пользователь будет ждать чужой SMTP, а падение внешнего сервиса утянет за собой его запрос. Правильное место для такой работы — фоновый воркер, и связать хендлер с воркером проще всего через Redis: команда RPush кладёт задание в список, LPop забирает его оттуда, и обе операции атомарны. В этом уроке мы соберём очередь целиком: постановку, блокирующее чтение, мониторинг глубины и разговор о потере задач — единственной настоящей занозе этого паттерна. Обзор места списков среди остальных структур был в уроке про типы данных Redis, а здесь берём один тип и выжимаем из него рабочий конвейер.
Список Redis: двусторонняя очередь под одним ключом#
Ментальная модель списка — двусвязный список строк, живущий под одним ключом: queue:emails — это не одно значение, а упорядоченный набор значений. Работа идёт с обоих концов, и оба конца дёшевы: RPush добавляет в хвост, LPush — в голову, LPop снимает с головы, RPop — с хвоста, каждая операция стоит O(1) независимо от длины. Redis ничего не сортирует: порядок элементов — это порядок вставки, и в этом вся предсказуемость типа. Полная длина доступна через LLen, чтение диапазона без вынимания — через LRange, обрезка — через LTrim. Как и со строками, тип ключа — часть контракта: если queue:emails когда-то был строкой, первая же команда записи упадёт с ошибкой WRONGTYPE, и лечится это переименованием ключа, а не сменой типа. Забегая вперёд: у списков нет TTL по элементам и нет подтверждений доставки — для этого есть потоки из урока про Streams, а сейчас очередь на двух командах.
Продюсер и воркер на RPush и LPop
GOfunc enqueue(ctx context.Context, job string) error {
// RPush добавляет в хвост: очередь растёт справа
return rdb.RPush(ctx, "queue:emails", job).Err()
}
func dequeue(ctx context.Context) (string, error) {
// LPop снимает с головы: первым вошёл — первым вышел
job, err := rdb.LPop(ctx, "queue:emails").Result()
if errors.Is(err, redis.Nil) {
return "", nil // пустая очередь — не ошибка
}
return job, err
}RPush возвращает новую длину списка, но для постановки важнее .Err() — задание либо в очереди, либо нет. LPop по пустому списку отдаёт знакомый redis.Nil, как чтение несуществующей строки: пустая очередь и отсутствующий ключ неотличимы, и это нормальная ситуация в жизни воркера, а не сбой. Пара RPush и LPop даёт строгий FIFO; LPush вместе с RPop зеркалит то же поведение с другого конца, но смешивать стороны без причины не стоит — порядок чтения станет сюрпризом.
Очередь задач: продюсер и воркер на двух командах#
Сценарий целиком выглядит так: HTTP-хендлер обрабатывает регистрацию, после записи пользователя в базу ставит в очередь письмо — одна строка кода с enqueue — и отвечает клиенту. Воркер живёт в отдельном процессе, крутит цикл чтения и делает работу в своём темпе. Приятное следствие атомарности: если воркеров несколько, ничего дополнительно изобретать не нужно — LPop гарантированно отдаёт каждый элемент ровно одному читателю, два воркера физически не могут получить одну задачу. Распределённая блокировка из урока про SETNX здесь не нужна: сервер Redis сам разруливает конкуренцию на стороне очереди. Список работает как буфер: продюсер может выстрелить тысячу задач за секунду, воркер — разгребать их минуту, и ни одна не потеряется, пока жив процесс Redis. Глубина очереди при этом становится честной метрикой здоровья — к ней вернёмся ниже. Разделение скоростей — вторая причина держать список между хендлером и воркером: пиковый трафик пишет с тысячами задач в секунду, воркер обрабатывает сотню, и разница копится в очереди вместо того, чтобы душить пользователей задержкой. Пиковый буфер всегда стоит денег памяти, поэтому за глубиной следят, а после затяжного пика проверяют, разгреблась ли она. Наконец, задания в списке — это простые строки или JSON; чем проще формат, тем легче смотреть на очередь глазами и отлаживать её руками.
Типичная ошибка — считать LPop безопасным чтением. Команда удаляет элемент из списка в момент выдачи; если воркер упал с неотправленным письмом на борту, задача исчезла вместе с ним, и redis-cli ничего не вернёт. Если потеря недопустима, используйте RPopLPush: задача переезжает в служебный список processing, откуда её возвращает смотритель, — или сразу берите Streams с подтверждениями из следующего урока по ходу курса.
Воркер с блокирующим BLPop
GOfunc worker(ctx context.Context) {
for {
items, err := rdb.BLPop(ctx, 5*time.Second, "queue:emails").Result()
if errors.Is(err, redis.Nil) {
continue // таймаут: задач нет, круг сначала
}
if err != nil {
log.Print(err) // сеть или закрытие: подождём и повторим
time.Sleep(time.Second)
continue
}
// items[0] — имя ключа, items[1] — сама задача
handle(ctx, items[1])
}
}BLPop ждёт появления задачи до таймаута вместо пустых вращений: LPop в цикле без паузы превратился бы в busy-loop, который греет процессор и гоняет пакеты вхолостую. На таймауте команда отвечает redis.Nil — штатный пустой такт, после которого воркер проверяет флаг выхода и идёт на новый круг. Результат приходит срезом: нулевой элемент — имя ключа, первый — значение. Для запуска нужен локальный Go и Redis в Docker — песочница сайта сетевые соединения не открывает.
Мониторинг: заглянуть в очередь, не вынимая#
Очередь без наблюдения превращается в чёрный ящик: продюсеры пишут, воркер молчит, и никто не заметил, как задания копились часами. Дешёвая диагностика — LLen: длина списка отвечает на вопрос, поспевает ли воркер, и идеально ложится в метрику или алерт вида «глубина queue:emails больше пятисот минуту». Заглянуть в содержимое без вынимания позволяет LRange с диапазоном индексов: ноль — голова, минус единица — хвост, а LRange 0..2 покажет три ближайшие задачи. Команда LTrim обрезает список до диапазона и открывает ещё один сценарий — кольцевой буфер последних N событий: пишете RPush-ом, обрезаете LTrim 0..99, и под ключом всегда лежит свежий хвост истории. Для такого журнала последних действий списки подходят лучше всего: и запись, и чистка — по одной команде, без фоновых процессов. В redis-cli всё это выглядит предельно наглядно: команда LLEN queue:emails отвечает числом, LRANGE queue:emails 0 2 — первыми задачами. Один нюанс: команду MONITOR для наблюдения за очередью в проде включать не стоит — она печатает все команды сервера и заметно тормозит его; LLen и LRange дешевле на порядки.
Глубина очереди и кольцевой буфер
GOfunc queueStats(ctx context.Context) {
n, _ := rdb.LLen(ctx, "queue:emails").Result()
head, _ := rdb.LRange(ctx, "queue:emails", 0, 2).Result()
fmt.Println("глубина очереди:", n)
fmt.Println("следующие задачи:", head)
// журнал последних событий: пишем, потом обрезаем
rdb.RPush(ctx, "events:recent", time.Now().Format(time.RFC3339))
rdb.LTrim(ctx, "events:recent", 0, 99)
}LLen по несуществующему ключу возвращает ноль без ошибки — отдельный случай обрабатывать не нужно, пустая очередь и отсутствующая неотличимы. LRange с диапазоном 0..2 читает три первых элемента, не снимая их с очереди: воркеры продолжают работать параллельно с вашим взглядом внутрь. Связка RPush и LTrim держит под ключом events:recent ровно сто последних записей — Redis обрезает хвост за O(N) от размера обрезки, дёшево и без кроновых задач.
Отдельного синтаксиса приоритетов у списков нет, и он не нужен: BLPop принимает несколько ключей и проверяет их по порядку. rdb.BLPop(ctx, 5*time.Second, "queue:high", "queue:low") разблокируется на первом доступном элементе — задачи из high уходят всегда раньше. Два списка, две команды постановки, один воркер — и система приоритетов готова.
Команды списков, которые реально пригодятся#
| Команда Redis | Метод go-redis v9 | Зачем нужна |
|---|---|---|
| RPUSH / LPUSH | RPush, LPush | Положить задание в хвост или в голову списка |
| LPOP / RPOP | LPop, RPop | Забрать элемент с головы или с хвоста |
| BLPOP / BRPOP | BLPop, BRPop | Ждать задачу до таймаута вместо пустого цикла |
| LRANGE | LRange | Просмотреть диапазон, не вынимая элементы |
| LLEN | LLen | Глубина очереди: метрика для алертов |
| LTRIM | LTrim | Обрезать список: кольцевой буфер последних N |
| RPOPLPUSH | RPopLPush | Забрать и положить в служебный список за один шаг |
Список или Go-канал: где чья очередь#
Моё правило простое: внутри одного процесса канал Go всегда лучше — он быстрее, типизирован, и select с таймаутами у него родной. Redis-список включается там, где концы очереди живут в разных процессах или даже на разных машинах: хендлер в API-сервисе пишет, воркер в другом бинарнике читает, между рестартами задачи обязаны доживать. Канал умрёт вместе с процессом, список Redis переживёт и его, и деплой, и обновление воркера — это и есть граница применимости. Обратная сторона живучести: очередь в Redis растёт, даже когда все воркеры умирают, поэтому глубина из LLen должна попасть на график, а не остаться в терминале отладки. Внутри воркера каналы и горутины из курса по продвинутому Go прекрасно сочетаются с BLPop: один читатель забирает задачи, пул горутин обрабатывает, и узким местом не становится ни Redis, ни канал.
Проверка: чтение пустого списка
Ключа queue:emails не существует. Что вернёт rdb.LPop(ctx, "queue:emails").Result()?
Проверка: два воркера на одной очереди
К очереди подключены два воркера, каждый читает через BLPop. Как распределится работа?
Что выведет LPop?
Прочитайте код и выберите, что он выведет:
rdb.RPush(ctx, "q", "a")
rdb.RPush(ctx, "q", "b")
rdb.LPush(ctx, "q", "c")
v, _ := rdb.LPop(ctx, "q").Result()
fmt.Println(v)Соберите очередь на списках
Допишите постановку задачи в хвост, чтение с головы и блокирующее ожидание с таймаутом.
Соберите фрагмент воркера
Расставьте строки по порядку: постановка двух задач, чтение первой, отправка и печать остатка очереди.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: списки и очереди
Очередь на двух командах — рабочий инструмент, и половина фоновых задач в реальных Go-сервисах живёт именно так. Но заноза из warn-блока никуда не делась: LPop не умеет подтверждений, и падение воркера остаётся тихой потерей. Ответ на это — потоки: следующий урок про Streams даёт журнал с идентификаторами, потребительскими группами и XAck. Для массовой постановки тысяч заданий за раз пригодятся пайплайны из урока про один рейс сети — тысячи RPush в одной поездке. Полный план курса лежит на странице курса по Redis, а домашнее задание ниже заставит потерять задачу намеренно: этот крайний случай дешевле прочувствовать локально, чем увидеть однажды в проде.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Список Redis — двусторонняя очередь строк под одним ключом: RPush растит хвост, LPop снимает голову, обе операции атомарны.
- Пара RPush и LPop даёт строгий FIFO; несколько воркеров не мешают друг другу, потому что каждый элемент уходит ровно одному читателю.
- BLPop ждёт задачу до таймаута вместо пустого цикла; redis.Nil на таймауте — штатный пустой такт, а не сбой.
- LPop удаляет задачу в момент выдачи: падение воркера теряет работу — спасают RPopLPush или Streams с подтверждениями.
- LLen — метрика глубины очереди, LRange — взгляд внутрь без вынимания, LTrim — кольцевой буфер последних N записей.
- Канал Go — для горутин одного процесса; список Redis — когда концы очереди в разных процессах и должны переживать рестарты.
Домашнее задание
Поднимите Redis в Docker, соберите enqueue, dequeue и воркер на BLPop из урока. Поставьте три задачи, убедитесь, что воркер обрабатывает их по порядку, и посмотрите глубину через LLen в redis-cli. Затем сломайте конвейер намеренно: добавьте os.Exit(1) в handle после чтения задачи — убедитесь, что задача исчезла из очереди навсегда. После этого перепишите чтение на RPopLPush в список processing и проверьте, что прерванная задача дожила до рестарта.
Личные заметкиПусто
Вопросы и ответы по уроку
Что будет, если воркеров больше одного?
Ничего страшного: каждая блокирующая команда выдаёт элемент ровно одному клиенту, и задача физически не может уйти двум воркерам. Это отличает список от Pub/Sub, где сообщение получают все подписчики. Дополнительная синхронизация не нужна — но и параллелизма больше числа воркеров вы не получите.
Как сделать приоритетную очередь?
Два списка — queue:high и queue:low — и один BLPop с двумя ключами: rdb.BLPop(ctx, timeout, "queue:high", "queue:low"). Команда проверяет ключи по порядку аргументов, поэтому из high возьмёт всегда раньше, чем из low. Три и более уровней масштабируются так же.
Почему бы не держать задачи в базе данных?
Можно, и для редких задач с транзакциями это нормально. Список Redis выигрывает там, где важны скорость постановки, буферизация пиков и простота: запись — одна команда в памяти, без индексов и блокировок строк. Типовая схема: Redis для горячего конвейера, база — для долговременного учёта выполненного.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Множества: SAdd, SInter и уникальность
- Отсортированные множества: лидерборды
- Pub/Sub: рассылка событий в реальном времени
- Streams: журнал событий вместо очереди
- Распределённые блокировки: SETNX
- Счётчики: INCR и атомарный рост
- Хэши: HSet, HGet и профили пользователей
- Кэш структур: JSON-сериализация в Redis
- Все курсы по 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