Перенесення Shared* у зовнішнє сховище

OxPHP\Shared\* працює всередині процесу. Завдяки цьому він швидкий і не має залежностей, але обмежує вас одним хостом і часом життя одного процесу. Ця сторінка — запасний вихід: коли вам потрібна координація між кількома хостами або збереження даних після перезапусків, тут описано, як перенести кожен тип Shared на бекенд Redis чи NATS (або подібний), не переписуючи застосунок.

Коли варто мігрувати

Найімовірніше, міграція вам не потрібна. Оптимальна ніша Shared\* — координація в межах одного хоста, без довговічності, із затримкою в мікросекунди — охоплює більше продакшн-сценаріїв, ніж зазвичай вважають. Переходьте на зовнішнє сховище лише тоді, коли справджується одна з цих умов:

  1. Ви запускаєте більше ніж один процес OxPHP. Кілька хостів, blue/green-розгортання з перекриттям або сайдкари, яким потрібно бачити той самий стан. Shared\* локальний для процесу; він не може перетнути межу процесу.
  2. Стан має пережити перезапуск. Поступове розгортання, збій або плановий перезапуск знищують кожен запис Shared\*. Якщо така втрата неприйнятна (лічильники для білінгу, добові квоти, позиції в черзі завдань), вам потрібна довговічність.
  3. Стан має пережити хост. Якщо будь-який із ваших хостів може зникнути, а стан при цьому має існувати далі, він мусить зберігатися деінде, а не на цьому хості.
  4. Вам потрібні читачі іншими мовами. Зовнішнє сховище може читати фонове завдання, написане на Go, конвеєр метрик або адміністративний інструмент. Shared\* доступний лише з PHP.

Якщо жодна з цих умов не стосується вас, внутрішньопроцесний примітив майже напевно є правильним вибором. Тримайте план міграції про запас, а не на гарячому шляху.

Абстракція

Більшість команд приходить до однієї й тієї самої схеми: інтерфейс із двома бекендами, вибір між якими задає конфігурація.

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) → Redis GETSET. Атомарно.
  • 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
<?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
<?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
<?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 — потрібні сторінки він прочитає сам.

prompt
Мені потрібно запускати більше ніж один екземпляр OxPHP, тож мій спільний стан має вийти за межі процесу. Прочитай сторінку `shared-state/migrating-to-external-store` через MCP-сервер документації OxPHP. Пройдися по всіх використаннях Shared\* у цьому репозиторії й для кожного назви зовнішній відповідник і семантику, яку я втрачу під час перенесення.

Див. також