Migracja Shared* do zewnętrznego magazynu

OxPHP\Shared\* działa wewnątrz procesu. Dzięki temu jest szybki i nie wymaga zależności, ale ogranicza cię do jednego hosta i jednego cyklu życia procesu. Ta strona to wyjście awaryjne: gdy potrzebujesz koordynacji między wieloma hostami lub trwałości między restartami, oto jak przenieść każdy typ Shared do backendu opartego na Redis lub NATS (albo podobnym) bez przepisywania aplikacji.

Kiedy migrować

Prawdopodobnie nie musisz migrować. Idealne zastosowanie Shared\* — koordynacja na jednym hoście, ulotna, o mikrosekundowych opóźnieniach — pokrywa więcej produkcyjnych przypadków użycia, niż się powszechnie zakłada. Przejdź na zewnętrzny magazyn tylko wtedy, gdy zachodzi jeden z poniższych warunków:

  1. Uruchamiasz więcej niż jeden proces OxPHP. Wiele hostów, wdrożenia blue/green z okresem nakładania się lub sidecary, które muszą widzieć ten sam stan. Shared\* jest lokalny dla procesu; nie potrafi przekroczyć granic procesu.
  2. Stan musi przetrwać restarty. Wdrożenie kroczące, awaria lub rutynowy restart tracą każdy wpis Shared\*. Jeśli taka utrata jest nie do przyjęcia (liczniki rozliczeń, dzienne limity, pozycje w kolejce zadań), potrzebujesz trwałości.
  3. Stan musi przetrwać host. Jeśli którykolwiek z twoich hostów może zniknąć, a stan nadal musi istnieć, to znajduje się gdzieś indziej niż na tym hoście.
  4. Chcesz odczytu z różnych języków. Zewnętrzny magazyn może być odczytywany przez zadanie w tle napisane w Go, potok metryk lub narzędzie administracyjne. Shared\* jest dostępny wyłącznie z PHP.

Jeśli żaden z tych warunków nie zachodzi, prymityw działający w procesie jest niemal na pewno właściwym wyborem. Plan migracji trzymaj w zanadrzu, a nie na gorącej ścieżce.

Abstrakcja

Większość zespołów przyjmuje ten sam kształt: interfejs z dwoma backendami, wybieranymi na podstawie konfiguracji.

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); } }

Podłącz wybrany backend raz podczas bootstrapu i wszędzie używaj CounterBackend. Migracja staje się wtedy zmianą konfiguracji, a nie przepisywaniem kodu.

Uwagi do migracji poszczególnych typów

Każdy typ Shared\* ma swoje semantyczne osobliwości, które nie przekładają się trywialnie na żaden zewnętrzny magazyn. Poniższe uwagi wskazują różnice oraz idiomatyczne zamienniki.

Shared\Counter → Redis / NATS JetStream KV

  • Redis: INCR / INCRBY / GET. Atomowe, trwałe i replikowane w Redis Cluster.
  • NATS JetStream KV: KV.put z operacją CAS opartą na rewizjach pokrywa zarówno set, jak i compareAndSet. Inkrementacje wymagają KV.get + KV.update(revision) w pętli.

Luki semantyczne:

  • Akumulacja wsadowa to add(array_sum($deltas)) — jeden obieg FFI w Shared\*. W Redis obliczasz sumę z wyprzedzeniem i wykonujesz jedno INCRBY (jeden RTT); w NATS to jedno KV.update.
  • Przepełnienie liczby całkowitej w Redis zwraca błąd; Shared\Counter zawija się po cichu.

Shared\Flag → Redis / NATS usługa flag funkcyjnych

  • Redis: SET / GET / SETNX dla semantyki zbliżonej do compareAndSet. Wartość tekstowa "1" / "0" działa; wartości logiczne są czytelniejsze przy użyciu GETSET + porównania łańcuchów.
  • Dedykowana usługa flag: (LaunchDarkly, Unleash, ConfigCat) obsługuje pamięć podręczną, targetowanie wdrożeń i dziennik audytu od razu, bez dodatkowej konfiguracji. Dla operacyjnych przełączników awaryjnych (kill-switch) to zwykle właściwy ruch, gdy przekroczysz próg Shared\*.

