MySQL 主从复制与读写分离——binlog 原理、延迟处理、故障切换实战
MySQL 主从复制与读写分离——binlog 原理、延迟处理、故障切换实战
适读人群:需要做读写分离或已有主从复制的后端工程师、DBA | 阅读时长:约17分钟 | 核心价值:彻底搞懂主从复制原理,掌握延迟和故障切换的实战处理方案
一次读从库读出"过去数据"的事故
2021年,我一个朋友老方在做电商系统的读写分离改造。改造完上线第三天,客服开始收到用户投诉:明明下了订单,刷新页面后订单不见了;过一会儿又出现了;但下单时看到的库存数量是不准的。
老方一开始以为是缓存问题,清了缓存还是一样。后来我帮他排查,发现是主从延迟导致的:用户下单写到主库,然后立刻查询(读从库),但从库还没同步到这条数据,所以查不到。等从库同步完,数据才"出现"了。
从库延迟大约 1-3 秒,但就是这 1-3 秒,让用户以为系统有 Bug。
这是读写分离最典型的问题,也是很多团队没有考虑到的。今天我们把主从复制的原理、延迟的处理和故障切换完整过一遍。
一、binlog:主从复制的基础
1.1 binlog 的三种格式
MySQL 的 binlog 是主从复制的核心,记录了所有对数据产生变更的 SQL 操作(DDL 和 DML)。
Statement 格式(SBR):记录原始 SQL 语句。
优点:日志量小,可读性好。
缺点:包含不确定函数(NOW()、UUID()、RAND())的 SQL 在从库执行时,结果可能与主库不同。
-- 主库执行:INSERT INTO t VALUES(NOW())
-- 记录的 binlog:INSERT INTO t VALUES(NOW())
-- 从库在不同时间执行,NOW() 返回不同值 → 数据不一致!Row 格式(RBR):记录每行数据的前后镜像(Before/After Image)。
优点:最精确,不依赖函数执行,保证数据一致性。
缺点:日志量大,全表更新会产生海量 binlog。
Mixed 格式:默认用 Statement,遇到不确定性 SQL 自动切换到 Row。
我的建议:生产环境统一用 Row 格式,数据一致性最重要,日志量大的问题通过 binlog 过期时间控制(binlog_expire_logs_seconds)。
1.2 主从复制完整流程
主库(Master)
↓ 1. 事务提交,写入 binlog(二进制日志)
↓ 2. dump thread 将 binlog 发送给从库的 IO thread
↓
从库(Slave)
↓ 3. IO thread 接收 binlog,写入 relay log(中继日志)
↓ 4. SQL thread 读取 relay log,回放 SQL
↓ 更新从库数据这个流程中,步骤 2-4 是异步的,这就是主从延迟的根本原因:主库提交事务后,从库需要时间(网络传输 + relay log 写入 + SQL 回放)才能同步完成。
1.3 半同步复制:增强数据安全性
MySQL 5.5+ 支持半同步复制(Semi-Synchronous Replication):主库在提交事务后,等待至少一个从库确认收到 binlog,才向客户端返回成功。这样即使主库立刻宕机,至少一个从库有最新数据。
-- 安装半同步插件(主库)
INSTALL PLUGIN rpl_semi_sync_master SONAME 'semisync_master.so';
SET GLOBAL rpl_semi_sync_master_enabled = 1;
SET GLOBAL rpl_semi_sync_master_timeout = 1000; -- 等待超时 1 秒后降级为异步
-- 安装半同步插件(从库)
INSTALL PLUGIN rpl_semi_sync_slave SONAME 'semisync_slave.so';
SET GLOBAL rpl_semi_sync_slave_enabled = 1;注意:半同步复制会增加写操作延迟,大约增加 1-2ms(等待从库ACK的网络RTT)。对于写操作 P99 要求极严格的场景,需要权衡。
二、主从延迟:识别、监控、处理
2.1 延迟监控
-- 查看从库延迟(在从库上执行)
SHOW SLAVE STATUS\G
-- 关键指标:
-- Seconds_Behind_Master: 从库落后主库的秒数(0 表示已追上)
-- Slave_IO_Running: IO 线程状态(Yes/No)
-- Slave_SQL_Running: SQL 线程状态(Yes/No)
-- Last_Error: 复制错误信息Seconds_Behind_Master 不完全准确(它是用从库当前执行的事务时间戳与主库当前时间相减),但作为参考指标够用。
更精确的延迟监控:可以用 Percona 的 pt-heartbeat 工具,主库定期写入心跳记录,从库检测心跳记录与当前时间的差值。
2.2 延迟的常见原因
原因一:从库 SQL thread 单线程回放
MySQL 5.6 之前,从库 SQL thread 是单线程的,如果主库并发写入量大,从库永远追不上。
MySQL 5.7+ 引入了基于组提交的并行复制(MTS),可以显著减少从库延迟:
-- 开启并行复制(在从库配置)
slave_parallel_workers = 8 -- 并行工作线程数,根据 CPU 核数设置
slave_parallel_type = LOGICAL_CLOCK -- 基于逻辑时钟的并行策略(5.7+ 推荐)原因二:大事务
一个耗时 30 秒的大事务(比如批量更新 100 万行),在主库上执行完后,从库需要重新回放这 30 秒的操作。在这段时间内,从库延迟持续增大。
解法:拆分大事务为小批量操作,每批 1000-5000 行,分多次提交。
踩坑一:删除大量历史数据导致从库严重延迟
现象:运维执行 DELETE FROM logs WHERE create_time < '2023-01-01',删除了 5000 万行数据。主库执行了 20 分钟,从库开始累积延迟,高峰时延迟超过 1 小时。
原因:大事务生成了巨量 binlog(Row 格式下,每行的 Before Image 都记录),从库回放时单线程处理,严重滞后。
解法:分批删除:
@Service
public class ArchiveService {
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 分批删除历史数据,避免大事务
* 每批删除 1000 行,删完一批 sleep 100ms,避免对主库造成持续压力
*/
public void deleteHistoryData(LocalDate beforeDate) {
String deleteSql = "DELETE FROM logs WHERE create_time < ? LIMIT 1000";
int totalDeleted = 0;
int deleted;
do {
deleted = jdbcTemplate.update(deleteSql, beforeDate);
totalDeleted += deleted;
log.info("已删除 {} 行历史数据", totalDeleted);
if (deleted > 0) {
try {
Thread.sleep(100); // 批次间停顿,让从库有追赶的机会
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
} while (deleted > 0);
log.info("历史数据删除完成,共删除 {} 行", totalDeleted);
}
}三、读写分离的正确实现
3.1 主从延迟下的一致性保障
回到文章开头老方的问题:用户下单后立刻查询,从库可能还没同步。
解法有几种:
方案一:强制读主库(最简单,但失去了读写分离的收益)
// Spring 中用 @Transactional(readOnly=false) 强制走主库
// 或者通过 AbstractRoutingDataSource 手动切换
public class ReadYourWriteService {
@Autowired
private DataSourceRouter dataSourceRouter;
public Order createAndRead(CreateOrderRequest req) {
// 写操作
Order order = createOrder(req);
// 标记:这次查询必须读主库
dataSourceRouter.setForceMaster();
try {
return orderMapper.selectById(order.getId());
} finally {
dataSourceRouter.clear();
}
}
}方案二:Session 级别的路由
在用户会话中记录最后一次写操作的时间戳,后续查询时如果时间戳距现在小于延迟阈值,就读主库:
@Component
public class ReadAfterWriteRouter {
// 基于 Cookie/Session 记录写操作时间戳
private static final ThreadLocal<Long> LAST_WRITE_TIME = new ThreadLocal<>();
private static final long DELAY_THRESHOLD_MS = 2000; // 2秒阈值
public static void recordWrite() {
LAST_WRITE_TIME.set(System.currentTimeMillis());
}
public static boolean shouldReadMaster() {
Long lastWrite = LAST_WRITE_TIME.get();
if (lastWrite == null) return false;
return System.currentTimeMillis() - lastWrite < DELAY_THRESHOLD_MS;
}
}方案三:用 Redis 做一致性标记
写操作成功后在 Redis 写一个标记(TTL 3-5 秒),读操作前检查标记是否存在,存在则读主库。这种方案适合分布式环境(多实例)。
四、故障切换:主库宕机的应急方案
4.1 手动切换流程
-- 1. 确认从库已追上(Seconds_Behind_Master=0)
SHOW SLAVE STATUS\G
-- 2. 停止从库复制
STOP SLAVE;
-- 3. 将从库提升为主库
RESET SLAVE ALL; -- 清除复制配置
-- 4. 修改应用配置,将写操作指向新主库
-- 5. 旧主库恢复后,作为从库接入新主库
CHANGE MASTER TO MASTER_HOST='new-master-ip', ...;
START SLAVE;4.2 自动切换(MHA/Orchestrator)
生产环境手动切换太慢(通常需要 5-15 分钟),建议使用 MHA(Master High Availability)或 Orchestrator 实现自动故障切换。
踩坑二:自动切换后出现脑裂
现象:主库由于网络抖动被误判为宕机,切换了新主库;原主库网络恢复后,出现了两个主库同时接收写入的情况(脑裂),导致数据不一致。
原因:MHA 在判断主库是否宕机时,没有做充分的多节点确认(仅依赖单个监控节点的判断)。
解法:
- 使用多个监控节点(至少 3 个)投票决策
- 在切换前通过 SSH 强制关闭旧主库的写入(STONITH,Shoot The Other Node In The Head)
- 切换后立刻在旧主库上设置
read_only=1,防止继续接收写入
主从复制和读写分离是大多数业务系统的标配。理解了 binlog 的原理、延迟的成因和故障切换的流程,就能在出问题时不慌不乱,有条不紊地处理。
