Redis · Списки

Списки

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

Что такое список

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) на каждый доступ хеш или другая структура

Ручка

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

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

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


Справочник методов

Добавление

push()

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

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

Аргумент Тип По умолчанию Что делает
$value mixed что положить. Строка или число; для остального нужен сериализатор
$cap ?int null максимальная длина списка. null — не ограничивать

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

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

С $cap список сам себя подрезает — шаблон «последние N событий»:

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

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

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

pushFront()

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

То же самое, но в голову — туда, откуда берёт pop(). Нужен в двух случаях: когда делают стек, и когда задачу нужно вернуть в очередь вне очереди.

Аргумент Тип По умолчанию Что делает
$value mixed что положить
$cap ?int null максимальная длина; хранятся новейшие, то есть первые $cap

Возвращает длину после добавления (и подрезки).

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

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

Изъятие

pop()

php
public function pop(): mixed

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

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

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

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

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

Если воркер упадёт между 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 float 0.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()

php
public function close(): void

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

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

Перенос

moveTo()

php
public function moveTo(self $target): mixed

Атомарно перемещает элемент из головы этого списка в хвост другого — то есть берёт оттуда же, откуда pop(), и кладёт туда же, куда push().

Аргумент Тип По умолчанию Что делает
$target RedisList куда переносить. Должен принадлежать той же конфигурации

Возвращает перенесённый элемент либо 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 int 0 индекс первого элемента среза
$stop int -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 int позиция; отрицательная отсчитывается от хвоста

Возвращает элемент либо null, если такого индекса нет или нет самого ключа.

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

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

Изменение

remove()

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

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

Аргумент Тип По умолчанию Что делает
$value mixed что удалять
$count int 1 сколько вхождений: > 0 — от головы, < 0 — от хвоста, 0 — все

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

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         (все оставшиеся)

Основное применение — подтверждение обработки в паре с moveTo():

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

trim()

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

Оставляет только указанный диапазон, удаляя всё остальное. Индексы — как в range(), включительно, отрицательные допустимы.

Аргумент Тип По умолчанию Что делает
$start int первый оставляемый индекс
$stop int последний оставляемый индекс, включительно

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

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

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

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

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

set()

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

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

Аргумент Тип По умолчанию Что делает
$index int позиция; отрицательная — от хвоста
$value mixed новое значение

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

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

Ключ целиком

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

expireKey()

php
public function expireKey(int $seconds): bool
Аргумент Тип По умолчанию Что делает
$seconds int через сколько секунд удалить ключ целиком

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

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

Срок считается от текущего момента, а не до момента: expireKey(time() + 3600) попросит пятьдесят шесть лет и не сообщит об этом.

keyTtl()

php
public function keyTtl(): ?int

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

deleteKey()

php
public function deleteKey(): bool

Удаляет список целиком. Аргументов нет. Возвращает true, если ключ существовал.

Служебное

name()

php
public function name(): string

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

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

configClass()

php
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() держится в стороне от пула