本文目录导读:

在PHP项目中优化数据库读写分离,核心思路是将读操作(SELECT)和写操作(INSERT/UPDATE/DELETE)分发到不同的数据库服务器上,以减轻主库压力、提高系统并发能力。
以下是具体的优化方案和最佳实践,从架构、实现、到监控层面逐步深入。
选择合适的读写分离架构
| 架构方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 应用层分离(代码层) | 中小型项目、单数据库 | 实现简单、无额外组件 | 代码侵入性强、维护成本高、难以应对复杂路由 |
| 中间件层分离(Proxy) | 高并发、大型项目 | 对应用透明、功能强大(如负载均衡、故障转移、SQL审计) | 引入额外网络延迟、运维复杂度高 |
| ORM/框架层分离 | 使用ORM框架(如Laravel/Lumen、ThinkPHP、Yii) | 低侵入性、配置简单、与框架深度集成 | 依赖于框架生态,扩展性受限 |
推荐方案:
- 小项目/快速迭代:使用ORM框架自带读写分离(如Laravel
read/write多个连接池)。 - 中大型/高可用要求:使用数据库中间件(ProxySQL / MyCat / ShardingSphere + Atlas)。
核心优化策略
1 主从复制延迟处理(最常见的坑)
现象:刚写入的数据(如发表评论、下单),用户立即刷新页面却读不到。
解决方案:
| 策略 | 实现方式 | 适用场景 |
|---|---|---|
| 强制读主(请求级别) | 对于关键操作(如支付回调、用户注册后跳转),标记本次请求全部走主库。 | 核心业务链路,需要强一致性 |
| 等待主从同步(半同步复制 + 自适应) | 开启MySQL半同步复制(rpl_semi_sync_master_wait_point=AFTER_SYNC),减少延迟概率;或查询主从延迟时间,若>阈值则读主。 |
对延迟敏感但无法改造代码时 |
| 降级方案:写入后携带version | 写成功后返回一个版本号/时间戳,读请求携带该值,如果从库数据版本过低则等待或读主。 | 需要强一致性缓存场景 |
代码示例(Laravel):
// 在写操作后强制该请求后续的读操作走主库
DB::transaction(function () {
// 写入操作
$order = Order::create([...]);
// 标记该请求后续读走主库
request()->request->set('_force_master', true);
});
// 在查询时判断
if (request()->has('_force_master')) {
// 强制使用 'mysql' 连接(主库)
$order = Order::on('mysql')->find($order->id);
} else {
$order = Order::find($order->id); // 默认走从库
}
2 连接池管理
- 短连接不要太高频:每个请求都建立新连接会压垮数据库。
- 使用持久连接:PHP-FPM模式下,在
php.ini中开启pdo_mysql.allow_persistent=On,或在框架中配置连接池(如Swoole/Hyperf常驻内存模式下用连接池)。 - 中间件ProxySQL自带连接池:可复用TCP连接,减少握手开销。
3 读写分离的路由规则优化
- 基于SQL类型:
SELECT走从库,INSERT/UPDATE/DELETE走主库(常规)。 - 基于事务:事务内的所有SQL(包括SELECT)必须走主库,避免读取未提交或已回滚的数据。
- 基于SQL注释/参数:中间件可识别
/*FORCE_MASTER*/注释,精细控制路由。
配置示例(ProxySQL规则):
# 给所有 SELECT 打上标签(除非显式指定走主) INSERT INTO mysql_query_rules (rule_id, active, match_digest, destination_hostgroup, apply) VALUES (100, 1, '^SELECT', 1, 1); # hostgroup 1 = 从库组 # 事务内的查询(已有BEGIN)强制走主 INSERT INTO mysql_query_rules (rule_id, active, match_digest, destination_hostgroup, apply) VALUES (200, 1, '^SELECT.*FORCE_MASTER', 0, 1); # hostgroup 0 = 主库组
4 缓存兜底(降低读库压力)
即使做了读写分离,读库也容易被热点数据(如首页、热门文章)打爆。
- 本地缓存:Redis/Memcached + PHP序列化。
- 二级缓存:数据库查询结果缓存到Redis,TTL设置合理。
- 主动失效:写操作后主动删除对应缓存键。
伪代码:
function getTrendingArticles() {
$key = 'articles:trending';
// 1. 先查Redis
if ($data = Redis::get($key)) return json_decode($data, true);
// 2. 从从库查询(避免主库压力)
$articles = DB::connection('read')->table('articles')->where(...)->get();
// 3. 写入缓存(过期时间60秒)
Redis::setex($key, 60, json_encode($articles));
return $articles;
}
监控与调优(持续优化)
| 监控维度 | 关键指标 |
|---|---|
| 主从延迟 | Seconds_Behind_Master(MySQL命令) + 主从心跳表延迟(精确到毫秒) |
| 分库命中率 | 读写分离规则是否正确命中(如ProxySQL的stats_mysql_query_rules) |
| 连接数 | 主库 vs 从库的连接数占比,避免从库连接过多导致资源耗尽 |
| 慢查询 | 读写分离后慢查询可能集中在从库(因查询量增大),需单独优化索引 |
推荐工具:
- Prometheus + Grafana 监控主从延迟、QPS。
- ProxySQL Stats:检查规则命中次数、执行计划。
常见避坑指南
-
不要所有SELECT都走从库:
- 事务内的SELECT必须主库。
- 锁定读(
SELECT ... FOR UPDATE/SELECT ... LOCK IN SHARE MODE)必须主库。
-
避免从库被写操作污染:
配置MySQL用户权限,从库只允许SELECT。
-
长连接 vs 短连接:
PHP-FPM模式下,每个请求创建新连接是短连接,建议用连接池中间件(如ProxySQL/PgBouncer)复用连接。
-
分库后全局逻辑:
如果有多个主从集群(分库分表+读写分离),路由规则要同时考虑分库键和读写分离:先根据分库键路由到对应集群,再根据读写规则分发到主/从。
完整实现示例(基于ThinkPHP 6 + 原生配置)
数据库配置(database.php):
// 主库
'connections' => [
'mysql' => [
'hostname' => '主库IP',
'database' => 'test',
'username' => 'write_user',
'password' => 'xxx',
],
// 从库(支持多个,随机或轮询)
'mysql_read1' => [
'hostname' => '从库1IP',
'database' => 'test',
'username' => 'read_user',
'password' => 'xxx',
'weight' => 1,
],
'mysql_read2' => [
'hostname' => '从库2IP',
'database' => 'test',
'username' => 'read_user',
'password' => 'xxx',
'weight' => 2, // 权重越高,分配的请求越多
],
],
读写分离中间件(自定义):
class ReadWriteMiddleware
{
public function handle($request, \Closure $next)
{
// 在请求开始时,将读写分离规则挂载到Db类
\think\facade\Db::setConfig([
'default' => 'mysql', // 主库
'query' => [
// 当执行SELECT时,自动路由到从库
'read' => ['mysql_read1', 'mysql_read2'],
],
]);
return $next($request);
}
}
注意:更推荐直接使用框架自带的读写分离配置(ThinkPHP的connections里配read数组),能减少自定义中间件的维护成本。
最终建议
| 场景 | 推荐方案 |
|---|---|
| 代码量小、性能要求不高 | ORM自带分离 + Redis缓存 |
| 中等规模、团队成熟 | 轻量级中间件(ProxySQL)+ 半同步复制 |
| 高并发、高可用 | 商用中间件(ShardingSphere)+ 一主多从 + 分库分表 + 缓存层 |
最后提醒:读写分离不能替代索引优化和SQL优化,如果读库压力依然很大,先从慢查询日志找根本问题(往往是全表扫描或未命中索引),再考虑增加从库或引入缓存。