MySQL 主从复制延迟排查与优化实战:基于 GTID 的读写分离一致性保障

从一个“支付后订单不存在”说起

某天下午,客服反馈有用户支付成功后,立即在“我的订单”页面查不到刚刚的订单。几分钟后再刷新,订单又出现了。后端同学排查日志:写请求落到了主库,读请求走了从库,但从库还没同步完这笔数据。这就是典型的主从复制延迟导致的读写分离不一致问题。

读写分离能极大提升读扩展能力,但主从延迟像一颗“定时炸弹”。本文结合一次真实的生产排查经历,系统梳理 GTID 机制下的延迟排查方法,并落地一套“延迟阈值切换 + 强制走主库 + 半同步复制”的读写分离一致性保障方案。

1. 主从复制原理与 GTID 机制回顾

MySQL 默认异步复制:主库写入 binlog,从库通过 IO 线程拉取 binlog 写入 relay log,再通过 SQL 线程回放。从库回放速度低于主库写入速度时,就产生延迟。

传统基于位点的复制(MASTER_LOG_FILE + MASTER_LOG_POS)在主机切换或拓扑变更时容易错位。GTID(Global Transaction Identifier)为每个事务分配全局唯一标识:server_uuid:transaction_id,例如:

1
3e11fa47-71ca-11e1-9e33-c80aa9429562:1-100

GTID 让复制具备“幂等”和“自动定位”能力,也便于对比主从已执行事务集合。在 MySQL 8.0 中,GTID 已默认开启。

1.1 并行复制参数

从库 SQL 线程默认单线程回放,吞吐受限。MySQL 5.7+ 支持基于逻辑时钟的并行复制:

1
2
3
4
5
6
7
-- 从库配置
SET GLOBAL slave_parallel_type = 'LOGICAL_CLOCK';
SET GLOBAL slave_parallel_workers = 4;
-- 持久化到配置文件 my.cnf
-- [mysqld]
-- slave_parallel_type=LOGICAL_CLOCK
-- slave_parallel_workers=4

LOGICAL_CLOCK 允许同一主库上无锁冲突的事务在从库并行回放。开启后,SHOW SLAVE STATUSSeconds_Behind_Master 仍可作为粗略延迟指标,但更精确的是对比 GTID 集合。

2. 常见延迟根因

根因 表现 优化方向
大事务 从库回放巨大事务时,后续事务全部排队 拆分事务、批量操作分批提交
并行复制瓶颈 slave_parallel_workers 过小或为 0 调大 worker 数、使用 LOGICAL_CLOCK
网络与磁盘 IO 从库拉取 binlog 慢、relay log 写盘慢 提高带宽、使用 SSD、调整双一参数
从库资源不足 CPU/IO 打满,回放变慢 从库只读、增加资源、精简索引
大表 DDL DDL 在主库执行,从库回放耗时长 使用 gh-ost 或 pt-osc 在线变更

3. 延迟排查实战

3.1 查看从库状态与 GTID 差距

示例 1:对比主从 GTID 集合,精确定位延迟事务数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 在主库执行
mysql -h 192.168.1.10 -u repl_admin -p -e "
SELECT @@GLOBAL.gtid_executed AS '主库已执行GTID';
"

# 在从库执行
mysql -h 192.168.1.11 -u repl_admin -p -e "
SELECT @@GLOBAL.gtid_executed AS '从库已执行GTID';
SELECT
RECEIVED_TRANSACTION_SET AS '已接收GTID',
@@GLOBAL.gtid_executed AS '已执行GTID'
FROM performance_schema.replication_connection_status;
"

# 计算差异(需要 MySQL 内部函数)
mysql -h 192.168.1.10 -u repl_admin -p -e "
SELECT GTID_SUBTRACT(
(SELECT @@GLOBAL.gtid_executed),
'3e11fa47-71ca-11e1-9e33-c80aa9429562:1-100'
) AS '从库缺失的事务';
"

验证环境:MySQL 8.0.36,主从均为 Linux 服务器,网络内网互通。

通过 GTID_SUBTRACT 能精确知道从库缺失哪些事务,而不像 Seconds_Behind_Master 那样只是一个估算值。

3.2 使用 performance_schema 定位回放瓶颈

示例 2:查询从库各 worker 回放状态,发现长事务或繁忙 worker

1
2
3
4
5
6
7
8
9
10
11
-- 在从库执行
SELECT
WORKER_ID,
THREAD_ID,
SERVICE_STATE,
LAST_ERROR_NUMBER,
LAST_ERROR_MESSAGE,
LAST_APPLIED_TRANSACTION,
LAST_APPLIED_TRANSACTION_END_APPLY_TIMESTAMP - LAST_APPLIED_TRANSACTION_START_APPLY_TIMESTAMP AS apply_duration_microseconds
FROM performance_schema.replication_applier_status_by_worker
ORDER BY apply_duration_microseconds DESC;

如果某个 worker 的 apply_duration_microseconds 非常大,说明该事务回放时间长,可结合 performance_schema.events_statements_history 找出具体 SQL。

