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:
- 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. - 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. - 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.
- 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
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.putz operacją CAS opartą na rewizjach pokrywa zarównoset, jak icompareAndSet. Inkrementacje wymagająKV.get+KV.update(revision)w pętli.
Luki semantyczne:
- Akumulacja wsadowa to
add(array_sum($deltas))— jeden obieg FFI wShared\*. W Redis obliczasz sumę z wyprzedzeniem i wykonujesz jednoINCRBY(jeden RTT); w NATS to jednoKV.update. - Przepełnienie liczby całkowitej w Redis zwraca błąd;
Shared\Counterzawija się po cichu.
Shared\Flag → Redis / NATS usługa flag funkcyjnych
- Redis:
SET/GET/SETNXdla semantyki zbliżonej docompareAndSet. Wartość tekstowa"1"/"0"działa; wartości logiczne są czytelniejsze przy użyciuGETSET+ 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)→ RedisGETSET. Atomowe.compareAndSet($expect, $new)→ skrypt Lua lubWATCH/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.DeadlockExceptionprzy 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 jakcheprasov/php-redis-lock. - etcd / Consul / Zookeeper: blokady oparte na sesjach z odnawianiem dzierżawy. Większy narzut operacyjny, ale silniejsze gwarancje.
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)wShared\Mutexatomowo 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/XREADGROUPpokrywa podstawowy wzorzec kolejki. Grupy konsumentów odpowiadają wielokonsumenckiej semantyceShared\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
recvzostaje 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
sendManyodwzorowuje 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/HSCANpokrywa kształt mapy z kluczami. - Wartości tekstowe z kluczami:
SET key:<k> valuezmaxEntriesegzekwowanym 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 alboSELECT ... FOR UPDATEw SQL. ZwykłeHGET+ obliczenie +HSETtraci atomowość.Map::setIfAbsentpokrywa prostszy przypadek jednorazowego wstawienia; zwraca poprzednią wartość (null, gdy klucza nie było i wartość została wstawiona), więc zwrotnulloznacza, ż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
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
// 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
$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]);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 backendzieShared\*przed przełączeniem i po nim, aby móc je porównać.
Powiązane
- Stan współdzielony — przegląd; kiedy pozostać w procesie.
- Obserwowalność stanu współdzielonego — instrumentuj oba backendy tak samo.
- Ograniczanie liczby żądań — wbudowany ogranicznik per IP (działa przed PHP; ortogonalny do limitów na poziomie PHP).