ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

PHP8.5怎么配置数据库读写分离中间件

PHP8.5怎么配置数据库读写分离中间件 前言读写分离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.52025 年 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_types1); // 需要 PHP 8.1在 PHP 8.5 上可直接运行 $dsn mysql:host10.0.0.10;port6033;dbnameshop;charsetutf8mb4; 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_types1); // ❌ 错误用两个不同的连接对象事务形同虚设 $writeDb new PDO(mysql:host10.0.0.10;port6033;dbnameshop, app, $pwd); $readDb new PDO(mysql:host10.0.0.10;port6033;dbnameshop, 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_types1); $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_types1); // 需要 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 PromotionPHP 8.0配合readonlyPHP 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 MODE6. 从库配置只读但应用仍然连它写❌ 负载均衡把 3306 直连从库当同构节点用 ✅ 从库开read_onlyON并让中间件通过它自动分组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_timeout2006 gone away频繁出现从库保护read_onlyON写流量误打从库主从数据分叉读写分离不是一个装上就完事的开关它的核心是为一致性划清边界哪些读可以忍受延迟哪些读必须回主库。把max_replication_lag和事务复用这两点做扎实90% 的写入查不到问题会自动消失。
返回列表