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. 熔断器:节点错误率 > 阈值 →短路 307. 读一致性等级:STRONG(主库)/SESSION(粘性)/EVENTUAL(从库),业务声明
  8. 基于角色的限流:管理后台读限 100 QPS,API 限 10000 QPS

  ---
  十、和 ProxySQL 的真实差距

  ┌────────────┬───────────────────────────┬───────────────────┐
  │    维度    │         ProxySQL          │  本方案(模式 A)   │
  ├────────────┼───────────────────────────┼───────────────────┤
  │ 部署形态   │ 独立进程,所有应用透明接入 │ 库形态,应用集成   │
  ├────────────┼───────────────────────────┼───────────────────┤
  │ 多语言支持 │ 全语言(MySQL 协议)        │ 仅 PHP            │
  ├────────────┼───────────────────────────┼───────────────────┤
  │ 路由能力   │ 强(规则引擎)              │ 强(代码可定制)    │
  ├────────────┼───────────────────────────┼───────────────────┤
  │ 资源占用   │ 较高(C++ 单进程)          │ 极低(随 PHP 进程) │
  ├────────────┼───────────────────────────┼───────────────────┤
  │ 适合规模   │ 大型多语言团队            │ 中小 PHP 团队     │
  ├────────────┼───────────────────────────┼───────────────────┤
  │ 改造成本   │ 零(透明)                  │ 中(改 DB 调用)    │
  └────────────┴───────────────────────────┴───────────────────┘

更多推荐