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