Redis · Пул

Пул соединений

Прикладной код пула не видит: он берёт стор и работает. Эта страница — про то, что происходит под ним, и про три решения, которые всё-таки принимает человек: сколько соединений, что делать при переполнении и что подключить при старте приложения.

Как раздаются соединения

Под Swoole

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

php
// одна корутина = одно соединение на всю её жизнь
$store->get('a');     // здесь соединение взято из пула
$store->set('b', 1);  // то же самое соединение
                     // конец запроса — соединение вернулось само

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

Без Swoole

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

Прикладной код одинаков в обоих случаях — ветвиться не нужно.

Что пул делает с соединениями

  • Проверяет перед выдачей. Соединение, простоявшее дольше полусекунды, проверяется через PING; мёртвое выселяется и заменяется новым. Горячие соединения проверку пропускают, поэтому нагруженный сервис за это не платит.
  • Следит за возрастом. Соединение старше получаса переоткрывается — так пул успевает за инфраструктурой, которая двигается под ним: прокси, балансировщики, failover-адреса.
  • Ограничивает число. Тысяча одновременных запросов не превращается в тысячу соединений: лишние ждут своей очереди.

Всё это — механика пакета CPool, общая с PPA.

Что происходит за один запрос

php
$store->get('a');       // 1. пул выдаёт соединение, defer поставлен
$store->set('b', 1);    // 2. то же соединение из контекста корутины
$store->hash('c')->all();  // 3. и снова оно же
                       // 4. корутина завершилась — defer вернул соединение

Пошагово на первом обращении: пул смотрит, есть ли свободное соединение; если есть и оно простояло дольше aliveBypassWindow — проверяет его PING’ом и заменяет, если оно мертво; если свободных нет, а потолок не достигнут — открывает новое; если потолок достигнут — ждёт до poolWaitTimeout. Полученное соединение кладётся в контекст корутины, и регистрируется defer.

Дальше все обращения в этой корутине берут соединение из контекста — ни новых проверок, ни новых заимствований. defer срабатывает и при обычном выходе, и при исключении, и при exit, поэтому потерять соединение нельзя даже в упавшем запросе.

Размер пула

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

Настройка По умолчанию Что делает
$poolMaxConnections 10 потолок числа соединений
$poolWaitTimeout 3.0 сколько ждать свободного
$keepaliveTime 0.0 фоновый пинг простаивающих; 0 — выключено
$idleTimeout 0.0 закрывать простаивающие; 0 — не закрывать
$minimumIdle 0 тёплый минимум соединений

Как прикидывать потолок: считать надо не по одному воркеру, а по всем. Число, которое видит сервер, — это worker_num × poolMaxConnections × число инстансов, и сравнивать его нужно с maxclients вашего Redis (по умолчанию 10 000 — заметно щедрее, чем max_connections у базы, поэтому и умолчание здесь выше, чем в PPA).

keepaliveTime включают, когда файрвол или сам Redis рвут простаивающие соединения: периодический пинг не даёт им умирать между запросами. idleTimeout — когда трафик идёт всплесками и держать пиковый пул всю ночь расточительно; тогда же задают minimumIdle, чтобы следующий всплеск не начинался с нуля.

С умолчаниями фонового таймера нет вовсе

Уборка включается, только если задан keepaliveTime или idleTimeout. Простаивающий пул не стоит ничего и ничем не занимает реактор воркера.

Переполнение

Когда все соединения заняты, заёмщик ждёт. Если за poolWaitTimeout ничего не освободилось:

php
RedisPoolException: RedisPool: no connection for [Main\Configurations\MainRedisConfig] 
ConnectionPool: no free connection within 3s raise maximumPoolSize or connectionTimeout.

Это защита, а не сбой: очередь внутри приложения лучше, чем max number of clients reached на сервере, который положит и соседние приложения.

Прежде чем поднимать потолок, поищите долгие запросы

Соединение занято всё время жизни корутины. Запрос, который сходил в Redis и потом полсекунды ждёт внешний API, всё это время держит соединение занятым — и пул из десяти соединений обслуживает двадцать запросов в секунду вместо тысяч. Поднятый потолок это замаскирует, а не вылечит.

Наблюдение

php
use Flytachi\Winter\Redis\RedisPool;

RedisPool::stats();
// [
//   'Main\Configurations\MainRedisConfig' => [
//       'total' => 4, 'idle' => 3, 'active' => 1, 'maximum' => 10,
//   ],
// ]

Числа на воркер: каждый воркер держит свой пул в своей памяти, поэтому запрос к /actuator/health показывает того воркера, который его обслужил. Не-корутинный путь пула не имеет и в отчёт не попадает.

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

Сломанное соединение

Обычно вмешиваться не нужно: ext-redis переподключается прозрачно — проверено и на явном close(), и на серверном CLIENT KILL, причём выбранная база и аутентификация восстанавливаются сами.

Если же команда упала и есть сомнения в соединении, о нём можно сообщить:

php
use Flytachi\Winter\Redis\RedisPool;

