diff --git a/README.ru.md b/README.ru.md index abd3cbd..acbd679 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,4 +1,5 @@ -# Расуваефф/переборка +# rasuvaeff/bulkhead + [![Latest Stable Version](https://poser.pugx.org/rasuvaeff/bulkhead/v)](https://packagist.org/packages/rasuvaeff/bulkhead) [![Total Downloads](https://poser.pugx.org/rasuvaeff/bulkhead/downloads)](https://packagist.org/packages/rasuvaeff/bulkhead) [![Build](https://github.com/rasuvaeff/bulkhead/actions/workflows/build.yml/badge.svg)](https://github.com/rasuvaeff/bulkhead/actions/workflows/build.yml) @@ -6,35 +7,44 @@ [![Psalm level](https://img.shields.io/badge/psalm-level_1-blue.svg)](https://github.com/rasuvaeff/bulkhead/actions/workflows/static-analysis.yml) [![PHP](https://img.shields.io/packagist/dependency-v/rasuvaeff/bulkhead/php)](https://packagist.org/packages/rasuvaeff/bulkhead) [![License](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg)](LICENSE.md) -Ограничитель межпроцессного параллелизма (переборка) для PHP-FPM. Ограничивает количество **одновременных** вызовов - для хрупкой зависимости во **всем пуле рабочих**, -, поэтому всплеск не может нагружать каждого рабочего процесса в нисходящий поток, который допускает только несколько соединений -. Превышение лимита вызывает fast-fail (или кратковременное ожидание) вместо -, каскадно вызывающего сбой. - - Счетчик, общий в Redis или APCu, является точкой координации: в - FPM без общего доступа ограничение должно находиться вне процесса, поскольку каждый запрос выполняется в - со своим собственным исполнителем. Дополняет автоматический выключатель (который решает *стоит ли* попробовать -) — перегородка решает *сколько одновременно*. - - > Используете помощника по программированию с искусственным интеллектом? [llms.txt](llms.txt) содержит компактную ссылку на API, которой вы можете поделиться с моделью. @@ЛИНИЯ@@ +[English version](README.md) + +Межпроцессный ограничитель параллелизма (bulkhead) для PHP-FPM. Ограничивает число +**одновременных** вызовов хрупкой зависимости во **всём пуле воркеров**: всплеск +нагрузки не может заставить каждого воркера штурмовать downstream, который держит +лишь несколько соединений. При превышении лимита вызовы быстро падают (fast-fail) +либо кратко ждут — вместо того, чтобы каскадно распространять сбой. + +Точка координации — общий счётчик в Redis или APCu: в shared-nothing-модели FPM +лимит должен жить вне процесса, потому что каждый запрос идёт в своём воркере. +Дополняет circuit breaker (который решает, *стоит ли* пробовать) — bulkhead решает, +*сколько одновременно*. + +> Используете AI-ассистента? В [llms.txt](llms.txt) — компактный API-справочник, +> которым можно поделиться с моделью. + ## Требования + - PHP 8.3+ -- [`rasuvaeff/duration`](https://github.com/rasuvaeff/duration) for the typed lease/wait values -- Для ограничения межпроцессного взаимодействия с несколькими хостами («RedisBulkheadStore»): доступный Redis - server plus **one** Redis client — [`predis/predis`](https://github.com/predis/predis) -^2.2 (чистый PHP, PredisScriptRunner) или ext-redis (PhpRedisScriptRunner). - Обе зависимости являются необязательными; установите тот, который используете. - - `ext-apcu` для ограничения межпроцессного взаимодействия с одним хостом (`ApcuBulkheadStore`) — необязательно, а не жесткая зависимость +- [`rasuvaeff/duration`](https://github.com/rasuvaeff/duration) для типизированных значений lease/wait +- Для межхостового ограничения (`RedisBulkheadStore`): доступный Redis-сервер + плюс **один** Redis-клиент — [`predis/predis`](https://github.com/predis/predis) + ^2.2 (чистый PHP, `PredisScriptRunner`) либо `ext-redis` (`PhpRedisScriptRunner`). + Обе зависимости опциональны; ставьте тот, что используете. +- `ext-apcu` для ограничения в пределах одного хоста (`ApcuBulkheadStore`) — + опционально, не обязательная зависимость ## Установка + ```bash composer require rasuvaeff/bulkhead # for RedisBulkheadStore with the pure-PHP client: composer require predis/predis ``` + ## Использование + ```php use Predis\Client; use Rasuvaeff\Bulkhead\BulkheadFullException; @@ -57,7 +67,8 @@ try { // All slots busy — degrade gracefully instead of hammering the dependency. } ``` -С ext-redis вместо predis: + +С `ext-redis` вместо predis: ```php use Rasuvaeff\Bulkhead\Redis\PhpRedisScriptRunner; @@ -66,7 +77,8 @@ $redis = new \Redis(); $redis->connect('127.0.0.1'); $store = new RedisBulkheadStore(new PhpRedisScriptRunner($redis)); ``` -Дополнительные ручки: + +Опциональные ручки: ```php $bulkhead = new SharedBulkhead( @@ -82,92 +94,109 @@ $bulkhead = new SharedBulkhead( onRejected: static fn(string $name, Duration $waited) => $metrics->increment("bulkhead.$name.rejected"), ); ``` + ### Публичный API + | Тип | Описание | - |---|---| - | `Переборка` | Интерфейс: `call(callable): смешанный`, `availableSlots(): int` | - | `Общая переборка` | Ограничивает параллелизм с помощью BulkheadStore; fast-fail или ждет до `maxWait`; предоставляет `name()`, `maxConcurrent()` | - | `BulkheadStore` | Резервное хранилище: `tryAcquire`, `release`, `activeCount` | - | `RedisBulkheadStore` | Многохостовое межпроцессное хранилище; отсортированный набор + Lua, атомарное приобретение, аренда TTL | - | `ApcuBulkheadStore` | Межпроцессное хранилище с одним хостом; Спин-блокировка APCu, атомное приобретение, аренда TTL | - | `InMemoryBulkheadStore` | Однопроцессное хранилище (тесты/CLI); не координирует процессы | - | `BulkheadScriptRunner` | Напечатанный шов при вызове сценария Redis (реализовать для другого клиента) | - | `Redis\PredisScriptRunner` | BulkheadScriptRunner с поддержкой Predis; EVALSHA с резервным вариантом EVAL | - | `Redis\PhpRedisScriptRunner` | BulkheadScriptRunner с поддержкой `ext-redis`; EVALSHA с резервным вариантом EVAL | - | `BulkheadFullException` | Вызывается, когда в течение `maxWait` нет свободного места; содержит `name`, `maxConcurrent` | - | `Спящий\СпящийИнтерфейс` | Стратегия ожидания во время опроса; `SystemSleeper`, `FakeSleeper` | @@ЛИНИЯ@@ -### Размер ручек -- **`maxConcurrent`** — то, что *нисходящий* поток* допускает, а не то, что пул может отправить -. Если зависимость легко обрабатывает около 10 одновременных подключений и вы - запускаете 3 хоста приложений, использующих один Redis, `maxConcurrent: 10` ограничивает все хосты - вместе. Чтобы что-то означать, оно должно быть меньше, чем количество рабочих FPM — - с 50 рабочими и `maxConcurrent: 100`, переборка никогда не задействуется. - - **`lease`** — строго больше, чем время выполнения обратного вызова в худшем случае, на практике -: тайм-аут нисходящего потока + запас прочности. Слишком коротко, и слоты - освобождаются в середине вызова (превышение лимита); слишком долго, и слот - для вышедшего из строя рабочего процесса остается занятым в течение всего срока аренды (ниже предела). Если обратный вызов представляет собой HTTP-вызов - с таймаутом 5 секунд, то `lease: Duration::секунды(10)` будет разумным началом. - - **`maxWait`** — как долго запрос может стоять в очереди в слот. `Duration::zero()` - быстрый сбой (немедленное сброс нагрузки); все, что дольше, меняет задержку на более низкий процент отказов -. Держите это под своим тайм-аутом запроса. - - **`pollJitter`** — установите значение `0,1`–`0,5`, когда много рабочих могут ждать одновременно, -, чтобы освободившийся слот не был забит каждым официантом в один и тот же тик 50 мс. @@ЛИНИЯ@@ -### Как лимит распространяется на работников -RedisBulkheadStore хранит отсортированный набор для каждой переборки: каждый активный слот является членом -, оцениваемым по истечении срока аренды. `tryAcquire` запускает один Lua-скрипт, который - удаляет истекшие члены, проверяет количество элементов на соответствие пределу и добавляет член - — так что проверка и добавление являются атомарными, и два рабочих не могут одновременно пропустить - за пределы лимита. Работник, который умирает во время разговора, ничего не теряет: оценка аренды - его участника пройдена, и слот возвращается при следующем приобретении. - - `ApcuBulkheadStore` хранит массив `token => expiresAt` для каждой переборки в одной записи APCu -. В APCu нет сценариев на стороне сервера, поэтому атомарность вместо этого достигается за счет спин-блокировки -: `tryAcquire`/`release` берет недолговечный ключ APCu (`apcu_add` как - create-if-absent) перед чтением или записью массива слотов, а сама блокировка - содержит TTL, поэтому исполнитель, который умирает, удерживая его, не блокирует другие -. Координирует работу только на одном и том же хосте** — общая память APCu - не распространяется на машины; используйте RedisBulkheadStore для распределения пула по хостам. @@ЛИНИЯ@@ +|---|---| +| `Bulkhead` | Интерфейс: `call(callable): mixed`, `availableSlots(): int` | +| `SharedBulkhead` | Ограничивает параллелизм через `BulkheadStore`; fast-fail или ожидание до `maxWait`; открывает `name()`, `maxConcurrent()` | +| `BulkheadStore` | Backing-хранилище: `tryAcquire`, `release`, `activeCount` | +| `RedisBulkheadStore` | Межхостовое cross-process-хранилище; sorted set + Lua, атомарный acquire, TTL lease | +| `ApcuBulkheadStore` | Однохостовое cross-process-хранилище; spinlock на APCu, атомарный acquire, TTL lease | +| `InMemoryBulkheadStore` | Однопроцессное хранилище (тесты/CLI); не координирует процессы | +| `BulkheadScriptRunner` | Типизированный шов поверх вызова Redis-скрипта (реализуйте для других клиентов) | +| `Redis\PredisScriptRunner` | `BulkheadScriptRunner` поверх predis; EVALSHA с откатом на EVAL | +| `Redis\PhpRedisScriptRunner` | `BulkheadScriptRunner` поверх `ext-redis`; EVALSHA с откатом на EVAL | +| `BulkheadFullException` | Выбрасывается, когда за `maxWait` нет свободного слота; несёт `name`, `maxConcurrent` | +| `Sleeper\SleeperInterface` | Стратегия ожидания при polling; `SystemSleeper`, `FakeSleeper` | + +### Подбор параметров + +- **`maxConcurrent`** — то, что выдерживает *downstream*, а не то, что пул может + отдать. Если зависимость спокойно держит ~10 одновременных соединений, а у вас + 3 хоста приложения с общим Redis, то `maxConcurrent: 10` ограничивает их сумму. + Значение должно быть меньше числа FPM-воркеров, иначе bulkhead никогда не + сработает: при 50 воркерах и `maxConcurrent: 100` он не включится. +- **`lease`** — строго больше худшего времени работы callback'а; на практике: + downstream-таймаут + запас. Слишком короткий — и слоты освобождаются прямо во + время вызова (превышение лимита); слишком длинный — и слот упавшего воркера + остаётся занятым всю аренду (недобор лимита). Для HTTP-вызова с таймаутом 5 с + нормальный старт — `lease: Duration::seconds(10)`. +- **`maxWait`** — как долго запрос может стоять в очереди за слотом. + `Duration::zero()` даёт fast-fail (немедленный сброс нагрузки); любое большее + значение разменивает задержку на более низкий процент отказов. Держите его + заметно меньше собственного таймаута запроса. +- **`pollJitter`** — ставьте `0.1`–`0.5`, когда одновременно могут ждать много + воркеров: иначе освобождённый слот атакуется всеми ожидающими на одном 50 мс тике. + +### Как лимит держится между воркерами + +`RedisBulkheadStore` хранит sorted set для каждого bulkhead'а: каждый активный +слот — это member с оценкой (score) по времени истечения аренды. `tryAcquire` +выполняет один Lua-скрипт, который удаляет протухшие member'ы, проверяет +кардинальность против лимита и добавляет member — поэтому проверка и добавление +атомарны, и два воркера не могут одновременно проскочить за лимит. Воркер, +который упал во время вызова, ничего не утекает: score аренды его member'а +проходит, и слот возвращается при следующем acquire. + +`ApcuBulkheadStore` хранит массив `token => expiresAt` для каждого bulkhead'а в +одной APCu-записи. В APCu нет серверных скриптов, поэтому атомарность +обеспечивается spinlock'ом: `tryAcquire`/`release` берут короткоживущий APCu-ключ +(`apcu_add` как create-if-absent) перед чтением или записью массива слотов, а +сама блокировка несёт TTL, так что воркер, умерший её держа, не блокирует +остальных намертво. Координирует только воркеры на **одном хосте** — общая память APCu не +распространяется на машины; для пула на несколько хостов используйте +`RedisBulkheadStore`. + ## Безопасность -- `name` проверяется на соответствие `/^[A-Za-z0-9_.:-]+$/` и становится частью ключа - Redis/APCu — ненадежные имена отклоняются, а не интерполируются вслепую. - - Значения передаются в скрипт Lua как связанные `ARGV`, без объединения строк. - - Пакет сам не открывает сетевые подключения; вы предоставляете клиент Redis. @@ЛИНИЯ@@ -## Предостережения -- **`lease` должно превышать самое продолжительное ожидаемое время выполнения обратного вызова.** Если вызов - длится дольше, чем его аренда, хранилище освобождает слот в середине выполнения, и другой исполнитель - может его получить - тогда параллелизм на короткое время превышает `maxConcurrent`. Размер - аренды превышает тайм-аут нисходящего потока. - - `maxWait` — это приблизительная граница, основанная на опросе (детализация по умолчанию 50 мс): - туда и обратно для каждой попытки не учитывается, поэтому реальное время стены может немного - превышать его. - - **Ожидание не FIFO.** Опрос официантов; тот, кто проголосует сразу после релиза -, выиграет слот. При постоянной перегрузке официант может не пройти мимо `maxWait` - и получить отказ, пока не дойдут более поздние прибытия. - — `availableSlots()` / `activeCount()` в Redis **write** (они удаляют истекшие члены -), поэтому их нельзя указать на реплику, доступную только для чтения. - — `InMemoryBulkheadStore` предназначен только для одного процесса — он **не** ограничивает пул FPM -. Используйте его для тестов и инструментов CLI. - - `ApcuBulkheadStore` ограничивает работников только на **одной машине**. Для пула -, распределенного по нескольким хостам, требуется RedisBulkheadStore. Два острых края спин-блокировки - APCu: - - вращение `tryAcquire`/`release` до ~100 мс (настраивается через - `lockMaxAttempts`/`lockRetryMicros`) для внутренней блокировки. Неудачная попытка - `tryAcquire` сообщает "полный"; неудачный запуск `release` оставляет слот до истечения срока аренды -. - - APCu не имеет функции сравнения и удаления, поэтому `unlock` не может подтвердить право собственности: держатель -, остановившийся после TTL блокировки в 1 с внутри критической секции размером в микросекунду -, может удалить блокировку преемника. Принято как незначительное для такого маленького критического раздела -; используйте Redis, если эта гарантия имеет для вас значение. @@ЛИНИЯ@@ + +- `name` валидируется против `/^[A-Za-z0-9_.:-]+$/` и становится частью + Redis/APCu-ключа — недоверенные имена отбрасываются, а не интерполируются вслепую. +- Значения попадают в Lua-скрипт как bound `ARGV`, без конкатенации строк. +- Пакет сам не открывает сетевых соединений — Redis-клиент поставляете вы. + +## Подводные камни + +- **`lease` должен превышать самое долгое ожидаемое время работы callback'а.** + Если вызов идёт дольше аренды, хранилище освобождает слот посреди выполнения, и + другой воркер может его забрать — параллелизм кратко превысит `maxConcurrent`. + Размер аренды должен быть больше downstream-таймаута. +- `maxWait` — приблизительная граница на polling (по умолчанию гранулярность + 50 мс): время сетевого round-trip до хранилища на каждую попытку не учитывается, + поэтому реальное wall time может слегка его превышать. +- **Ожидание не FIFO.** Ожидающие опрашивают; слот достаётся тому, кто опросил + сразу после release. При устойчивой перегрузке ожидающий может голодать дольше + `maxWait` и быть отброшенным, пока более поздние проходят. +- `availableSlots()` / `activeCount()` в Redis **пишут** (они удаляют протухшие + member'ы), поэтому их нельзя наводить на read-only-реплику. +- `InMemoryBulkheadStore` работает только в рамках одного процесса — он **не** + ограничивает пул FPM. Используйте его для тестов и CLI-инструментов. +- `ApcuBulkheadStore` ограничивает воркеров только в пределах **одной машины**. + Пул на несколько хостов требует `RedisBulkheadStore`. Два острых угла + APCu-spinlock'а: + - `tryAcquire`/`release` крутятся до ~100 мс (настраивается через + `lockMaxAttempts`/`lockRetryMicros`) на внутренней блокировке. Провалившийся + `tryAcquire` сообщает «full»; провалившийся `release` оставляет слот + истекать по аренде. + - В APCu нет compare-and-delete, поэтому `unlock` не проверяет владение: держатель, + застрявший дольше TTL блокировки (1 с) внутри микросекундного критического + участка, может удалить блокировку преемника. Принято как незначительное для + столь короткого участка; используйте Redis, если эта гарантия для вас важна. + ## Примеры -См. [examples/](examples/) для работоспособных сценариев. - | Скрипт | Шоу | Нужен сервер? | - |---|---|---| - | `basic.php` | Хранилище в памяти, быстрое сбой при заполнении | нет | - | `redis.php` | Ограничение межпроцессного взаимодействия с помощью Redis | да (`REDIS_HOST`) | - | `apcu.php` | Ограничение межпроцессного взаимодействия с одним хостом с помощью APCu | нет (нужен ext-apcu) | @@ЛИНИЯ@@ +См. [examples/](examples/) — запускаемые скрипты. + +| Скрипт | Показывает | Нужен сервер? | +|---|---|---| +| `basic.php` | Хранилище в памяти, fast-fail при заполнении | нет | +| `redis.php` | Cross-process-ограничение через Redis | да (`REDIS_HOST`) | +| `apcu.php` | Однохостовое cross-process-ограничение через APCu | нет (нужен `ext-apcu`) | + ## Разработка -На хосте нет PHP/Composer — запустите в Docker через образ `composer:2`: + +На хосте нет PHP/Composer — запускайте через Docker-образ `composer:2`: ```bash docker run --rm -v "$PWD":/app -w /app composer:2 composer install @@ -175,11 +204,12 @@ docker run --rm -v "$PWD":/app -w /app composer:2 composer build docker run --rm -v "$PWD":/app -w /app composer:2 composer cs:fix docker run --rm -v "$PWD":/app -w /app composer:2 composer test ``` -Для интеграционных тестов требуется сервер Redis (автопропуск, если не установлен `REDIS_HOST`), - `ext-apcu` (самопропуск через `ApcuBulkheadStore::isAvailable()`) и `ext-redis` - (автопропуск через `extension_loaded('redis')`); базовый образ `composer:2` не имеет - ни одного из них, поэтому запустите пакет в образе, содержащем `apcu`, `pcntl` и `redis` - (плюс `apc.enable_cli=1`): + +Интеграционным тестам нужен Redis-сервер (автопропуск без `REDIS_HOST`), +`ext-apcu` (автопропуск через `ApcuBulkheadStore::isAvailable()`) и `ext-redis` +(автопропуск через `extension_loaded('redis')`); в базовом образе `composer:2` их +нет, поэтому гоняйте suite в образе с `apcu`, `pcntl` и `redis` (плюс +`apc.enable_cli=1`): ```bash docker run -d --name bh-redis -p 6379:6379 redis:7-alpine @@ -187,5 +217,7 @@ docker run --rm --network host -v "$PWD":/app -w /app -e REDIS_HOST=127.0.0.1 \ vendor/bin/testo --suite=Integration docker rm -f bh-redis ``` + ## Лицензия -[BSD-3-пункт](LICENSE.md) + +[BSD-3-Clause](LICENSE.md)