Redis · Пул

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

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

Пакет flytachi/winter-redisПоверх CPoolПо умолчанию 10 соединений на воркер

Что такое пул и зачем

Проблема. Соединение с Redis — это один сокет с последовательным протоколом: команда, ответ, следующая команда. Пока по нему идёт один запрос, второй через него не пройдёт. В классическом PHP это не имело значения — процесс обслуживал один запрос и умирал. Резидентный воркер обслуживает много запросов одновременно, каждый в своей корутине, и одно общее соединение здесь означает, что две корутины пишут в него вперемежку: ответ приходит на чужой запрос, в логе появляется packets out of order, а под нагрузкой падает воркер целиком.

Открывать соединение под каждый запрос — другая крайность: рукопожатие, аутентификация и выбор базы на каждый чих, а тысяча одновременных запросов превращается в тысячу соединений и max number of clients reached на сервере.

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

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

Под 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 120.0 фоновый пинг простаивающих; 0 — выключено
$idleTimeout 600.0 закрывать простаивающие; 0 — не закрывать
$minimumIdle 0 тёплый минимум соединений

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

Уборка включена по умолчанию, и для Redis это острее, чем для базы: пул здесь вдесятеро на воркера, и простой, за который сервер закрыл сокеты, оставлял следующему запросу десять трупов на разбор. keepaliveTime пингует простаивающие раз в две минуты — ровно от этого защищает директива timeout самого Redis, которая на многих managed-сервисах закрывает неактивных клиентов. idleTimeout через десять минут простоя отдаёт соединения обратно, вместо того чтобы держать worker_num × 10 штук всю ночь.

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

Таймер заводится первой выдачей

Пока из пула не взяли ни одного соединения, таймера нет и пул не стоит ничего. После первой выдачи он живёт до RedisPool::shutdown() — см. предупреждение ниже.

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

Когда все соединения заняты, заёмщик ждёт. Если за 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() — пул о нём ничего не знает и потолок на него не распространяется, поэтому считать их приходится самому.

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

Исключение Когда Что делать
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 и не рвёт ли их что-то быстрее, чем раз в две минуты.

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

Ничего делать не нужно. Ядро подключает пул само — при старте, если пакет установлен:

text
Kernel::init()
└── DepSupport::has(Dep::Redis)
    ├── RedisPool::setLogger(...)                    логи пула уходят в канал 'Redis'
    └── ForkReset::register(RedisPool::reset(...))   форк-безопасность

workerExit
└── RedisPool::shutdown()                             закрытие при остановке воркера

Проверить, что подключение состоялось, можно по логу: с уровнем debug пул начинает рассказывать о себе сразу — config registered, pool created, slot opened.

Почему форк — отдельная забота

fork() копирует файловые дескрипторы, поэтому соединение, открытое до форка, оказывается физически одним и тем же у родителя и потомка. Две команды, ушедшие в один сокет из двух процессов, ломают протокол обоим, а симптом — packets out of order или ответ на чужой запрос — не указывает в сторону форка вовсе.

reset() забывает унаследованные соединения не закрывая их: закрыть означало бы оборвать соединение родителя. Потомок открывает свои при первом обращении.

Демоны и процессы Winter форкаются, поэтому регистрация нужна — и ядро делает её за вас.

Если приложение поднимается без ядра

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

RedisPool::setLogger(LoggerFactory::getLogger(RedisPool::class));
ForkReset::register(static fn() => RedisPool::reset());

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


Справочник RedisPool

RedisPool — статический фасад над всеми пулами процесса. Прикладному коду он обычно не нужен: клиента выдаёт стор, а закрытием и сбросом занимается bootstrap приложения. Методы описаны здесь потому, что через них проходит диагностика и потому, что два из них делают почти одно и то же — и перепутать их дорого.

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

store()

Возвращает клиента текущей единицы работы.

Под Swoole берёт соединение из пула при первом обращении в корутине, запоминает его в контексте корутины и вешает defer, который вернёт соединение, когда корутина завершится. Без Swoole отдаёт единственное соединение процесса. Именно этот метод и вызывает RedisStore::raw().

Синтаксис

php
public static function store(string $configClass): \Redis

Параметры

$configClass — класс конфигурации, MainRedisConfig::class.

Возвращает

\Redis — клиента, уже привязанного к текущей корутине. Отпускать его вручную не нужно.

Ошибки

RedisPoolException — если за poolWaitTimeout выдача не дошла до живого соединения: все заняты, открыть новое не удалось, либо окно ушло на мёртвые соединения (сервер отвечает на подключение, но не обслуживает). Настоящая причина лежит в getPrevious().

config()

Возвращает регистрационный экземпляр конфигурации.

Нужен, чтобы прочитать настройки или проверить доступность, не занимая соединение из пула: адрес, номер базы, ping().

Синтаксис

php
public static function config(string $configClass): RedisConfigInterface

Параметры

$configClass — класс конфигурации.

Возвращает

Экземпляр конфигурации; создаётся один раз на класс и переиспользуется.

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

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

dedicated()

Открывает новое соединение вне пула и отдаёт его во владение вызывающему.

Нужен для блокирующих команд: SUBSCRIBE, BLPOP, XREAD BLOCK занимают соединение на всё время ожидания, и держать под это соединение пула означало бы отобрать его у обычных запросов.

Синтаксис

php
public static function dedicated(string $configClass): RedisConfigInterface

Параметры

$configClass — класс конфигурации.

Возвращает

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

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

stats()

Возвращает занятость каждого пула.

Считается для текущего воркера: у каждого воркера свой пул, и запрос видит тот, который его обслужил.

Синтаксис

php
public static function stats(): array

Параметры

Нет.

Возвращает

Массив, где ключ — класс конфигурации, а значение — четыре числа: total (открыто всего), idle (свободно), active (выдано) и maximum (потолок).

Без Swoole массив пуст: пулов там нет.

showConfigs()

Возвращает все конфигурации, зарегистрированные в процессе.

Дверь для health-проверок: обойти список и опросить каждую точку подключения pingDetail(), не зная заранее, сколько их в приложении.

Синтаксис

php
public static function showConfigs(): array

Параметры

Нет.

Возвращает

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

reportFailure()

Сообщает пулу о сбое, случившемся на выданном соединении.

Метод решает, виновато ли соединение: разрыв связи — соединение выселяется, и следующий запрос получит новое; отказ команде — соединение исправно, и его никто не трогает.

Синтаксис

php
public static function reportFailure(string $configClass, \Throwable $error): bool

Параметры

$configClass — класс конфигурации, на соединении которой произошёл сбой.

$error — исключение так, как его бросил драйвер.

Возвращает

true, если ошибка классифицирована как потеря соединения и соединение выселено; false, если сервер здоров.

setLogger()

Задаёт, куда пул пишет события.

Синтаксис

php
public static function setLogger(LoggerInterface $logger): void

Параметры

$logger — любой PSR-3 логгер. По умолчанию пул молчит.

shutdown()

Закрывает все пулы и соединения, которыми владеет процесс.

Вызывается при остановке воркера. Кроме сокетов снимает таймер обслуживания: живой таймер не даст реактору воркера завершиться.

Синтаксис

php
public static function shutdown(): void

reset()

Забывает все пулы и соединения, не закрывая их.

Вызывается в дочернем процессе сразу после fork(). Разница с shutdown() принципиальная: форк копирует файловые дескрипторы, поэтому сокет, унаследованный от родителя, физически тот же самый — закрыв его в потомке, вы оборвёте соединение родителя.

Синтаксис

php
public static function reset(): void

Дальше

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