Redis · Конфигурация

Конфигурация

Конфигурация описывает одну точку подключения: куда идти, чем аутентифицироваться, какую базу выбрать. Из неё пул строит все свои соединения, поэтому всё, что должно быть одинаковым у каждого соединения, живёт здесь.

Пакет flytachi/winter-redisБазовый класс RedisConfigПул по умолчанию 10 соединений

Что такое конфигурация и зачем

Проблема. Адрес, пароль, номер базы и таймауты нужны в трёх местах сразу: стору — чтобы знать, куда идти; пулу — чтобы открывать соединения; health-проверке — чтобы обойти все точки подключения приложения. Если это массив в файле настроек, каждое из этих мест должно откуда-то узнать его ключ, а связь между строкой 'cache' и настоящим сервером существует только в голове разработчика.

Решение. Точка подключения описывается классом. Класс сам себе идентификатор: на него ссылаются типом, а не строкой, опечатка становится ошибкой автозагрузки, а IDE переименовывает его вместе со всеми упоминаниями. Свойства при этом типизированы, и $port, приехавший из .env строкой, до драйвера не доедет.

Класс конфигурации

Наследуется от RedisConfig, заполняется в setUp():

main/Configurations/MainRedisConfig.php
<?php

namespace Main\Configurations;

use Flytachi\Winter\Redis\Config\RedisConfig;

class MainRedisConfig extends RedisConfig
{
  public function setUp(): void
  {
      $this->host     = env('REDIS_HOST', 'localhost');
      $this->port     = (int) env('REDIS_PORT', 6379);
      $this->password = env('REDIS_PASS', '');
  }
}

setUp() вызывается один раз на каждый создаваемый экземпляр — то есть на каждое соединение в пуле. Читать здесь окружение нормально; ходить в базу или в сеть — нет.

Что можно задать

