php

Redis в PHP: phpredis, кеширование, сессии и очереди задач

Если в коде есть запрос к MySQL, который выполняется на каждом хите страницы, или вы храните сессии в файлах при нескольких бэкендах — пора подключать Redis. Это in-memory хранилище ключ-значение, которое работает в десятки раз быстрее БД и закрывает три задачи: кеш, сессии и очереди задач. В статье — практические рецепты на чистом PHP с расширением phpredis: подключение, базовые операции, шаблон cache-aside, сессии для нескольких серверов и очередь задач на LPUSH/BRPOP.

Схема: Redis между PHP-приложением, базой данных и worker-процессом
Redis как промежуточный слой: PHP сначала идёт в кеш, при промахе — в БД. Worker читает задачи из очереди.

Установка phpredis и подключение

На Debian/Ubuntu расширение ставится одной командой. На Beget и других shared-хостингах phpredis обычно уже включён — проверяется через phpinfo() или из CLI:

# Установка на сервер (Ubuntu/Debian)
sudo apt install php-redis redis-server
sudo systemctl enable --now redis-server

# Проверка из CLI
php -m | grep -i redis
# redis

# Проверка, что сам сервер отвечает
redis-cli ping
# PONG

Если phpredis недоступен (например, на хостинге без модулей C), используйте чистый PHP-клиент predis/predis через Composer. API почти идентичный, но phpredis в 3–5 раз быстрее за счёт нативной реализации.

<?php
// Подключение через phpredis
$redis = new Redis();
$redis->connect('127.0.0.1', 6379, 1.5); // host, port, timeout

// Если Redis защищён паролем
// $redis->auth('your_secret');

// Выбор базы (по умолчанию 0..15)
$redis->select(0);

// Префикс для всех ключей — изоляция от других приложений на том же Redis
$redis->setOption(Redis::OPT_PREFIX, 'app:');

// Сериализация массивов и объектов без ручного json_encode
$redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_PHP);

echo $redis->ping(); // +PONG

Важно: используйте pconnect() вместо connect() в FPM-окружении — соединение переживёт запрос и переиспользуется следующим. На высоких RPS это убирает накладные расходы на TCP-handshake.


Базовые структуры данных

Redis — не просто key-value. Под каждым ключом может лежать строка, хэш, список, множество или sorted set. Подбирать структуру нужно под задачу, а не складывать всё в SET.

<?php
// Строки: счётчики, простые значения
$redis->set('user:42:online', 1);
$redis->expire('user:42:online', 300); // TTL 5 минут
$redis->incr('stats:views:2026-05-18'); // атомарный счётчик

// Хэш: объект с полями (профиль пользователя)
$redis->hMSet('user:42', [
    'name'  => 'Иван',
    'email' => 'ivan@example.com',
    'role'  => 'admin',
]);
$name = $redis->hGet('user:42', 'name');

// Список: очередь, лента событий
$redis->lPush('feed:42', 'Новое уведомление');
$redis->lTrim('feed:42', 0, 99); // храним только последние 100

// Множество: уникальные значения (теги, ids онлайн-пользователей)
$redis->sAdd('online_users', 42, 17, 99);
$count = $redis->sCard('online_users');

// Sorted set: рейтинги, leaderboard
$redis->zAdd('leaderboard', 1500, 'player:42');
$top10 = $redis->zRevRange('leaderboard', 0, 9, true); // топ-10 с очками

Правило выбора: если значение читается целиком — строка с json_encode. Если меняется по полям — хэш. Если нужен порядок и удаление с краёв — список. Уникальность — множество. Сортировка по числу — sorted set.


Кеширование запросов: шаблон cache-aside

Самый частый сценарий — закешировать тяжёлый SQL-запрос. Шаблон cache-aside: приложение само проверяет кеш, при промахе идёт в БД и кладёт результат обратно. Redis в этой схеме ничего не знает о БД.

<?php
function getPopularPosts(Redis $redis, PDO $pdo, int $limit = 10): array
{
    $cacheKey = "popular_posts:limit:{$limit}";

    // 1. Пробуем достать из кеша
    $cached = $redis->get($cacheKey);
    if ($cached !== false) {
        return $cached; // OPT_SERIALIZER уже распакует
    }

    // 2. Промах — идём в БД
    $sql = 'SELECT id, title, views FROM posts
            WHERE status = "publish"
            ORDER BY views DESC LIMIT :lim';
    $stmt = $pdo->prepare($sql);
    $stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
    $stmt->execute();
    $posts = $stmt->fetchAll(PDO::FETCH_ASSOC);

    // 3. Кладём в кеш с TTL 5 минут
    $redis->setex($cacheKey, 300, $posts);

    return $posts;
}

Два правила, которые экономят часы отладки:

  • Всегда ставьте TTL через setex или expire. Кеш без срока жизни рано или поздно превратится в свалку устаревших данных.
  • Инвалидируйте по событию. Когда меняется пост — удаляйте все связанные с ним ключи через del или unlink (неблокирующий вариант).
<?php
// Инвалидация после обновления поста
function invalidatePostCache(Redis $redis, int $postId): void
{
    $redis->unlink("post:{$postId}");
    $redis->unlink("post:{$postId}:comments");

    // Списки кешей с шаблонными ключами — через SCAN, не KEYS
    $iterator = null;
    while ($keys = $redis->scan($iterator, "popular_posts:*", 100)) {
        $redis->unlink(...$keys);
    }
}

Никогда не используйте KEYS * на боевом сервере — это блокирующая операция, которая на больших базах кладёт Redis на несколько секунд. SCAN работает порциями и не блокирует другие команды.


Сессии PHP в Redis