try {
  $store->set('key', 'value');
} catch (\RedisException $e) {
  RedisPool::reportFailure(MainRedisConfig::class, $e);
  throw $e;
}

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

Соединение в обход пула

Есть команды, которые занимают соединение на всё время своей работы: BLPOP, BRPOP, SUBSCRIBE, MONITOR. Взять такую на пуловом соединении — значит держать слот пула столько же, и несколько потребителей опустошат пул, пока сам Redis простаивает.

php
use FlytachiWinterRedisRedisPool;

$config = RedisPool::dedicated(MainRedisConfig::class);
$redis  = $config->connection();

$redis->setOption(Redis::OPT_READ_TIMEOUT, -1);   // блокирующее чтение ждёт сколько нужно
$redis->subscribe(['events'], $handler);

$config->disconnect();

dedicated() открывает соединение, которое никому не отдаётся и не возвращается: им владеет вызывающий. Такое соединение не попадает в stats() — пул о нём ничего не знает и потолок на него не распространяется, поэтому считать их приходится самому.

Для очередей это уже сделано: $list->consume() берёт такое соединение сам и закрывает по close().

Справочник RedisPool

Статический фасад; в прикладном коде обычно не нужен — им пользуются сторы.

Метод Что делает
store($configClass) клиент текущей единицы работы; из пула под Swoole, единственное соединение без него
config($configClass) регистрационный экземпляр конфигурации — читать настройки, не подключаться
dedicated($configClass) новое соединение вне пула, во владении вызывающего
stats() занятость каждого пула: total, idle, active, maximum
showConfigs() все зарегистрированные конфигурации, для health-проверок
reportFailure($configClass, $error) сообщить о сбое на выданном соединении
setLogger($logger) куда писать события пула
shutdown() закрыть всё, что процесс открыл, — при завершении воркера
reset() забыть всё не закрывая — в потомке после fork()

Какая ошибка что означает

Исключение Когда Что делать
RedisPoolException соединение не получено: пул полон либо сервер недоступен смотреть stats() и причину в getPrevious()
RedisCommandException сервер отказал команде: не тот тип ключа, не число в счётчике это ошибка в коде, а не в инфраструктуре
RedisFeatureException команда новее сервера (сроки жизни полей хеша) обновить сервер или обойтись expireKey()
RedisException (драйвера) обрыв связи, таймаут чтения соединение подозрительное — reportFailure()

Первые три — наши, последнее приходит из ext-redis как есть.

Что писать в лог

С установленным логгером пул рассказывает о себе на уровне debug, а о потерях — на warning:

bash
DEBUG  config registered: MainConfigurationsMainRedisConfig redis://localhost:6379/0
DEBUG  pool created: MainConfigurationsMainRedisConfig maxConnections=10
DEBUG  slot opened: MainConfigurationsMainRedisConfig redis://localhost:6379/0
DEBUG  cid=7 borrow: MainConfigurationsMainRedisConfig
DEBUG  cid=7 release: MainConfigurationsMainRedisConfig
WARN   cid=9 evict: MainConfigurationsMainRedisConfig

Несколько slot opened подряд означают, что пул растёт под нагрузкой; частые evict — что соединения умирают между запросами, и стоит посмотреть в сторону keepaliveTime.

Подключение к жизненному циклу приложения

Три вызова в bootstrap. Все три необязательны, но каждый закрывает конкретную дыру.

main/bootstrap.php
use Flytachi\Winter\Kernel\Process\ForkReset;
use Flytachi\Winter\Logger\LoggerFactory;
use Flytachi\Winter\Redis\RedisPool;

// 1. Логи пула: открытие слотов, выдача, возврат, выселение.
RedisPool::setLogger(LoggerFactory::getLogger(RedisPool::class));

// 2. Форк-безопасность: потомок обязан забыть унаследованные сокеты, не закрывая их.
ForkReset::register(static fn() => RedisPool::reset());

Почему форк отдельно

fork() копирует файловые дескрипторы, поэтому соединение, открытое до форка, оказывается общим у родителя и потомка — и протокол ломается у обоих. reset() забывает соединения не закрывая их: закрыть означало бы оборвать соединение родителя. Демоны и процессы Winter форкаются, поэтому регистрация нужна.

Третий вызов — RedisPool::shutdown() — нужен только если вы включили keepaliveTime или idleTimeout: тогда у пула появляется таймер, а воркер не может завершиться, пока его реактор держит повторяющийся таймер. С умолчаниями таймера нет и делать ничего не нужно.

У PPA это подключено ядром, у Redis — пока вручную

Ядро само регистрирует форк-сброс и закрытие пула для базы данных (PpaConnectionPool). Для winter-redis этого пока нет: пакет самостоятельный и в зависимости ядра ещё не входит, поэтому две строки выше пишутся в приложении. Когда пакет войдёт в ядро, они станут не нужны.

Дальше

  • Сторы — что видит прикладной код
  • CPool — сам пул, если нужно подключить к нему свой драйвер
  • PPA — тот же подход для базы данных