swoole方案 跨数据中心微服务多活容灾架构
·
正常:请求 → 本机房节点(dc1)
dc1全挂:请求 → 自动切到备用机房(dc2)
dc1恢复:流量自动切回来
三个状态机(熔断器):
CLOSED → 正常转发
OPEN → 直接跳过,不试了(熔断中)
HALF_OPEN → 试一个请求,成功就恢复,失败继续熔断
---
代码
<?php
define('LOCAL_DC', 'dc1'); // 本节点所在机房
define('FAIL_THRESHOLD', 3); // 连续失败3次开熔断
define('CIRCUIT_TTL', 30); // 熔断30秒后半开尝试
// 节点表:所有机房的节点状态,共享内存跨worker可见
$nodeTable = new Swoole\Table(1024);
$nodeTable->column('dc', Swoole\Table::TYPE_STRING, 16);
$nodeTable->column('host', Swoole\Table::TYPE_STRING, 64);
$nodeTable->column('port', Swoole\Table::TYPE_INT);
$nodeTable->column('fails', Swoole\Table::TYPE_INT);
$nodeTable->column('circuit', Swoole\Table::TYPE_STRING, 16); // closed/open/half_open
$nodeTable->column('open_until', Swoole\Table::TYPE_INT);
$nodeTable->create();
foreach ([
'dc1-1' => ['dc' => 'dc1', 'host' => '10.1.0.1', 'port' => 8080],
'dc1-2' => ['dc' => 'dc1', 'host' => '10.1.0.2', 'port' => 8080],
'dc2-1' => ['dc' => 'dc2', 'host' => '10.2.0.1', 'port' => 8080],
'dc2-2' => ['dc' => 'dc2', 'host' => '10.2.0.2', 'port' => 8080],
] as $id => $n) {
$nodeTable->set($id, [...$n, 'fails' => 0, 'circuit' => 'closed', 'open_until' => 0]);
}
// ─── 熔断器:选节点,本机房优先,全挂切备用 ─────────────────
function pickNode(Swoole\Table $t, string $localDc): ?array {
$local = $remote = [];
$now = time();
foreach ($t as $id => $node) {
if ($node['circuit'] === 'open') {
if ($now < $node['open_until']) continue; // 熔断期内跳过
$t->set($id, [...$node, 'circuit' => 'half_open']); // 到期转半开
$node['circuit'] = 'half_open';
}
($node['dc'] === $localDc ? $local : $remote)[$id] = $node;
}
// 本机房有节点就用本机房,否则降级到备用机房
$pool = $local ?: $remote;
if (!$pool) return null;
$id = array_rand($pool);
return ['id' => $id, ...$pool[$id]];
}
function markFail(Swoole\Table $t, string $id): void {
$n = $t->get($id);
$fails = $n['fails'] + 1;
if ($fails >= FAIL_THRESHOLD) {
$t->set($id, [...$n, 'fails' => $fails, 'circuit' => 'open', 'open_until' => time() + CIRCUIT_TTL]);
echo "[熔断开启] $id,{$n['dc']} 机房失败节点+1\n";
} else {
$t->set($id, [...$n, 'fails' => $fails]);
}
}
function markOk(Swoole\Table $t, string $id): void {
$n = $t->get($id);
$t->set($id, [...$n, 'fails' => 0, 'circuit' => 'closed', 'open_until' => 0]);
}
// ─── 转发请求到目标节点 ──────────────────────────────────────
function forward(array $node, \Swoole\Http\Request $req): array {
$c = new Swoole\Coroutine\Http\Client($node['host'], $node['port']);
$c->set(['timeout' => 3]);
$c->setMethod($req->server['request_method']);
$c->setHeaders(array_merge($req->header ?? [], ['X-Forwarded-DC' => LOCAL_DC]));
$c->setData($req->rawContent() ?? '');
$ok = $c->execute($req->server['request_uri']);
$result = ['ok' => $ok && $c->statusCode < 500, 'status' => $c->statusCode, 'body' => $c->body];
$c->close();
return $result;
}
// ─── Swoole 服务 ─────────────────────────────────────────────
$server = new Swoole\Http\Server('0.0.0.0', 9502);
$server->set(['worker_num' => swoole_cpu_num()]);
// worker 0 跑主动健康检查:每5秒探活所有节点
$server->on('workerStart', function ($server, $workerId) use ($nodeTable) {
if ($workerId !== 0) return;
Swoole\Timer::tick(5000, function () use ($nodeTable) {
foreach ($nodeTable as $id => $node) {
if ($node['circuit'] === 'open' && time() < $node['open_until']) continue;
Swoole\Coroutine::create(function () use ($id, $node, $nodeTable) {
$c = new Swoole\Coroutine\Http\Client($node['host'], $node['port']);
$c->set(['timeout' => 2]);
$ok = $c->execute('/health') && $c->statusCode === 200;
$c->close();
$ok ? markOk($nodeTable, $id) : markFail($nodeTable, $id);
echo ($ok ? '[恢复]' : '[故障]') . " $id ({$node['dc']})\n";
});
}
});
});
$server->on('request', function ($req, $res) use ($nodeTable) {
$node = pickNode($nodeTable, LOCAL_DC);
if (!$node) {
$res->status(503);
$res->end(json_encode(['error' => '全部机房不可用']));
return;
}
$result = forward($node, $req);
if (!$result['ok']) {
markFail($nodeTable, $node['id']);
// 立刻重试:换一个节点(可能已切到备用机房)
$retry = pickNode($nodeTable, LOCAL_DC);
if ($retry && $retry['id'] !== $node['id']) {
$result = forward($retry, $req);
$node = $retry;
markOk($nodeTable, $retry['id']);
}
} else {
markOk($nodeTable, $node['id']);
}
$degraded = $node['dc'] !== LOCAL_DC;
if ($degraded) echo "[降级] 流量已切到备用机房 {$node['dc']}\n";
$res->header('X-Node', $node['id']);
$res->header('X-DC', $node['dc']);
$res->header('X-Degraded', $degraded ? 'true' : 'false');
$res->status($result['status'] ?: 502);
$res->end($result['body'] ?: json_encode(['error' => '上游异常']));
});
$server->start();
---
解释
为什么要两层检测:主动健康检查(定时探活)发现节点慢慢挂掉;被动熔断(请求失败计数)发现节点突然挂掉。两层结合,发现故
障更快
熔断器三个状态
CLOSED ──(连续失败≥3次)──> OPEN ──(30秒后)──> HALF_OPEN
↑ │
└──────────────(请求成功)──────────────────────┘
OPEN 期间直接跳过这个节点,不发请求,避免大量请求堆积在死节点上
pickNode 本机房优先:先把所有节点分成 $local 和 $remote
两桶,本机房桶不为空就从本机房选,本机房全挂了(桶空了)才从备用机房选。切换是自动的,不需要人工干预
X-Degraded: true:响应头告诉调用方"这次请求走的是备用机房",方便监控告警。看到这个头大量出现,说明本机房出问题了
半开探活:熔断30秒后不是直接恢复,而是放一个请求进去试试。成功了才关闭熔断,失败了重新计30秒。防止机房刚恢复就被大量流
量打垮
Swoole\Table 跨 worker 共享:4个 worker 进程共用同一份节点状态表。worker 0 的健康检查更新了节点状态,其他 worker
的请求路由立刻感知到,不用 Redis
---
测试
php proxy.php
# 正常请求,走本机房
curl -v http://localhost:9502/api/users
# X-DC: dc1
# X-Degraded: false
# 模拟 dc1 全挂(停掉 dc1 的节点)
# 3次失败后熔断开启,自动切流
# 再请求,走备用机房
curl -v http://localhost:9502/api/users
# X-DC: dc2
# X-Degraded: true ← 已降级
# dc1 恢复后,30秒内自动切回
curl -v http://localhost:9502/api/users
# X-DC: dc1
# X-Degraded: false ← 恢复正常
更多推荐
所有评论(0)