用户刚下单成功,刷新页面却看不到订单------这就是主从延迟的幽灵。本文将揭秘大厂如何通过读写分离架构和主从延迟处理方案,在不改业务代码的前提下,让MySQL读性能提升3倍,同时优雅解决主从延迟问题。

文章目录
-
- 一、场景引入:一次主从延迟引发的客诉
-
- [1.1 真实案例](#1.1 真实案例)
- [1.2 主从延迟的常见原因](#1.2 主从延迟的常见原因)
- 二、解决方案:读写分离+主从延迟处理
-
- [2.1 读写分离架构](#2.1 读写分离架构)
- [2.2 核心优势](#2.2 核心优势)
- 三、实战代码:从零实现读写分离
-
- [3.1 动态数据源配置](#3.1 动态数据源配置)
- [3.2 数据源上下文](#3.2 数据源上下文)
- [3.3 动态路由数据源](#3.3 动态路由数据源)
- [3.4 MyBatis拦截器:自动路由](#3.4 MyBatis拦截器:自动路由)
- [3.5 强制读主库注解](#3.5 强制读主库注解)
- [3.6 业务层使用](#3.6 业务层使用)
- 四、高级进阶:主从延迟处理方案
-
- [4.1 延迟检测与自动切换](#4.1 延迟检测与自动切换)
- [4.2 延迟感知路由](#4.2 延迟感知路由)
- [4.3 延迟补偿:读己之写](#4.3 延迟补偿:读己之写)
- 五、预判问题与解答
- 六、面试高频考点
- 七、总结与最佳实践
-
- [7.1 核心要点回顾](#7.1 核心要点回顾)
- [7.2 性能提升数据](#7.2 性能提升数据)
- 八、参考与拓展
一、场景引入:一次主从延迟引发的客诉
1.1 真实案例
某电商平台上线读写分离后,用户投诉不断:
时间线:
T+0秒:用户点击"立即购买"
T+50ms:主库写入订单成功
T+100ms:用户跳转到"我的订单"页面
T+150ms:从库查询订单,返回空(主从延迟!)
T+200ms:用户看到"暂无订单",以为下单失败
T+250ms:用户再次点击"立即购买",重复下单!
后果:
- 用户重复下单,产生退款纠纷
- 客服工单量激增
- 运营后台统计不准
- 用户信任度下降
问题根源:MySQL主从同步是异步的,主库写入后,从库需要一定时间才能同步。在高并发场景下,延迟可能达到秒级甚至分钟级。
1.2 主从延迟的常见原因
| 原因 | 说明 | 解决方案 |
|---|---|---|
| 网络延迟 | 主从库不在同一机房 | 就近部署或使用专线 |
| 从库IO瓶颈 | 从库磁盘性能差 | 升级SSD、优化IO |
| 大事务 | 主库执行大事务,从库回放慢 | 拆分大事务 |
| DDL操作 | ALTER TABLE等DDL操作阻塞复制 | 使用pt-online-schema-change |
| 锁竞争 | 从库上有大量查询,锁竞争 | 增加从库、优化查询 |
| 单线程复制 | MySQL 5.6之前单线程回放 | 开启并行复制 |
二、解决方案:读写分离+主从延迟处理
2.1 读写分离架构
读写分离架构:
┌─────────────────────────────────────────────────────────────────────┐
│ 应用层(业务代码) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 动态数据源路由层 │ │
│ │ │ │
│ │ 根据SQL类型自动路由: │ │
│ │ - INSERT/UPDATE/DELETE → 主库 │ │
│ │ - SELECT → 从库(默认) │ │
│ │ - SELECT + 特殊标记 → 主库(强制读主库) │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────┴───────────────────┐ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ MySQL主库 │ │ MySQL从库 │ │
│ │ │ │ │ │
│ │ 写操作 │ ─────── 同步 ───────→ │ 读操作 │ │
│ │ 事务 │ binlog → relay log │ 查询 │ │
│ │ DDL │ │ 报表 │ │
│ │ │ │ │ │
│ │ 延迟监控 │ │ 延迟监控 │ │
│ └─────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
2.2 核心优势
| 优势 | 说明 |
|---|---|
| 读性能提升 | 读请求分散到多个从库,QPS线性扩展 |
| 高可用 | 主库故障可快速切换到从库 |
| 数据备份 | 从库天然是主库的实时备份 |
| 报表隔离 | 复杂报表查询不影响主库 |
三、实战代码:从零实现读写分离
3.1 动态数据源配置
java
/**
* 动态数据源配置
* 支持主从切换、强制读主库
*/
@Configuration
public class DynamicDataSourceConfig {
/**
* 主数据源
*/
@Bean
@ConfigurationProperties(prefix = "spring.datasource.master")
public DataSource masterDataSource() {
return DataSourceBuilder.create().build();
}
/**
* 从数据源1
*/
@Bean
@ConfigurationProperties(prefix = "spring.datasource.slave1")
public DataSource slave1DataSource() {
return DataSourceBuilder.create().build();
}
/**
* 从数据源2
*/
@Bean
@ConfigurationProperties(prefix = "spring.datasource.slave2")
public DataSource slave2DataSource() {
return DataSourceBuilder.create().build();
}
/**
* 动态数据源
*/
@Bean
public DataSource dynamicDataSource() {
DynamicRoutingDataSource dynamicDataSource = new DynamicRoutingDataSource();
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("master", masterDataSource());
targetDataSources.put("slave1", slave1DataSource());
targetDataSources.put("slave2", slave2DataSource());
dynamicDataSource.setTargetDataSources(targetDataSources);
dynamicDataSource.setDefaultTargetDataSource(masterDataSource());
return dynamicDataSource;
}
}
3.2 数据源上下文
java
/**
* 数据源上下文
* 使用ThreadLocal保证线程安全
*/
public class DataSourceContext {
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
/**
* 设置数据源
*/
public static void set(String dataSource) {
CONTEXT.set(dataSource);
}
/**
* 获取当前数据源
*/
public static String get() {
return CONTEXT.get();
}
/**
* 清除数据源
*/
public static void clear() {
CONTEXT.remove();
}
/**
* 强制使用主库
*/
public static void forceMaster() {
CONTEXT.set("master");
}
/**
* 是否强制读主库
*/
public static boolean isForceMaster() {
return "master".equals(CONTEXT.get());
}
}
3.3 动态路由数据源
java
/**
* 动态路由数据源
* 根据SQL类型和上下文自动选择数据源
*/
public class DynamicRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
String dataSource = DataSourceContext.get();
if (dataSource != null) {
return dataSource;
}
// 默认根据SQL类型路由
if (SqlTypeHolder.isWrite()) {
return "master";
}
// 读操作:轮询选择从库
return selectSlave();
}
/**
* 轮询选择从库
*/
private String selectSlave() {
int index = (int)(System.currentTimeMillis() % 2) + 1;
return "slave" + index;
}
}
3.4 MyBatis拦截器:自动路由
java
/**
* MyBatis SQL拦截器
* 根据SQL类型自动设置数据源
*/
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "update",
args = {MappedStatement.class, Object.class})
})
@Component
@Slf4j
public class DataSourceRoutingInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
String methodName = invocation.getMethod().getName();
if ("query".equals(methodName)) {
// 查询操作:默认走从库
if (!DataSourceContext.isForceMaster()) {
DataSourceContext.set("slave1");
}
} else {
// 更新操作:强制走主库
DataSourceContext.set("master");
}
try {
return invocation.proceed();
} finally {
DataSourceContext.clear();
}
}
}
3.5 强制读主库注解
java
/**
* 强制读主库注解
* 用于解决主从延迟问题
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface MasterRoute {
}
/**
* 强制读主库切面
*/
@Aspect
@Component
@Slf4j
public class MasterRouteAspect {
@Around("@annotation(com.example.annotation.MasterRoute)")
public Object around(ProceedingJoinPoint point) throws Throwable {
DataSourceContext.forceMaster();
log.debug("🔒 强制读主库: {}", point.getSignature().getName());
try {
return point.proceed();
} finally {
DataSourceContext.clear();
}
}
}
3.6 业务层使用
java
@Service
@Slf4j
public class OrderService {
@Autowired
private OrderMapper orderMapper;
/**
* 创建订单(自动写主库)
*/
@Transactional
public Order createOrder(CreateOrderDTO dto) {
Order order = new Order();
BeanUtils.copyProperties(dto, order);
order.setCreateTime(LocalDateTime.now());
orderMapper.insert(order);
log.info("✅ 订单创建成功: orderId={}", order.getId());
return order;
}
/**
* 查询订单列表(自动读从库)
*/
public List<Order> listOrders(Long userId) {
return orderMapper.selectByUserId(userId);
}
/**
* 查询刚创建的订单(强制读主库,解决主从延迟)
*/
@MasterRoute
public Order getOrderImmediately(Long orderId) {
return orderMapper.selectById(orderId);
}
/**
* 支付回调(强制读主库,确保读到最新状态)
*/
@MasterRoute
@Transactional
public void handlePayCallback(Long orderId, String tradeNo) {
Order order = orderMapper.selectById(orderId);
if (order == null || order.getStatus() != OrderStatus.PENDING_PAYMENT) {
log.warn("⚠️ 订单状态异常: orderId={}, status={}",
orderId, order != null ? order.getStatus() : "null");
return;
}
order.setStatus(OrderStatus.PAID);
order.setTradeNo(tradeNo);
order.setPayTime(LocalDateTime.now());
orderMapper.updateById(order);
log.info("✅ 支付成功: orderId={}, tradeNo={}", orderId, tradeNo);
}
}
四、高级进阶:主从延迟处理方案
4.1 延迟检测与自动切换
java
/**
* 主从延迟检测服务
*/
@Component
@Slf4j
public class ReplicationLagMonitor {
@Autowired
private JdbcTemplate slaveJdbcTemplate;
@Autowired
private StringRedisTemplate redisTemplate;
private static final String LAG_KEY = "mysql:replication:lag";
private static final long LAG_THRESHOLD_MS = 1000; // 延迟阈值1秒
/**
* 定时检测主从延迟
*/
@Scheduled(fixedRate = 5000)
public void checkLag() {
try {
// 查询从库的Seconds_Behind_Master
Long lagSeconds = slaveJdbcTemplate.queryForObject(
"SHOW SLAVE STATUS",
(rs, rowNum) -> rs.getLong("Seconds_Behind_Master")
);
if (lagSeconds != null) {
long lagMs = lagSeconds * 1000;
redisTemplate.opsForValue().set(LAG_KEY, String.valueOf(lagMs));
if (lagMs > LAG_THRESHOLD_MS) {
log.warn("⚠️ 主从延迟过高: {}ms", lagMs);
// 触发告警
}
}
} catch (Exception e) {
log.error("❌ 延迟检测异常", e);
}
}
/**
* 获取当前延迟
*/
public long getCurrentLag() {
String lagStr = redisTemplate.opsForValue().get(LAG_KEY);
return lagStr != null ? Long.parseLong(lagStr) : 0;
}
/**
* 是否延迟过高
*/
public boolean isLagHigh() {
return getCurrentLag() > LAG_THRESHOLD_MS;
}
}
4.2 延迟感知路由
java
/**
* 延迟感知数据源路由
* 延迟过高时,读操作也走主库
*/
@Component
@Slf4j
public class LagAwareDataSourceRouter {
@Autowired
private ReplicationLagMonitor lagMonitor;
/**
* 获取数据源(考虑延迟)
*/
public String route(boolean isWrite) {
if (isWrite) {
return "master";
}
// 读操作:检查延迟
if (lagMonitor.isLagHigh()) {
log.warn("⚠️ 主从延迟过高,读操作降级到主库");
return "master";
}
return "slave";
}
}
4.3 延迟补偿:读己之写
java
/**
* 读己之写(Read-Your-Writes)
* 用户刚写入的数据,后续读取一定能读到
*/
@Service
@Slf4j
public class ReadYourWritesService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private OrderMapper orderMapper;
private static final String WRITE_MARK_PREFIX = "write:mark:";
private static final long MARK_TTL = 5; // 5秒
/**
* 标记刚写入的数据
*/
public void markWrite(Long userId, Long orderId) {
String key = WRITE_MARK_PREFIX + userId;
redisTemplate.opsForSet().add(key, String.valueOf(orderId));
redisTemplate.expire(key, MARK_TTL, TimeUnit.SECONDS);
}
/**
* 查询时检查是否刚写入
*/
public Order getOrder(Long userId, Long orderId) {
String key = WRITE_MARK_PREFIX + userId;
Boolean isRecentWrite = redisTemplate.opsForSet()
.isMember(key, String.valueOf(orderId));
if (Boolean.TRUE.equals(isRecentWrite)) {
// 刚写入的数据,强制读主库
DataSourceContext.forceMaster();
try {
return orderMapper.selectById(orderId);
} finally {
DataSourceContext.clear();
}
}
// 普通查询,走从库
return orderMapper.selectById(orderId);
}
}
五、预判问题与解答
Q1:读写分离后,事务怎么处理?
A:
事务处理策略:
1. 单库事务(推荐):
- 读写都在同一个库内
- 使用@Transactional即可
- 保证ACID
2. 跨库事务(分布式事务):
- 方案A:Seata(AT模式)
- 方案B:基于MQ的最终一致性
- 方案C:TCC补偿型事务
3. 最佳实践:
- 尽量避免跨库事务
- 通过合理的业务拆分,让事务内操作都在同一库
- 必须跨库时用Seata
Q2:主从延迟一般多久?怎么监控?
A:
延迟情况:
- 正常情况:毫秒级(< 100ms)
- 高并发:秒级(1-5s)
- 大事务:分钟级(甚至小时级)
监控方法:
1. SHOW SLAVE STATUS\G
- Seconds_Behind_Master:从库落后主库的秒数
2. 自定义监控:
- 在主库写入时记录时间戳
- 在从库查询时对比时间戳
- 计算实际延迟
3. 告警阈值:
- 警告:> 1秒
- 严重:> 5秒
- 紧急:> 30秒
Q3:从库挂了怎么办?
A:
从库故障处理:
1. 自动切换:
- 检测到从库不可用
- 读操作自动切换到其他从库
- 如果没有可用从库,降级到主库
2. 告警通知:
- 发送告警通知运维
- 记录故障日志
3. 恢复后处理:
- 从库恢复后,检查数据一致性
- 如果数据不一致,需要重新同步
4. 代码实现:
- 使用连接池的健康检查
- 配置故障转移策略
Q4:读写分离和分库分表怎么配合?
A:
配合策略:
1. 先读写分离:
- 单库性能不足时,先加从库
- 实现简单,风险低
2. 再分库分表:
- 单库数据量超过5000万
- 读写分离后仍然不够
3. 组合使用:
- 每个分片都有主从结构
- 读写分离 + 分库分表 = 高可用 + 高性能
4. 架构演进:
- 单库 → 读写分离 → 分库分表 → 读写分离+分库分表
Q5:强制读主库会不会导致主库压力过大?
A:
风险控制:
1. 限制范围:
- 只对关键操作强制读主库
- 如:支付回调、订单状态查询
- 普通查询仍然走从库
2. 延迟感知:
- 延迟高时,部分读操作降级到主库
- 延迟恢复后,自动切回从库
3. 缓存兜底:
- 热点数据缓存到Redis
- 减少直接读库的压力
4. 监控告警:
- 监控主库QPS
- 超过阈值时告警
六、面试高频考点
考点1:读写分离的原理是什么?
参考答案:
读写分离原理:
1. 主从复制:
- 主库写入binlog
- 从库IO线程读取binlog,写入relay log
- 从库SQL线程回放relay log
2. 路由层:
- 写操作路由到主库
- 读操作路由到从库
- 通过动态数据源实现
3. 延迟问题:
- 主从复制是异步的
- 存在延迟窗口
- 需要特殊处理
考点2:主从延迟怎么解决?
参考答案:
解决方案:
1. 强制读主库:
- 对延迟敏感的操作,强制走主库
- 使用注解或API标记
2. 延迟感知路由:
- 检测主从延迟
- 延迟高时,读操作降级到主库
3. 读己之写:
- 用户刚写入的数据,标记为"热数据"
- 后续读取强制走主库
4. 优化复制性能:
- 开启并行复制
- 优化网络
- 升级硬件
考点3:动态数据源怎么实现?
参考答案:
实现步骤:
1. 继承AbstractRoutingDataSource:
- 重写determineCurrentLookupKey()
- 根据上下文返回数据源key
2. 使用ThreadLocal:
- 保证线程安全
- 每个线程独立选择数据源
3. MyBatis拦截器:
- 拦截SQL执行
- 根据SQL类型自动路由
4. 注解+AOP:
- @MasterRoute注解
- 切面拦截,强制走主库
考点4:MySQL主从复制的原理?
参考答案:
主从复制原理:
1. 主库:
- 写入操作记录到binlog
- binlog是二进制日志,记录所有数据变更
2. 从库IO线程:
- 连接主库,请求binlog
- 读取binlog,写入relay log
3. 从库SQL线程:
- 读取relay log
- 在从库上重放SQL
4. 复制模式:
- 异步复制(默认):主库不等待从库确认
- 半同步复制:主库等待至少一个从库确认
- 同步复制:主库等待所有从库确认(性能差)
七、总结与最佳实践
7.1 核心要点回顾
读写分离核心流程:
┌─────────────────────────────────────────────────────────────┐
│ 1. 数据源配置 │
│ ├── 主库:写操作 │
│ ├── 从库1:读操作 │
│ └── 从库2:读操作(负载均衡) │
│ │
│ 2. 动态路由 │
│ ├── MyBatis拦截器:根据SQL类型自动路由 │
│ ├── @MasterRoute注解:强制读主库 │
│ └── 延迟感知:延迟高时降级到主库 │
│ │
│ 3. 延迟处理 │
│ ├── 强制读主库(关键操作) │
│ ├── 读己之写(用户刚写入的数据) │
│ └── 延迟监控(定时检测,告警) │
│ │
│ 4. 故障处理 │
│ ├── 从库故障:自动切换到其他从库 │
│ └── 主库故障:主从切换 │
└─────────────────────────────────────────────────────────────┘
7.2 性能提升数据
某电商平台实测数据:
| 指标 | 优化前(单库) | 优化后(1主2从) | 提升 |
|---|---|---|---|
| 读QPS | 3000 | 9000 | 3倍↑ |
| 写QPS | 3000 | 3000 | 不变 |
| 读响应时间 | 50ms | 20ms | 60%↓ |
| 主库CPU | 80% | 40% | 50%↓ |
| 可用性 | 单点故障 | 高可用 | 显著提升 |
八、参考与拓展
互动讨论:你们公司做了读写分离吗?有没有遇到过主从延迟的问题?是怎么解决的?欢迎在评论区分享!
如果本文对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,持续获取更多Java后端技术干货!