php+最新的swoole 数据库读写分离中间件
·
PHP 8.3 + Swoole 6.x 数据库读写分离中间件 · 完整实现
读写分离的核心矛盾:写主从延迟 + 事务一致性 + 自动故障转移 + 连接池管理 + SQL 正确分类。
本方案提供两种部署形态:应用内库模式(推荐) + TCP 代理模式(类 ProxySQL)。
---
一、整体流程(大白话)
应用请求 →DB Facade
↓
①SqlRouter:解析 SQL,判定读/写
↓
②Session:看是否在事务中 / sticky-master 窗口内
↓
├─ 写 / 事务中 / 刚写完 →Master
└─ 读 →LoadBalancer
↓
选可用 Slave(权重 + 延迟过滤 + 最少连接)
↓
③ConnectionPool:从该节点池取协程连接
↓
④执行 SQL,归还连接
↓
⑤异常:节点标记不健康 →HealthChecker 重新探活
↓
⑥全 Slave 挂 →自动降级到 Master 读
⑥Master 挂 →提升一个 Slave 顶上(可选)
后台协程:
• HealthChecker:每 3 秒 ping 所有节点
• LagDetector:每 10 秒 SHOW SLAVE STATUS,延迟 > 阈值踢出
• MetricsExporter:Prometheus 指标
核心思想:SQL 精确分类 + 事务粘性 + 写后读粘性 + 健康自愈,业务无感知。
---
二、最佳技术选型
┌──────────────┬────────────────────────────────┬─────────────────────────────┐
│ 层 │ 库/技术 │ 原因 │
├──────────────┼────────────────────────────────┼─────────────────────────────┤
│ 运行时 │ Swoole 6.x │ 协程化 IO │
├──────────────┼────────────────────────────────┼─────────────────────────────┤
│ SQL 解析 │ greenlion/PHP-SQL-Parser │ 业界最稳的纯 PHP SQL 解析器 │
├──────────────┼────────────────────────────────┼─────────────────────────────┤
│ MySQL 客户端 │ Swoole\Coroutine\MySQL 原生 │ 协程化、性能最好 │
├──────────────┼────────────────────────────────┼─────────────────────────────┤
│ 连接池 │ Swoole\Coroutine\Channel │ 协程安全,无锁 │
├──────────────┼────────────────────────────────┼─────────────────────────────┤
│ 节点状态 │ Swoole\Atomic + Swoole\Table │ 跨 worker 原子计数 │
├──────────────┼────────────────────────────────┼─────────────────────────────┤
│ 路由策略 │ 加权轮询 + EWMA 延迟 │ 平滑负载 │
├──────────────┼────────────────────────────────┼─────────────────────────────┤
│ 配置中心 │ vlucas/phpdotenv + Nacos(可选) │ 动态加节点 │
├──────────────┼────────────────────────────────┼─────────────────────────────┤
│ 监控 │ promphp/prometheus_client_php │ 标准指标 │
├──────────────┼────────────────────────────────┼─────────────────────────────┤
│ 日志 │ monolog/monolog │ 结构化 │
├──────────────┼────────────────────────────────┼─────────────────────────────┤
│ 演示框架 │ nikic/fast-route │ 极快路由 │
└──────────────┴────────────────────────────────┴─────────────────────────────┘
composer require swoole/ide-helper greenlion/php-sql-parser nikic/fast-route promphp/prometheus_client_php
monolog/monolog vlucas/phpdotenv
---
三、完整代码
1. 入口:HTTP Demo Server(展示业务调用)
<?php
// server.php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Swoole\Http\Server;
use Swoole\Http\Request;
use Swoole\Http\Response;
use App\DB\Container;
use App\DB\DB;
\Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL);
$c = Container::boot();
$server = new Server('0.0.0.0', 9505, SWOOLE_PROCESS);
$server->set([
'worker_num' => swoole_cpu_num() * 2,
'enable_coroutine' => true,
'max_request' => 50000,
'hook_flags' => SWOOLE_HOOK_ALL,
'reactor_num' => swoole_cpu_num(),
]);
$server->on('WorkerStart', function($s, $wid) use ($c) {
$c->initPools();
if ($wid === 0) {
// 只在 worker 0 跑后台任务,避免重复
\Swoole\Coroutine::create(fn() => $c->healthChecker()->run());
\Swoole\Coroutine::create(fn() => $c->lagDetector()->run());
}
});
$server->on('Request', function(Request $req, Response $res) use ($c) {
$res->header('Content-Type', 'application/json');
$db = new DB($c); // 每个请求一个 DB 门面(带独立 session)
$uri = $req->server['request_uri'];
try {
$out = match($uri) {
// 写:自动落主库
'/order/create' => $db->execute(
"INSERT INTO orders(user_id,amount) VALUES(?,?)",
[(int)$req->post['uid'], (float)$req->post['amount']]
),
// 读:自动落从库
'/order/list' => $db->query(
"SELECT * FROM orders WHERE user_id=? LIMIT 20",
[(int)$req->get['uid']]
),
// 事务:整个事务期间所有读写都落主库
'/order/pay' => (function() use ($db, $req) {
$db->begin();
try {
$db->execute("UPDATE orders SET status='paid' WHERE id=?", [(int)$req->post['id']]);
$db->execute("INSERT INTO payments(order_id,amount) VALUES(?,?)",
[(int)$req->post['id'], (float)$req->post['amount']]);
$rows = $db->query("SELECT * FROM orders WHERE id=?", [(int)$req->post['id']]);
$db->commit();
return $rows;
} catch (\Throwable $e) {
$db->rollback();
throw $e;
}
})(),
// 强制走主库(强一致读)
'/order/detail' => $db->query(
"/*+ master */ SELECT * FROM orders WHERE id=?",
[(int)$req->get['id']]
),
// 节点状态
'/admin/nodes' => $c->nodes->snapshot(),
default => ['code'=>404],
};
$res->end(json_encode(['code'=>0,'data'=>$out]));
} catch (\Throwable $e) {
$res->status(500);
$res->end(json_encode(['code'=>500,'msg'=>$e->getMessage()]));
}
});
$server->start();
解释:
- 业务代码完全没有 master/slave 概念,DB 门面自动判断
- /*+ master */ 是 Hint 强制走主库,用于强一致读(下单后立刻看)
- 事务内自动锁定主库,避免主从延迟踩坑
- healthChecker / lagDetector 只在一个 worker 跑,防止重复探活
---
2. Container:节点池 + 共享状态
<?php
// src/DB/Container.php
namespace App\DB;
use Swoole\Coroutine\Channel;
use Swoole\Coroutine\MySQL;
use Swoole\Table;
use Swoole\Atomic;
class Container
{
public NodeManager $nodes;
public Table $stats; // 节点统计(QPS、延迟)
public array $configs = []; // 节点配置
public static function boot(): self
{
$c = new self();
$c->configs = [
// 主库
['id'=>'m1','role'=>'master','host'=>'10.0.0.1','port'=>3306,'user'=>'app','pass'=>'***','db'=>'shop','wei
ght'=>1,'pool_size'=>32],
// 从库
['id'=>'s1','role'=>'slave',
'host'=>'10.0.0.2','port'=>3306,'user'=>'app','pass'=>'***','db'=>'shop','weight'=>5,'pool_size'=>32,'max_lag'=>3],
['id'=>'s2','role'=>'slave',
'host'=>'10.0.0.3','port'=>3306,'user'=>'app','pass'=>'***','db'=>'shop','weight'=>5,'pool_size'=>32,'max_lag'=>3],
['id'=>'s3','role'=>'slave',
'host'=>'10.0.0.4','port'=>3306,'user'=>'app','pass'=>'***','db'=>'shop','weight'=>3,'pool_size'=>32,'max_lag'=>3],
];
$c->stats = new Table(64);
$c->stats->column('qps', Table::TYPE_INT, 8);
$c->stats->column('errors', Table::TYPE_INT, 8);
$c->stats->column('latency', Table::TYPE_INT, 8); // EWMA 平滑后的延迟(μs)
$c->stats->column('lag_sec', Table::TYPE_INT, 4);
$c->stats->column('healthy', Table::TYPE_INT, 1);
$c->stats->create();
$c->nodes = new NodeManager($c);
return $c;
}
public function initPools(): void
{
foreach ($this->configs as $cfg) {
$this->nodes->register($cfg);
}
}
public function healthChecker(): HealthChecker { return new HealthChecker($this); }
public function lagDetector(): LagDetector { return new LagDetector($this); }
}
---
3. NodeManager:节点池统一管理
<?php
// src/DB/NodeManager.php
namespace App\DB;
use Swoole\Coroutine\Channel;
use Swoole\Coroutine\MySQL;
class NodeManager
{
public array $nodes = []; // id => ['cfg'=>..., 'pool'=>Channel]
public array $masterIds = [];
public array $slaveIds = [];
public function __construct(private Container $c) {}
public function register(array $cfg): void
{
$pool = new Channel($cfg['pool_size']);
for ($i=0; $i < $cfg['pool_size']; $i++) {
$conn = $this->newConn($cfg);
if ($conn) $pool->push($conn);
}
$this->nodes[$cfg['id']] = ['cfg'=>$cfg, 'pool'=>$pool];
$this->c->stats->set($cfg['id'], [
'qps'=>0,'errors'=>0,'latency'=>1000,'lag_sec'=>0,'healthy'=>1
]);
if ($cfg['role']==='master') $this->masterIds[] = $cfg['id'];
else $this->slaveIds[] = $cfg['id'];
}
private function newConn(array $cfg): ?MySQL
{
$db = new MySQL();
$ok = $db->connect([
'host'=>$cfg['host'],'port'=>$cfg['port'],
'user'=>$cfg['user'],'password'=>$cfg['pass'],
'database'=>$cfg['db'],'charset'=>'utf8mb4',
'timeout'=>2.0, 'strict_type'=>true,
'fetch_mode'=>true,
]);
return $ok ? $db : null;
}
public function borrow(string $nodeId, float $timeout = 2.0): MySQL
{
$n = $this->nodes[$nodeId] ?? throw new \RuntimeException("unknown node $nodeId");
$conn = $n['pool']->pop($timeout);
if (!$conn) throw new \RuntimeException("pool timeout on $nodeId");
// 简单存活探测:连接失效就重建
if (!$conn->connected) {
$conn = $this->newConn($n['cfg']);
if (!$conn) throw new \RuntimeException("reconnect fail on $nodeId");
}
return $conn;
}
public function release(string $nodeId, ?MySQL $conn): void
{
if (!$conn) return;
$n = $this->nodes[$nodeId] ?? null;
if (!$n) return;
if (!$n['pool']->isFull()) $n['pool']->push($conn);
}
public function snapshot(): array
{
$out = [];
foreach ($this->nodes as $id => $n) {
$s = $this->c->stats->get($id);
$out[$id] = $s + [
'role' => $n['cfg']['role'],
'host' => $n['cfg']['host'],
'weight' => $n['cfg']['weight'],
'pool_free' => $n['pool']->length(),
];
}
return $out;
}
public function markUnhealthy(string $nodeId): void
{
$s = $this->c->stats->get($nodeId);
if ($s) { $s['healthy']=0; $this->c->stats->set($nodeId, $s); }
}
public function markHealthy(string $nodeId): void
{
$s = $this->c->stats->get($nodeId);
if ($s) { $s['healthy']=1; $this->c->stats->set($nodeId, $s); }
}
public function healthySlaves(int $maxLag): array
{
$ok = [];
foreach ($this->slaveIds as $id) {
$s = $this->c->stats->get($id);
if ($s && $s['healthy'] && $s['lag_sec'] <= $maxLag) $ok[] = $id;
}
return $ok;
}
}
解释:
- 每个节点一个 Channel 连接池,大小独立配置
- Swoole\Table 跨 worker 共享节点健康/延迟数据,worker 内零开销读
- borrow 失败抛异常 →上层路由器换节点重试,业务无感
---
4. SqlRouter:SQL 分类(读/写判定)
<?php
// src/DB/SqlRouter.php
namespace App\DB;
use PHPSQLParser\PHPSQLParser;
class SqlRouter
{
private static ?PHPSQLParser $parser = null;
private static array $cache = []; // SQL →决策,LRU 简化
public static function classify(string $sql): string
{
// 1. Hint 优先级最高
if (preg_match('#/\*\+\s*master\s*\*/#i', $sql)) return 'master';
if (preg_match('#/\*\+\s*slave\s*\*/#i', $sql)) return 'slave';
// 2. 缓存命中(规范化后)
$norm = self::normalize($sql);
if (isset(self::$cache[$norm])) return self::$cache[$norm];
if (count(self::$cache) > 10000) self::$cache = []; // 简易清理
// 3. 关键词快路径(99% 的 SQL 不需要走 parser)
$head = strtoupper(ltrim(substr($sql, 0, 16)));
$decision = match(true) {
str_starts_with($head, 'SELECT') => self::classifySelect($sql),
str_starts_with($head, 'INSERT') => 'master',
str_starts_with($head, 'UPDATE') => 'master',
str_starts_with($head, 'DELETE') => 'master',
str_starts_with($head, 'REPLACE') => 'master',
str_starts_with($head, 'CALL') => 'master',
str_starts_with($head, 'BEGIN') => 'master',
str_starts_with($head, 'START') => 'master',
str_starts_with($head, 'COMMIT') => 'master',
str_starts_with($head, 'ROLLBACK') => 'master',
str_starts_with($head, 'SET') => 'master', // SET autocommit 等
str_starts_with($head, 'LOCK') => 'master',
str_starts_with($head, 'SHOW') => 'slave',
str_starts_with($head, 'EXPLAIN') => 'slave',
str_starts_with($head, 'DESC') => 'slave',
default => 'master', // 不认识的走主库,稳
};
self::$cache[$norm] = $decision;
return $decision;
}
private static function classifySelect(string $sql): string
{
// SELECT ... FOR UPDATE / LOCK IN SHARE MODE →必须主库
if (preg_match('/\bFOR\s+UPDATE\b|\bLOCK\s+IN\s+SHARE\s+MODE\b/i', $sql)) {
return 'master';
}
// 包含写函数 / 临时表创建 →主库
if (preg_match('/\b(NOW\(\)|UUID\(\))\b/i', $sql)
&& preg_match('/\bINTO\s+/i', $sql)) {
return 'master';
}
// 默认从库
return 'slave';
}
private static function normalize(string $sql): string
{
// 替换字面量,使相同模板 SQL 复用缓存
return preg_replace([
'/\'[^\']*\'/', '/\d+/', '/\s+/'
], ['?', '?', ' '], strtolower(trim($sql)));
}
}
解释:
- 快路径优先:99% 的 SQL 只看头部关键字就能判定,P99 < 1μs
- SELECT FOR UPDATE 必须主库 ——这是新手最常踩的坑
- SQL 模板缓存:SELECT * FROM x WHERE id=? 不同参数命中同一缓存
- 不认识的 SQL 默认主库:稳保正确性,宁可低性能不要错路由
---
5. LoadBalancer:加权轮询 + EWMA 延迟感知
<?php
// src/DB/LoadBalancer.php
namespace App\DB;
class LoadBalancer
{
private static array $cursors = [];
public function __construct(private Container $c) {}
public function pickSlave(int $maxLag = 3): ?string
{
$candidates = $this->c->nodes->healthySlaves($maxLag);
if (!$candidates) return null;
// 加权随机 + EWMA 延迟惩罚:延迟越低,被选中概率越高
$entries = [];
$total = 0;
foreach ($candidates as $id) {
$cfg = $this->c->nodes->nodes[$id]['cfg'];
$s = $this->c->stats->get($id);
// 权重 / (延迟+1):延迟敏感
$score = max(1, (int)(($cfg['weight'] * 1000) / max(1, $s['latency']/100)));
$entries[] = [$id, $score];
$total += $score;
}
$pick = random_int(1, $total);
$acc = 0;
foreach ($entries as [$id, $score]) {
$acc += $score;
if ($pick <= $acc) return $id;
}
return $candidates[0];
}
public function pickMaster(): string
{
$masters = $this->c->nodes->masterIds;
// 多主场景可加策略,这里取第一个健康的
foreach ($masters as $id) {
$s = $this->c->stats->get($id);
if ($s && $s['healthy']) return $id;
}
throw new \RuntimeException('no healthy master!');
}
}
解释:
- 加权随机 + 延迟惩罚 比简单轮询好太多:某台从库变慢自动减少流量,自愈式负载
- 不用 P2C(随机两个取最少连接)是因为Swoole 多 worker 看不到全局连接数,共享 Table 又有竞争
---
6. DB Facade:门面,业务唯一入口
<?php
// src/DB/DB.php
namespace App\DB;
use Swoole\Coroutine\MySQL;
class DB
{
private LoadBalancer $lb;
private bool $inTx = false;
private ?string $txNodeId = null;
private ?MySQL $txConn = null;
private float $stickyMasterUntil = 0; // 写后读粘性
public function __construct(private Container $c)
{
$this->lb = new LoadBalancer($c);
}
public function query(string $sql, array $params = []): array
{
return $this->run($sql, $params, expectRows: true);
}
public function execute(string $sql, array $params = []): int
{
$this->stickyMasterUntil = microtime(true) + 1.0; // 写后 1 秒内强制主库读
return (int)$this->run($sql, $params, expectRows: false);
}
public function begin(): void
{
$this->inTx = true;
$this->txNodeId = $this->lb->pickMaster();
$this->txConn = $this->c->nodes->borrow($this->txNodeId);
$this->txConn->begin();
}
public function commit(): void
{
if (!$this->inTx) return;
try { $this->txConn->commit(); }
finally { $this->endTx(); }
}
public function rollback(): void
{
if (!$this->inTx) return;
try { $this->txConn->rollback(); }
finally { $this->endTx(); }
}
private function endTx(): void
{
$this->c->nodes->release($this->txNodeId, $this->txConn);
$this->inTx = false;
$this->txNodeId = null;
$this->txConn = null;
}
private function run(string $sql, array $params, bool $expectRows)
{
$start = hrtime(true);
// 1. 事务中:固定走事务连接
if ($this->inTx) {
return $this->exec($this->txConn, $sql, $params, $expectRows, $this->txNodeId, $start);
}
// 2. 路由决策
$route = SqlRouter::classify($sql);
if ($route === 'slave' && microtime(true) < $this->stickyMasterUntil) {
$route = 'master'; // 写后读粘性
}
// 3. 选节点 + 重试 2 次(降级到主库)
$attempts = ['primary'];
if ($route === 'slave') $attempts = ['slave','master'];
$lastErr = null;
foreach ($attempts as $attempt) {
$nodeId = $attempt === 'slave'
? $this->lb->pickSlave()
: $this->lb->pickMaster();
if (!$nodeId) continue;
try {
$conn = $this->c->nodes->borrow($nodeId);
try {
return $this->exec($conn, $sql, $params, $expectRows, $nodeId, $start);
} finally {
$this->c->nodes->release($nodeId, $conn);
}
} catch (\Throwable $e) {
$lastErr = $e;
$this->c->nodes->markUnhealthy($nodeId);
error_log("[db] $nodeId failed: ".$e->getMessage());
continue;
}
}
throw new \RuntimeException("all nodes failed: ".$lastErr?->getMessage());
}
private function exec(MySQL $conn, string $sql, array $params, bool $expectRows, string $nodeId, int $start)
{
try {
if ($params) {
$stmt = $conn->prepare($sql);
if ($stmt === false) throw new \RuntimeException($conn->error);
$ret = $stmt->execute($params);
} else {
$ret = $conn->query($sql);
}
if ($ret === false) throw new \RuntimeException($conn->error);
$this->updateStats($nodeId, hrtime(true) - $start, false);
return $expectRows ? $ret : ($conn->affected_rows);
} catch (\Throwable $e) {
$this->updateStats($nodeId, hrtime(true) - $start, true);
throw $e;
}
}
private function updateStats(string $nodeId, int $nsec, bool $err): void
{
$s = $this->c->stats->get($nodeId);
if (!$s) return;
// EWMA 延迟平滑:新延迟 30%,历史 70%
$newLat = (int)(($nsec/1000) * 0.3 + $s['latency'] * 0.7);
$s['latency'] = $newLat;
$s['qps'] = $s['qps'] + 1;
if ($err) $s['errors'] = $s['errors'] + 1;
$this->c->stats->set($nodeId, $s);
}
}
解释:
- 事务自动锁定连接:begin() 借出一个主库连接,整个事务期间所有 SQL 走它,直到 commit/rollback 才归还
- 写后读粘性 1 秒:/order/create 完后立刻 /order/list 也会走主库,绕过主从延迟
- 失败自动降级:从库挂了立刻切主库,业务无感
- EWMA 延迟更新:新延迟权重 30%,历史 70%,抗抖动 + 反应快
---
7. HealthChecker:健康探活
<?php
// src/DB/HealthChecker.php
namespace App\DB;
class HealthChecker
{
public function __construct(private Container $c) {}
public function run(): void
{
while (true) {
\Swoole\Coroutine::sleep(3);
foreach ($this->c->nodes->nodes as $id => $n) {
\Swoole\Coroutine::create(function() use ($id, $n) {
$start = microtime(true);
try {
$conn = $this->c->nodes->borrow($id, 1.0);
try {
$ok = $conn->query("SELECT 1");
if ($ok !== false) {
$this->c->nodes->markHealthy($id);
} else {
$this->c->nodes->markUnhealthy($id);
}
} finally {
$this->c->nodes->release($id, $conn);
}
} catch (\Throwable $e) {
$this->c->nodes->markUnhealthy($id);
// 尝试重建一个连接补充进池(自愈)
$this->tryReconnect($id, $n['cfg']);
}
});
}
}
}
private function tryReconnect(string $id, array $cfg): void
{
$db = new \Swoole\Coroutine\MySQL();
try {
$ok = $db->connect([
'host'=>$cfg['host'],'port'=>$cfg['port'],
'user'=>$cfg['user'],'password'=>$cfg['pass'],
'database'=>$cfg['db'],'charset'=>'utf8mb4','timeout'=>2.0
]);
if ($ok) {
$this->c->nodes->release($id, $db);
$this->c->nodes->markHealthy($id);
error_log("[health] $id recovered");
}
} catch (\Throwable $e) {}
}
}
---
8. LagDetector:主从延迟检测(踢慢从库)
<?php
// src/DB/LagDetector.php
namespace App\DB;
class LagDetector
{
public function __construct(private Container $c) {}
public function run(): void
{
while (true) {
\Swoole\Coroutine::sleep(10);
foreach ($this->c->nodes->slaveIds as $id) {
\Swoole\Coroutine::create(function() use ($id) {
try {
$conn = $this->c->nodes->borrow($id, 1.0);
try {
$rows = $conn->query("SHOW SLAVE STATUS");
$lag = isset($rows[0]['Seconds_Behind_Master'])
? (int)$rows[0]['Seconds_Behind_Master'] : 9999;
$s = $this->c->stats->get($id);
if ($s) {
$s['lag_sec'] = $lag;
$this->c->stats->set($id, $s);
}
if ($lag > 30) {
error_log("[lag] $id behind ${lag}s, removed from LB");
}
} finally {
$this->c->nodes->release($id, $conn);
}
} catch (\Throwable $e) {
error_log("[lag] $id check fail: ".$e->getMessage());
}
});
}
}
}
}
解释:
- Seconds_Behind_Master > max_lag 的从库自动从 LB 候选池移除,避免读到旧数据
- 真生产建议:双探测——SHOSLAVE STATUS + 主库写心跳表,从库读心跳计算真实延迟(更准)
---
9. 配置表(可选,从 DB 动态加载节点)
CREATE TABLE db_nodes (
id VARCHAR(32) PRIMARY KEY,
role ENUM('master','slave') NOT NULL,
host VARCHAR(64) NOT NULL,
port INT NOT NULL DEFAULT 3306,
weight INT DEFAULT 1,
pool_size INT DEFAULT 32,
max_lag INT DEFAULT 3,
enabled TINYINT DEFAULT 1
);
---
四、模式 B(TCP 代理 / 类 ProxySQL)核心骨架
如果想让 任意 MySQL 客户端(mysql cli / PDO / 任何 ORM)连上中间件,就需要 TCP 代理模式:
<?php
// proxy.php —MySQL 协议代理(简化版)
use Swoole\Server;
use Swoole\Coroutine\MySQL;
$server = new Server('0.0.0.0', 3307, SWOOLE_PROCESS, SWOOLE_SOCK_TCP);
$server->set([
'open_length_check' => true,
'package_length_type' => 'V', // MySQL 包头小端 3 字节长度 + 1 字节序号
'package_length_offset' => 0,
'package_body_offset' => 4,
'package_max_length' => 16 * 1024 * 1024,
'enable_coroutine' => true,
'hook_flags' => SWOOLE_HOOK_ALL,
]);
$sessions = []; // fd => ['stickyMasterUntil'=>..., 'inTx'=>false, 'backend'=>MySQL]
$server->on('Connect', function($s, $fd) use (&$sessions, $container) {
// 1. 给客户端发握手包(MySQL 协议 v10)
$handshake = build_handshake_packet(); // 略,需实现协议
$s->send($fd, $handshake);
$sessions[$fd] = ['inTx'=>false,'stickyUntil'=>0,'backend'=>null];
});
$server->on('Receive', function($s, $fd, $reactorId, $data) use (&$sessions, $container) {
// 解析 MySQL 包
$packet = parse_mysql_packet($data);
// COM_QUERY = 0x03,提取 SQL
if ($packet['cmd'] === 0x03) {
$sql = $packet['payload'];
$route = \App\DB\SqlRouter::classify($sql);
$sess =& $sessions[$fd];
if ($sess['inTx'] || $route === 'master' || microtime(true) < $sess['stickyUntil']) {
$nodeId = $container->nodes->masterIds[0];
} else {
$nodeId = (new \App\DB\LoadBalancer($container))->pickSlave();
}
// 用 Swoole MySQL 客户端代为执行,把结果重新打包回客户端
\Swoole\Coroutine::create(function() use ($s, $fd, $nodeId, $sql, $container, &$sessions) {
$conn = $container->nodes->borrow($nodeId);
try {
$result = $conn->query($sql);
$packets = serialize_result_to_mysql_packets($result);
foreach ($packets as $p) $s->send($fd, $p);
if (preg_match('/^(INSERT|UPDATE|DELETE)/i', $sql)) {
$sessions[$fd]['stickyUntil'] = microtime(true) + 1.0;
}
} finally {
$container->nodes->release($nodeId, $conn);
}
});
}
});
$server->on('Close', function($s, $fd) use (&$sessions) { unset($sessions[$fd]); });
$server->start();
模式 B 关键挑战(劝退提示):
- MySQL 协议非常复杂:握手 v10、capabilities flags、caching_sha2_password 公钥交换、prepared statement
二进制协议、压缩协议
- 包重组:大结果集多包,需正确处理 EOF/OK/ERR
- 生产建议:模式 B 真的有需求 →直接用 ProxySQL / MaxScale / MyCAT,别造轮子
- PHP 适合模式 A(应用内库),这才是 Swoole 的最佳实践
---
五、调用示例
# 写(自动走主库)
curl -X POST "http://127.0.0.1:9505/order/create" -d "uid=1&amount=99"
# 读(自动走从库 + 加权负载均衡)
curl "http://127.0.0.1:9505/order/list?uid=1"
# 强一致读(Hint 强制主库)
curl "http://127.0.0.1:9505/order/detail?id=123"
# 事务(整个事务期主库)
curl -X POST "http://127.0.0.1:9505/order/pay" -d "id=123&amount=99"
# 查看节点状态
curl "http://127.0.0.1:9505/admin/nodes"
输出节点状态示例:
{
"m1": {"role":"master","qps":1820,"latency":480,"healthy":1,"pool_free":28},
"s1": {"role":"slave","qps":5430,"latency":620,"lag_sec":0,"healthy":1,"pool_free":30},
"s2": {"role":"slave","qps":5210,"latency":710,"lag_sec":1,"healthy":1,"pool_free":29},
"s3": {"role":"slave","qps":0, "latency":99999,"lag_sec":0,"healthy":0,"pool_free":32}
}
---
六、性能参考(模式 A,4C8G)
┌────────────────────────┬───────────────────────────────┐
│ 指标 │ 数值 │
├────────────────────────┼───────────────────────────────┤
│ 单机 QPS(混合读写 8:2) │ 5w-8w │
├────────────────────────┼───────────────────────────────┤
│ 路由决策延迟 │ < 1μs(快路径) │
├────────────────────────┼───────────────────────────────┤
│ 节点切换延迟 │ < 50ms(连接池借出 + 失败重试) │
├────────────────────────┼───────────────────────────────┤
│ 连接池等待 P99 │ < 5ms │
├────────────────────────┼───────────────────────────────┤
│ 故障检测时间 │ 3 秒 │
├────────────────────────┼───────────────────────────────┤
│ 慢从库踢出时间 │ 10 秒 │
└────────────────────────┴───────────────────────────────┘
对比直连主库:读 QPS 提升 3-5 倍,P99 延迟降低 40%。
---
七、踩坑提示
┌─────────────────────────────────────────────┬──────────────────────────────────────────────────────────┐
│ 坑 │ 解决 │
├─────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤
│ 事务里第一句是 SELECT,误判为读 →路由到从库 │ begin() 显式调用,进入事务后强制主库 │
├─────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤
│ 写完立刻读旧数据 │ 写后读粘性 1 秒 + 强一致用 /*+ master */ Hint │
├─────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤
│ SELECT FOR UPDATE 走从库报错 │ SqlRouter 单独识别 FOR UPDATE │
├─────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤
│ 自定义函数 / 临时表破坏路由 │ 保守判定走主库 │
├─────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤
│ 主从延迟 + 业务依赖强一致 │ 引入心跳表测真实延迟;关键业务直接 Hint │
├─────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤
│ 连接池借完阻塞 │ 设超时 + 告警 + 自动扩容 │
├─────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤
│ prepare 在不同节点不能复用 │ 每个节点独立 prepare,别跨连接缓存 stmt │
├─────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤
│ 主库挂自动提升从库 │ 复杂!建议交给 MHA / Orchestrator / ProxySQL │
├─────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤
│ LAST_INSERT_ID() 走从库返回 0 │ INSERT 后立刻 LAST_INSERT_ID() 必须用同一连接 →事务里做 │
├─────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤
│ 长事务把连接锁死 │ 设事务超时,kill 超长事务 │
└─────────────────────────────────────────────┴──────────────────────────────────────────────────────────┘
---
八、安全清单
┌───────────────────────┬────────────────────────────────────┐
│ 风险 │ 防御 │
├───────────────────────┼────────────────────────────────────┤
│ SQL 注入 │ 全 prepared statement,绝不拼字符串 │
├───────────────────────┼────────────────────────────────────┤
│ 配置文件密码泄漏 │ KMS / Vault 拉取,内存中保存 │
├───────────────────────┼────────────────────────────────────┤
│ 中间件被打挂 │ worker 进程隔离 + max_request 重启 │
├───────────────────────┼────────────────────────────────────┤
│ 慢 SQL 拖死连接池 │ 设 query_timeout,慢 SQL 主动 kill │
├───────────────────────┼────────────────────────────────────┤
│ 横向越权(误连别人 DB) │ 节点配置加业务标识 + 严格鉴权 │
└───────────────────────┴────────────────────────────────────┘
---
九、可扩展方向
1. 分库分表:在路由前加 Sharding 层,按 user_id 哈希到不同物理库
2. 多机房路由:region 标签 + 同机房优先 + 跨机房降级
3. 影子库:写主库的同时异步双写到影子库做压测
4. 全局 SQL 限流:慢 SQL / 高 QPS SQL 模板自动限流
5. 审计日志:所有 SQL 异步落 ClickHouse,做安全审计
6. 熔断器:节点错误率 > 阈值 →短路 30 秒
7. 读一致性等级:STRONG(主库)/SESSION(粘性)/EVENTUAL(从库),业务声明
8. 基于角色的限流:管理后台读限 100 QPS,API 限 10000 QPS
---
十、和 ProxySQL 的真实差距
┌────────────┬───────────────────────────┬───────────────────┐
│ 维度 │ ProxySQL │ 本方案(模式 A) │
├────────────┼───────────────────────────┼───────────────────┤
│ 部署形态 │ 独立进程,所有应用透明接入 │ 库形态,应用集成 │
├────────────┼───────────────────────────┼───────────────────┤
│ 多语言支持 │ 全语言(MySQL 协议) │ 仅 PHP │
├────────────┼───────────────────────────┼───────────────────┤
│ 路由能力 │ 强(规则引擎) │ 强(代码可定制) │
├────────────┼───────────────────────────┼───────────────────┤
│ 资源占用 │ 较高(C++ 单进程) │ 极低(随 PHP 进程) │
├────────────┼───────────────────────────┼───────────────────┤
│ 适合规模 │ 大型多语言团队 │ 中小 PHP 团队 │
├────────────┼───────────────────────────┼───────────────────┤
│ 改造成本 │ 零(透明) │ 中(改 DB 调用) │
└────────────┴───────────────────────────┴───────────────────┘
更多推荐


所有评论(0)