Shared* を外部ストアへ移行する

OxPHP\Shared\* はプロセス内で動作します。そのおかげで高速かつ依存関係なしで使えますが、その代わり単一ホスト・単一プロセスのライフタイムに制限されます。このページは、その制約からの脱出口です。複数ホスト間での協調や再起動をまたいだ永続性が必要になったとき、アプリケーションを書き換えずに各 Shared 型を Redis や NATS(あるいは類似の)バックエンドへ移す方法を説明します。

移行するタイミング

おそらく移行は必要ありません。Shared\* が最も得意とする領域、つまり単一ホストで揮発的、マイクロ秒レイテンシの協調は、多くの人が想定する以上に多くの本番ユースケースをカバーします。外部ストアへ移すのは、次のいずれかが当てはまる場合だけにしてください。

  1. OxPHP プロセスを複数実行している。 複数ホスト、重複期間のある blue/green デプロイ、あるいは同じ状態を参照する必要のあるサイドカーなどです。Shared\* はプロセスローカルであり、プロセス境界を越えることはできません。
  2. 状態が再起動をまたいで残らなければならない。 ローリングデプロイ、クラッシュ、あるいは通常の再起動によって、すべての Shared\* エントリは失われます。その損失が許容できない場合(課金カウンター、日次クォータ、ワークキューの位置など)は、永続性が必要です。
  3. 状態がホストをまたいで残らなければならない。 いずれかのホストが消滅する可能性があり、それでも状態が存在し続ける必要があるなら、その状態はこのホスト以外のどこかに置くことになります。
  4. 別の言語からも読み取りたい。 外部ストアは、Go で書かれたバックグラウンドジョブ、メトリクスパイプライン、管理ツールなどから読み取れます。Shared\* は PHP 専用です。

これらのいずれも当てはまらないなら、プロセス内のプリミティブがほぼ間違いなく正しい選択です。移行プランはホットパスに持ち込まず、いざというときの備えとして懐にしまっておきましょう。

抽象化

多くのチームは同じ形を採用します。2 つのバックエンドを持つインターフェースを用意し、設定で選択するというものです。

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: リビジョンベースの CAS を用いた KV.putsetcompareAndSet の両方をカバーします。インクリメントには、ループ内での KV.getKV.update(revision) が必要です。

意味論上のギャップ:

  • バッチ集約は add(array_sum($deltas)) であり、Shared\* では FFI の往復 1 回で済みます。Redis では合計を事前に計算し、INCRBY を 1 回だけ実行します(RTT 1 回)。NATS では KV.update 1 回です。
  • 整数オーバーフローは、Redis ではエラーを返しますが、Shared\Counter は黙って値を折り返します。

Shared\Flag → Redis / NATS feature-flag service

  • Redis: compareAndSet に近い意味論には SET / GET / SETNX を使います。文字列値 "1" / "0" でも動きますが、真偽値としては GETSET と文字列比較を組み合わせるほうがすっきりします。
  • 専用のフラグサービス:(LaunchDarkly、Unleash、ConfigCat)はキャッシュ、ロールアウトのターゲティング、監査ログを標準で扱ってくれます。運用上のキルスイッチとしては、Shared\* の適用限界を超えたら、たいていこれが正解です。

意味論上のギャップ:

  • swap($new) → Redis の GETSET。アトミックです。
  • compareAndSet($expect, $new) → Lua スクリプトか WATCH/MULTI。ヘルパーとしてラップする価値があります。
  • 外部のフラグサービスはたいてい値をローカルにキャッシュするため、読み取りが必ずしもネットワークの往復になるとは限りません。これは通常は問題ありませんが、変更については結果整合性になると考えておいてください。

Shared\Once → Database bootstrap table

  • パターン: 一意制約付きの冪等な INSERT を行い、衝突した場合は SELECT します。
  • SQL: INSERT INTO once (key, value) VALUES (?, ?) ON CONFLICT (key) DO NOTHING; SELECT value FROM once WHERE key = ?
  • Redis: SETNXGET

意味論上のギャップ:

  • Shared\Once::getOrInit(callable) は、競合に勝ったプロセス内でファクトリを実行します。外部ストアでは、ファクトリは冪等でなければなりません(2 つのライターが両方とも実行し、値は 1 つだけが勝つ可能性があります)。さもなければ、リーダー選出のラッパーが必要です。
  • 再入時の DeadlockException に相当するものは外部には存在しません。ストアの挙動をそのまま引き継ぐことになりますが、それはたいてい「何もしない」です。

Shared\Mutex → Redis distributed lock

  • Redis: 「Redlock」パターン、または保証を緩められるなら、よりシンプルな SET NX EX による単一キーのロックです。cheprasov/php-redis-lock のようなライブラリがこれをラップしています。
  • etcd / Consul / Zookeeper: リース更新を伴うセッションベースのロックです。運用上のオーバーヘッドは大きくなりますが、より強い保証が得られます。
