将 Shared* 迁移到外部存储
OxPHP\Shared\* 是进程内的。这让它快速且无需依赖,但也把你限制在单台主机和单个进程的生命周期之内。本页就是那道"逃生门":当你需要跨主机协调或跨重启的持久性时,这里介绍如何在不重写应用的前提下,把每种 Shared 类型迁移到 Redis 或 NATS(或类似)后端。
何时迁移
你多半并不需要迁移。Shared\* 的最佳适用场景——单主机、临时性、微秒级延迟的协调——覆盖的生产用例比人们想象的要多。只有在下列情况之一成立时,才迁移到外部存储:
- 你运行了不止一个 OxPHP 进程。 多台主机、存在重叠期的蓝绿部署,或者需要看到同一份状态的 sidecar。
Shared\*是进程本地的;它无法跨越进程边界。 - 状态必须在重启后依然存在。 滚动部署、崩溃或例行重启都会丢失每一条
Shared\*记录。如果这种丢失不可接受(计费计数器、每日配额、工作队列位置),你就需要持久化。 - 状态必须在主机消失后依然存在。 如果你的任意一台主机都可能消失,而状态仍需存在,那它就得存放在本主机之外的某个地方。
- 你希望有跨语言的读取方。 外部存储可以被用 Go 编写的后台任务、指标管道或管理工具读取。
Shared\*仅限 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同时涵盖set和compareAndSet。递增则需要在循环中使用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)→ RedisGETSET。原子。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
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
// 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
$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_*指标,以便你进行对比。