MySQL主从复制延迟优化:binlog格式选择与并行复制配置
大约 9 分钟
MySQL主从复制延迟优化:binlog格式选择与并行复制配置
适读人群:Java后端开发、DBA、运维工程师 | 阅读时长:约24分钟
开篇故事
2020年,我们的读写分离方案上线后,每隔几天就会有用户反馈「刚下的订单,去我的订单列表看不到」。
明明写入了主库,为什么从库查不到?
排查过程:登上从库,执行 SHOW SLAVE STATUS\G:
Seconds_Behind_Master: 4747秒的主从延迟!用户下单成功后,立刻被路由到从库查询,而从库落后主库47秒,自然查不到。
那一周,我把MySQL的主从复制原理从头翻了一遍,从binlog格式到并行复制,最终把延迟从47秒压到了不超过1秒。
今天把这个过程完整还原。
一、主从复制的架构与延迟根因
1.1 主从复制架构
主库(Master) 从库(Slave)
+------------------+ +------------------+
| 业务写操作 | | |
| ↓ | binlog | IO Thread |
| binlog写入 |------------>| (接收binlog) |
| (sync_binlog=1) | | ↓ |
+------------------+ | relay log |
| ↓ |
| SQL Thread |
| (回放relay log) |
+------------------+1.2 延迟的根因分类
主从延迟的常见原因:
1. 大事务:一个事务修改了100万行,binlog很大,从库回放耗时长
2. DDL操作:ALTER TABLE在从库是单线程串行执行
3. 从库单线程回放:MySQL 5.6之前,SQL Thread是单线程
4. 主库并发高:主库并发写入,从库串行回放,追不上
5. 网络带宽:binlog传输速度受限
6. 从库机器性能弱:IO、CPU比主库差二、底层原理:binlog格式与并行复制
2.1 binlog的三种格式
STATEMENT格式:
记录执行的SQL语句本身
优点:binlog文件小,可读性好
缺点:某些SQL在从库回放结果不一致(NOW()、RAND()、UUID()等非确定函数)
binlog内容示例:
UPDATE orders SET updated_at = NOW() WHERE user_id = 123;
-- 主库:NOW()=2022-01-01 12:00:00
-- 从库回放时:NOW()=2022-01-01 12:00:05 ← 不一致!ROW格式:
记录每一行数据的实际变化(before/after image)
优点:从库回放结果一定和主库一致,支持所有操作
缺点:binlog文件大(UPDATE了100万行,就记录100万行的before/after)
binlog内容示例:
### UPDATE `test`.`orders`
### WHERE
### @1=123 /* id */
### @2='old_status' /* status */
### SET
### @1=123
### @2='new_status' /* status */MIXED格式:
默认用STATEMENT,遇到不确定的SQL(涉及非确定函数、临时表等)自动切换为ROW
MySQL认为MIXED是折中方案,但实际运维中我更推荐直接用ROW生产建议:
# my.cnf
binlog_format = ROW
binlog_row_image = MINIMAL # 只记录变化的列(default是FULL)
# MINIMAL能显著减小binlog体积2.2 并行复制的演进
MySQL 5.6:基于库的并行复制
主库: DB1写操作 + DB2写操作
↓
从库SQL Thread:
Worker1 → 回放DB1的操作
Worker2 → 回放DB2的操作(并行)
局限:只有多个数据库才能并行,单库多表的场景无法并行MySQL 5.7:基于组提交的并行复制(MTS)
主库的组提交原理:
事务1: prepare ──┐
事务2: prepare ──┤─ 同一个组 → 一起fsync写binlog → 一起commit
事务3: prepare ──┘
关键洞察:同一个组提交的事务,在commit阶段之前没有锁冲突
因此从库可以并行回放同一个组的事务!
binlog中的关键字段:
last_committed: 上一组的序号(必须等这一组之前的都完成才能开始)
sequence_number: 当前事务在组内的序号
配置:
slave_parallel_type = LOGICAL_CLOCK # 基于组提交的并行复制
slave_parallel_workers = 8 # 并行worker数(通常=CPU核数)MySQL 8.0:WriteSet并行复制
WriteSet记录每个事务修改的行(通过行的哈希值)
如果两个事务的WriteSet没有交集(修改的不是同一行),就可以并行
优点:比组提交并行度更高,因为即使不在同一个组,只要没有行冲突就能并行
配置:
binlog_transaction_dependency_tracking = WRITESET
transaction_write_set_extraction = XXHASH642.3 延迟监控的关键指标
SHOW SLAVE STATUS\G
重要字段解读:
Seconds_Behind_Master: 47 ← 主从延迟秒数(最直观的指标)
Master_Log_File: binlog.000123 ← 从库IO Thread正在读主库的哪个binlog文件
Read_Master_Log_Pos: 1234567 ← IO Thread已读到的位置
Relay_Log_File: relay.000456 ← SQL Thread正在回放的relay log文件
Exec_Master_Log_Pos: 1200000 ← SQL Thread已回放到的主库位置
Master_Log_Pos - Exec_Master_Log_Pos = 延迟的字节数三、完整解决方案与代码
3.1 并行复制配置
# 从库 my.cnf 配置
# 并行复制类型(推荐LOGICAL_CLOCK)
slave_parallel_type = LOGICAL_CLOCK
# 并行worker数(通常 = CPU核数,可以设置为1.5-2倍核数)
slave_parallel_workers = 16
# 8.0+ 使用WriteSet获得更高并行度
binlog_transaction_dependency_tracking = WRITESET
transaction_write_set_extraction = XXHASH64
# relay log优化
relay_log_recovery = ON # 崩溃恢复时自动重新拉取relay log
sync_relay_log = 0 # 异步刷新relay log(提高IO性能)
relay_log_info_repository = TABLE # relay log信息存表,崩溃安全
# 从库binlog配置(级联复制场景需要开启)
log_slave_updates = ON
# 大事务优化:增大relay log文件大小
max_relay_log_size = 500M3.2 主库优化:加速binlog写入
# 主库 my.cnf
# binlog格式(ROW + MINIMAL减少binlog体积)
binlog_format = ROW
binlog_row_image = MINIMAL
# 组提交优化:延迟等待更多事务加入组(提高并行度)
binlog_group_commit_sync_delay = 100 # 等待100微秒收集更多事务
binlog_group_commit_sync_no_delay_count = 10 # 或凑够10个事务就不等了
# binlog刷盘策略
sync_binlog = 1 # 每次提交都fsync(安全)
innodb_flush_log_at_trx_commit = 1 # redo log也同步刷盘
# 大事务检测告警
max_binlog_size = 500M # 单个binlog文件最大500MB3.3 Java代码:读写分离时处理主从延迟
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
/**
* 动态数据源路由
* 支持强制主库读(解决主从延迟导致的读取旧数据问题)
*/
public class DynamicDataSource extends AbstractRoutingDataSource {
// ThreadLocal标记当前线程是否强制走主库
private static final ThreadLocal<Boolean> FORCE_MASTER =
ThreadLocal.withInitial(() -> false);
public static void forceMaster() {
FORCE_MASTER.set(true);
}
public static void clearForceMaster() {
FORCE_MASTER.remove();
}
public static boolean isForceMaster() {
return FORCE_MASTER.get();
}
@Override
protected Object determineCurrentLookupKey() {
// 强制主库读
if (FORCE_MASTER.get()) {
return "master";
}
// 事务内强制走主库(防止读到从库的旧数据)
if (TransactionSynchronizationManager.isActualTransactionActive()) {
return "master";
}
return "slave";
}
}
/**
* 注解:标记该方法需要强制读主库
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ForceMaster {}
/**
* AOP切面:处理@ForceMaster注解
*/
@Aspect
@Component
public class ForceMasterAspect {
@Around("@annotation(com.example.annotation.ForceMaster)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
try {
DynamicDataSource.forceMaster();
return joinPoint.proceed();
} finally {
DynamicDataSource.clearForceMaster();
}
}
}
/**
* 使用示例:下单后立即查询,强制走主库
*/
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Transactional // 事务内自动走主库
public Order createOrder(CreateOrderRequest req) {
Order order = buildOrder(req);
orderMapper.insert(order);
return order;
}
@ForceMaster // 强制主库读(下单后立刻查询的场景)
public Order getOrderAfterCreate(Long orderId) {
return orderMapper.selectById(orderId);
}
// 普通查询:可以走从库
public List<Order> listOrders(Long userId) {
return orderMapper.selectByUserId(userId);
}
}3.4 主从延迟监控告警
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class ReplicationLagMonitor {
@Autowired
private SlaveDataSource slaveDataSource; // 直接连从库的DataSource
@Autowired
private AlertService alertService;
// 延迟告警阈值(秒)
private static final int WARN_THRESHOLD = 5;
private static final int CRITICAL_THRESHOLD = 30;
@Scheduled(fixedDelay = 10_000)
public void checkReplicationLag() {
try (Connection conn = slaveDataSource.getConnection();
Statement stmt = conn.createStatement()) {
ResultSet rs = stmt.executeQuery("SHOW SLAVE STATUS");
if (rs.next()) {
int lag = rs.getInt("Seconds_Behind_Master");
String ioRunning = rs.getString("Slave_IO_Running");
String sqlRunning = rs.getString("Slave_SQL_Running");
String lastError = rs.getString("Last_Error");
// 检查复制是否正常运行
if (!"Yes".equals(ioRunning) || !"Yes".equals(sqlRunning)) {
alertService.sendCritical("主从复制异常",
String.format("IO_Running=%s, SQL_Running=%s, Error=%s",
ioRunning, sqlRunning, lastError));
return;
}
// 检查延迟
if (lag >= CRITICAL_THRESHOLD) {
alertService.sendCritical("主从延迟严重",
String.format("延迟=%d秒,超过阈值%d秒", lag, CRITICAL_THRESHOLD));
} else if (lag >= WARN_THRESHOLD) {
alertService.sendWarning("主从延迟告警",
String.format("延迟=%d秒", lag));
}
}
} catch (SQLException e) {
alertService.sendCritical("主从延迟检查失败", e.getMessage());
}
}
}四、踩坑实录
坑1:开启并行复制后,从库数据乱序
症状:开启slave_parallel_workers=8后,偶尔出现从库数据比主库少几行,或者关联数据不完整。
根因:两个事务T1(INSERT user)和T2(INSERT order,order.user_id=T1的user)在同一个组提交,从库并行回放,T2先于T1完成,导致外键约束错误。
报错:
[ERROR] Slave SQL: Error 'Cannot add or update a child row: a foreign key
constraint fails', Error_code: 1452解决方案:
# 如果使用外键,必须保证从库按依赖顺序执行
# 方案1:禁用外键检查(从库只是读,可以接受)
slave_foreign_key_checks = 0
# 方案2:使用LOGICAL_CLOCK而不是DATABASE并行(LOGICAL_CLOCK本身保证顺序)
slave_parallel_type = LOGICAL_CLOCK
# LOGICAL_CLOCK基于组提交,同组内事务不会有锁冲突,顺序安全
# 方案3:主库不使用外键(推荐)
# 大厂通常不使用数据库外键,由应用层保证数据一致性坑2:大事务导致从库延迟突然飙升
场景:每天晚上跑一个批量更新任务,更新1000万条记录,从库延迟从0秒飙到600秒。
-- 问题SQL(在主库执行)
UPDATE orders SET status = 'ARCHIVED'
WHERE created_at < '2021-01-01';
-- 影响1000万行,一个超大事务从库SHOW SLAVE STATUS:
Seconds_Behind_Master: 587
-- SQL Thread在回放这个超大事务的UPDATE,一条条更新1000万行解决方案:大事务拆小事务
@Service
public class ArchiveService {
@Scheduled(cron = "0 2 * * * ?") // 每天凌晨2点执行
public void archiveOldOrders() {
LocalDate cutoffDate = LocalDate.now().minusYears(1);
long lastId = 0;
int batchSize = 1000; // 每批1000条
int totalUpdated = 0;
while (true) {
// 每批独立事务,执行完立即提交
int updated = archiveOneBatch(lastId, batchSize, cutoffDate);
totalUpdated += updated;
if (updated < batchSize) break; // 没有更多数据了
// 批次间休眠,给从库追赶时间
checkAndWaitForSlave();
}
log.info("归档完成,共处理 {} 条记录", totalUpdated);
}
@Transactional
public int archiveOneBatch(long lastId, int size, LocalDate cutoff) {
// 查找需要归档的ID
List<Long> ids = orderMapper.selectIdsForArchive(lastId, size, cutoff);
if (ids.isEmpty()) return 0;
// 批量更新
orderMapper.batchUpdateStatus(ids, "ARCHIVED");
return ids.size();
}
private void checkAndWaitForSlave() {
// 检查从库延迟,超过5秒就等待
int lag = getSlaveReplicationLag();
while (lag > 5) {
log.info("等待从库追赶,当前延迟={}秒", lag);
Thread.sleep(1000);
lag = getSlaveReplicationLag();
}
}
}坑3:binlog_format=STATEMENT时,RC隔离级别导致主从不一致
这个坑前面MVCC章节提到过,这里再从binlog角度说一遍。
-- 主库(RC隔离级别 + STATEMENT格式)
START TRANSACTION;
-- 主库看到 id IN (1,2,3)
UPDATE orders SET status='DONE' WHERE created_at < '2022-01-01';
COMMIT;
-- binlog记录: UPDATE orders SET status='DONE' WHERE created_at < '2022-01-01'主库回放时:影响了100行
从库回放时(稍后执行):其他事务新增了5行created_at<'2022-01-01'的数据
从库影响了105行! → 主从不一致报错:使用pt-table-checksum检查时发现差异:
Differences on slave: 5 rows differ from master永久解决方案:
# my.cnf
binlog_format = ROW # ROW格式记录具体行变化,与SQL执行时机无关
binlog_row_image = MINIMAL # 只记录变化列,节省空间五、总结与延伸
主从复制延迟是读写分离架构的老大难问题,把优化路径总结一下:
排查顺序:
SHOW SLAVE STATUS确认延迟秒数和复制是否正常- 看
Exec_Master_Log_Pos和Read_Master_Log_Pos的差距,判断是IO慢还是SQL慢 - 如果SQL Thread慢:看是否有大事务,是否开启了并行复制
- 如果IO Thread慢:看网络带宽,binlog文件是否太大
配置优化清单:
- 主库:
binlog_format=ROW,binlog_row_image=MINIMAL,开启组提交 - 从库:
slave_parallel_type=LOGICAL_CLOCK,slave_parallel_workers=16 - 应用层:强制主库读注解,事务内不走从库,下单后立查走主库
应用层兜底:延迟无法完全消除,应用层必须有fallback机制(如强制读主库注解)。
