Перенесення Shared* у зовнішнє сховище
OxPHP\Shared\* працює всередині процесу. Завдяки цьому він швидкий і не має залежностей, але обмежує вас одним хостом і часом життя одного процесу. Ця сторінка — запасний вихід: коли вам потрібна координація між кількома хостами або збереження даних після перезапусків, тут описано, як перенести кожен тип Shared на бекенд Redis чи NATS (або подібний), не переписуючи застосунок.
Коли варто мігрувати
Найімовірніше, міграція вам не потрібна. Оптимальна ніша Shared\* — координація в межах одного хоста, без довговічності, із затримкою в мікросекунди — охоплює більше продакшн-сценаріїв, ніж зазвичай вважають. Переходьте на зовнішнє сховище лише тоді, коли справджується одна з цих умов:
- Ви запускаєте більше ніж один процес OxPHP. Кілька хостів, blue/green-розгортання з перекриттям або сайдкари, яким потрібно бачити той самий стан.
Shared\*локальний для процесу; він не може перетнути межу процесу. - Стан має пережити перезапуск. Поступове розгортання, збій або плановий перезапуск знищують кожен запис
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-виклик уShared\*. У Redis заздалегідь обчисліть суму й виконайте одинINCRBY(один RTT); у NATS це одинKV.update. - Переповнення цілого числа в Redis повертає помилку;
Shared\Counterмовчки переходить через межу.
Shared\Flag → Redis / сервіс прапорців функцій NATS
- Redis:
SET/GET/SETNXдля семантики, близької доcompareAndSet. Рядкове значення"1"/"0"працює; булеві значення чистіше реалізувати черезGETSET+ порівняння рядків. - Окремий сервіс прапорців (LaunchDarkly, Unleash, ConfigCat) одразу забезпечує кешування, таргетування розгортань і журнал аудиту. Для операційних аварійних вимикачів це зазвичай правильний крок, щойно ви переросли
Shared\*.
Семантичні розбіжності:
swap($new)→ RedisGETSET. Атомарно.compareAndSet($expect, $new)→ Lua-скрипт абоWATCH/MULTI. Варто обгорнути в допоміжну функцію.- Зовнішні сервіси прапорців зазвичай кешують значення локально; ваше читання не завжди означає мережевий запит. Як правило, це прийнятно, але зміни поширюватимуться з кінцевою узгодженістю.
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)запускає фабрику всередині процесу, коли виграє. У зовнішньому сховищі фабрика має бути ідемпотентною (двоє записувачів можуть обидва її виконати, і збережеться лише одне значення), або вам знадобиться обгортка з вибором лідера.DeadlockExceptionпри повторному вході не має зовнішнього відповідника — ви успадковуєте поведінку сховища, а воно зазвичай нічого не робить.
Shared\Mutex → розподілене блокування в Redis
- Redis: патерн «Redlock» або простіше блокування одного ключа через
SET NX EX, якщо ваші гарантії послаблені. Бібліотеки на кшталтcheprasov/php-redis-lockобгортають це. - etcd / Consul / Zookeeper: блокування на основі сесій із поновленням оренди. Більше операційних витрат, зате сильніші гарантії.
Внутрішньопроцесні м’ютекси миттєві й коректні; розподілені блокування повільні й дають лише гарантії за принципом best-effort. Розраховуйте на те, що семантика зміниться: проєктуйте критичні секції як ідемпотентні з доставкою at-least-once.
Семантичні розбіжності:
withLock($fn)уShared\Mutexпередає захищене значення в замикання за посиланням і зберігає зміни замикання, перш ніж звільнити блокування; значення, яке повертає замикання, повертається викликачеві. З блокуванням у Redis ви мусите явно прочитати, обчислити, а потім записати, і цей запис може конкурувати зі сторонньою операцією.- Якщо замикання кидає виняток,
Shared\Mutexзвільняє блокування й зберігає все, що замикання вже записало у значення, тож наступний виклик бачить наполовину зафіксований стан. Розподілене блокування поводиться так само, щойно ви його звільните. Забезпечуйте узгодженість компенсувальною дією.
Shared\Channel → NATS JetStream / Redis Streams / SQS / Kafka
- NATS JetStream: найближчий семантичний відповідник. Довговічний, обмежений, MPMC, зі зміщеннями споживачів і доставкою at-least-once.
- Redis Streams:
XADD/XREADGROUPпокривають базовий патерн черги. Групи споживачів відповідають семантиці кількох споживачівShared\Channel. - SQS / Kafka: галузеві стандарти. Kafka — правильний вибір для високопродуктивних потоків подій; SQS — для простих черг завдань.
Семантичні розбіжності:
- Блокувальний
recvзамінюється довгим опитуванням. Код споживача змінюється з «повернути null під час закриття» на «опитувати з тайм-аутом, обробляти перепідключення». - Пакетування
sendManyвідповідає налаштуванням linger/batch у Kafka або конвеєризації в Redis. close()не має зовнішнього аналога. Коректно зупиніть виробників і дайте споживачам вичерпати чергу; сигналу «елементів більше не буде ніколи» не існує.- Впорядкування всередині процесу перетворюється на доставку at-least-once через мережу. Ключі ідемпотентності на боці споживача обов’язкові.
Shared\Map → хеш Redis / KV-сервіс / база даних
- Хеш Redis:
HGET/HSET/HDEL/HSCANпокривають форму мапи з ключами. - Рядкові значення за ключами:
SET key:<k> valueз обмеженнямmaxEntries, яке забезпечує витіснення LRU. - Таблиця бази даних зі стовпцем TTL: рядки — це записи; витіснення виконує фоновий прибиральник. Це саме те, що потрібно, коли значення більші за кілька сотень байтів.
Семантичні розбіжності:
- Цикл повторних спроб
Map::compareAndSet(атомарна ідіома «читання-зміна-запис» у 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-частина знову стає безстановою.
Семантичні розбіжності:
- Витіснення за тайм-аутом простою в пулі замінюється власною перевіркою справності бібліотеки.
- Колбеки 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.
Буфер наскрізного запису
Навантаження з переважанням запису буферизують дані в 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\*до й після перемикання, щоб мати змогу порівняти.
Промпти для вашого агента
Ці промпти розраховані на агента з підключеним MCP-сервером документації OxPHP — потрібні сторінки він прочитає сам.
Мені потрібно запускати більше ніж один екземпляр OxPHP, тож мій спільний стан має вийти за межі процесу. Прочитай сторінку `shared-state/migrating-to-external-store` через MCP-сервер документації OxPHP.
Пройдися по всіх використаннях Shared\* у цьому репозиторії й для кожного назви зовнішній відповідник і семантику, яку я втрачу під час перенесення.Див. також
- Спільний стан — огляд; коли варто залишатися всередині процесу.
- Спостережуваність Shared — інструментуйте обидва бекенди однаково.
- Обмеження частоти запитів — вбудований обмежувач за IP (працює до PHP; не залежить від обмежень на рівні PHP).