前言
读写分离(Read-Write Splitting)上线后最常见的症状有三种:写入成功了但立刻查不到(刚下单,订单列表里没这条);偶尔报SQLSTATE[HY000] 2006 MySQL server has gone away;以及读请求全部打在从库上,主库空闲而从库被打满。
这些症状的根因分别是:主从复制延迟(Replication Lag)、连接被中间件或 MySQL 的wait_timeout掐断、流量路由规则只按 SQL 前缀匹配。它们都不是 PHP 代码本身的 bug,而是"中间件(Middleware)配置"层面的问题。
本文讲的"中间件"指部署在 PHP 与 MySQL 之间的代理层,主流选择是 ProxySQL、MySQL Router、MaxScale。也有应用层方案(在 PHP 里自己路由),两类的取舍会在第一节说清。文中 PHP 侧代码以 PHP 8.5(2025 年 11 月发布)为运行环境,语法要求 PHP 8.1+。
一、三种方案的取舍
先说结论:能上代理层就上代理层。应用层路由看起来简单,但一旦有多个服务、多种语言,每处都要重新实现一遍,且很容易漏掉事务里的读。
| 方案 | 代表实现 | PHP 侧改动 | 主从延迟可控 | 适用规模 |
|---|---|---|---|---|
| 代理层中间件 | ProxySQL、MySQL Router、MaxScale | 几乎为零(改端口) | 支持(max_replication_lag) | 中大型,多语言共用 |
| 应用层路由 | 自写 Router 类 | 大,需封装 PDO | 需自己做 | 小型、单服务 |
| 框架层 | 各框架自带的读写分离配置 | 小 | 一般不支持 | 已有框架时最省事 |
需要特别提醒:历史上 PHP 生态有一个mysqlnd_ms扩展能做读写分离,但它早已停止维护,不支持 PHP 8,不要在新项目里使用。同理,mysqlnd的mysqlnd_mysql系列插件也不要在 PHP 8.5 上考虑。
二、ProxySQL 的配置
ProxySQL 是一个以 MySQL 协议对外服务的代理,PHP 连它就像连普通 MySQL。默认端口有两个:业务端口 6033,管理端口 6032。
先定义后端服务器组:hostgroup_id = 10放主库,20放从库。max_replication_lag是 ProxySQL 的杀手级功能——延迟超过这个秒数的从库会被自动摘掉,这是解决"写入后立刻读不到"最有效的手段。
-- 在 6032 管理端口执行 INSERT INTO mysql_servers(hostgroup_id, hostname, port, weight, max_replication_lag) VALUES (10, '10.0.0.11', 3306, 1, 0), (20, '10.0.0.12', 3306, 1, 3), (20, '10.0.0.13', 3306, 1, 3); LOAD MYSQL SERVERS TO RUNTIME; SAVE MYSQL SERVERS TO DISK;接着配置路由规则。match_digest匹配的是参数归一化之后的 SQL 摘要(比如SELECT * FROM t WHERE id = 1会变成SELECT * FROM t WHERE id = ?),所以一条规则能覆盖同类查询。顺序很重要,rule_id小的先匹配。
INSERT INTO mysql_query_rules(rule_id, active, match_digest, destination_hostgroup, apply) VALUES -- 1. 带 FOR UPDATE 的读必须走主库 (1, 1, '^SELECT.*FOR UPDATE$', 10, 1), -- 2. 事务控制语句交给 ProxySQL 自己按下面的事务规则处理 (2, 1, '^BEGIN|^START TRANSACTION', 10, 0), -- 3. 写入走主库 (3, 1, '^(INSERT|UPDATE|DELETE|REPLACE|CREATE|ALTER|DROP|TRUNCATE)', 10, 1), -- 4. 其余 SELECT 走从库 (4, 1, '^SELECT', 20, 1); LOAD MYSQL QUERY RULES TO RUNTIME; SAVE MYSQL QUERY RULES TO DISK;最后确认 ProxySQL 能感知主从拓扑:配好mysql_replication_hostgroups后,它会读从库的read_only变量,自动把主库放进写组、从库放进读组。
INSERT INTO mysql_replication_hostgroups(writer_hostgroup, reader_hostgroup, comment) VALUES (10, 20, 'writer/reader'); LOAD MYSQL SERVERS TO RUNTIME;PHP 侧只需要把连接指向 ProxySQL 的 6033 端口,其余不变:
<?php declare(strict_types=1); // 需要 PHP 8.1+,在 PHP 8.5 上可直接运行 $dsn = 'mysql:host=10.0.0.10;port=6033;dbname=shop;charset=utf8mb4'; try { $pdo = new PDO($dsn, 'app_rw', getenv('DB_PASSWORD') ?: '', [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // 关键:关掉模拟预处理,把参数真正交给 MySQL 侧预处理 PDO::ATTR_EMULATE_PREPARES => false, ]); echo "connected: " . $pdo->query('SELECT VERSION()')->fetchColumn() . PHP_EOL; } catch (PDOException $e) { // ProxySQL 连接失败时,报错信息里通常带 "ProxySQL" 字样 fwrite(STDERR, 'connect failed: ' . $e->getMessage() . PHP_EOL); exit(1); }三、事务里的读写必须落在同一个组
ProxySQL 有一条容易忽略的默认行为:事务开始后,同一条连接上的所有语句都会发到同一个 hostgroup,直到COMMIT/ROLLBACK。这对"事务里先写后读"是救命的,但前提是你的事务确实走了代理。
在 PHP 里最常见的事故是这样写的:
<?php declare(strict_types=1); // ❌ 错误:用两个不同的连接对象,事务形同虚设 $writeDb = new PDO('mysql:host=10.0.0.10;port=6033;dbname=shop', 'app', $pwd); $readDb = new PDO('mysql:host=10.0.0.10;port=6033;dbname=shop', 'app', $pwd); $writeDb->beginTransaction(); $writeDb->exec("INSERT INTO orders (sn, amount) VALUES ('S001', 100)"); $writeDb->commit(); // 这里可能读不到刚插入的行:命中了从库,且从库还没同步完 $row = $readDb->query("SELECT * FROM orders WHERE sn = 'S001'")->fetch(); var_dump($row); // 可能是 false正确做法有两个层次。第一层:事务内始终复用同一个 PDO 连接。第二层:对"写后必须立刻读"的场景,用SELECT ... FOR UPDATE或者显式指向主库——ProxySQL 的第一条规则已经覆盖了FOR UPDATE。
<?php declare(strict_types=1); $pdo->beginTransaction(); try { $pdo->exec("INSERT INTO orders (sn, amount) VALUES ('S001', 100)"); // FOR UPDATE 会命中规则 1,强制走主库,保证读到刚写入的数据 $stmt = $pdo->query("SELECT * FROM orders WHERE sn = 'S001' FOR UPDATE"); $row = $stmt->fetch(); $pdo->commit(); } catch (Throwable $e) { $pdo->rollBack(); throw $e; }四、应用层方案:自己做一个最小路由器
如果不方便部署代理,也可以做应用层路由。下面这段是完整可运行的实现(用 SQLite 演示逻辑,换成 MySQL 只改 DSN),核心是写操作后自动"钉"住主库若干毫秒,遮住复制延迟:
<?php declare(strict_types=1); // 需要 PHP 8.1+ final class ReadWriteRouter { private ?PDO $master = null; private ?PDO $replica = null; /** 主库钉住到期的微秒时间戳 */ private float $pinnedUntil = 0.0; public function __construct( private readonly string $masterDsn, private readonly string $replicaDsn, private readonly string $user, private readonly string $password, private readonly float $pinSeconds = 0.5, ) {} private function connect(string $dsn): PDO { return new PDO($dsn, $this->user, $this->password, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_EMULATE_PREPARES => false, ]); } public function master(): PDO { return $this->master ??= $this->connect($this->masterDsn); } /** 读:默认走从库,处于"钉住窗口"内则走主库 */ public function read(): PDO { if (microtime(true) < $this->pinnedUntil) { return $this->master(); } return $this->replica ??= $this->connect($this->replicaDsn); } /** 写:走主库,并把后续读钉在主库上一小段时间 */ public function write(string $sql, array $params = []): PDOStatement { $stmt = $this->master()->prepare($sql); $stmt->execute($params); $this->pinnedUntil = microtime(true) + $this->pinSeconds; return $stmt; } } // ---- 演示 ---- $file = sys_get_temp_dir() . '/rw_demo.sqlite'; @unlink($file); $router = new ReadWriteRouter('sqlite:' . $file, 'sqlite:' . $file, '', ''); $router->write('CREATE TABLE orders (id INTEGER PRIMARY KEY, sn TEXT)'); $router->write('INSERT INTO orders (sn) VALUES (?)', ['S001']); $row = $router->read()->query("SELECT sn FROM orders")->fetch(PDO::FETCH_ASSOC); echo 'read after write: ' . ($row['sn'] ?? 'NULL') . PHP_EOL; $rows = array_map( fn(array $r): string => $r['sn'], $router->read()->query('SELECT sn FROM orders')->fetchAll(PDO::FETCH_ASSOC) ); echo 'all orders: ' . implode(',', $rows) . PHP_EOL; unlink($file);输出:
read after write: S001 all orders: S001这段代码里有两点值得注意。构造函数属性提升(Constructor Property Promotion,PHP 8.0)配合readonly(PHP 8.1)让依赖声明很干净;??=保证了惰性连接,读请求不会白白建立一个主库连接——在生产上,这个细节能显著降低主库的连接数。
常见坑点
1. 拿READ COMMITTED之外的事务隔离级别去猜延迟
❌ 认为"事务提交后从库马上就有了",于是写入后立刻在另一个连接上读 ✅ 写完后的关键读走主库(FOR UPDATE或钉住窗口),别靠猜
2. 长连接不设wait_timeout,被中间件或 MySQL 掐断
❌ 用 PHP-FPM 常驻进程 + 默认 PDO 长连接,夜间空闲后第一个请求报2006 gone away✅ 显式设置PDO::ATTR_PERSISTENT时同步设置wait_timeout,或不用持久连接
3.max_replication_lag设成 0,从库全被摘掉
❌max_replication_lag = 0被理解为"不检查",实际会摘掉任何有一丁点延迟的从库,读流量全压主库 ✅ 按业务容忍度设成 2~5 秒;确认主库从库时间同步
4. 规则只匹配 SQL 前缀,WITH/注释开头的查询漏网
❌ 只写^SELECT,于是/* comment */ SELECT ...和WITH ... SELECT命中不了任何规则,走了默认组 ✅ 规则里先剥离注释和空白前缀,或补^\s*/\*与^WITH的规则
5. 写操作用了SELECT开头的存储过程或SELECT ... INTO
❌ 认为"SELECT 一定是读",把SELECT ... FOR UPDATE以外带副作用的 SELECT 都发到从库 ✅ 明确识别写语句,规则 1 里优先匹配FOR UPDATE/LOCK IN SHARE MODE
6. 从库配置只读但应用仍然连它写
❌ 负载均衡把 3306 直连从库当同构节点用 ✅ 从库开read_only=ON并让中间件通过它自动分组
7. 用LAST_INSERT_ID()却换了连接
❌ 写入用连接 A,紧接着用连接 B 执行SELECT LAST_INSERT_ID()✅lastInsertId()必须在同一个连接(PDO 实例)上调用
8. 不关PDO::ATTR_EMULATE_PREPARES,参数在客户端拼接
❌ 走代理时开着模拟预处理,SQL 摘要里混入字面量,match_digest规则失配 ✅PDO::ATTR_EMULATE_PREPARES => false,让参数真正以占位符形式到达 MySQL
总结
| 关注点 | 推荐配置 | 不配的后果 |
|---|---|---|
| 读写分流 | 代理层按match_digest分流 | 应用层到处硬编码,容易漏 |
| 复制延迟 | max_replication_lag2~5 秒 | 写完读不到,脏数据投诉 |
| 事务一致性 | 事务复用同一 PDO,关键读走主库 | 事务形同虚设,读到旧数据 |
| 连接稳定 | 关模拟预处理 + 合理wait_timeout | 2006 gone away频繁出现 |
| 从库保护 | read_only=ON | 写流量误打从库,主从数据分叉 |
读写分离不是一个"装上就完事"的开关,它的核心是为一致性划清边界:哪些读可以忍受延迟,哪些读必须回主库。把max_replication_lag和事务复用这两点做扎实,90% 的"写入查不到"问题会自动消失。