最も難しい移行

プロセス内のミューテックスは即時かつ正確ですが、分散ロックは遅く、ベストエフォートの保証しか提供しません。意味論は変わるものと想定してください。at-least-once かつ冪等なクリティカルセクションになるよう設計しましょう。

意味論上のギャップ:

  • Shared\Mutexwith($fn) は、クロージャの戻り値をアトミックにガードされたストレージへ書き戻します。Redis ロックでは、明示的に読み取り、計算し、それから書き込む必要があり、その書き込みは無関係な操作と競合する可能性があります。
  • ポイズニング: 外部ロックには「ポイズン状態」がありません。分散クリティカルセクション内でクロージャが例外を投げると、ロックは解放され、次の呼び出し元は中途半端にコミットされた状態を目にします。整合性は、isPoisoned() を模倣するのではなく、補償アクションで扱ってください。

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 のバッチ処理は、Kafka の linger/batch 設定や Redis のパイプライニングに対応します。
  • close() に相当する外部の仕組みはありません。プロデューサーをグレースフルに停止し、コンシューマーに処理し切らせます。「これ以上アイテムは来ない」と告げるシグナルは存在しません。
  • プロセス内での順序保証は、ネットワークをまたいだ at-least-once 配信になります。コンシューマー側での冪等キーは必須です。

Shared\Map → Redis hash / a KV service / a database

  • Redis ハッシュ: HGET / HSET / HDEL / HSCAN がキー付きマップの形をカバーします。
  • キー付きの文字列値: SET key:<k> value を使い、maxEntries は LRU の追い出しによって強制します。
  • TTL カラムを持つデータベーステーブル: 行がエントリで、追い出しはバックグラウンドのスイーパーが処理します。値が数百バイトより大きい場合はこれが適しています。

意味論上のギャップ:

  • Map::compareAndSet のリトライループ(Shared\* のアトミックな RMW イディオム)は、Redis ではサーバーサイドの Lua スクリプトに、SQL では SELECT ... FOR UPDATE にしなければなりません。素の HGET + 計算 + HSET ではアトミック性が失われます。Map::setIfAbsent は、より単純な「一度だけ挿入する」ケースをカバーします。これは以前の値を返します(キーが存在せず値が挿入された場合は null)。したがって null が返れば挿入が行われたことを意味します。
  • Map のサイクル安全性は外部には存在しません。閉じるべき Shareable グラフが存在しないため、サイクルを閉じてしまうことは決してありません。
  • ネストした Shareable は「別のキーを用意し、値の中にポインターをエンコードする」形になります。その管理は自分で行うことになります。

Shared\Pool → Client library pools

  • ライブラリ自身のプールを優先してください。 PDO、Guzzle、HTTP クライアント、そしてほとんどのデータベースドライバーには成熟したプーリング機構があります。それらを Shared\Pool で再発明しないでください。
  • プロキシサービス: ホストごとの Postgres/MySQL プーリングには、pgbouncer / proxysql がプーリングの境界をインフラ層で終端します。PHP 側は再びステートレスになります。

意味論上のギャップ:

  • プールのアイドルタイムアウトによる追い出しは、ライブラリ自身のヘルスチェックに置き換わります。
  • ファクトリ/破棄コールバックは、ライブラリの接続ライフサイクルに置き換わります。
  • ホストをまたぐ場合は、1 つの大きなプールではなく、サービスごとのプール(ダウンストリームごとに 1 つ)が必要になることがあります。

具体例: テナントごとのレートリミッター

以下は、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 秒に 1 回アクセスし、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); }

無効化は、すべての OxPHP プロセスが購読する Redis の pub/sub チャネル経由か、ローカル Map の TTL 経由で行います。

ライトスルーバッファ

書き込み主体のワークロードでは、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\* プリミティブを 1 つ特定する。「すべて」を一度に移行しないこと。
  • インターフェースを抽出し、両方のバックエンドを配線する。
  • 整合性(at-most-once か at-least-once か)を決め、インターフェース上で明示する。
  • 両方のバックエンドを同じ統合テストスイートでテストする。
  • レイテンシを測定する。外部ストアは 1 操作あたり 0.1〜5 ms を追加する。ホットパスでそれを吸収できることを確認すること。
  • 外部ストアがダウンした場合に備える。フェイルオープン(リクエストを通す)か、フェイルクローズ(503 を返す)か? 正解はドメインによって異なります。
  • 切り替えの前後で比較できるよう、Shared\* バックエンドで oxphp_shared_* メトリクスを有効にする。

関連

  • 共有状態 — 概要。プロセス内にとどめるべきタイミング。
  • 共有オブザーバビリティ — 両方のバックエンドを同じ方法で計装する。
  • レート制限 — 組み込みの IP ごとのリミッター(PHP より前に動作し、PHP レベルの制限とは直交します)。