将 Shared* 迁移到外部存储

OxPHP\Shared\* 是进程内的。这让它快速且无需依赖,但也把你限制在单台主机和单个进程的生命周期之内。本页就是那道"逃生门":当你需要跨主机协调或跨重启的持久性时,这里介绍如何在不重写应用的前提下,把每种 Shared 类型迁移到 Redis 或 NATS(或类似)后端。

何时迁移

你多半并不需要迁移。Shared\* 的最佳适用场景——单主机、临时性、微秒级延迟的协调——覆盖的生产用例比人们想象的要多。只有在下列情况之一成立时,才迁移到外部存储:

  1. 你运行了不止一个 OxPHP 进程。 多台主机、存在重叠期的蓝绿部署,或者需要看到同一份状态的 sidecar。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: 基于版本号 CAS 的 KV.put 同时涵盖 setcompareAndSet。递增则需要在循环中使用 KV.get + KV.update(revision)

语义差异:

  • 批量累加是 add(array_sum($deltas))——在 Shared\* 中只需一次 FFI 往返。在 Redis 中,预先算好总和再做一次 INCRBY(一次 RTT);在 NATS 中则是一次 KV.update
  • 整数溢出在 Redis 中会返回错误;Shared\Counter 则会静默回绕。

Shared\Flag → Redis / NATS feature-flag service

  • Redis:SET / GET / SETNX 实现类似 compareAndSet 的语义。字符串值 "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: SETNX + GET

语义差异:

  • Shared\Once::getOrInit(callable) 在竞争获胜时会在进程内运行工厂函数。在外部存储中,工厂函数必须是幂等的(两个写入方可能都运行它,而只有一个值胜出),否则你就需要一个领导者选举的包装层。
  • 重入时抛出的 DeadlockException 没有对应的外部等价物——你只能承接存储本身的行为,而它通常什么也不做。

Shared\Mutex → Redis distributed lock

  • Redis: "Redlock" 模式,或者在保证要求较宽松时使用更简单的 SET NX EX 单键锁。诸如 cheprasov/php-redis-lock 这样的库对此做了封装。
  • etcd / Consul / Zookeeper: 基于会话、带租约续期的锁。运维开销更大,但保证更强。
最棘手的迁移

进程内互斥锁是瞬时且正确的;分布式锁则缓慢,且只提供尽力而为的保证。假定语义会发生变化:按照至少一次、幂等的临界区来设计。

语义差异:

  • Shared\Mutex 中的 with($fn) 会原子地把闭包的返回值提交回受保护的存储。而使用 Redis 锁时,你必须显式地读取、计算、再写入,而这次写入可能与某个无关的操作发生竞争。
  • 中毒(Poisoning):外部锁没有"中毒"状态。如果你的闭包在分布式临界区内抛出异常,你会释放锁,让下一个调用方看到只提交了一半的状态。应通过补偿操作来处理一致性,而不是去模仿 isPoisoned()

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

  • NATS JetStream: 语义上最接近的匹配。持久、有界、MPMC(多生产者多消费者),带有消费者偏移量和至少一次投递。
  • Redis Streams: XADD / XREADGROUP 覆盖了基本的队列模式。消费者组匹配 Shared\Channel 的多消费者语义。
  • SQS / Kafka: 业界主力。Kafka 适合高吞吐的事件流;SQS 适合简单的工作队列。

语义差异:

  • 阻塞式 recv 被长轮询取代。 你的消费者代码从"关闭时返回 null"变成"带超时地轮询,处理重连"。
  • sendMany 的批处理 对应到 Kafka 的 linger/batch 配置或 Redis 的流水线(pipelining)。
  • close() 没有外部对应物。优雅地停止生产者,让消费者把消息排空;不存在一个能表示"从此再无消息"的信号。
  • 进程内的顺序性 会变成跨网络的至少一次投递。消费者一侧的幂等键是必需的。

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

  • Redis 哈希: HGET / HSET / HDEL / HSCAN 覆盖了键控映射的形态。
  • 按键存储的字符串值: 使用 SET key:<k> value,并通过 LRU 淘汰来强制执行 maxEntries
  • 带 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 一侧则重新变回无状态。

语义差异:

  • 连接池的空闲超时淘汰被库自身的健康检查取代。
  • 工厂/销毁回调被库的连接生命周期取代。
  • 跨主机时,你可能需要按服务划分的连接池(每个下游一个),而不是一个大池子。

一个具体案例:按租户的限流器

下面是来自 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 缓存。你每 N 秒才访问一次 Redis,却每秒访问 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 发布/订阅频道来使缓存失效,或者依靠本地 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\* 原语。不要一次性迁移"所有东西"。
  • 抽取一个接口;把两套后端都接上。
  • 决定一致性——至多一次还是至少一次——并在接口中把它明确表达出来。
  • 用同一套集成测试对两套后端进行测试。
  • 测量延迟。外部存储每次操作会增加 0.1–5 ms——验证你的应用在热点路径上能够承受这一开销。
  • 为外部存储宕机做好预案:失败时放行(让请求通过)还是失败时关闭(返回 503)?正确答案取决于具体业务领域。
  • 在切换前后都启用 Shared\* 后端上的 oxphp_shared_* 指标,以便你进行对比。

相关

  • 共享状态 —— 概览;何时应保持在进程内。
  • 共享可观测性 —— 用同样的方式为两套后端进行插桩。
  • 限流 —— 内置的按 IP 限流器(在 PHP 之前运行;与 PHP 层面的限制彼此正交)。