Файловые сессии не работают, когда у вас несколько фронтенд-серверов за балансировщиком: пользователь логинится на одном, а следующий запрос попадает на другой, и сессии нет. Перенос сессий в Redis решает это за один параметр в php.ini.

# В php.ini или в .user.ini
session.save_handler = redis
session.save_path = "tcp://127.0.0.1:6379?prefix=PHPSESS:&auth=secret"
session.gc_maxlifetime = 86400

То же самое можно задать прямо из кода, если нет доступа к ini-файлу:

<?php
ini_set('session.save_handler', 'redis');
ini_set('session.save_path', 'tcp://127.0.0.1:6379?prefix=PHPSESS:');
ini_set('session.gc_maxlifetime', 86400);

session_start();
$_SESSION['user_id'] = 42;
$_SESSION['cart'] = ['item1', 'item2'];

После этого все PHP-серверы, подключённые к одному Redis, видят одну и ту же сессию. Никакой sticky session на балансировщике не нужен. Redis сам удалит просроченные ключи через TTL — отдельный gc не вызывается.


Очередь задач на LPUSH и BRPOP

Тяжёлые операции — отправка email, генерация PDF, ресайз изображений — не нужно делать в HTTP-запросе. Положите задачу в очередь, отдайте пользователю ответ, а отдельный worker разберёт очередь в фоне.

<?php
// producer.php — публикация задачи (вызывается из контроллера)
$redis = new Redis();
$redis->pconnect('127.0.0.1', 6379);

$job = [
    'type'    => 'send_email',
    'to'      => 'user@example.com',
    'subject' => 'Регистрация подтверждена',
    'body'    => 'Спасибо за регистрацию!',
    'created' => time(),
];

$redis->lPush('queue:emails', json_encode($job));
echo "Задача поставлена в очередь, длина: " . $redis->lLen('queue:emails');

Worker — это CLI-скрипт, который висит на brPop (блокирующий POP) и обрабатывает задачи по мере поступления. Под supervisord или systemd он перезапускается при падении.

<?php
// worker.php — запускается из CLI
$redis = new Redis();
$redis->pconnect('127.0.0.1', 6379);
$redis->setOption(Redis::OPT_READ_TIMEOUT, -1); // без таймаута на BRPOP

echo "Worker запущен, ждём задачи...\n";

while (true) {
    // BRPOP — блокирующая попытка взять задачу
    // 0 = ждать бесконечно
    $result = $redis->brPop(['queue:emails'], 0);
    if (!$result) {
        continue;
    }

    [$queue, $raw] = $result;
    $job = json_decode($raw, true);

    try {
        switch ($job['type']) {
            case 'send_email':
                mail($job['to'], $job['subject'], $job['body']);
                echo "[OK] Email -> {$job['to']}\n";
                break;
            default:
                echo "[SKIP] Неизвестный тип: {$job['type']}\n";
        }
    } catch (\Throwable $e) {
        // Кладём в очередь dead-letter для разбора
        $redis->lPush('queue:emails:failed', $raw);
        echo "[FAIL] {$e->getMessage()}\n";
    }
}

Запуск под supervisord, который держит worker живым и пишет логи:

# /etc/supervisor/conf.d/email-worker.conf
[program:email-worker]
command=/usr/bin/php /var/www/app/worker.php
autostart=true
autorestart=true
numprocs=2
stderr_logfile=/var/log/email-worker.err.log
stdout_logfile=/var/log/email-worker.out.log
user=www-data

Запустить параллельно несколько worker-процессов (numprocs=2) — и они конкурентно разберут одну и ту же очередь, потому что BRPOP атомарен. Никакой блокировки в коде не нужно.


Pipeline и MULTI: меньше round-trip

Если нужно выполнить 100 операций подряд, не делайте 100 отдельных запросов — отправьте их одним пакетом через pipeline. Это уменьшает сетевые задержки в десятки раз.

<?php
// Плохо: 100 round-trip
foreach ($userIds as $id) {
    $redis->sAdd('online', $id);
}

// Хорошо: 1 round-trip
$pipe = $redis->multi(Redis::PIPELINE);
foreach ($userIds as $id) {
    $pipe->sAdd('online', $id);
}
$pipe->exec();

// MULTI — транзакция (атомарность всех команд)
$redis->multi()
    ->decr('balance:42')
    ->incr('balance:17')
    ->exec();

Разница между PIPELINE и MULTI: pipeline просто склеивает запросы в пакет, транзакция гарантирует, что между командами никто не вклинится. Для счётчиков и кеша достаточно pipeline.


Чеклист на продакшене

  • Лимит памяти: в redis.conf ставьте maxmemory 512mb и maxmemory-policy allkeys-lru — Redis сам вытеснит редко используемые ключи.
  • Сохранение на диск: для кеша достаточно save "" (отключить RDB). Для сессий и очередей включайте appendonly yes.
  • Bind на 127.0.0.1: не открывайте Redis наружу без TLS и пароля — это популярный вектор атак.
  • Мониторинг: redis-cli --latency, INFO memory, SLOWLOG GET 10. Если used_memory близко к maxmemory, увеличивайте лимит или чистите кеш.
  • Никогда не вызывайте FLUSHALL и KEYS * в боевой среде. Для очистки используйте SCAN + UNLINK.
  • Префикс ключей: app:user:42 вместо user_42 — проще разбирать дампы и фильтровать через SCAN MATCH.

Redis — это не магия и не «база на стероидах». Это очень быстрая структура данных в памяти с сетевым интерфейсом. Если использовать её по назначению — для горячих данных, сессий и буфера между HTTP и фоном — приложение начинает летать. Если складывать в неё всё подряд без TTL и инвалидации — превратится в неуправляемую кучу.