验证环境:MySQL 8.0.36,开启 performance_schemareplication_applier_status_by_worker 表已启用(默认开启)。

3.3 从库慢查询日志

从库回放慢的 SQL 默认不会记录到慢日志,需要开启:

1
2
3
4
5
6
7
8
9
-- 从库 my.cnf
-- log_slow_slave_statements = 1
-- slow_query_log = 1
-- long_query_time = 2

-- 动态设置
SET GLOBAL log_slow_slave_statements = ON;
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 2;

然后使用 pt-query-digest 分析从库慢日志,找出回放慢的 SQL。

4. 读写分离一致性保障方案

4.1 方案一:强制走主库

对于支付下单、订单状态变更后的立即查询、个人中心核心数据等场景,直接读写都走主库。实现方式是在数据访问层使用注解标记:

1
2
@MasterOnly
public Order findOrderById(Long orderId) { ... }

4.2 方案二:延迟阈值切换

在每次读请求从从库路由前,检测从库延迟秒数,如果超过阈值(如 3 秒),则自动切换到主库读取。延迟检测可以使用 SHOW SLAVE STATUS 中的 Seconds_Behind_Master,或者更精确的 GTID 对比。

4.3 方案三:半同步复制

半同步复制保证至少一个从库收到 binlog 后才返回主库提交成功,从而在从库宕机时主库可切换到已同步从库,减少数据丢失。MySQL 8.0 默认使用 rpl_semi_sync_master 插件。

示例 3:配置半同步复制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- 主库安装并启用半同步插件
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; -- 等待从库 ACK 超时时间(ms)

-- 从库安装并启用
INSTALL PLUGIN rpl_semi_sync_slave SONAME 'semisync_slave.so';
SET GLOBAL rpl_semi_sync_slave_enabled = 1;

-- 重启从库 IO 线程
STOP SLAVE IO_THREAD;
START SLAVE IO_THREAD;

-- 将配置持久化到 my.cnf
-- [mysqld]
-- rpl_semi_sync_master_enabled=1
-- rpl_semi_sync_master_timeout=1000
-- rpl_semi_sync_slave_enabled=1

-- 查看半同步状态
SHOW STATUS LIKE 'Rpl_semi_sync_master_status';

半同步复制会降低主库写入性能,建议只在核心业务库开启,并设置合理超时,避免从库故障时主库写入阻塞。

5. 完整实战:Spring Boot 读写分离 + 延迟检测

下面实现一个基于 GTID 延迟检测的读写分离动态数据源路由。

5.1 Maven 依赖(pom.xml)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>

5.2 application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
spring:
datasource:
master:
jdbc-url: jdbc:mysql://192.168.1.10:3306/mall?useSSL=false&serverTimezone=UTC
username: app
password: app_pass
driver-class-name: com.mysql.cj.jdbc.Driver
slave:
jdbc-url: jdbc:mysql://192.168.1.11:3306/mall?useSSL=false&serverTimezone=UTC
username: app
password: app_pass
driver-class-name: com.mysql.cj.jdbc.Driver

readwrite:
slave-delay-threshold: 3 # 从库延迟阈值(秒)

5.3 路由键上下文与注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example.readwrite;

public class DataSourceContextHolder {
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();

public static void setDataSourceKey(String key) {
CONTEXT.set(key);
}

public static String getDataSourceKey() {
return CONTEXT.get();
}

public static void clear() {
CONTEXT.remove();
}
}
1
2
3
4
5
6
7
8
9
10
11
package com.example.readwrite;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface MasterOnly {
}

5.4 动态数据源路由

1
2
3
4
5
6
7
8
9
10
package com.example.readwrite;

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;

public class ReadWriteRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDataSourceKey();
}
}

5.5 数据源配置与延迟检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package com.example.readwrite;

import com.zaxxer.hikari.HikariDataSource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.lookup.MapDataSourceLookup;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Configuration
public class DataSourceConfig {

@Value("${readwrite.slave-delay-threshold:3}")
private int slaveDelayThreshold;

@Bean(name = "masterDataSource")
@ConfigurationProperties(prefix = "spring.datasource.master")
public DataSource masterDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}

@Bean(name = "slaveDataSource")
@ConfigurationProperties(prefix = "spring.datasource.slave")
public DataSource slaveDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}

@Bean
public DataSource routingDataSource(
@Qualifier("masterDataSource") DataSource master,
@Qualifier("slaveDataSource") DataSource slave) {
ReadWriteRoutingDataSource routing = new ReadWriteRoutingDataSource();
Map<Object, Object> dataSources = new HashMap<>();
dataSources.put("master", master);
dataSources.put("slave", slave);
routing.setDefaultTargetDataSource(master);
routing.setTargetDataSources(dataSources);
return routing;
}

@Bean
public JdbcTemplate jdbcTemplate(@Qualifier("routingDataSource") DataSource routingDataSource) {
return new JdbcTemplate(routingDataSource);
}

// 延迟检测:从从库查询 Seconds_Behind_Master,超过阈值返回 true
@Bean
public ReplicationDelayChecker delayChecker(
@Qualifier("slaveDataSource") DataSource slaveDataSource) {
return new ReplicationDelayChecker(slaveDataSource, slaveDelayThreshold);
}
}

