读写分离的延迟问题:主从同步延迟检测与业务层的处理策略
读写分离的延迟问题:主从同步延迟检测与业务层的处理策略
适读人群:中高级Java工程师 | 阅读时长:约18分钟 | 技术栈:Spring Boot 3.x、MyBatis Plus、ShardingSphere、MySQL 8.0
开篇故事
2021年,我们的电商系统做了读写分离,用 ShardingSphere 配置了 1 主 2 从,读操作走从库,写操作走主库。刚上线时效果很好,主库压力降低了将近 60%。
但随后出现了一个很奇怪的用户反馈:用户修改了收货地址,然后立刻进入下单页面,结果下单页面显示的还是旧地址。用户改了两三次,都是一样的问题,直到刷新了几次才显示正确。
排查下来发现是读写分离的主从同步延迟问题:用户更新收货地址(写主库),然后下单页面读取地址列表(读从库),而从库的同步延迟约 300ms 到 2 秒,在这个窗口期内读到的还是旧数据。
更严重的问题发生在下单流程里:创建订单(写主库)之后立刻查询订单详情(读从库),从库延迟导致查不到刚创建的订单,用户看到的是"订单创建失败"的提示,但实际订单已经在主库了——用户重新下单,导致重复下单。
那一周我们处理了约 1200 个重复订单的退款工单。
一、核心问题分析
主从同步延迟的来源
MySQL 的主从复制是半同步或异步的:
异步复制(默认):主库执行完事务立刻返回给客户端,不等待从库确认。从库的 IO 线程负责从主库拉取 binlog,SQL 线程负责回放 binlog,整个过程是异步的,延迟从几毫秒到几秒不等。
半同步复制:主库等至少一个从库确认收到 binlog 后才返回。减少了数据丢失风险,但不消除延迟(从库收到不等于执行完)。
延迟的主要来源:
- 网络延迟(主从机房距离)
- 从库 SQL 线程的回放速度(从库的 IO 性能、CPU 速度)
- 主库大事务(一个大事务在从库回放需要很长时间)
- 从库高负载(从库同时在处理大量读请求,CPU/IO 资源不足)
延迟的测量方式
MySQL 提供了 SHOW SLAVE STATUS 命令,其中 Seconds_Behind_Master 字段表示从库落后主库的秒数。但这个字段有局限性:它只精确到秒,且在某些异常情况下会显示 0 但实际有延迟。
更精确的方式是用 pt-heartbeat 或自定义心跳表来测量。
二、检测主从延迟
自定义心跳检测
/**
* 主从延迟检测服务
* 原理:定期往主库写一个时间戳,从从库读取,计算差值
*/
@Service
@Slf4j
public class ReplicationLagDetector {
@Autowired
@Qualifier("masterDataSource")
private DataSource masterDataSource;
@Autowired
@Qualifier("slaveDataSource")
private DataSource slaveDataSource;
private volatile long currentLagMs = 0L;
/**
* 每秒检测一次主从延迟
*/
@Scheduled(fixedRate = 1000)
public void detectLag() {
try {
long writeTime = System.currentTimeMillis();
// 写主库心跳
try (Connection masterConn = masterDataSource.getConnection();
PreparedStatement ps = masterConn.prepareStatement(
"INSERT INTO replication_heartbeat(id, ts) VALUES(1, ?) " +
"ON DUPLICATE KEY UPDATE ts = ?")) {
ps.setLong(1, writeTime);
ps.setLong(2, writeTime);
ps.execute();
}
// 从从库读取心跳
long readTime = System.currentTimeMillis();
long heartbeatTs = 0L;
try (Connection slaveConn = slaveDataSource.getConnection();
PreparedStatement ps = slaveConn.prepareStatement(
"SELECT ts FROM replication_heartbeat WHERE id = 1")) {
ResultSet rs = ps.executeQuery();
if (rs.next()) {
heartbeatTs = rs.getLong(1);
}
}
long lag = readTime - heartbeatTs;
currentLagMs = Math.max(0, lag);
if (currentLagMs > 3000) {
log.warn("主从延迟过高:{}ms", currentLagMs);
}
} catch (Exception e) {
log.error("主从延迟检测失败", e);
}
}
public long getCurrentLagMs() {
return currentLagMs;
}
public boolean isSlaveReady(long maxAcceptableLagMs) {
return currentLagMs <= maxAcceptableLagMs;
}
}-- 心跳表
CREATE TABLE replication_heartbeat (
id TINYINT PRIMARY KEY,
ts BIGINT NOT NULL COMMENT '时间戳(毫秒)'
) ENGINE=InnoDB;基于 Micrometer 上报延迟指标
@Component
public class ReplicationLagMetrics {
@Autowired
private ReplicationLagDetector lagDetector;
@Autowired
private MeterRegistry meterRegistry;
@PostConstruct
public void registerMetrics() {
Gauge.builder("db.replication.lag.ms", lagDetector, ReplicationLagDetector::getCurrentLagMs)
.description("MySQL主从复制延迟(毫秒)")
.register(meterRegistry);
}
}三、业务层的处理策略
策略一:强制走主库(适用于写后立读场景)
/**
* 自定义注解:强制当前操作走主库
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ForceMaster {
}/**
* AOP 切面:在 ShardingSphere 的路由前,设置 hint 强制走主库
*/
@Aspect
@Component
@Order(-1) // 优先级最高,在事务切面之前执行
public class ForceMasterAspect {
@Around("@annotation(ForceMaster) || @within(ForceMaster)")
public Object forceMaster(ProceedingJoinPoint joinPoint) throws Throwable {
// ShardingSphere 的 HintManager 可以强制路由到主库
HintManager hintManager = HintManager.getInstance();
hintManager.setMasterRouteOnly();
try {
return joinPoint.proceed();
} finally {
hintManager.close();
}
}
}使用示例:
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Transactional
public Order createOrder(CreateOrderRequest request) {
// 写主库
Order order = buildOrder(request);
orderMapper.insert(order);
return order;
}
/**
* 写后立读:强制走主库,避免主从延迟导致查不到刚创建的数据
*/
@ForceMaster
public Order getOrderAfterCreate(Long orderId) {
return orderMapper.selectById(orderId);
}
/**
* 一般查询:走从库,允许短暂不一致
*/
public Order getOrder(Long orderId) {
return orderMapper.selectById(orderId);
}
}策略二:写后缓存(减少主从延迟问题)
@Service
@Slf4j
public class UserAddressService {
@Autowired
private UserAddressMapper addressMapper;
@Autowired
private StringRedisTemplate redisTemplate;
private static final String ADDRESS_CACHE_KEY_PREFIX = "user:address:";
private static final Duration CACHE_TTL = Duration.ofMinutes(5);
/**
* 更新地址后,同步更新缓存
* 这样下单页面从缓存读到的是最新数据,不走从库
*/
@Transactional
public void updateAddress(Long userId, UpdateAddressRequest request) {
// 写主库
addressMapper.updateAddress(userId, request);
// 同步更新缓存(覆盖旧数据)
List<Address> addresses = addressMapper.selectByUserId(userId); // 这里走主库(同一事务内)
redisTemplate.opsForValue().set(
ADDRESS_CACHE_KEY_PREFIX + userId,
JSON.toJSONString(addresses),
CACHE_TTL
);
}
/**
* 读地址:优先走缓存,缓存 miss 再走从库
*/
public List<Address> getAddresses(Long userId) {
String cached = redisTemplate.opsForValue().get(ADDRESS_CACHE_KEY_PREFIX + userId);
if (cached != null) {
return JSON.parseArray(cached, Address.class);
}
// 缓存 miss 走从库(可能有延迟,但这时候数据一般已经同步了)
return addressMapper.selectByUserId(userId);
}
}策略三:延迟感知路由(动态切换主从)
/**
* 自适应路由:根据主从延迟动态决定是否走从库
*/
@Component
@Slf4j
public class AdaptiveReadRouter {
@Autowired
private ReplicationLagDetector lagDetector;
// 延迟超过此值时,强制走主库(防止数据一致性问题)
private static final long MAX_ACCEPTABLE_LAG_MS = 1000L;
/**
* 判断当前读操作是否应该走从库
*/
public boolean shouldReadFromSlave(ReadConsistencyLevel consistencyLevel) {
return switch (consistencyLevel) {
// 强一致性:始终走主库
case STRONG -> false;
// 最终一致性:始终走从库
case EVENTUAL -> true;
// 自适应:根据当前延迟动态决定
case ADAPTIVE -> lagDetector.isSlaveReady(MAX_ACCEPTABLE_LAG_MS);
};
}
}public enum ReadConsistencyLevel {
/** 强一致性,走主库 */
STRONG,
/** 最终一致性,走从库(可能读到旧数据) */
EVENTUAL,
/** 自适应,延迟小则走从库,延迟大则走主库 */
ADAPTIVE
}策略四:Wait Replication(强一致性兜底)
对于资金相关的写后读,可以在写完主库后,等待从库追上:
@Service
@Slf4j
public class PaymentConsistentReadService {
@Autowired
private JdbcTemplate masterJdbcTemplate;
@Autowired
private JdbcTemplate slaveJdbcTemplate;
/**
* 写主库后,等待从库追上再读(最强一致性保证,有性能代价)
* MySQL 的 WAIT_FOR_EXECUTED_GTID_SET 可以等待从库执行到指定 GTID
*/
@Transactional
public PaymentRecord createAndReadPayment(CreatePaymentRequest request) {
// 1. 写主库,获取当前 GTID
masterJdbcTemplate.execute("INSERT INTO payment ...");
String gtid = masterJdbcTemplate.queryForObject(
"SELECT @@global.gtid_executed", String.class);
// 2. 等待从库追上(最多等 5 秒)
Integer result = slaveJdbcTemplate.queryForObject(
"SELECT WAIT_FOR_EXECUTED_GTID_SET(?, 5)", Integer.class, gtid);
if (result == null || result == 1) {
log.warn("从库等待超时,强制走主库读取");
// 降级走主库
return masterJdbcTemplate.queryForObject(
"SELECT * FROM payment WHERE ...", PaymentRecord.class);
}
// 3. 从库已追上,安全读取
return slaveJdbcTemplate.queryForObject(
"SELECT * FROM payment WHERE ...", PaymentRecord.class);
}
}四、生产调优
MySQL 主从延迟的优化
从库并行回放(MySQL 5.7+):
# my.cnf - 从库配置
slave_parallel_type = LOGICAL_CLOCK # 逻辑时钟并行
slave_parallel_workers = 8 # 8个并行工作线程
slave_preserve_commit_order = ON # 保证提交顺序一致减少大事务:一个大事务(如批量更新 10 万条)在主库执行快,但在从库回放时需要同等时间,期间其他事务都在等待。拆分大事务为小批量操作,可以显著减少复制延迟。
五、踩坑实录
坑一:开篇故事的修复方案
修复分两步:
- 用户更新地址后,同步更新 Redis 缓存,下单页面优先读缓存,绕开从库延迟。
- 下单流程在创建订单后,用
@ForceMaster强制走主库查询订单详情,避免从库延迟导致查不到刚创建的订单。
修复后重复下单的问题完全消失。
坑二:事务内读走从库
Spring 的 @Transactional 事务内,有时候读操作也会走从库(取决于 ShardingSphere 的配置),导致事务内读到事务开始前的旧数据。
修复:ShardingSphere 配置将事务内的读强制走主库:
rules:
- !READWRITE_SPLITTING
loadBalancers:
roundRobin:
type: ROUND_ROBIN
dataSourceGroups:
myGroup:
writeDataSourceName: master
readDataSourceNames:
- slave0
- slave1
# 事务内读走主库
transactionalReadQueryStrategy: PRIMARY坑三:主从延迟监控告警阈值设置
我们最初把主从延迟告警阈值设成了 10 秒,结果很多用户已经遇到数据不一致问题了,告警才触发。后来改成了 1 秒告警、3 秒严重告警,能在用户大量感知之前就发现问题并处理。
六、总结
读写分离延迟问题的处理策略金字塔(从强到弱):
最强:WAIT_FOR_EXECUTED_GTID_SET,写后等待从库追上。代价:每次写后读增加延迟,仅用于资金等关键场景。
强:写后强制走主库读(ForceMaster)。代价:部分读走主库,主库压力增加。
中:写后缓存,读优先走缓存。代价:缓存一致性需要维护。
弱:自适应路由,延迟高时走主库,延迟低时走从库。代价:实现稍复杂,有监控开销。
最弱:接受最终一致性,允许短暂读到旧数据。适合不敏感的数据(如商品浏览量、用户积分余额的展示)。
绝大多数业务场景,采用"写后缓存 + 关键场景强制主库"的组合就能解决问题,不需要引入 GTID 等复杂机制。
