Redis · Списки

Списки

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

Пакет flytachi/winter-redisКласс RedisListМетодов 18

Что такое список и зачем

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

Решение. Список Redis — общая на всех последовательность с быстрыми операциями по краям. Продюсер добавляет в один конец, потребитель забирает из другого, и Redis однопоточен: изъятие атомарно, поэтому одну задачу никогда не получат двое.

php
$jobs = $store->list('jobs');

$jobs->push(json_encode(['type' => 'email', 'to' => $email]));   // продюсер

$job = $jobs->consume(timeout: 5);                               // потребитель

Свойства структуры

Redis-список — это двусвязный список строк, а не массив. Из этого следует всё остальное:

  • Добавление и изъятие с любого конца — O(1), независимо от длины. Миллион элементов не делает push медленнее.
  • Доступ по индексу — O(N): чтобы дойти до середины, Redis идёт по элементам. at(500000) честно пройдёт полмиллиона узлов.
  • Порядок задаёт вставка, не значение. Список не сортируется и допускает повторы.
  • Длина — до 4 294 967 295 элементов.

Ключ существует ровно столько, сколько в нём есть хотя бы один элемент: последний изъятый элемент удаляет и сам ключ. Обратное тоже верно — push в несуществующий ключ создаёт его. Отдельно «создавать» список не нужно и нечем.

Элементы — всегда строки

Список хранит байты. Массивы и объекты попадут в него, только если у конфигурации задан сериализатор; иначе массив превратится в строку "Array", и скажет об этом только PHP-warning.

Где принято применять

Очередь задач. Самое частое. Продюсер добавляет в хвост, потребитель забирает из головы — получается FIFO.

Журнал последних N событий. Список плюс ограничение длины: аудит действий пользователя, последние ошибки, лента активности. Старое вытесняется само.

Буфер между быстрым и медленным. Обработчик запроса кладёт запись, фоновый воркер разбирает пачками. Всплеск не роняет медленную часть, потому что список принимает быстрее, чем воркер разбирает.

Стек. Добавлять и забирать с одного конца — LIFO. Встречается реже очереди.

А вот где список — неправильный выбор:

Задача Почему не список Что вместо
Проверить, есть ли элемент O(N) по всей длине множество (SET)
Хранить уникальные значения список допускает повторы множество
Держать в порядке по значению порядок только по вставке сортированное множество (ZSET)
Несколько потребителей, каждому все сообщения элемент достаётся одному стрим или pub/sub
Нужна история и переигрывание изъятый элемент исчезает стрим (XADD)
Читать из середины часто O(N) на каждый доступ хеш или другая структура

Справочник

RedisList

RedisList — ручка на один список: объект, через который выполняются команды над этим ключом. Получают её у стора:

php
$jobs = $store->list('jobs');    // сервер увидит 'queue:jobs'

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

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

Добавление

push()

Добавляет элемент в хвост списка.

Это тот конец, из которого pop() не берёт, — поэтому пара push() + pop() даёт очередь: первым пришёл, первым ушёл.

Синтаксис

php
public function push(mixed $value, ?int $cap = null): int

Параметры

$value — что положить. Строка или число; для массивов и объектов нужен сериализатор.

$cap — максимальная длина списка. По умолчанию null — не ограничивать. С заданным значением список сам себя подрезает, оставляя $cap последних элементов.

Возвращает

Длину списка после добавления. С $cap — длину после подрезки, то есть не больше $cap.

Пример

php
$jobs->push(json_encode(['type' => 'email', 'to' => $email]));   // 1
$jobs->push(json_encode(['type' => 'sms']));                     // 2

Журнал последних N

php
foreach ($lines as $line) {
  $audit->push($line, cap: 1000);      // хранится только тысяча последних
}

$audit->count();   // 1000, сколько бы строк ни пришло

Ограничение выполняется одной транзакцией (MULTI: добавить, подрезать), поэтому список ни на мгновение не бывает длиннее $cap — и не остаётся длиннее, если что-то сорвётся между двумя командами.

pushFront()

Добавляет элемент в голову списка — туда, откуда берёт pop().