Свойство По умолчанию Что это
$host 'localhost' адрес сервера; допускает схему (tls://, unix://)
$port 6379 порт
$username '' пользователь ACL (Redis 6+); пусто — пользователь default
$password '' пароль; пустая строка — AUTH не отправляется
$databaseIndex 0 номер базы (SELECT)
$timeout 1.5 секунд на открытие сокета
$readTimeout 2.0 секунд на ответ уже открытого соединения
$serializer Redis::SERIALIZER_NONE как кодировать значения
$context [] контекст потока: CA, клиентский сертификат, проверка узла

Два таймаута, и они про разное

Их путают чаще прочего, а последствия у них разные.

$timeout — сколько ждать установления соединения. Исчерпан — соединение не открылось, и пул получит PoolException, который наружу выйдет как RedisPoolException. Это про недоступный сервер.

$readTimeout — сколько ждать ответа на уже отправленную команду. Исчерпан — RedisException: read error on connection, и это выглядит как обрыв, а не как таймаут. Это про сервер, который принял команду и молчит.

php
$this->timeout     = 1.5;    // не достучались — падаем быстро
$this->readTimeout = 2.0;    // ответа нет дольше двух секунд — считаем связь потерянной

readTimeout и блокирующие команды

BLPOP, BRPOP, SUBSCRIBE заставляют сервер молчать намеренно — ровно до появления данных. Для соединения это неотличимо от зависшего сервера, поэтому consume(timeout: 5) на соединении с readTimeout = 2.0 умрёт на второй секунде с ошибкой чтения. Измерено.

Ручка списка это учитывает и поднимает читающий таймаут на время ожидания сама — но если пишете блокирующую команду через raw(), поднимайте его руками: $redis->setOption(Redis::OPT_READ_TIMEOUT, -1) снимает ограничение (именно -1; 0 означает «не ждать вовсе»).

Оба значения — на одно соединение, а не на запрос: получение соединения из пула ограничено отдельной настройкой $poolWaitTimeout, и в неё входит не только ожидание свободного, но и выбрасывание мёртвых с открытием замены.

Аутентификация

Пароля достаточно для обычной установки — он аутентифицирует пользователя default:

php
$this->password = env('REDIS_PASS', '');

Начиная с Redis 6 есть ACL: отдельные пользователи со своими правами и доступом к своему набору ключей. Управляемые Redis у облачных провайдеров часто выдают именно такого пользователя, а не пароль от default:

php
$this->username = env('REDIS_USER', 'app');
$this->password = env('REDIS_PASS');

Если $username пуст, отправляется AUTH <пароль>; если задан — AUTH <пользователь> <пароль>. Проверить, кем вы подключились, можно так:

php
$config->connection()->rawCommand('ACL', 'WHOAMI');   // 'app'

Пароль не попадает в логи пакета

getDsn() намеренно собирается из хоста, порта и базы — ни пароля, ни пользователя в нём нет, поэтому строку можно писать в лог целиком. Пул логирует именно её.

TLS и unix-сокет

Схема указывается прямо в адресе — драйвер разбирает её сам:

php
$this->host = 'tls://redis.example.com';
$this->port = 6380;

Материалы для TLS — доверенный корневой сертификат, клиентский сертификат, требования к проверке узла — передаются через $context:

php
$this->host    = 'tls://redis.example.com';
$this->port    = 6380;
$this->context = [
  'stream' => [
      'cafile'      => '/etc/ssl/certs/redis-ca.pem',
      'verify_peer' => true,
  ],
];

Для локального сокета:

php
$this->host = '/var/run/redis/redis.sock';
$this->port = 0;                                  // порт не используется

Ошибки TLS приходят предупреждениями, а не исключениями

Драйвер сообщает о неудачном рукопожатии PHP-warning’ом (Failed to enable crypto), и соединение при этом может считаться установленным. Проверяйте настройку явным ping() при первом деплое, а не по отсутствию исключений.

Несколько конфигураций

Конфигурация — это не «настройки Redis в приложении», а одна точка подключения. Их бывает несколько, и это нормально:

php
class MainRedisConfig extends RedisConfig    { /* база 0 — сессии, кеш */ }
class QueueRedisConfig extends RedisConfig   { /* база 1 — очереди          */ }
class MetricsRedisConfig extends RedisConfig { /* другой сервер целиком     */ }

Каждая получает собственный пул со своим потолком, и в RedisPool::stats() они видны по отдельности. Сторы привязываются к нужной через $redisConfigClassName.

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

Номер базы

Задаётся в конфигурации и только там:

php
class QueueRedisConfig extends RedisConfig
{
  public function setUp(): void
  {
      $this->host          = env('REDIS_HOST', 'localhost');
      $this->databaseIndex = 3;
  }
}

Нужна вторая база — второй класс конфигурации. Он получит собственный пул со своими соединениями, и путаницы между ними не возникнет по построению.

Не переключайте базу на лету

SELECT меняет состояние соединения, а соединение возвращается в пул. Следующий запрос получил бы его вместе с чужой базой и писал бы не туда — молча, без единой ошибки. Поэтому в API стора нет ничего похожего на select(): смена базы возможна только вместе со сменой конфигурации.

Выбранная база переживает и обрыв связи: ext-redis переподключается прозрачно и восстанавливает AUTH и SELECT сам, так что соединение не «просыпается» на базе номер ноль.

Сериализация значений

По умолчанию — SERIALIZER_NONE: на сервер уходят ровно те байты, что вы передали. Это единственная форма, которую прочитают redis-cli и приложения на других языках.

Платой за это становится то, что значение должно быть строкой или числом:

php
$store->set('user', ['id' => 1]);   // → в базе строка "Array", и только PHP-warning
$store->get('user');                 // 'Array'

Ошибка не выбрасывается — поэтому выбор сериализатора и сделан свойством конфигурации, где он виден один раз и на месте:

php
use Redis;

class CacheRedisConfig extends RedisConfig
{
  public function setUp(): void
  {
      $this->host       = env('REDIS_HOST', 'localhost');
      $this->serializer = Redis::SERIALIZER_PHP;    // массивы и объекты как есть
  }
}
Значение Что даёт Чем платите
SERIALIZER_NONE байты как есть, читаемо всеми только строки и числа
SERIALIZER_PHP любые значения PHP, включая объекты формат читает только PHP
SERIALIZER_JSON любые массивы, читаемо другими языками объекты возвращаются как stdClass

Сериализатор меняют до того, как в базе появились данные

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

Размер пула

По умолчанию пул на конфигурацию — 10 соединений, ожидание свободного — 3 секунды. Чтобы задать своё, конфигурация объявляет себя пул-осведомлённой:

main/Configurations/MainRedisConfig.php
<?php

namespace Main\Configurations;

use Flytachi\Winter\Redis\Config\RedisConfig;
use Flytachi\Winter\Redis\Pool\RedisPoolConfigInterface;
use Flytachi\Winter\Redis\Pool\RedisPoolTrait;

class MainRedisConfig extends RedisConfig implements RedisPoolConfigInterface
{
  use RedisPoolTrait;

  public int   $poolMaxConnections = 20;
  public float $poolWaitTimeout    = 5.0;

  public function setUp(): void
  {
      $this->host = env('REDIS_HOST', 'localhost');
  }
}

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

Разовое подключение

Для скрипта, миграции или теста класс заводить незачем — есть inline-вариант:

scripts/warmup.php
<?php
require 'vendor/autoload.php';

use Flytachi\Winter\Redis\Config\Call\RedisCall;

$redis = (new RedisCall(
  host:          '127.0.0.1',
  port:          6379,
  databaseIndex: 3,
))->connection();

$redis->set('warm', '1');

Каждый такой объект открывает своё соединение и закрывает его, когда его соберёт сборщик мусора. В приложении, обслуживающем запросы, так делать не нужно — там конфигурация и пул.

Методы конфигурации

Прикладной код эти методы не вызывает: соединение ему выдаёт пул, а команды — стор. Нужны они там, где приложение разговаривает с Redis напрямую: health-эндпоинт, обслуживающий скрипт, диагностика.

Экземпляр конфигурации берут у пула:

php
use Flytachi\Winter\Redis\RedisPool;

$config = RedisPool::config(MainRedisConfig::class);

setUp()

Заполняет свойства конфигурации. Единственный метод, который вы обязаны написать сами.

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

Синтаксис

php
public function setUp(): void

Параметры

Нет — метод пишет в свойства объекта.

connection()

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

Ленивость существенна: объект конфигурации можно создать, передать, разобрать — и ни одного сокета при этом не откроется.

Синтаксис

php
public function connection(): \Redis

Возвращает

Объект \Redis — клиента расширения ext-redis, уже прошедшего AUTH и SELECT.

Это не то же самое, что взять соединение из пула

connection() держит собственное соединение конфигурации, мимо пула. Прикладному коду нужен стор — он получает клиента текущей единицы работы.

connect(), disconnect(), reconnect()

Управляют сокетом вручную.

connect() открывает соединение, если оно ещё не открыто; повторный вызов ничего не делает. disconnect() закрывает его. reconnect() — то и другое подряд.

Синтаксис

php
public function connect(): void
public function disconnect(): void
public function reconnect(): void

ping()

Проверяет, отвечает ли сервер.

Не бросает ни при каких обстоятельствах: недоступный сервер, неверный пароль, оборванный сокет — всё это false. Поэтому метод можно звать из health-эндпоинта, не оборачивая в try.

Синтаксис

php
public function ping(): bool

Возвращает

true, если сервер ответил; false при любой ошибке.

pingDetail()

То же самое, но с задержкой и текстом ошибки.

Это то, что отдают в /actuator/health: ответ не только сообщает «жив ли», но и показывает задержку, а при отказе — причину, избавляя от похода в логи. Как и ping(), не бросает.

Синтаксис

php
public function pingDetail(): array

Возвращает

Массив из трёх ключей: status (bool), latency (время ответа в миллисекундах) и error (текст исключения или null).

Результат

json
{ "status": true, "latency": 0.32, "error": null }

getDsn()

Собирает строку подключения из свойств.

Синтаксис

php
public function getDsn(): string

Возвращает

Строку вида redis://localhost:6379/0. Ни пароля, ни имени пользователя в ней нет — поэтому её можно писать в лог целиком, и пул логирует именно её.

Остальные методы

Простые читатели свойств — описывать в них нечего.

Метод Что возвращает
getHost() адрес сервера
getPort() порт
getUsername() имя пользователя ACL
getDatabaseIndex() номер базы
getSerializer() выбранный сериализатор

Экземпляр из config() — не тот, что в пуле

RedisPool::config() возвращает регистрационный экземпляр: он существует, чтобы читать настройки. Соединения держат другие экземпляры — по одному на слот пула, и именно их проверяет и закрывает пул.

Дальше

  • Сторы — как этим пользоваться в прикладном коде
  • Пул соединений — сколько соединений и что при переполнении