Пул соединений
Прикладной код пула не видит: он берёт стор и работает. Эта страница — про то, что происходит под ним, и про три решения, которые всё-таки принимает человек: сколько соединений, что делать при переполнении и что подключить при старте приложения.
Что такое пул и зачем
Проблема. Соединение с Redis — это один сокет с последовательным протоколом: команда,
ответ, следующая команда. Пока по нему идёт один запрос, второй через него не пройдёт. В
классическом PHP это не имело значения — процесс обслуживал один запрос и умирал.
Резидентный воркер обслуживает много запросов одновременно, каждый в своей корутине, и
одно общее соединение здесь означает, что две корутины пишут в него вперемежку: ответ
приходит на чужой запрос, в логе появляется packets out of order, а под нагрузкой падает
воркер целиком.
Открывать соединение под каждый запрос — другая крайность: рукопожатие, аутентификация и
выбор базы на каждый чих, а тысяча одновременных запросов превращается в тысячу соединений
и max number of clients reached на сервере.
Решение. Набор готовых соединений, из которого корутина берёт одно на время работы и возвращает по завершении. Соединения переиспользуются, их число ограничено сверху, мёртвые заменяются. Прикладной код при этом не меняется вовсе: он берёт стор и работает.
Как раздаются соединения
Под Swoole
На каждый класс конфигурации — свой пул. Первое обращение внутри корутины берёт
из него соединение и кладёт в контекст корутины; defer возвращает его, когда
корутина завершилась — и при обычном выходе, и при исключении.
// одна корутина = одно соединение на всю её жизнь
$store->get('a'); // здесь соединение взято из пула
$store->set('b', 1); // то же самое соединение
// конец запроса — соединение вернулось самоОтсюда два следствия, которые полезно держать в голове. Первое: две корутины никогда не делят сокет, поэтому вперемежку команды не идут. Второе: соединение занято всё время жизни корутины, а не на время команды, — если запрос делает одну команду в начале и ещё одну в конце, пул считает его занятым всё это время.
Без Swoole
Процесс обслуживает одну единицу работы за раз, распределять нечего: на конфигурацию заводится одно самоподдерживающееся соединение на весь процесс. Проверки живости и срок жизни у него те же, поэтому долгоживущий CLI-воркер не просыпается с сокетом, который сервер закрыл несколько часов назад.
Прикладной код одинаков в обоих случаях — ветвиться не нужно.
Что пул делает с соединениями
- Проверяет перед выдачей. Соединение, простоявшее дольше полусекунды, проверяется
через
PING; мёртвое выселяется и заменяется новым. Горячие соединения проверку пропускают, поэтому нагруженный сервис за это не платит. - Следит за возрастом. Соединение старше получаса переоткрывается — так пул успевает за инфраструктурой, которая двигается под ним: прокси, балансировщики, failover-адреса.
- Ограничивает число. Тысяча одновременных запросов не превращается в тысячу соединений: лишние ждут своей очереди.
Всё это — механика пакета CPool, общая с PPA.
Что происходит за один запрос
$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 ничего не
освободилось:
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, всё это время держит соединение занятым — и пул из десяти соединений обслуживает двадцать запросов в секунду вместо тысяч. Поднятый потолок это замаскирует, а не вылечит.
Наблюдение
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, причём выбранная база и
аутентификация восстанавливаются сами.
Если же команда упала и есть сомнения в соединении, о нём можно сообщить:
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 простаивает.
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:
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
и не рвёт ли их что-то быстрее, чем раз в две минуты.
Подключение к жизненному циклу приложения
Ничего делать не нужно. Ядро подключает пул само — при старте, если пакет установлен:
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().
Синтаксис
public static function store(string $configClass): \RedisПараметры
$configClass — класс конфигурации, MainRedisConfig::class.
Возвращает
\Redis — клиента, уже привязанного к текущей корутине. Отпускать его вручную не нужно.
Ошибки
RedisPoolException — если за poolWaitTimeout выдача не дошла до живого соединения:
все заняты, открыть новое не удалось, либо окно ушло на мёртвые соединения (сервер
отвечает на подключение, но не обслуживает). Настоящая причина лежит в getPrevious().
config()
Возвращает регистрационный экземпляр конфигурации.
Нужен, чтобы прочитать настройки или проверить доступность, не занимая соединение из
пула: адрес, номер базы, ping().
Синтаксис
public static function config(string $configClass): RedisConfigInterfaceПараметры
$configClass — класс конфигурации.
Возвращает
Экземпляр конфигурации; создаётся один раз на класс и переиспользуется.
Экземпляр из `config()` — не тот, что в пуле
Он существует, чтобы читать настройки. Соединения держат другие экземпляры — по одному на слот пула, и именно их проверяет и закрывает пул.
dedicated()
Открывает новое соединение вне пула и отдаёт его во владение вызывающему.
Нужен для блокирующих команд: SUBSCRIBE, BLPOP, XREAD BLOCK занимают соединение на
всё время ожидания, и держать под это соединение пула означало бы отобрать его у обычных
запросов.
Синтаксис
public static function dedicated(string $configClass): RedisConfigInterfaceПараметры
$configClass — класс конфигурации.
Возвращает
Новый экземпляр конфигурации с уже открытым соединением. Закрыть его — обязанность вызывающего.
Для очередей это уже сделано: $list->consume() берёт
такое соединение сам и закрывает по close().
stats()
Возвращает занятость каждого пула.
Считается для текущего воркера: у каждого воркера свой пул, и запрос видит тот, который его обслужил.
Синтаксис
public static function stats(): arrayПараметры
Нет.
Возвращает
Массив, где ключ — класс конфигурации, а значение — четыре числа: total (открыто всего),
idle (свободно), active (выдано) и maximum (потолок).
Без Swoole массив пуст: пулов там нет.
showConfigs()
Возвращает все конфигурации, зарегистрированные в процессе.
Дверь для health-проверок: обойти список и опросить каждую точку подключения
pingDetail(), не зная заранее, сколько их
в приложении.
Синтаксис
public static function showConfigs(): arrayПараметры
Нет.
Возвращает
Массив экземпляров конфигураций, ключ — имя класса. Попадают в него только те, к которым уже обращались: регистрация ленивая.
reportFailure()
Сообщает пулу о сбое, случившемся на выданном соединении.
Метод решает, виновато ли соединение: разрыв связи — соединение выселяется, и следующий запрос получит новое; отказ команде — соединение исправно, и его никто не трогает.
Синтаксис
public static function reportFailure(string $configClass, \Throwable $error): boolПараметры
$configClass — класс конфигурации, на соединении которой произошёл сбой.
$error — исключение так, как его бросил драйвер.
Возвращает
true, если ошибка классифицирована как потеря соединения и соединение выселено; false,
если сервер здоров.
setLogger()
Задаёт, куда пул пишет события.
Синтаксис
public static function setLogger(LoggerInterface $logger): voidПараметры
$logger — любой PSR-3 логгер. По умолчанию пул молчит.
shutdown()
Закрывает все пулы и соединения, которыми владеет процесс.
Вызывается при остановке воркера. Кроме сокетов снимает таймер обслуживания: живой таймер не даст реактору воркера завершиться.
Синтаксис
public static function shutdown(): voidreset()
Забывает все пулы и соединения, не закрывая их.
Вызывается в дочернем процессе сразу после fork(). Разница с shutdown()
принципиальная: форк копирует файловые дескрипторы, поэтому сокет, унаследованный от
родителя, физически тот же самый — закрыв его в потомке, вы оборвёте соединение родителя.
Синтаксис
public static function reset(): void