Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 11 из 25 ~18 мин +20 XP

Списки: LPush, RPop и очереди задач

Списки Redis — это очередь задач в чистом виде: продюсер кладёт задания в хвост ключа, воркер забирает с головы, и весь обмен живёт в памяти за доли миллисекунды. В уроке собираем пару продюсер-воркер на go-redis v9: RPush для постановки, LPop и BLPop для чтения, LRange для взгляда внутрь. Отдельно разберём, где простая очередь теряет задачи и что с этим делать.

Содержание урока · 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

GO
func 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; чем проще формат, тем легче смотреть на очередь глазами и отлаживать её руками.

Воркер с блокирующим BLPop

GO
func 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 дешевле на порядки.

Глубина очереди и кольцевой буфер

GO
func 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) от размера обрезки, дёшево и без кроновых задач.

Команды списков, которые реально пригодятся#

Команда RedisМетод go-redis v9Зачем нужна
RPUSH / LPUSHRPush, LPushПоложить задание в хвост или в голову списка
LPOP / RPOPLPop, RPopЗабрать элемент с головы или с хвоста
BLPOP / BRPOPBLPop, BRPopЖдать задачу до таймаута вместо пустого цикла
LRANGELRangeПросмотреть диапазон, не вынимая элементы
LLENLLenГлубина очереди: метрика для алертов
LTRIMLTrimОбрезать список: кольцевой буфер последних N
RPOPLPUSHRPopLPushЗабрать и положить в служебный список за один шаг

Список или 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)

Соберите очередь на списках

Допишите постановку задачи в хвост, чтение с головы и блокирующее ожидание с таймаутом.

rdb.(ctx, "queue:emails", job) job, _ := rdb.(ctx, "queue:emails").Result() items, _ := rdb.(ctx, 5*time.Second, "queue:emails").Result()

Соберите фрагмент воркера

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: списки и очереди

Термин

RPush / LPush

нажмите, чтобы перевернуть
Определение

Добавление в хвост или в голову списка. Обе операции O(1); выбор стороны определяет порядок чтения — RPush плюс LPop даёт строгий FIFO.

1 / 6

Очередь на двух командах — рабочий инструмент, и половина фоновых задач в реальных 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 и проверьте, что прерванная задача дожила до рестарта.

Личные заметкиПусто
Хранятся только в вашем браузере (Redis · Списки: LPush, RPop и очереди задач)

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

Что будет, если воркеров больше одного?

Ничего страшного: каждая блокирующая команда выдаёт элемент ровно одному клиенту, и задача физически не может уйти двум воркерам. Это отличает список от Pub/Sub, где сообщение получают все подписчики. Дополнительная синхронизация не нужна — но и параллелизма больше числа воркеров вы не получите.

Как сделать приоритетную очередь?

Два списка — queue:high и queue:low — и один BLPop с двумя ключами: rdb.BLPop(ctx, timeout, "queue:high", "queue:low"). Команда проверяет ключи по порядку аргументов, поэтому из high возьмёт всегда раньше, чем из low. Три и более уровней масштабируются так же.

Почему бы не держать задачи в базе данных?

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

Термины урока

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

Следующая ступень учебного пути

JWT25 уроков~12 чпрактика25 уроков уже доступны

JWT-аутентификация в Go: golang-jwt и bcrypt

Разбираем JWT в Go: устройство токена, golang-jwt v5, подписи HMAC и RSA, refresh-токены, bcrypt, middleware авторизации и лучшие практики.

  • Понимать анатомию JWT и почему payload не зашифрован
  • Выпускать и проверять токены через golang-jwt v5
  • Хешировать пароли bcrypt и сравнивать безопасно
  • Писать middleware аутентификации для chi и Gin