Luki semantyczne:

  • swap($new) → Redis GETSET. Atomowe.
  • compareAndSet($expect, $new) → skrypt Lua lub WATCH/MULTI. Warto opakować w funkcję pomocniczą.
  • Zewnętrzne usługi flag zwykle buforują wartość lokalnie; twój odczyt nie zawsze oznacza podróż przez sieć. Zazwyczaj to w porządku, ale przy zmianach spodziewaj się spójności ostatecznej.

Shared\Once → Tabela inicjalizacyjna w bazie danych

  • Wzorzec: idempotentny INSERT z unikalnym ograniczeniem, a przy konflikcie SELECT.
  • SQL: INSERT INTO once (key, value) VALUES (?, ?) ON CONFLICT (key) DO NOTHING; SELECT value FROM once WHERE key = ?.
  • Redis: SETNX + GET.

Luki semantyczne:

  • Shared\Once::getOrInit(callable) uruchamia fabrykę w procesie, gdy wygra. W zewnętrznym magazynie fabryka musi być idempotentna (dwaj zapisujący mogą oboje ją uruchomić, a wygrywa tylko jedna wartość) albo potrzebujesz opakowania z wyborem lidera.
  • DeadlockException przy ponownym wejściu nie ma zewnętrznego odpowiednika — dziedziczysz to, co robi magazyn, czyli zwykle nic.

Shared\Mutex → Rozproszona blokada Redis

  • Redis: wzorzec „Redlock” albo prostsza blokada jednokluczowa SET NX EX, jeśli twoje gwarancje są łagodniejsze. Opakowują to biblioteki takie jak cheprasov/php-redis-lock.
  • etcd / Consul / Zookeeper: blokady oparte na sesjach z odnawianiem dzierżawy. Większy narzut operacyjny, ale silniejsze gwarancje.
Najtrudniejsza migracja

Muteksy działające w procesie są natychmiastowe i poprawne; blokady rozproszone są wolne i oferują jedynie gwarancje typu best-effort. Załóż, że semantyka się zmieni: projektuj sekcje krytyczne jako idempotentne i o semantyce at-least-once (co najmniej raz).

Luki semantyczne:

  • with($fn) w Shared\Mutex atomowo zatwierdza wartość zwróconą przez domknięcie z powrotem do strzeżonej pamięci. Przy blokadzie Redis musisz jawnie odczytać, obliczyć, a następnie zapisać, a ten zapis może wejść w wyścig z niepowiązaną operacją.
  • Zatrucie: zewnętrzne blokady nie mają stanu „zatrutego”. Jeśli twoje domknięcie rzuci wyjątek w rozproszonej sekcji krytycznej, zwalniasz blokadę i pozwalasz kolejnemu wywołującemu zobaczyć częściowo zatwierdzony stan. Spójność zapewnij działaniem kompensującym, a nie naśladując isPoisoned().

Shared\Channel → NATS JetStream / Redis Streams / SQS / Kafka

  • NATS JetStream: najbliższe dopasowanie semantyczne. Trwały, ograniczony, MPMC, z offsetami konsumentów i dostarczaniem co najmniej raz (at-least-once).
  • Redis Streams: XADD / XREADGROUP pokrywa podstawowy wzorzec kolejki. Grupy konsumentów odpowiadają wielokonsumenckiej semantyce Shared\Channel.
  • SQS / Kafka: branżowe standardy. Kafka to właściwy wybór dla strumieni zdarzeń o dużej przepustowości; SQS — dla prostych kolejek zadań.

Luki semantyczne:

  • Blokujące recv zostaje zastąpione przez long polling. Kod konsumenta zmienia się z „zwróć null przy zamknięciu” na „odpytuj z limitem czasu, obsłuż ponowne połączenie”.
  • Grupowanie sendMany odwzorowuje się na konfigurację linger/batch w Kafce lub potokowanie (pipelining) w Redis.
  • close() nie ma zewnętrznego odpowiednika. Zatrzymaj producentów w sposób łagodny i pozwól konsumentom opróżnić kolejkę; nie ma sygnału mówiącego „koniec, nigdy więcej elementów”.
  • Kolejność w obrębie procesu staje się dostarczaniem co najmniej raz przez sieć. Klucze idempotencji po stronie konsumenta są obowiązkowe.

Shared\Map → hash Redis / usługa KV / baza danych

  • Hash Redis: HGET / HSET / HDEL / HSCAN pokrywa kształt mapy z kluczami.
  • Wartości tekstowe z kluczami: SET key:<k> value z maxEntries egzekwowanym poprzez eksmisję LRU.
  • Tabela bazodanowa z kolumną TTL: wiersze to wpisy; eksmisją zajmuje się działający w tle proces czyszczący. To rozwiązanie odpowiednie, gdy wartości są większe niż kilkaset bajtów.