Нужен в двух случаях: когда делают стек и когда задачу нужно поставить вперёд очереди.

Синтаксис

php
public function pushFront(mixed $value, ?int $cap = null): int

Параметры

$value — что положить.

$cap — максимальная длина. По умолчанию null. Подрезка сохраняет $cap первых элементов — то есть, поскольку кладут в голову, новейшие.

Возвращает

Длину списка после добавления и подрезки.

Пример

php
// стек: кладём и берём с одного конца
$undo->pushFront($action);
$undo->pop();            // последнее действие

// приоритетная задача — вперёд очереди
$jobs->pushFront($urgentJob);

Изъятие

pop()

Забирает элемент из головы и удаляет его из списка.

Операция атомарна: сколько бы воркеров ни звали pop() одновременно, каждый элемент достанется ровно одному.

Синтаксис

php
public function pop(): mixed

Параметры

Нет.

Возвращает

Элемент, либо null, если список пуст или его вообще нет. Драйвер на этом месте отдаёт false; null выбран, чтобы «пусто» не путалось с сохранённым false.

Пример

php
while (($job = $jobs->pop()) !== null) {
  $this->handle($job);      // разобрать всё, что накопилось, и выйти
}

Изъятый элемент существует только в памяти воркера

Если воркер упадёт между pop() и завершением обработки, задача исчезнет — и узнать об этом будет неоткуда. Когда это неприемлемо, вместо изъятия используют перенос: moveTo().

popBack()

Забирает элемент из хвоста и удаляет его из списка.

Синтаксис

php
public function popBack(): mixed

Параметры

Нет.

Возвращает

Элемент, либо null, если список пуст или его нет.

Пример

php
$jobs->push('a');
$jobs->push('b');

$jobs->popBack();    // 'b' — последний добавленный
$jobs->pop();        // 'a' — первый добавленный

Ожидание

consume()

Ждёт появления элемента и забирает его из головы.

Если элемент уже есть, возвращает сразу — ожидание начинается только на пустом списке. Это основной инструмент потребителя: он позволяет не опрашивать список в цикле, а спать, пока не появится работа.

Синтаксис

php
public function consume(float $timeout = 0.0): mixed

Параметры

$timeout — сколько секунд ждать. По умолчанию 0.0 — ждать неограниченно долго.

Возвращает

Элемент, либо null, если время вышло, а список так и остался пуст.

Пример

main/Daemons/JobWorker.php
$jobs = $store->list('jobs');

while (!$this->stopping) {
  $job = $jobs->consume(timeout: 5);

  if ($job !== null) {
      $this->handle($job);
  }
  // null — просто истёк таймаут; проверяем флаг остановки и ждём снова
}

$jobs->close();

Таймаут здесь нужен не для того, чтобы «не ждать слишком долго», а чтобы цикл периодически возвращал управление — иначе демон не заметит сигнал остановки.

`consume()` не берёт соединение из пула

Блокирующая команда занимает своё соединение на всё время ожидания. Десять потребителей с timeout: 30 на пуле из десяти соединений заняли бы весь пул на полминуты, и все остальные запросы упали бы с «нет свободного соединения» — при том что Redis в это время простаивает, и выглядеть это будет как «Redis тормозит».

Поэтому ручка открывает собственное соединение при первом consume() и переиспользует его при следующих. Именно поэтому ручку и стоит сохранять на весь цикл: новая ручка — новое соединение.

Читающий таймаут поднимается автоматически

Соединение настроено бросать read error on connection, если сервер молчит дольше readTimeout (по умолчанию 2 секунды), а блокирующее чтение — это ровно «сервер намеренно молчит». Поэтому consume() поднимает читающий таймаут на время ожидания. Без этого consume(timeout: 5) умирал бы на второй секунде — и не по таймауту, а с ошибкой соединения.

close()

Закрывает соединение, которое открыл consume().

Вызывать безопасно всегда: если consume() не использовался, метод ничего не делает. Потеря последней ссылки на ручку закрывает соединение и без явного вызова, но в долгоживущем демоне на это лучше не полагаться.

Синтаксис

php
public function close(): void

Параметры

Нет.

Возвращает

