Списки
Список — упорядоченная последовательность строк под одним ключом. В приложениях это почти всегда очередь задач или журнал последних событий. Ниже — что это за структура, где её уместно применять, и разбор каждого метода с аргументами и примерами.
Что такое список и зачем
Проблема. Работа, которую нельзя делать прямо сейчас, должна где-то подождать. Письмо, отчёт, выгрузка — всё, что дольше запроса, откладывают в сторону, а разбирает это кто-то другой. Держать очередь в памяти процесса нельзя: воркеров несколько, и перезапуск уносит всё накопленное. Держать в базе — значит опрашивать её в цикле и выяснять на каждом шаге, кто из воркеров какую строку уже взял.
Решение. Список Redis — общая на всех последовательность с быстрыми операциями по краям. Продюсер добавляет в один конец, потребитель забирает из другого, и Redis однопоточен: изъятие атомарно, поэтому одну задачу никогда не получат двое.
$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 — ручка на один список: объект, через который выполняются команды над этим
ключом. Получают её у стора:
$jobs = $store->list('jobs'); // сервер увидит 'queue:jobs'list() ничего не выполняет — это представление, а не
запрос. Префикс применяется здесь один раз, поэтому дальше его негде забыть. Соединения
ручка тоже не держит: каждый вызов спрашивает клиента у стора — кроме
consume(), у которого своё соединение.
Ручку можно создавать на каждый вызов, но для цикла потребителя её лучше сохранить: тогда блокирующие чтения переиспользуют одно соединение вместо того, чтобы открывать новое.
Добавление
push()
Добавляет элемент в хвост списка.
Это тот конец, из которого pop() не берёт, — поэтому пара push() + pop()
даёт очередь: первым пришёл, первым ушёл.
Синтаксис
public function push(mixed $value, ?int $cap = null): intПараметры
$value — что положить. Строка или число; для массивов и объектов нужен сериализатор.
$cap — максимальная длина списка. По умолчанию null — не ограничивать. С заданным
значением список сам себя подрезает, оставляя $cap последних элементов.
Возвращает
Длину списка после добавления. С $cap — длину после подрезки, то есть не больше $cap.
Пример
$jobs->push(json_encode(['type' => 'email', 'to' => $email])); // 1
$jobs->push(json_encode(['type' => 'sms'])); // 2Журнал последних N
foreach ($lines as $line) {
$audit->push($line, cap: 1000); // хранится только тысяча последних
}
$audit->count(); // 1000, сколько бы строк ни пришлоОграничение выполняется одной транзакцией (MULTI: добавить, подрезать), поэтому список
ни на мгновение не бывает длиннее $cap — и не остаётся длиннее, если что-то
сорвётся между двумя командами.
pushFront()
Добавляет элемент в голову списка — туда, откуда берёт pop().
Нужен в двух случаях: когда делают стек и когда задачу нужно поставить вперёд очереди.
Синтаксис
public function pushFront(mixed $value, ?int $cap = null): intПараметры
$value — что положить.
$cap — максимальная длина. По умолчанию null. Подрезка сохраняет $cap первых
элементов — то есть, поскольку кладут в голову, новейшие.
Возвращает
Длину списка после добавления и подрезки.
Пример
// стек: кладём и берём с одного конца
$undo->pushFront($action);
$undo->pop(); // последнее действие
// приоритетная задача — вперёд очереди
$jobs->pushFront($urgentJob);Изъятие
pop()
Забирает элемент из головы и удаляет его из списка.
Операция атомарна: сколько бы воркеров ни звали pop() одновременно, каждый элемент
достанется ровно одному.
Синтаксис
public function pop(): mixedПараметры
Нет.
Возвращает
Элемент, либо null, если список пуст или его вообще нет. Драйвер на этом месте отдаёт
false; null выбран, чтобы «пусто» не путалось с сохранённым false.
Пример
while (($job = $jobs->pop()) !== null) {
$this->handle($job); // разобрать всё, что накопилось, и выйти
}Изъятый элемент существует только в памяти воркера
Если воркер упадёт между pop() и завершением обработки, задача исчезнет — и узнать об
этом будет неоткуда. Когда это неприемлемо, вместо изъятия используют перенос:
moveTo().
popBack()
Забирает элемент из хвоста и удаляет его из списка.
Синтаксис
public function popBack(): mixedПараметры
Нет.
Возвращает
Элемент, либо null, если список пуст или его нет.
Пример
$jobs->push('a');
$jobs->push('b');
$jobs->popBack(); // 'b' — последний добавленный
$jobs->pop(); // 'a' — первый добавленныйОжидание
consume()
Ждёт появления элемента и забирает его из головы.
Если элемент уже есть, возвращает сразу — ожидание начинается только на пустом списке. Это основной инструмент потребителя: он позволяет не опрашивать список в цикле, а спать, пока не появится работа.
Синтаксис
public function consume(float $timeout = 0.0): mixedПараметры
$timeout — сколько секунд ждать. По умолчанию 0.0 — ждать неограниченно долго.
Возвращает
Элемент, либо null, если время вышло, а список так и остался пуст.
Пример
$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() не использовался, метод ничего не делает.
Потеря последней ссылки на ручку закрывает соединение и без явного вызова, но в
долгоживущем демоне на это лучше не полагаться.
Синтаксис
public function close(): voidПараметры
Нет.
Возвращает
Ничего.
Перенос
moveTo()
Атомарно перемещает элемент из головы этого списка в хвост другого.
То есть берёт оттуда же, откуда pop(), и кладёт туда же, куда
push(). Это основа очереди, которая не теряет задачи при падении воркера:
элемент в любой момент находится ровно в одном из двух списков.
Синтаксис
public function moveTo(self $target): mixedПараметры
$target — список, в который переносить. Должен принадлежать той же конфигурации.
Возвращает
Перенесённый элемент, либо null, если исходный список пуст.
Ошибки
LogicException — если списки принадлежат разным конфигурациям.
Пример
$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 выполняется на одном соединении, поэтому оба ключа обязаны жить на одной точке
подключения. Перенос в список другой конфигурации отклоняется исключением — без этой
проверки элемент записался бы в нашу базу под чужим именем, и всё выглядело бы
работающим.
Перенос в самого себя
Элемент уезжает из головы в хвост, то есть список проворачивается. Так обходят очередь по кругу, ничего не теряя:
$workers->moveTo($workers); // 'a','b','c' → 'b','c','a'Чтение
Ни один метод в этом разделе ничего не изымает.
count()
Сообщает длину списка.
Синтаксис
public function count(): intПараметры
Нет.
Возвращает
Число элементов; для несуществующего ключа — 0. Операция O(1): Redis хранит длину, а не
считает её.
Пример
if ($jobs->count() > 10_000) {
$this->alert('очередь растёт быстрее, чем разбирается');
}range()
Возвращает срез списка, не изменяя его.
Синтаксис
public function range(int $start = 0, int $stop = -1): arrayПараметры
$start — индекс первого элемента среза. По умолчанию 0.
$stop — индекс последнего элемента, включительно. По умолчанию -1.
Оба индекса могут быть отрицательными — отсчёт от хвоста: -1 последний, -2
предпоследний. Умолчания 0, -1 означают «весь список». Границы за пределами длины
ошибкой не считаются: срез просто окажется короче или пустым.
Возвращает
Массив элементов; для несуществующего ключа — пустой массив.
Пример
$events->range(); // всё
$events->range(0, 9); // первые десять
$events->range(-5, -1); // последние пять
$events->range(100, 200); // [] — если элементов меньше`$stop` включается в результат
Это отличается от array_slice(): range(0, 9) вернёт десять элементов, а не
девять. Так устроена сама команда LRANGE.
at()
Возвращает элемент по индексу, не изымая его.
Синтаксис
public function at(int $index): mixedПараметры
$index — позиция. Отрицательная отсчитывается от хвоста: -1 последний.
Возвращает
Элемент, либо null, если такого индекса нет или нет самого ключа.
Пример
$jobs->at(0); // следующая задача, не забирая её
$jobs->at(-1); // последняя добавленная
$jobs->at(999); // nullПомните про O(N): это дёшево у краёв и дорого в середине длинного списка.
Изменение
remove()
Удаляет элементы, равные переданному значению.
Сравнение идёт по байтам, как их видит сервер. Основное применение — подтверждение
обработки в паре с moveTo().
Синтаксис
public function remove(mixed $value, int $count = 1): intПараметры
$value — что удалять.
$count — сколько вхождений удалить и с какой стороны искать. Положительное число — от
головы, отрицательное — от хвоста, 0 — все вхождения. По умолчанию 1.
Возвращает
Число фактически удалённых элементов.
Пример
// список: 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 (все оставшиеся)Подтверждение задачи
$processing->remove($job); // ровно один экземпляр этой задачиtrim()
Оставляет только указанный диапазон, удаляя всё остальное.
Для «последних N» отдельный trim() обычно не нужен — есть push() с
аргументом cap, который делает то же самое атомарно вместе с добавлением.
Синтаксис
public function trim(int $start, int $stop): boolПараметры
$start — первый оставляемый индекс.
$stop — последний оставляемый индекс, включительно.
Индексы работают так же, как в range(): отрицательные допустимы и
отсчитываются от хвоста.
Возвращает
true, если команда выполнена.
Пример
$events->trim(0, 999); // оставить тысячу первых
$events->trim(-100, -1); // оставить сотню последнихДиапазон вне длины опустошает список
trim(5, 10) на списке из двух элементов удалит оба, и ключ перестанет существовать —
Redis не хранит пустые контейнеры. Это не ошибка и ничем не сообщается, поэтому границы
стоит считать, а не угадывать.
set()
Переписывает элемент на заданной позиции. Длину списка не меняет.
Синтаксис
public function set(int $index, mixed $value): boolПараметры
$index — позиция. Отрицательная отсчитывается от хвоста.
$value — новое значение.
Возвращает
true при успехе и false, если индекса не существует. Исключения нет намеренно: список
сам знает свою длину, и выход за неё — обычный ответ, а не сбой.
Пример
$jobs->set(0, $patchedJob); // true
$jobs->set(999, $x); // false, список не изменилсяКлюч целиком
У элементов списка нет собственных сроков жизни — в отличие от полей хеша. Срок задаётся всему ключу.
expireKey()
Задаёт срок жизни всему списку.
Синтаксис
public function expireKey(int $seconds): boolПараметры
$seconds — через сколько секунд удалить ключ целиком. Отсчёт от текущего момента, а не
до момента: expireKey(time() + 3600) попросит пятьдесят шесть лет и не сообщит об этом.
Возвращает
true, если срок установлен; false, если ключа нет.
Пример
$draft->push($chunk);
$draft->expireKey(3600); // черновик живёт часkeyTtl()
Сообщает, сколько списку осталось жить.
Синтаксис
public function keyTtl(): ?intПараметры
Нет.
Возвращает
Оставшиеся секунды, либо null — и когда срок не задан, и когда ключа нет.
deleteKey()
Удаляет список целиком.
Синтаксис
public function deleteKey(): boolПараметры
Нет.
Возвращает
true, если ключ существовал.
Служебное
name()
Возвращает имя ключа с префиксом — то, что видит сервер.
Нужно, когда работаете со списком через raw(): там
префикс не применяется.
Синтаксис
public function name(): stringПараметры
Нет.
Возвращает
Полное имя ключа.
Пример
$store->raw()->lPos($jobs->name(), $needle); // команда, которой нет в ручкеconfigClass()
Возвращает класс конфигурации, на которой живёт список.
Им же пользуется moveTo(), чтобы не дать перенести элемент между разными
точками подключения.
Синтаксис
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()держится в стороне от пула