业务发展起来之后,数据库压力往往呈现一个共同特征:读多写少。一个电商系统里,浏览商品、查询订单、加载列表的读请求,可能比下单、改资料的写请求多出十倍甚至几十倍。如果所有请求都压在一个 MySQL 实例上,实例的 CPU 和 IO 会被读请求吃掉,写入反而得不到保障。这时候一个自然的想法就是把读请求分散到多个副本上——这就是读写分离。
这篇文章讲清楚读写分离的完整链路:它依赖什么、有哪几种实现方式、负载怎么分、延迟这个绕不开的问题怎么处理,以及它到底能解决什么、不能解决什么。文中命令基于常见 MySQL 5.7 与 8.0,涉及具体中间件时只讲思路,不编造版本细节,实际以官方文档为准。
读写分离的前提:主从复制
读写分离不是凭空实现的,它的地基是主从复制。基本角色是两个:主库(Master)负责处理写请求并把变更记录下来,从库(Slave 或 Replica)从主库同步这些变更并应用到自己的数据上。之后把读请求路由到从库,写请求仍然走主库。
主从复制的基本原理
MySQL 的主从复制核心是三样东西。主库把数据变更按顺序写入二进制日志(binlog);从库通过一个 IO 线程连到主库,读取 binlog 并写入自己的中继日志(relay log);从库再由一个 SQL 线程读取中继日志并在本地重放,从而与主库保持一致。
主库还需要一个二进制日志转储线程,负责把 binlog 事件发给各个从库。这三个线程协作,就完成了数据的异步流动。
开启复制的基本步骤
先确认主库配置。以下示例仅作演示,生产环境务必先备份、再变更、并安排维护窗口。
# 主库 /etc/my.cnf(RHEL/CentOS/Rocky)或 /etc/mysql/my.cnf(Debian/Ubuntu)的 [mysqld] 段
[mysqld]
server-id = 1
log-bin = mysql-bin
binlog_format = ROW
gtid_mode = ON
enforce_gtid_consistency = ON
修改配置后需要重启 MySQL,重启属于高风险操作,务必在维护窗口进行并做好回滚准备。
# RHEL/CentOS/Rocky
sudo systemctl restart mysqld
# Debian/Ubuntu
sudo systemctl restart mysql
然后在主库创建复制账号并查看状态:
mysql -uroot -p -e "CREATE USER 'repl'@'%' IDENTIFIED BY '强密码请自行替换';"
mysql -uroot -p -e "GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';"
mysql -uroot -p -e "FLUSH PRIVILEGES;"
mysql -uroot -p -e "SHOW MASTER STATUS\G"
在从库配置 server-id(必须与主库不同)后,用 GTID 方式建立复制:
mysql -uroot -p -e "CHANGE MASTER TO
MASTER_HOST='主库地址',
MASTER_USER='repl',
MASTER_PASSWORD='强密码',
MASTER_AUTO_POSITION=1;"
mysql -uroot -p -e "START SLAVE;"
mysql -uroot -p -e "SHOW SLAVE STATUS\G"
SHOW SLAVE STATUS 里要重点看 Slave_IO_Running 和 Slave_SQL_Running 是否都是 Yes,以及 Seconds_Behind_Master(MySQL 8.0 中语义有变化,可结合 performance_schema.replication_applier_status_by_worker 更精确判断)反映的延迟。MySQL 8.0 起官方推荐用 CHANGE REPLICATION SOURCE TO 和 START REPLICA 等新语法,旧语法仍然可用。
复制模式:异步、半同步、组复制
不同的复制模式对数据安全和延迟的权衡不同。
| 模式 | 主库是否等从库确认 | 数据安全性 | 延迟 | 适用场景 |
|---|---|---|---|---|
| 异步复制 | 不等 | 主库宕机可能丢数据 | 最低 | 对一致性要求不高的读扩展 |
| 半同步复制 | 至少一个从库收到确认 | 较高 | 略高 | 需要兼顾安全与延迟 |
| 组复制(MGR) | 多数派确认 | 高 | 较高 | 对一致性要求高的生产库 |
半同步复制通过一个插件实现,主库在提交前等待至少一个从库确认已收到 binlog(注意默认是"收到"而非"应用完成"),能显著降低主库崩溃时的数据丢失风险,代价是写入延迟略增。
三种实现方式
知道主从复制的原理后,读写分离的落地方式主要有三种:代理层、应用层多数据源、以及驱动/SDK 方式。
代理层方案
代理层是在应用和 MySQL 之间放一个中间进程,应用认为自己在连一个普通 MySQL,由代理负责把写请求发到主库、读请求发到从库,并管理后端连接。ProxySQL、MyCat、ShardingSphere-Proxy 都属于这一类。
代理层的好处是对应用几乎透明,支持多语言,连接管理、负载均衡、故障摘除、查询规则都可以在代理层统一配置,不用改业务代码。代价是多了一跳网络,代理本身也可能成为瓶颈或单点,需要做高可用(通常部署两个代理加一个虚拟 IP 或负载均衡)。
以 ProxySQL 的管理接口为例,大致思路是这样的(具体语法以对应版本文档为准):
# 连接 ProxySQL 管理端口(默认 6032)
mysql -uadmin -padmin -h127.0.0.1 -P6032
-- 添加后端实例,hostgroup 10 作为写组,20 作为读组
INSERT INTO mysql_servers(hostgroup_id, hostname, port) VALUES (10, '主库IP', 3306);
INSERT INTO mysql_servers(hostgroup_id, hostname, port) VALUES (20, '从库1IP', 3306);
INSERT INTO mysql_servers(hostgroup_id, hostname, port) VALUES (20, '从库2IP', 3306);
-- 配置读写分离规则,读走 20 组,其余走 10 组
INSERT INTO mysql_query_rules(rule_id, active, match_pattern, destination_hostgroup)
VALUES (1, 1, '^SELECT.*FOR UPDATE$', 10);
INSERT INTO mysql_query_rules(rule_id, active, match_pattern, destination_hostgroup)
VALUES (2, 1, '^SELECT', 20);
-- 一定记得持久化并加载到运行时
LOAD MYSQL SERVERS TO RUNTIME;
LOAD MYSQL QUERY RULES TO RUNTIME;
SAVE MYSQL SERVERS TO DISK;
SAVE MYSQL QUERY RULES TO DISK;
这里有个细节:SELECT ... FOR UPDATE 必须走主库,因为它涉及当前读和加锁,走从库会破坏语义。所以规则里要把它单独拦出来。类似的还有事务中的读请求,通常也应该整段走主库。
应用层多数据源
应用层自己维护主库和从库两个数据源,通过注解、AOP 或拦截器判断当前操作是读还是写,选择对应数据源。Spring 生态里的 AbstractRoutingDataSource 就是这类思路的代表。
这种方式的优点是少一跳网络、控制灵活,可以结合业务语义做很细的路由,比如"下单后立刻查询订单"强制走主库。缺点是要侵入应用代码,多语言、多框架都要各自实现,配置分散,也容易因为判断逻辑写错而路由错误。
驱动/SDK 方式
介于两者之间的是各种客户端 SDK 和连接代理,比如数据库驱动自带的多主机支持、云厂商的读写分离地址。这类方案通常是"给一个读写分离的连接串,由驱动或云平台内部完成路由",对应用比较友好,但灵活性依赖具体产品能力。
负载分配策略
多个从库之间怎么分配读请求,也有讲究。
| 策略 | 说明 | 优点 | 缺点 |
|---|---|---|---|
| 轮询 | 依次分发到各从库 | 简单均匀 | 不考虑从库负载差异 |
| 加权轮询 | 按权重分配 | 可适配不同规格 | 需人工维护权重 |
| 最少连接 | 发给当前连接最少的从库 | 动态均衡 | 实现略复杂 |
| 延迟感知 | 优先发给延迟低的从库 | 缓解旧数据问题 | 需实时采集延迟 |
实践中最常见的是轮询或加权轮询。如果各从库规格一致、延迟接近,轮询就够用;如果从库有新旧和性能差异,加权或最少连接更合适。延迟感知能缓解"读到旧数据",但采集和判定本身有开销,且延迟指标可能抖动,需要配合其他手段一起用。
主从延迟与读到旧数据
读写分离最棘手的问题是主从延迟。写操作在主库提交后,binlog 传到从库再重放需要时间,这段时间里从库的数据是旧的。如果用户刚提交订单就立刻查询,而查询被路由到了还没同步完的从库,就会"查不到自己刚下的单"。
延迟的来源包括:从库压力过大导致 SQL 线程跟不上、主库大事务或大批量写入、从库上有慢查询或大 DDL、网络抖动、单线程重放瓶颈(即便多线程复制也有并发限制)。经验上,网络良好、配置合理时,延迟可以保持在毫秒到秒级;但如果从库被大量分析查询拖慢,延迟可能到分钟级甚至更久。
处理方式有几种。第一,强制关键读走主库,比如"写后立即读"的请求、事务内的读请求,通过代理规则或应用注解指定。第二,使用半同步复制,至少保证数据已经传到从库,减少主库宕机丢数据,但它并不能保证从库已经"应用"完。第三,对于 GTID 复制的场景,可以在读之前等待从库追上某个位点,也就是常说的"等待同步",牺牲一点延迟换取数据新鲜度。
-- 查看从库延迟相关状态
SHOW SLAVE STATUS\G
-- MySQL 8.0 可通过以下方式等待从库追到指定 GTID
SELECT WAIT_FOR_EXECUTED_GTID_SET('GTID集合字符串', 超时秒数);
还有一种务实做法是"按会话粘性":同一个用户会话在一段时间内的读请求都走同一从库,并在写后的一小段时间内强制走主库。不过这引入了状态管理,复杂度不低,要看业务是否值得。
代价与限制
读写分离不是万能的,它主要解决读压力,对写瓶颈几乎无能为力。如果瓶颈在主库的写入 IOPS 或锁竞争,加再多从库也没用,因为写还是只有一个主库。
其他限制还包括:主从延迟带来的数据新鲜度问题;从库会增加运维成本(监控、备份、故障处理都要翻倍);主从切换需要考虑数据一致性和连接迁移;从库上的查询如果太重,会拖慢复制线程,反过来加剧延迟。所以在做读写分离之前,先确认瓶颈确实是读,而不是慢 SQL、缺索引或锁竞争——后面这些用优化查询往往比加从库更划算。
监控方面,要盯住复制延迟、从库负载、代理层路由分布和主从数据一致性。
# 从库复制延迟(旧语法)
mysql -uroot -p -e "SHOW SLAVE STATUS\G" | grep -E "Running|Seconds_Behind"
# 查看主从 GTID 执行情况(MySQL 8.0)
mysql -uroot -p -e "SELECT @@gtid_executed, @@gtid_purged;"
常见问题
读写分离能解决写入瓶颈吗?
不能。写请求仍然只有主库处理,读写分离只是把读压力分散到从库。写入瓶颈要靠优化事务、加索引、分库分表或提升硬件来解决。
所有读请求都应该走从库吗?
不是。事务内的读、写后立即读、要求强一致的读,都建议走主库。误走从库可能因为延迟读到旧数据,破坏业务逻辑。
半同步复制能保证不丢数据吗?
半同步能降低主库崩溃丢数据的概率,但默认是"从库收到 binlog"就确认,不代表从库已经应用完。极端情况下仍有丢失窗口,高一致性要求可考虑组复制。
从库延迟很大怎么排查?
先看是 IO 线程慢还是 SQL 线程慢。如果 IO 线程正常而 SQL 线程落后,多半是从库执行压力大或有大事务;检查从库慢查询、是否有重查询、是否开了多线程复制。
代理层会不会成为单点?
会有这个风险。通常部署至少两个代理实例,用虚拟 IP、负载均衡或客户端连接串做故障转移,并监控代理自身的健康状态。
总结
- 读写分离的基础是主从复制,主库记录 binlog,从库通过 IO 线程和中继日志、SQL 线程重放。
- 复制模式有异步、半同步和组复制,异步延迟最低但可能丢数据,半同步和组复制更安全但延迟更高。
- 实现方式有代理层、应用层多数据源和驱动/SDK 三类,代理层对应用透明但多一跳,应用层灵活但要侵入代码。
- 负载分配常见轮询和加权轮询,从库差异大时可用最少连接或延迟感知策略。
- 主从延迟是最大痛点,"写后立即读"、事务内读和强一致读都应强制走主库,必要时用半同步或等待位点。
- 读写分离只解决读压力,做之前先确认瓶颈真的是读,而不是慢 SQL、缺索引或锁竞争。
企业QQ咨询




