本文目录导读:

为PHP项目实现读写分离,通常是为了提升数据库的并发处理能力,核心思想是将写操作(INSERT、UPDATE、DELETE) 发送到主库(Master),将读操作(SELECT) 发送到从库(Slave)。
以下是几种主流且实用的实现方案,从简单到复杂排序:
基于框架的路由(推荐,最常用)
大多数现代PHP框架(如Laravel、Symfony、ThinkPHP、Yii2)都内置了对读写分离的支持,这是最优雅、侵入性最低的方式。
Laravel 示例
Laravel 的数据库配置文件 config/database.php 中支持定义多个读/写连接。
// config/database.php
'mysql' => [
'read' => [
'host' => [
'192.168.1.2', // 从库IP
'192.168.1.3', // 另一个从库(支持负载均衡)
],
// 可选:读库的数据库名、用户名、密码(如果不设置,默认使用主库的)
'database' => 'your_database',
'username' => 'readonly_user',
'password' => 'readonly_pass',
],
'write' => [
'host' => [
'192.168.1.1', // 主库IP
],
],
'driver' => 'mysql',
'database' => 'your_database',
'username' => 'write_user',
'password' => 'write_pass',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
// ...
],
- 工作原理:Laravel 的 Eloquent ORM 或 DB 门面在执行查询时,会自动判断:
- 如果执行
select、first、get等读操作,则从read数组中选取一个主机连接。 - 如果执行
insert、update、delete等写操作,则连接write数组中的主机。
- 如果执行
- 注意:Laravel 会维护同一个请求内的单例连接,如果你在写操作后立即读,Laravel 默认不会保证读到的数据是最新的(主从延迟问题),解决方案见下文。
ThinkPHP 示例
ThinkPHP 6.0+ 在数据库配置中支持分布式数据库。
// config/database.php
return [
// 默认连接
'default' => env('database.driver', 'mysql'),
'connections' => [
'mysql' => [
'type' => 'mysql',
'hostname' => '192.168.1.1', // 主库
'database' => 'your_database',
'username' => 'write_user',
'password' => 'write_pass',
// 读写分离配置
'read_hostname' => ['192.168.1.2', '192.168.1.3'], // 从库列表
// 可选:从库用户名密码不同时设置
'read_username' => 'readonly_user',
'read_password' => 'readonly_pass',
// ...
],
],
];
- 工作原理:TP 的查询构造器会自动根据 SQL 语句类型路由到不同主机。
使用数据库中间件(高并发、大规模场景)
当应用服务器很多(几十台以上),或者需要更精细的流量控制和HA(高可用)时,中间件是最佳选择,对应用层完全透明。
ProxySQL(推荐)
-
原理:运行在应用服务器和MySQL之间,作为一个代理层,应用连接ProxySQL(就像连接MySQL一样),ProxySQL根据SQL语句或连接属性将请求转发给后端主/从库。
-
优点:无需修改任何PHP代码;支持连接池、缓存、熔断;支持自动检测主库故障并切换。
-
架构:
PHP App->ProxySQL->MySQL Master/Slave(s) -
配置片段:
-- 在ProxySQL管理界面执行 INSERT INTO mysql_servers (hostgroup_id, hostname, port) VALUES (0, '192.168.1.1', 3306); -- 主库组 INSERT INTO mysql_servers (hostgroup_id, hostname, port) VALUES (1, '192.168.1.2', 3306); -- 从库组 INSERT INTO mysql_query_rules (rule_id, active, match_pattern, destination_hostgroup, apply) VALUES (1, 1, '^SELECT', 1, 1); -- SELECT走从库 INSERT INTO mysql_query_rules (rule_id, active, match_pattern, destination_hostgroup, apply) VALUES (2, 1, '^INSERT|^UPDATE|^DELETE', 0, 1); -- 写操作走主库
MySQL Router
- MySQL官方提供的轻量级中间件,常用于InnoDB Cluster,功能相对ProxySQL简单,主要做自动路由。
手动连接切换(最底层,不推荐)
如果项目没有使用框架,或者框架不支持,可以手动封装一个数据库操作类。
class DB {
private static $master = null; // 主库连接
private static $slave = null; // 从库连接
public static function connect($isWrite = true) {
if ($isWrite) {
if (self::$master === null) {
self::$master = new mysqli('主库IP', 'user', 'pass', 'db');
}
return self::$master;
} else {
if (self::$slave === null) {
// 可以随机选择一个从库
$slaves = ['192.168.1.2', '192.168.1.3'];
self::$slave = new mysqli($slaves[array_rand($slaves)], 'read_user', 'pass', 'db');
}
return self::$slave;
}
}
public static function query($sql) {
$isWrite = !preg_match('/^SELECT/i', trim($sql));
$conn = self::connect($isWrite);
return $conn->query($sql);
}
}
- 缺点:麻烦、容易出错、难以维护、不能利用连接池。
关键问题:主从延迟与“写后立即读”
这是读写分离最大的坑,用户写入数据后,可能立刻查询,但数据还未同步到从库(延迟可能长则几秒),解决方案:
-
强制读主库(推荐):
- 对于对一致性要求极高的操作(如支付成功后的订单状态查询),可以通过框架的API强制使用写库连接。
- Laravel:
DB::connection('write')->select('...'); - 通用:在业务代码里加判断,
if ($needFreshData) { $db = getMaster(); } else { $db = getSlave(); }
-
缓存标记:
- 写入主库后,在缓存(Redis)中记录一个标记(如
user_123_updated_time),读从库前检查标记,如果标记时间很新,则等待或读主库。
- 写入主库后,在缓存(Redis)中记录一个标记(如
-
等待从库同步(半同步复制):
配置 MySQL 半同步复制,确保主库提交后,至少有一个从库收到数据日志,但这会增加写入延迟,不是所有场景都适用。
-
GTID 与 waits:
- 在从库查询前,可以执行
WAIT_UNTIL_SQL_THREAD_AFTER_GTIDS,等待指定GTID(全局事务标识)执行完毕,但会增加从库的查询延迟,性能较差。
- 在从库查询前,可以执行
总结建议
| 阶段/场景 | 推荐方式 | 原因 |
|---|---|---|
| 小型项目/刚起步 | 不实现读写分离 | 单库足够,增加复杂度反而降低效率。 |
| 中小型项目/已有框架 | 框架内置配置(方案一) | 最简单、侵入低、快速,记得处理“写后立即读”。 |
| 中型项目/高并发 | ProxySQL(方案二) | 对应用完全透明,运维方便,支持动态调整。 |
| 大型项目/微服务 | 数据库中间件 + 业务层分离 | 不同服务可能连接不同数据库实例。 |
最后建议:
- 不要过早优化:如果单库 QPS(每秒查询率)低于 2000-3000,读写分离带来的收益有限,而复杂度显著增加。
- 做好监控:无论是哪种方案,都需要监控主从延迟(
SHOW SLAVE STATUS中的Seconds_Behind_Master),设置告警阈值。 - 安全考虑:从库通常用只读账号,权限最小化。