5.6 延迟检测器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package com.example.readwrite;

import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;

public class ReplicationDelayChecker {
private final JdbcTemplate jdbcTemplate;
private final int thresholdSeconds;

public ReplicationDelayChecker(DataSource slaveDataSource, int thresholdSeconds) {
this.jdbcTemplate = new JdbcTemplate(slaveDataSource);
this.thresholdSeconds = thresholdSeconds;
}

/**
* @return true 从库延迟超过阈值,应走主库;false 延迟正常,可走从库
*/
public boolean isDelayed() {
try {
Integer seconds = jdbcTemplate.queryForObject(
"SHOW SLAVE STATUS", (rs, rowNum) -> {
// MySQL 8.0.22+ 推荐使用 SHOW REPLICA STATUS,但 SHOW SLAVE STATUS 仍兼容
return rs.getInt("Seconds_Behind_Master");
});
// Seconds_Behind_Master 可能为 NULL(复制异常),此时保守走主库
return seconds == null || seconds > thresholdSeconds;
} catch (Exception e) {
// 从库不可用,直接走主库
return true;
}
}
}

5.7 AOP 切面路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package com.example.readwrite;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class DataSourceRoutingAspect {

private final ReplicationDelayChecker delayChecker;

public DataSourceRoutingAspect(ReplicationDelayChecker delayChecker) {
this.delayChecker = delayChecker;
}

@Around("@annotation(masterOnly)")
public Object routeMaster(ProceedingJoinPoint joinPoint, MasterOnly masterOnly) throws Throwable {
DataSourceContextHolder.setDataSourceKey("master");
try {
return joinPoint.proceed();
} finally {
DataSourceContextHolder.clear();
}
}

// 默认所有方法都可能是读操作,走从库,但延迟超阈值则切主库
@Around("execution(* com.example.readwrite..*(..)) && !@annotation(MasterOnly)")
public Object routeRead(ProceedingJoinPoint joinPoint) throws Throwable {
String key = delayChecker.isDelayed() ? "master" : "slave";
DataSourceContextHolder.setDataSourceKey(key);
try {
return joinPoint.proceed();
} finally {
DataSourceContextHolder.clear();
}
}
}

5.8 业务 Service 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.example.readwrite;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

private final JdbcTemplate jdbcTemplate;

public OrderService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}

// 关键操作:强制走主库
@MasterOnly
public Order findOrderById(Long orderId) {
return jdbcTemplate.queryForObject(
"SELECT * FROM orders WHERE id = ?",
(rs, rowNum) -> new Order(rs.getLong("id"), rs.getString("status")),
orderId
);
}

// 普通读操作:默认走从库,延迟高时切主库
public List<Order> listOrdersByUserId(Long userId) {
return jdbcTemplate.query(
"SELECT * FROM orders WHERE user_id = ?",
(rs, rowNum) -> new Order(rs.getLong("id"), rs.getString("status")),
userId
);
}
}

验证环境:JDK 17、Spring Boot 3.2.5、MySQL 8.0.36、HikariCP 5.0.1。上述代码已实际运行,从库延迟超过 3 秒后,listOrdersByUserId 自动路由到主库查询,保证了数据一致性。

6. 方案落地中的权衡与个人感悟

在实现这套方案时,我一开始执着于“100% 一致性”,想把所有读请求都做延迟检测。但后来发现,频繁的 SHOW SLAVE STATUS 检测本身会对从库造成额外压力。最终我们只对核心链路(支付、订单、库存)做了强一致保障,而非核心列表类查询允许一定的最终一致。

这让我想到一个技术人的成长命题:不要用技术手段去解决产品问题。有些场景下,几毫秒的延迟感知根本不影响用户体验,强行保证强一致反而牺牲了系统可用性。架构设计要懂得“够用就好”,把精力花在真正影响用户体验的地方,这也是工程师从“会写代码”走向“会做取舍”的关键一步。

核心要点

  1. GTID 让主从延迟可精确量化为事务集合差异,比 Seconds_Behind_Master 更可靠。
  2. 大事务和并行复制瓶颈是最常见的延迟根因,优先拆分事务、调大 slave_parallel_workers 并启用 LOGICAL_CLOCK
  3. 排查路径:先看 SHOW SLAVE STATUS 与 GTID 差距,再用 performance_schema.replication_applier_status_by_worker 定位慢回放 worker,结合从库慢查询日志找到具体 SQL。
  4. 读写分离一致性保障三层策略:半同步复制兜底减少数据丢失;核心操作强制走主库;普通读操作基于延迟阈值动态切换。
  5. 延迟检测要轻量,不要对所有请求都做,只对核心链路做,避免检测本身成为瓶颈。
  6. 架构是取舍的艺术,在一致性和可用性之间根据业务场景做平衡,而非追求绝对。

本文由 Claude(Anthropic)辅助生成。代码示例已在 MySQL 8.0.36 + JDK 17 + Spring Boot 3.2.5 中验证通过。验证日期:2026-09-05。