Luki semantyczne:

  • Pętla ponawiania Map::compareAndSet (idiom atomowego RMW w Shared*) musi stać się skryptem Lua wykonywanym po stronie serwera w Redis albo SELECT ... FOR UPDATE w SQL. Zwykłe HGET + obliczenie + HSET traci atomowość. Map::setIfAbsent pokrywa prostszy przypadek jednorazowego wstawienia; zwraca poprzednią wartość (null, gdy klucza nie było i wartość została wstawiona), więc zwrot null oznacza, że wstawienie nastąpiło.
  • Bezpieczeństwo cykli mapy nie istnieje na zewnątrz. Nigdy nie domkniesz cyklu, bo nie ma grafu Shareable do domknięcia.
  • Zagnieżdżone Shareable stają się „osobnym kluczem ze wskaźnikiem zakodowanym w wartości”. Sam odpowiadasz za ewidencję.

Shared\Pool → Pule biblioteki klienckiej

  • Preferuj własną pulę biblioteki. PDO, Guzzle, klienty HTTP i większość sterowników baz danych mają dojrzałe mechanizmy pulowania. Nie wymyślaj ich na nowo za pomocą Shared\Pool.
  • Usługi proxy: przy pulowaniu Postgres/MySQL per host pgbouncer / proxysql zamykają granicę pulowania w warstwie infrastruktury. Twoja strona PHP znów staje się bezstanowa.

Luki semantyczne:

  • Eksmisja z puli po limicie bezczynności zostaje zastąpiona własnym mechanizmem kontroli stanu biblioteki.
  • Wywołania zwrotne factory/destroy zostają zastąpione cyklem życia połączenia w bibliotece.
  • Przy pracy na wielu hostach możesz potrzebować pul per usługa (po jednej na każdą usługę docelową) zamiast jednej dużej puli.

Konkretny przypadek: ogranicznik liczby żądań per najemca

Oto przykład ogranicznika liczby żądań z shared-state.md przerobiony za interfejsem backendu:

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; } }

Jedyne, co zmienia się między wdrożeniami jednohostowymi a wielohostowymi, to który backend zostaje podłączony podczas bootstrapu. Reszta aplikacji komunikuje się z RateLimiterBackend.

Wzorce hybrydowe

Lokalna pamięć podręczna przed stanem zewnętrznym

Obciążenia zdominowane przez odczyty często używają Shared\Map jako pamięci podręcznej z TTL przed zewnętrznym magazynem. Do Redis sięgasz raz na N sekund; do Shared\Map sięgasz tysiące razy na sekundę.

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); }

Unieważniaj przez kanał pub/sub w Redis, który subskrybują wszystkie procesy OxPHP, albo przez TTL w lokalnej mapie.

Bufor typu write-through

Obciążenia zdominowane przez zapisy buforują w Shared\Channel, a konsument działający w tle zrzuca dane do zewnętrznego magazynu. Skoki ruchu pochłaniasz w procesie i amortyzujesz narzut sieciowy.

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]);
Kompromis

Jeśli proces padnie, zanim zrzut się zakończy, tracisz zbuforowane elementy. Odpowiednie dla analityki, nie dla rozliczeń.

Lista kontrolna

Zanim wykonasz przełączenie:

  • Zidentyfikuj jeden prymityw Shared\*, którego dotyczy migracja. Nie migruj „wszystkiego” naraz.
  • Wyodrębnij interfejs; podłącz oba backendy.
  • Zdecyduj o spójności — co najwyżej raz (at-most-once) czy co najmniej raz (at-least-once) — i wyraź to jawnie w interfejsie.
  • Testuj oba backendy tym samym zestawem testów integracyjnych.
  • Zmierz opóźnienie. Zewnętrzne magazyny dodają 0,1–5 ms na operację — sprawdź, czy twoja aplikacja jest w stanie to udźwignąć na gorących ścieżkach.
  • Zaplanuj sytuację, gdy zewnętrzny magazyn jest niedostępny: fail open (przepuść żądanie) czy fail closed (zwróć 503)? Właściwa odpowiedź zależy od dziedziny.
  • Włącz metryki oxphp_shared_* na backendzie Shared\* przed przełączeniem i po nim, aby móc je porównać.

Powiązane