直接手动硬编码切主从连接会出问题,因事务中混用从库导致查不到新数据、读操作误走主库破坏负载均衡、orm隐式写操作无法预判;可靠方案是让代理类基于sql语义(insert/update/delete/事务状态)自动路由,统一管理连接与状态。

为什么直接在代码里手动切主从连接会出问题
硬编码判断 $isWrite 然后选 $master 或 $slave,短期看着行,但很快会踩坑:事务中混用从库连接导致查不到刚写入的数据;读操作被路由到主库却没走事务上下文,破坏负载均衡;更麻烦的是,ORM 或查询构建器(比如 Laravel 的 DB::table())内部可能隐式执行写操作(如 insertGetId()),你根本没法提前预判。
真正可靠的读写分离不是“让开发者决定读还是写”,而是让连接池和执行上下文自己感知语义。核心原则只有一条:所有显式开启的事务、INSERT/UPDATE/DELETE 语句、以及 LAST_INSERT_ID() 这类依赖主库状态的调用,必须走主库;其余 SELECT 默认走从库。
用 PDO + 代理类封装主从连接池
不改原有数据库调用方式,而是把 PDO 实例包装进一个 DBProxy 类,让它接管 query()、prepare()、beginTransaction() 等方法:
class DBProxy
{
private $master;
private $slaves = [];
private $inTransaction = false;
private $lastWrite = false;
public function __construct($masterDsn, array $slaveDsns)
{
$this->master = new PDO($masterDsn, $user, $pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
foreach ($slaveDsns as $dsn) {
$this->slaves[] = new PDO($dsn, $user, $pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
}
}
public function query($sql, $params = [])
{
$pdo = $this->resolveConnection($sql);
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
return $stmt;
}
private function resolveConnection($sql)
{
// 显式事务期间一律走主库
if ($this->inTransaction) {
return $this->master;
}
// 写操作关键词检测(简单但有效)
$sql = trim(strtoupper($sql));
if (str_starts_with($sql, 'INSERT ') || str_starts_with($sql, 'UPDATE ') ||
str_starts_with($sql, 'DELETE ') || str_starts_with($sql, 'REPLACE ') ||
str_starts_with($sql, 'CALL ')) {
$this->lastWrite = true;
return $this->master;
}
// SELECT 且非事务中 → 轮询从库
if (str_starts_with($sql, 'SELECT ')) {
return $this->slaves[array_rand($this->slaves)];
}
// 其他语句(如 SET、SHOW、BEGIN)统一走主库
return $this->master;
}
}
注意点:
-
str_starts_with()是 PHP 8+ 函数,若用 PHP 7.x,请改用substr($sql, 0, 6) === 'SELECT' - 不要依赖 SQL 注释或空格来判断类型,
SELECT /*+ slave */ ...这种“提示”容易被 ORM 重写或忽略,不可靠 - 这个代理不处理连接失败重试,生产环境需加
try/catch+ 降级逻辑(比如从库全挂时自动切主库读)
事务边界必须由代理统一管理
如果允许用户直接调用 $pdo->beginTransaction(),代理就失去控制权。所以 DBProxy 必须暴露自己的事务方法,并在内部标记状态:
public function beginTransaction()
{
$this->inTransaction = true;
return $this->master->beginTransaction();
}
public function commit()
{
$result = $this->master->commit();
$this->inTransaction = false;
$this->lastWrite = false;
return $result;
}
public function rollback()
{
$result = $this->master->rollback();
$this->inTransaction = false;
$this->lastWrite = false;
return $result;
}
关键细节:
- 事务中所有语句都必须发往同一个连接(即主库),否则会报错
SQLSTATE[HY000]: General error: 2014 Cannot execute queries while other unbuffered queries are active - 不能在事务中途切换连接,哪怕只是想查个配置表也不行 —— 这是 MySQL 协议限制,不是代理能绕过的
- 代理要主动清掉
$this->lastWrite标志,否则一次写操作后后续所有 SELECT 都被误判为需主库
如何兼容现有 PDO 使用习惯而不改业务代码
最轻量的做法是把 DBProxy 实现为 PDO 的外观(Facade),而不是继承 —— 因为 PDO 是 final 类,无法继承。但你可以让框架容器绑定它为 PDO 类型别名(Laravel 示例):
// 在服务提供者中
$this->app->singleton(PDO::class, function ($app) {
return new DBProxy(
config('database.connections.mysql.master'),
config('database.connections.mysql.slaves')
);
});
这样所有 type-hint PDO 的地方(比如 Repository 构造函数)都能自动注入代理实例,业务层完全无感。但要注意:
- 不能直接调用
PDO::getAttribute()等底层方法,代理需按需透传 - 原生
PDOStatement返回值无法被代理拦截,所以query()和prepare()必须返回自定义 Statement 包装类,才能继续控制 fetch 行为 - 如果项目用了 Doctrine DBAL 或 Laravel Query Builder,它们有自己的连接抽象层,此时应优先适配其接口(如实现
Doctrine\DBAL\Driver\Connection),而非强行塞进 PDO 代理
INSERT 让 $lastWrite 一直为 true,或者事务没正确 commit 导致 $inTransaction 滞留。这些状态必须严格与实际执行流对齐,不能靠“大概率正确”。php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











