Пул соединений
Прикладной код пула не видит: он берёт стор и работает. Эта страница — про то, что происходит под ним, и про три решения, которые всё-таки принимает человек: сколько соединений, что делать при переполнении и что подключить при старте приложения.
Как раздаются соединения
Под 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 |
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 ничего не
освободилось:
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() — пул о нём ничего не
знает и потолок на него не распространяется, поэтому считать их приходится самому.
Для очередей это уже сделано: $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:
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. Все три необязательны, но каждый закрывает конкретную дыру.
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 этого пока нет: пакет самостоятельный и в
зависимости ядра ещё не входит, поэтому две строки выше пишутся в приложении. Когда
пакет войдёт в ядро, они станут не нужны.