Migrer Shared* vers un magasin externe
OxPHP\Shared\* est intra-processus. Cela le rend rapide et sans dépendance, mais vous limite à un seul hôte et à une seule durée de vie de processus. Cette page est la porte de sortie : lorsque vous avez besoin d'une coordination multi-hôte ou de durabilité à travers les redémarrages, voici comment déplacer chaque type Shared vers un backend Redis ou NATS (ou similaire) sans réécrire votre application.
Quand migrer
Vous n'avez probablement pas besoin de migrer. Le point idéal pour Shared\* — coordination sur un seul hôte, éphémère, à latence de l'ordre de la microseconde — couvre plus de cas d'usage en production que les gens ne le supposent. Ne passez à un magasin externe que lorsque l'une de ces conditions est vraie :
- Vous exécutez plus d'un processus OxPHP. Plusieurs hôtes, des déploiements blue/green avec chevauchement, ou des sidecars qui doivent voir le même état.
Shared\*est local au processus ; il ne peut pas franchir les frontières de processus. - L'état doit survivre aux redémarrages. Un déploiement progressif, un plantage ou un redémarrage de routine perd chaque entrée
Shared\*. Si la perte est inacceptable (compteurs de facturation, quotas quotidiens, positions dans une file de travail), vous avez besoin de durabilité. - L'état doit survivre à l'hôte. Si l'un de vos hôtes peut disparaître et que l'état doit toujours exister, il réside ailleurs que sur cet hôte.
- Vous voulez des lecteurs multi-langage. Un magasin externe peut être lu par une tâche d'arrière-plan écrite en Go, un pipeline de métriques ou un outil d'administration.
Shared\*est réservé à PHP.
Si aucune de ces conditions ne s'applique, la primitive intra-processus est presque certainement le bon choix. Gardez le plan de migration sous le coude, pas dans votre chemin critique.
L'abstraction
La plupart des équipes adoptent la même forme : une interface avec deux backends, choisis par configuration.
<?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);
}
}Câblez le backend choisi une seule fois au démarrage et utilisez CounterBackend partout. La migration devient alors un basculement de configuration, pas une réécriture.
Notes de migration par type
Chaque type Shared\* a des particularités sémantiques qui ne se traduisent pas trivialement vers n'importe quel magasin externe. Les notes ci-dessous soulignent les différences et les remplacements idiomatiques.
Shared\Counter → Redis / NATS JetStream KV
- Redis :
INCR/INCRBY/GET. Atomique, durable et répliqué dans Redis Cluster. - NATS JetStream KV :
KV.putavec un CAS basé sur les révisions couvre à la foissetetcompareAndSet. Les incréments nécessitentKV.get+KV.update(revision)en boucle.
Écarts sémantiques :
- L'accumulation par lots est
add(array_sum($deltas))— un seul aller-retour FFI dansShared\*. Dans Redis, précalculez la somme et faites un seulINCRBY(un seul RTT) ; dans NATS, c'est un seulKV.update. - Le dépassement d'entier dans Redis renvoie une erreur ;
Shared\Counter, lui, reboucle silencieusement.
Shared\Flag → service de feature flags Redis / NATS
- Redis :
SET/GET/SETNXpour des sémantiques proches decompareAndSet. Une valeur chaîne"1"/"0"fonctionne ; les booléens sont plus propres viaGETSET+ comparaison de chaînes. - Service de flags dédié : (LaunchDarkly, Unleash, ConfigCat) gère le cache, le ciblage de déploiement progressif et la piste d'audit clé en main. Pour les kill-switches opérationnels, c'est généralement le bon choix une fois que vous franchissez le seuil
Shared\*.
Écarts sémantiques :
swap($new)→GETSETde Redis. Atomique.compareAndSet($expect, $new)→ script Lua ouWATCH/MULTI. Vaut la peine d'être encapsulé dans un utilitaire.- Les services de flags externes mettent généralement la valeur en cache localement ; votre lecture n'est pas toujours un aller-retour réseau. C'est généralement acceptable, mais attendez-vous à une cohérence à terme sur les changements.
Shared\Once → table d'amorçage en base de données
- Pattern : INSERT idempotent avec une contrainte d'unicité, puis SELECT en cas de conflit.
- SQL :
INSERT INTO once (key, value) VALUES (?, ?) ON CONFLICT (key) DO NOTHING; SELECT value FROM once WHERE key = ?. - Redis :
SETNX+GET.
Écarts sémantiques :
Shared\Once::getOrInit(callable)exécute la factory dans le processus quand il l'emporte. Dans un magasin externe, la factory doit être idempotente (deux écrivains peuvent tous deux l'exécuter et une seule valeur l'emporte) ou vous avez besoin d'un wrapper d'élection de leader.DeadlockExceptionen cas de réentrance n'a pas d'équivalent externe — vous héritez de ce que fait le magasin, c'est-à-dire généralement rien.
Shared\Mutex → verrou distribué Redis
- Redis : le pattern « Redlock », ou le verrou mono-clé plus simple
SET NX EXsi vos garanties sont relâchées. Des bibliothèques commecheprasov/php-redis-lockl'encapsulent. - etcd / Consul / Zookeeper : verrous basés sur une session avec renouvellement de bail. Plus de charge opérationnelle mais des garanties plus fortes.
Les mutex intra-processus sont instantanés et corrects ; les verrous distribués sont lents et n'offrent que des garanties best-effort. Partez du principe que la sémantique va changer : concevez pour de l'at-least-once, avec des sections critiques idempotentes.
Écarts sémantiques :
with($fn)dansShared\Mutexvalide atomiquement la valeur de retour de la closure dans le stockage protégé. Avec un verrou Redis, vous devez explicitement lire, calculer, puis écrire, et l'écriture peut entrer en concurrence avec une opération sans rapport.- Empoisonnement : les verrous externes n'ont pas d'état « empoisonné ». Si votre closure lève une exception dans une section critique distribuée, vous relâchez le verrou et laissez l'appelant suivant voir un état à moitié validé. Gérez la cohérence via une action compensatoire, pas en imitant
isPoisoned().
Shared\Channel → NATS JetStream / Redis Streams / SQS / Kafka
- NATS JetStream : la correspondance sémantique la plus proche. Durable, borné, MPMC, avec des offsets de consommateur et une livraison at-least-once.
- Redis Streams :
XADD/XREADGROUPcouvre le pattern de file basique. Les groupes de consommateurs correspondent à la sémantique multi-consommateur deShared\Channel. - SQS / Kafka : des incontournables de l'industrie. Kafka est le bon choix pour les flux d'événements à haut débit ; SQS pour les files de travail simples.
Écarts sémantiques :
- Le
recvbloquant est remplacé par du long polling. Votre code consommateur passe de « renvoyer null à la fermeture » à « interroger avec un délai d'expiration, gérer la reconnexion ». - Le traitement par lots de
sendManycorrespond à la configuration linger/batch de Kafka ou au pipelining de Redis. close()n'a pas d'analogue externe. Arrêtez les producteurs gracieusement et laissez les consommateurs se vider ; il n'y a aucun signal qui dise « plus jamais d'éléments ».- L'ordonnancement intra-processus devient une livraison at-least-once à travers un réseau. Les clés d'idempotence côté consommateur sont obligatoires.
Shared\Map → hash Redis / un service KV / une base de données
- Hash Redis :
HGET/HSET/HDEL/HSCANcouvre la forme de map à clés. - Valeurs chaîne à clés :
SET key:<k> valueavec unmaxEntriesimposé via une éviction LRU. - Table de base de données avec colonne TTL : les lignes sont des entrées ; un balayeur d'arrière-plan gère l'éviction. C'est ce que vous voulez quand les valeurs dépassent quelques centaines d'octets.
Écarts sémantiques :
- La boucle de réessai
Map::compareAndSet(l'idiome RMW atomique de Shared*) doit devenir un script Lua côté serveur dans Redis ou unSELECT ... FOR UPDATEen SQL. Un simpleHGET+ calcul +HSETperd l'atomicité.Map::setIfAbsentcouvre le cas plus simple d'insertion unique ; il renvoie la valeur précédente (nullquand la clé était absente et que la valeur a été insérée), donc un retournullsignifie que l'insertion a eu lieu. - La sûreté face aux cycles de Map n'existe pas en externe. Vous ne fermerez jamais un cycle car il n'y a pas de graphe Shareable à fermer.
- Les Shareables imbriqués deviennent « une clé séparée avec un pointeur encodé dans la valeur ». C'est vous qui gérez la comptabilité.
Shared\Pool → pools de bibliothèques clientes
- Préférez le pool propre à la bibliothèque. PDO, Guzzle, les clients HTTP et la plupart des pilotes de bases de données disposent d'un pooling mature. Ne les réinventez pas avec un
Shared\Pool. - Services proxy : pour le pooling Postgres/MySQL par hôte, pgbouncer / proxysql placent la frontière de pooling au niveau de la couche d'infrastructure. Votre côté PHP redevient sans état.
Écarts sémantiques :
- L'éviction par délai d'inactivité du pool est remplacée par le contrôle de santé propre à la bibliothèque.
- Les callbacks de factory/destroy sont remplacés par le cycle de vie de connexion de la bibliothèque.
- En multi-hôte, vous pourriez avoir besoin de pools par service (un par dépendance en aval) plutôt que d'un seul gros pool.
Un cas concret : limiteur de débit par tenant
Voici l'exemple de limiteur de débit de shared-state.md retravaillé derrière une interface de backend :
<?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;
}
}La seule chose qui change entre les déploiements mono-hôte et multi-hôte, c'est quel backend est câblé au démarrage. Le reste de l'application dialogue avec RateLimiterBackend.
Patterns hybrides
Cache local devant l'état externe
Les charges de travail à dominante lecture utilisent souvent Shared\Map comme cache TTL devant un magasin externe. Vous touchez Redis une fois toutes les N secondes ; vous touchez Shared\Map des milliers de fois par seconde.
<?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);
}Invalidez via un canal pub/sub Redis auquel tous les processus OxPHP s'abonnent, ou via un TTL dans la Map locale.
Buffer write-through
Les charges de travail à dominante écriture tamponnent dans un Shared\Channel et un consommateur d'arrière-plan vide le tampon vers le magasin externe. Vous absorbez les rafales dans le processus et amortissez la surcharge réseau.
<?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]);Si le processus meurt avant que le vidage soit terminé, vous perdez les éléments tamponnés. Approprié pour l'analytique, pas pour la facturation.
Checklist
Avant de basculer :
- Identifiez l'unique primitive
Shared\*à l'origine de la migration. Ne migrez pas « tout » d'un coup. - Extrayez une interface ; câblez les deux backends.
- Décidez de la cohérence — at-most-once ou at-least-once — et rendez-la explicite dans l'interface.
- Testez les deux backends avec la même suite de tests d'intégration.
- Mesurez la latence. Les magasins externes ajoutent 0,1–5 ms par opération — vérifiez que votre application peut l'absorber sur les chemins critiques.
- Prévoyez le cas où le magasin externe est indisponible : fail open (laisser passer la requête) ou fail closed (renvoyer 503) ? La bonne réponse dépend du domaine.
- Activez les métriques
oxphp_shared_*sur le backendShared\*avant et après le basculement pour pouvoir comparer.
Connexes
- État partagé — vue d'ensemble ; quand rester dans le processus.
- Observabilité partagée — instrumentez les deux backends de la même manière.
- Limitation de débit — le limiteur par IP intégré (s'exécute avant PHP ; orthogonal aux limites au niveau PHP).