Ничего.

Перенос

moveTo()

Атомарно перемещает элемент из головы этого списка в хвост другого.

То есть берёт оттуда же, откуда pop(), и кладёт туда же, куда push(). Это основа очереди, которая не теряет задачи при падении воркера: элемент в любой момент находится ровно в одном из двух списков.

Синтаксис

php
public function moveTo(self $target): mixed

Параметры

$target — список, в который переносить. Должен принадлежать той же конфигурации.

Возвращает

Перенесённый элемент, либо null, если исходный список пуст.

Ошибки

LogicException — если списки принадлежат разным конфигурациям.

Пример

php
$jobs       = $store->list('jobs');
$processing = $store->list('jobs:processing');

$job = $jobs->moveTo($processing);

if ($job !== null) {
  $this->handle($job);
  $processing->remove($job);      // подтверждаем: обработано
}

Что делать с задачами, оставшимися в processing после падения воркера — вернуть в jobs, разбудить алерт, отложить в «мёртвые», — решает приложение. Пакет даёт инструмент и не навязывает политику: сроки и цену повтора знает только автор задачи.

Только внутри одной конфигурации

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

Перенос в самого себя

Элемент уезжает из головы в хвост, то есть список проворачивается. Так обходят очередь по кругу, ничего не теряя:

php
$workers->moveTo($workers);    // 'a','b','c' → 'b','c','a'

Чтение

Ни один метод в этом разделе ничего не изымает.

count()

Сообщает длину списка.

Синтаксис

php
public function count(): int

Параметры

Нет.

Возвращает

Число элементов; для несуществующего ключа — 0. Операция O(1): Redis хранит длину, а не считает её.

Пример

php
if ($jobs->count() > 10_000) {
  $this->alert('очередь растёт быстрее, чем разбирается');
}

range()

Возвращает срез списка, не изменяя его.

Синтаксис

php
public function range(int $start = 0, int $stop = -1): array

Параметры

$start — индекс первого элемента среза. По умолчанию 0.

$stop — индекс последнего элемента, включительно. По умолчанию -1.

Оба индекса могут быть отрицательными — отсчёт от хвоста: -1 последний, -2 предпоследний. Умолчания 0, -1 означают «весь список». Границы за пределами длины ошибкой не считаются: срез просто окажется короче или пустым.

Возвращает

Массив элементов; для несуществующего ключа — пустой массив.

Пример

php
$events->range();          // всё
$events->range(0, 9);      // первые десять
$events->range(-5, -1);    // последние пять
$events->range(100, 200);  // [] — если элементов меньше

`$stop` включается в результат

Это отличается от array_slice(): range(0, 9) вернёт десять элементов, а не девять. Так устроена сама команда LRANGE.

at()

Возвращает элемент по индексу, не изымая его.

Синтаксис

php
public function at(int $index): mixed

Параметры

$index — позиция. Отрицательная отсчитывается от хвоста: -1 последний.

Возвращает

Элемент, либо null, если такого индекса нет или нет самого ключа.

Пример

php
$jobs->at(0);     // следующая задача, не забирая её
$jobs->at(-1);    // последняя добавленная
$jobs->at(999);   // null

Помните про O(N): это дёшево у краёв и дорого в середине длинного списка.

Изменение

remove()

Удаляет элементы, равные переданному значению.

Сравнение идёт по байтам, как их видит сервер. Основное применение — подтверждение обработки в паре с moveTo().

Синтаксис

php
public function remove(mixed $value, int $count = 1): int

Параметры

$value — что удалять.

$count — сколько вхождений удалить и с какой стороны искать. Положительное число — от головы, отрицательное — от хвоста, 0 — все вхождения. По умолчанию 1.

Возвращает

Число фактически удалённых элементов.

Пример

php
// список: a, b, a, c, a
$list->remove('a');            // 1 → b, a, c, a   (первое от головы)
$list->remove('a', -1);        // 1 → b, a, c      (последнее от хвоста)
$list->remove('a', 0);         // 1 → b, c         (все оставшиеся)

Подтверждение задачи

php
$processing->remove($job);     // ровно один экземпляр этой задачи

