如何为PHP项目实现读写分离?

wen PHP项目 1

本文目录导读:

如何为PHP项目实现读写分离?

  1. 方案一:基于框架的路由(推荐,最常用)
  2. 方案二:使用数据库中间件(高并发、大规模场景)
  3. 方案三:手动连接切换(最底层,不推荐)
  4. 关键问题:主从延迟与“写后立即读”
  5. 总结建议

为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 门面在执行查询时,会自动判断:
    • 如果执行 selectfirstget 等读操作,则从 read 数组中选取一个主机连接。
    • 如果执行 insertupdatedelete 等写操作,则连接 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);
    }
}
  • 缺点:麻烦、容易出错、难以维护、不能利用连接池。

关键问题:主从延迟与“写后立即读”

这是读写分离最大的坑,用户写入数据后,可能立刻查询,但数据还未同步到从库(延迟可能长则几秒),解决方案:

  1. 强制读主库(推荐)

    • 对于对一致性要求极高的操作(如支付成功后的订单状态查询),可以通过框架的API强制使用写库连接。
    • LaravelDB::connection('write')->select('...');
    • 通用:在业务代码里加判断,if ($needFreshData) { $db = getMaster(); } else { $db = getSlave(); }
  2. 缓存标记

    • 写入主库后,在缓存(Redis)中记录一个标记(如 user_123_updated_time),读从库前检查标记,如果标记时间很新,则等待或读主库。
  3. 等待从库同步(半同步复制)

    配置 MySQL 半同步复制,确保主库提交后,至少有一个从库收到数据日志,但这会增加写入延迟,不是所有场景都适用。

  4. GTID 与 waits

    • 在从库查询前,可以执行 WAIT_UNTIL_SQL_THREAD_AFTER_GTIDS,等待指定GTID(全局事务标识)执行完毕,但会增加从库的查询延迟,性能较差。

总结建议

阶段/场景 推荐方式 原因
小型项目/刚起步 不实现读写分离 单库足够,增加复杂度反而降低效率。
中小型项目/已有框架 框架内置配置(方案一) 最简单、侵入低、快速,记得处理“写后立即读”。
中型项目/高并发 ProxySQL(方案二) 对应用完全透明,运维方便,支持动态调整。
大型项目/微服务 数据库中间件 + 业务层分离 不同服务可能连接不同数据库实例。

最后建议

  • 不要过早优化:如果单库 QPS(每秒查询率)低于 2000-3000,读写分离带来的收益有限,而复杂度显著增加。
  • 做好监控:无论是哪种方案,都需要监控主从延迟(SHOW SLAVE STATUS 中的 Seconds_Behind_Master),设置告警阈值。
  • 安全考虑:从库通常用只读账号,权限最小化。

抱歉,评论功能暂时关闭!