Перенос Shared* во внешнее хранилище
OxPHP\Shared\* работает внутри процесса. Это делает его быстрым и не требующим зависимостей, но ограничивает одним хостом и временем жизни одного процесса. Эта страница — запасной выход: когда вам нужна координация между несколькими хостами или долговечность при перезапусках, здесь описано, как перенести каждый тип Shared на бэкенд Redis или NATS (или аналогичный) без переписывания приложения.
Когда выполнять миграцию
Скорее всего, миграция вам не нужна. Оптимальная область применения Shared\* — координация в пределах одного хоста, эфемерная, с микросекундными задержками — покрывает больше продакшен-сценариев, чем принято думать. Переходите на внешнее хранилище только если верно одно из следующего:
- У вас работает более одного процесса OxPHP. Несколько хостов, blue/green-развёртывания с перекрытием или sidecar-контейнеры, которым нужно видеть то же состояние.
Shared\*локален для процесса; он не может пересекать границы процессов. - Состояние должно переживать перезапуски. Плавающее развёртывание (rolling deploy), сбой или штатный перезапуск теряет каждую запись
Shared\*. Если такая потеря недопустима (счётчики биллинга, дневные квоты, позиции в очереди задач), вам нужна долговечность. - Состояние должно переживать сам хост. Если любой из ваших хостов может исчезнуть, а состояние всё равно должно существовать, оно должно жить где-то помимо этого хоста.
- Вам нужны читатели на других языках. Внешнее хранилище может читать фоновая задача, написанная на Go, конвейер метрик или административный инструмент.
Shared\*доступен только из PHP.
Если ничего из перечисленного не применимо, внутрипроцессный примитив почти наверняка правильный выбор. Держите план миграции про запас, а не на горячем пути.
Абстракция
Большинство команд приходят к одной и той же форме: интерфейс с двумя бэкендами, выбираемыми через конфигурацию.
<?php
interface CounterBackend
{
public function inc(string $key, int $by = 1): int;
public function get(string $key): int;
public function reset(string $key): int;
}
final class SharedCounterBackend implements CounterBackend
{
public function inc(string $key, int $by = 1): int
{
$counter = OxPHP\Shared\Registry::counter(
"counter:{$key}",
fn () => new OxPHP\Shared\Counter(),
);
return $counter->add($by);
}
public function get(string $key): int
{
$counter = OxPHP\Shared\Registry::counter(
"counter:{$key}",
fn () => new OxPHP\Shared\Counter(),
);
return $counter->get();
}
public function reset(string $key): int
{
$counter = OxPHP\Shared\Registry::counter(
"counter:{$key}",
fn () => new OxPHP\Shared\Counter(),
);
return $counter->set(0);
}
}
final class RedisCounterBackend implements CounterBackend
{
public function __construct(private Redis $redis) {}
public function inc(string $key, int $by = 1): int
{
return (int) $this->redis->incrBy("counter:{$key}", $by);
}
public function get(string $key): int
{
return (int) ($this->redis->get("counter:{$key}") ?? 0);
}
public function reset(string $key): int
{
// GETSET is atomic: one round-trip, returns the prior value.
return (int) ($this->redis->getSet("counter:{$key}", 0) ?? 0);
}
}Подключите выбранный бэкенд один раз при инициализации и используйте CounterBackend повсюду. Тогда миграция сводится к переключению конфигурации, а не к переписыванию.
Замечания по миграции для каждого типа
У каждого типа Shared\* есть семантические особенности, которые не переносятся тривиально ни в одно внешнее хранилище. В заметках ниже отмечены различия и идиоматичные замены.
Shared\Counter → Redis / NATS JetStream KV
- Redis:
INCR/INCRBY/GET. Атомарно, долговечно и реплицируется в Redis Cluster. - NATS JetStream KV:
KV.putс CAS на основе ревизий покрывает иset, иcompareAndSet. Инкременты требуютKV.get+KV.update(revision)в цикле.
Семантические расхождения:
- Пакетное накопление — это
add(array_sum($deltas)), один FFI-round-trip вShared\*. В Redis предварительно вычислите сумму и сделайте одинINCRBY(один RTT); в NATS это одинKV.update. - Целочисленное переполнение в Redis возвращает ошибку;
Shared\Counterмолча оборачивает значение (wrap-around).
Shared\Flag → Redis / NATS сервис фиче-флагов
- Redis:
SET/GET/SETNXдля семантики, близкой кcompareAndSet. Строковое значение"1"/"0"работает; булевы значения чище выражаются черезGETSET+ сравнение строк. - Специализированный сервис флагов: (LaunchDarkly, Unleash, ConfigCat) из коробки берёт на себя кэш, таргетинг раскатки и журнал аудита. Для эксплуатационных аварийных выключателей (kill-switch) это обычно правильный ход, как только вы переходите порог
Shared\*.
Семантические расхождения:
swap($new)→ RedisGETSET. Атомарно.compareAndSet($expect, $new)→ Lua-скрипт илиWATCH/MULTI. Стоит обернуть во вспомогательную функцию.- Внешние сервисы флагов обычно кэшируют значение локально; ваше чтение не всегда является сетевым round-trip. Обычно это нормально, но при изменениях ожидайте согласованности в конечном счёте (eventual consistency).
Shared\Once → таблица инициализации в базе данных
- Паттерн: идемпотентный INSERT с уникальным ограничением, затем SELECT при конфликте.
- SQL:
INSERT INTO once (key, value) VALUES (?, ?) ON CONFLICT (key) DO NOTHING; SELECT value FROM once WHERE key = ?. - Redis:
SETNX+GET.
Семантические расхождения:
Shared\Once::getOrInit(callable)выполняет фабрику внутри процесса, когда выигрывает гонку. Во внешнем хранилище фабрика должна быть идемпотентной (два писателя могут оба её выполнить, но победит только одно значение), либо вам нужна обёртка с выбором лидера (leader election).DeadlockExceptionпри повторном входе не имеет внешнего эквивалента — вы наследуете то, что делает хранилище, а это обычно ничего.
Shared\Mutex → распределённая блокировка Redis
- Redis: паттерн «Redlock» или более простая одноключевая блокировка
SET NX EX, если ваши требования к гарантиям смягчены. Библиотеки вродеcheprasov/php-redis-lockоборачивают это. - etcd / Consul / Zookeeper: блокировки на основе сессий с продлением аренды (lease). Больше эксплуатационных накладных расходов, но более сильные гарантии.
Внутрипроцессные мьютексы мгновенны и корректны; распределённые блокировки медленны и дают лишь гарантии по принципу best-effort. Исходите из того, что семантика изменится: проектируйте под критические секции с семантикой at-least-once и идемпотентностью.
Семантические расхождения:
with($fn)вShared\Mutexатомарно фиксирует возвращаемое замыканием значение обратно в защищаемое хранилище. С блокировкой Redis вы должны явно прочитать, вычислить, а затем записать, и запись может вступить в гонку с несвязанной операцией.- Отравление (poisoning): у внешних блокировок нет «отравленного» состояния. Если ваше замыкание бросает исключение внутри распределённой критической секции, вы освобождаете блокировку и позволяете следующему вызывающему увидеть наполовину зафиксированное состояние. Обеспечивайте согласованность через компенсирующее действие, а не имитируя
isPoisoned().
Shared\Channel → NATS JetStream / Redis Streams / SQS / Kafka
- NATS JetStream: ближайшее семантическое соответствие. Долговечный, ограниченной ёмкости, MPMC, со смещениями потребителей (consumer offsets) и доставкой at-least-once.
- Redis Streams:
XADD/XREADGROUPпокрывает базовый паттерн очереди. Группы потребителей (consumer groups) соответствуют многопотребительской семантикеShared\Channel. - SQS / Kafka: отраслевые стандарты. Kafka — правильный выбор для высокопроизводительных потоков событий; SQS — для простых очередей задач.
Семантические расхождения:
- Блокирующий
recvзаменяется на long polling. Код вашего потребителя меняется с «вернуть null при закрытии» на «опрашивать с таймаутом, обрабатывать переподключение». - Пакетирование
sendManyотображается на настройки linger/batch в Kafka или на конвейеризацию (pipelining) в Redis. close()не имеет внешнего аналога. Корректно остановите производителей и дайте потребителям вычерпать очередь; нет сигнала, который говорит «больше элементов не будет никогда».- Внутрипроцессная упорядоченность превращается в доставку at-least-once по сети. Ключи идемпотентности на стороне потребителя обязательны.
Shared\Map → хеш Redis / KV-сервис / база данных
- Хеш Redis:
HGET/HSET/HDEL/HSCANпокрывает форму отображения по ключу. - Строковые значения по ключу:
SET key:<k> valueсmaxEntries, обеспечиваемым через LRU-вытеснение. - Таблица базы данных со столбцом TTL: строки — это записи; фоновый сборщик занимается вытеснением. Это то, что вам нужно, когда значения больше нескольких сотен байт.
Семантические расхождения:
- Цикл повторных попыток
Map::compareAndSet(атомарная RMW-идиома из Shared*) должен превратиться в серверный Lua-скрипт в Redis илиSELECT ... FOR UPDATEв SQL. ПростойHGET+ вычисление +HSETтеряет атомарность.Map::setIfAbsentпокрывает более простой случай однократной вставки; он возвращает предыдущее значение (null, когда ключ отсутствовал и значение было вставлено), так что возвратnullозначает, что вставка произошла. - Защита от циклов у Map снаружи не существует. Вы никогда не замкнёте цикл, потому что нет графа Shareable, который можно замкнуть.
- Вложенные Shareable превращаются в «отдельный ключ с указателем, закодированным в значении». Учёт лежит на вас.
Shared\Pool → пулы клиентских библиотек
- Предпочитайте собственный пул библиотеки. PDO, Guzzle, HTTP-клиенты и большинство драйверов баз данных имеют зрелые механизмы пулинга. Не изобретайте их заново с помощью
Shared\Pool. - Прокси-сервисы: для пулинга Postgres/MySQL на уровне хоста pgbouncer / proxysql переносят границу пулинга на инфраструктурный уровень. Ваша PHP-сторона снова становится stateless.
Семантические расхождения:
- Вытеснение по таймауту простоя в пуле заменяется собственной проверкой работоспособности библиотеки.
- Колбэки factory/destroy заменяются жизненным циклом соединения самой библиотеки.
- Между хостами вам могут понадобиться пулы для каждого сервиса (по одному на каждый нижестоящий сервис), а не один большой пул.
Конкретный случай: ограничитель частоты запросов для каждого арендатора
Вот пример ограничителя частоты запросов из shared-state.md, переработанный за интерфейсом бэкенда:
<?php
interface RateLimiterBackend
{
public function allow(string $key, int $max, int $windowSecs): bool;
}
final class SharedRateLimiterBackend implements RateLimiterBackend
{
public function __construct(private OxPHP\Shared\Map $buckets) {}
public function allow(string $key, int $max, int $windowSecs): bool
{
$now = time();
while (true) {
$current = $this->buckets->get($key);
if ($current === null || $now - $current['start'] >= $windowSecs) {
$next = ['count' => 1, 'start' => $now];
} else {
$next = ['count' => $current['count'] + 1, 'start' => $current['start']];
}
if ($this->buckets->compareAndSet($key, $current, $next)) {
return $next['count'] <= $max;
}
// Lost the race — re-read and try again.
}
}
}
final class RedisRateLimiterBackend implements RateLimiterBackend
{
/**
* Atomic fixed-window counter. Load this script once at bootstrap
* via `$redis->script('load', $lua)` and keep the resulting SHA.
*/
private const SCRIPT = <<<'LUA'
local current = redis.call('GET', KEYS[1])
if current then
local c = tonumber(current) + 1
redis.call('SET', KEYS[1], c, 'KEEPTTL')
return c
end
redis.call('SET', KEYS[1], 1, 'EX', ARGV[1])
return 1
LUA;
public function __construct(
private Redis $redis,
private string $scriptSha,
) {}
public static function withLoadedScript(Redis $redis): self
{
$sha = $redis->script('load', self::SCRIPT);
return new self($redis, $sha);
}
public function allow(string $key, int $max, int $windowSecs): bool
{
$count = (int) $this->redis->evalSha($this->scriptSha, ["rl:{$key}"], [$windowSecs]);
return $count <= $max;
}
}Единственное, что меняется между однохостовыми и многохостовыми развёртываниями, — это какой бэкенд подключается при инициализации. Остальная часть приложения общается с RateLimiterBackend.
Гибридные паттерны
Локальный кэш перед внешним состоянием
Нагрузки с преобладанием чтения часто используют Shared\Map как TTL-кэш перед внешним хранилищем. Вы обращаетесь к Redis раз в N секунд; к Shared\Map — тысячи раз в секунду.
<?php
// Insert on miss, read on hit. setIfAbsent inserts only when the key is
// absent and returns the previous value — read the cached value back with get().
$cfg = $cache->get($tenantId);
if ($cfg === null) {
$cache->setIfAbsent($tenantId, loadFromRedis($tenantId));
$cfg = $cache->get($tenantId);
}Инвалидируйте через канал pub/sub в Redis, на который подписаны все процессы OxPHP, или через TTL в локальном Map.
Сквозной буфер записи (write-through)
Нагрузки с преобладанием записи буферизуют данные в Shared\Channel, а фоновый потребитель сбрасывает их во внешнее хранилище. Вы поглощаете всплески внутри процесса и амортизируете сетевые накладные расходы.
<?php
$writes = new OxPHP\Shared\Channel(capacity: 10_000);
oxphp_async(function () use ($writes) {
while (($batch = $writes->recvMany(100, 500))) { // up to 100 items, 500ms wait
writeBatchToRedis($batch);
}
});
// Hot path
$writes->trySend([$key, $value]);Если процесс умрёт до завершения сброса, вы потеряете буферизованные элементы. Подходит для аналитики, но не для биллинга.
Чек-лист
Перед переключением:
- Определите единственный примитив
Shared\*, стоящий за миграцией. Не мигрируйте «всё» сразу. - Выделите интерфейс; подключите оба бэкенда.
- Определитесь с согласованностью — at-most-once или at-least-once — и сделайте её явной в интерфейсе.
- Тестируйте оба бэкенда одним и тем же набором интеграционных тестов.
- Измерьте задержку. Внешние хранилища добавляют 0.1–5 мс на операцию — убедитесь, что ваше приложение может это выдержать на горячих путях.
- Спланируйте поведение при недоступности внешнего хранилища: fail open (пропускать запрос) или fail closed (отдавать 503)? Правильный ответ зависит от предметной области.
- Включите метрики
oxphp_shared_*на бэкендеShared\*до и после переключения, чтобы можно было сравнить.
Связанные материалы
- Разделяемое состояние — обзор; когда оставаться внутри процесса.
- Наблюдаемость разделяемого состояния — инструментируйте оба бэкенда одинаково.
- Ограничение частоты запросов — встроенный ограничитель на каждый IP (работает до PHP; ортогонален ограничениям на уровне PHP).