trim()

Оставляет только указанный диапазон, удаляя всё остальное.

Для «последних N» отдельный trim() обычно не нужен — есть push() с аргументом cap, который делает то же самое атомарно вместе с добавлением.

Синтаксис

php
public function trim(int $start, int $stop): bool

Параметры

$start — первый оставляемый индекс.

$stop — последний оставляемый индекс, включительно.

Индексы работают так же, как в range(): отрицательные допустимы и отсчитываются от хвоста.

Возвращает

true, если команда выполнена.

Пример

php
$events->trim(0, 999);      // оставить тысячу первых
$events->trim(-100, -1);    // оставить сотню последних

Диапазон вне длины опустошает список

trim(5, 10) на списке из двух элементов удалит оба, и ключ перестанет существовать — Redis не хранит пустые контейнеры. Это не ошибка и ничем не сообщается, поэтому границы стоит считать, а не угадывать.

set()

Переписывает элемент на заданной позиции. Длину списка не меняет.

Синтаксис

php
public function set(int $index, mixed $value): bool

Параметры

$index — позиция. Отрицательная отсчитывается от хвоста.

$value — новое значение.

Возвращает

true при успехе и false, если индекса не существует. Исключения нет намеренно: список сам знает свою длину, и выход за неё — обычный ответ, а не сбой.

Пример

php
$jobs->set(0, $patchedJob);    // true
$jobs->set(999, $x);           // false, список не изменился

Ключ целиком

У элементов списка нет собственных сроков жизни — в отличие от полей хеша. Срок задаётся всему ключу.

expireKey()

Задаёт срок жизни всему списку.

Синтаксис

php
public function expireKey(int $seconds): bool

Параметры

$seconds — через сколько секунд удалить ключ целиком. Отсчёт от текущего момента, а не до момента: expireKey(time() + 3600) попросит пятьдесят шесть лет и не сообщит об этом.

Возвращает

true, если срок установлен; false, если ключа нет.

Пример

php
$draft->push($chunk);
$draft->expireKey(3600);     // черновик живёт час

keyTtl()

Сообщает, сколько списку осталось жить.

Синтаксис

php
public function keyTtl(): ?int

Параметры

Нет.

Возвращает

Оставшиеся секунды, либо null — и когда срок не задан, и когда ключа нет.

deleteKey()

Удаляет список целиком.

Синтаксис

php
public function deleteKey(): bool

Параметры

Нет.

Возвращает

true, если ключ существовал.

Служебное

name()

Возвращает имя ключа с префиксом — то, что видит сервер.

Нужно, когда работаете со списком через raw(): там префикс не применяется.

Синтаксис

php
public function name(): string

Параметры

Нет.

Возвращает

Полное имя ключа.

Пример

php
$store->raw()->lPos($jobs->name(), $needle);     // команда, которой нет в ручке

configClass()

Возвращает класс конфигурации, на которой живёт список.

Им же пользуется moveTo(), чтобы не дать перенести элемент между разными точками подключения.

Синтаксис

php
public function configClass(): string

Параметры

Нет.

Возвращает

Полное имя класса конфигурации.

Соответствие командам Redis

Имена методов описывают намерение; вот что уходит на сервер.

Метод Команда
push(value) / push(value, cap) RPUSH / MULTI(RPUSH, LTRIM)
pushFront(value) / pushFront(value, cap) LPUSH / MULTI(LPUSH, LTRIM)
pop() / popBack() LPOP / RPOP
consume(timeout) BLPOP
moveTo(list) LMOVE
count() / range() / at() LLEN / LRANGE / LINDEX
remove() / trim() / set() LREM / LTRIM / LSET
expireKey() / keyTtl() / deleteKey() EXPIRE / TTL / DEL

Команд, которых здесь нет — LPOS, LINSERT, RPOPLPUSH, BLMPOP, LMPOP, — можно достать через raw(), не забывая name().

Дальше

  • Хеши — поля и их собственные сроки жизни
  • Сторы — строки, счётчики, raw() и транзакции
  • Стримы — когда нужна история и учёт доставки
  • Пул соединений — почему consume() держится в стороне от пула