Skip to content

读写分离深度解析

面试官:读写分离了解吗?实现原理是什么?

:读写分离基于 MySQL 主从复制,主库处理写操作,从库处理读操作,通过中间件(ShardingSphere/MyCat)或应用层(动态数据源)路由 SQL。

面试官:主从复制有延迟,读从库可能读到旧数据,如何解决?

这个追问考察你对「一致性 vs 性能」权衡的理解。能说清多种方案及适用场景的候选人,才是高阶选手。


Q1:MySQL 主从复制的原理是什么?必考

Section titled “Q1:MySQL 主从复制的原理是什么?”

主从复制架构

应用
├───── 写操作 ──→ 主库(Master)
│ │
│ ├─ 执行 SQL
│ └─ 写入 binlog
└───── 读操作 ──→ 从库(Slave)
从库复制流程:
主库 │ 从库
┌─────────────┐ │ ┌─────────────┐
│ binlog 文件 │◄─────────────┼─────────│ IO 线程 │
└─────────────┘ binlog dump │ └─────────────┘
线程 │ │
│ ↓
│ ┌─────────────┐
│ │ Relay Log │
│ └─────────────┘
│ │
│ ↓
│ ┌─────────────┐
└─────────│ SQL 线程 │
└─────────────┘
┌─────────────┐
│ 从库数据 │
└─────────────┘

复制流程三步骤

1. 主库:写入 binlog
- 执行写操作(INSERT/UPDATE/DELETE)
- 将变更记录到 binlog(二进制日志)
- binlog dump 线程等待从库连接
2. 从库:IO 线程拉取 binlog
- IO 线程连接主库,请求 binlog
- 主库 binlog dump 线程发送 binlog
- 从库将 binlog 写入 Relay Log(中继日志)
3. 从库:SQL 线程重放
- SQL 线程读取 Relay Log
- 重放 SQL 到从库(顺序执行)
- 从库数据与主库一致

binlog 的三种格式

格式记录内容优点缺点适用场景
STATEMENT原始 SQL 语句日志体积小;易读含随机函数时从库结果不同;不确定性简单场景;对一致性要求不高
ROW(推荐)每行数据变更精确;无歧义;从库结果一致日志体积大(大量修改时)高一致性要求;主流方案
MIXED自动选择折中方案复杂场景可能退化为 STATEMENT兼容性场景

STATEMENT vs ROW 举例

-- 场景:更新用户积分,随机加 1-10 分
UPDATE users SET points = points + FLOOR(RAND() * 10 + 1);
-- STATEMENT 格式:
-- 记录:UPDATE users SET points = points + FLOOR(RAND() * 10 + 1);
-- 问题:从库执行时 RAND() 重新计算 → 主从数据不一致
-- ROW 格式:
-- 记录:每行修改前后的值
-- user_id=1: points 100 → 105 (主库执行的随机值)
-- user_id=2: points 200 → 207
-- 从库直接应用数据变更 → 主从一致

配置建议

# my.cnf
log_bin = mysql-bin
binlog_format = ROW # 推荐 ROW 格式
binlog_row_image = FULL # 记录完整行数据(用于数据恢复)
sync_binlog = 1 # 每次事务提交都刷盘(牺牲性能换安全)

Q2:主从复制延迟是什么?如何监控?如何优化?必考

Section titled “Q2:主从复制延迟是什么?如何监控?如何优化?”

主从延迟来源

主库执行事务 → 写 binlog(同步,耗时 t1)
从库 IO 线程拉取 binlog(网络延迟 t2)
从库 SQL 线程重放(串行执行,耗时 t3)
总延迟 = t1 + t2 + t3
关键瓶颈:主库并发写入 vs 从库串行重放

监控命令

-- 在从库执行
SHOW SLAVE STATUS\G
关键指标:
Master_Log_File: mysql-bin.000123 -- 主库 binlog 文件
Read_Master_Log_Pos: 9876 -- IO 线程读取位置
Relay_Master_Log_File: mysql-bin.000123
Exec_Master_Log_Pos: 9870 -- SQL 线程执行位置
Seconds_Behind_Master: 10 -- !!!核心指标!!! 从库落后主库 10 秒
Slave_IO_Running: Yes -- IO 线程状态
Slave_SQL_Running: Yes -- SQL 线程状态

延迟原因与优化

延迟原因优化方案效果
从库配置低(CPU/内存/磁盘)从库配置 >= 主库减少 SQL 重放时间
从库有大查询阻塞 SQL 线程从库只用于读写分离,不做分析查询减少阻塞
网络带宽不足(大事务 binlog 传输慢)主从间专线;增大带宽减少网络延迟
大事务(ALTER TABLE)低峰期执行;在线 DDL 工具减少单次延迟
SQL 线程串行重放开启并行复制最有效

并行复制(MySQL 5.6+)

# my.cnf(从库配置)
slave_parallel_type = LOGICAL_CLOCK # 按组提交并行
slave_parallel_workers = 8 # 8 个 SQL 线程并行重放
原理:
主库并发提交的事务 → binlog 中记录组信息
从库识别同一组的事务 → 并行重放(无锁冲突)
性能提升:延迟从 10s 降到 <1s

延迟监控脚本

#!/bin/bash
# 每分钟检查主从延迟
DELAY=$(mysql -e "SHOW SLAVE STATUS\G" | grep "Seconds_Behind_Master" | awk '{print $2}')
if [ "$DELAY" -gt 10 ]; then
echo "主从延迟过大: ${DELAY}秒" | mail -s "MySQL主从延迟告警" admin@example.com
fi

链式追问二:读写分离的一致性问题

Section titled “链式追问二:读写分离的一致性问题”

Q3:写后立即读,如何保证读到最新数据?必考

Section titled “Q3:写后立即读,如何保证读到最新数据?”

问题场景

用户操作:
1. 修改头像(POST /api/user/avatar)
→ 写主库:UPDATE users SET avatar='new.jpg' WHERE id=123
→ 主库执行成功,binlog 发送到从库
2. 立即刷新页面(GET /api/user/profile)
→ 读从库:SELECT avatar FROM users WHERE id=123
→ 从库延迟,还未重放 binlog
→ 读到旧头像 old.jpg
用户感知:以为修改失败,困惑

解决方案对比

方案原理优点缺点适用场景
强制读主库写后读直接路由主库简单;强一致主库压力大低频写操作;用户敏感操作
等待主从同步半同步复制强一致;无业务侵入写延迟增加金融场景;强一致性要求
主键路由写后标记,短期内读主库性能好;折中方案需要缓存通用方案
读自己写Session 内始终读主库简单;用户体验好Session 内所有读都走主库用户操作密集场景

方案一:强制读主库(最简单)

// ShardingSphere 注解强制走主库
@ShardingTransactionType(TransactionType.LOCAL) // 事务内自动走主库
@Transactional
public void updateAvatar(Long userId, String avatar) {
userMapper.updateAvatar(userId, avatar);
// 事务内读取自动走主库
User user = userMapper.findById(userId);
}
// 读取时强制走主库(dynamic-datasource 注解)
@DS("master") // 使用 dynamic-datasource 注解
public User getCurrentUser(Long userId) {
return userMapper.findById(userId);
}

方案二:半同步复制(强一致)

# 主库配置
plugin_load = "rpl_semi_sync_master=semisync_master.so"
rpl_semi_sync_master_enabled = ON
rpl_semi_sync_master_timeout = 1000 # 等待从库确认超时时间(ms)
# 从库配置
plugin_load = "rpl_semi_sync_slave=semisync_slave.so"
rpl_semi_sync_slave_enabled = ON
流程:
1. 主库执行事务 → 写 binlog
2. 至少一个从库确认收到 binlog → 返回客户端成功
3. 读取时,确保至少一个从库已同步 → 读到最新数据
性能影响:
写延迟增加 = 网络RTT(约 1-5ms)
适用场景:金融交易、用户敏感操作

方案三:主键路由读主库(推荐)

// 核心思路:刚写入的数据,短期内(如 5s)直接去主库读
@Service
public class UserService {
@Autowired
private RedisTemplate<String, String> redis;
@Autowired
private UserMapper userMapper;
@Transactional
public void updateAvatar(Long userId, String avatar) {
// 1. 更新主库
userMapper.updateAvatar(userId, avatar);
// 2. 在 Redis 中标记:该用户刚写入,5s 内读主库
redis.opsForValue().set(
"master:user:" + userId,
"1",
Duration.ofSeconds(5)
);
}
public User getUserById(Long userId) {
// 3. 检查 Redis 标记
if (redis.hasKey("master:user:" + userId)) {
// 刚写入,走主库
return masterUserMapper.findById(userId);
}
// 走从库
return slaveUserMapper.findById(userId);
}
}

方案四:读自己写(Read Your Writes)

// Session 级别:同一用户的操作在同一个数据库连接上,始终读主库
@Controller
public class UserController {
@GetMapping("/profile")
public String profile(@SessionAttribute Long userId, Model model) {
// 将用户 ID 绑定到 ThreadLocal
UserContextHolder.setUserId(userId);
// 所有查询都走主库(在 Session 有效期内)
User user = userService.getUserById(userId);
model.addAttribute("user", user);
return "profile";
}
}
// ShardingSphere 配置:Session 级路由
spring:
shardingsphere:
props:
sql-show: true
check-table-metadata-enabled: true

Q4:ShardingSphere 如何实现读写分离路由?高频

Section titled “Q4:ShardingSphere 如何实现读写分离路由?”

ShardingSphere JDBC 配置

spring:
shardingsphere:
datasource:
names: master,slave0,slave1
master:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://master:3306/mydb
username: root
password: root
slave0:
type: com.zaxxer.hikari.HikariDataSource
jdbc-url: jdbc:mysql://slave0:3306/mydb
username: root
password: root
slave1:
type: com.zaxxer.hikari.HikariDataSource
jdbc-url: jdbc:mysql://slave1:3306/mydb
username: root
password: root
rules:
readwrite-splitting:
data-sources:
myds:
type: STATIC
props:
write-data-source-name: master
read-data-source-names: slave0,slave1
load-balancer-name: round_robin
load-balancers:
round_robin:
type: ROUND_ROBIN # 轮询
# 也支持:
# RANDOM - 随机
# WEIGHT - 权重(需配置权重)

ShardingSphere 路由规则

SQL 类型 路由目标 说明
────────────────────────────────────────────────────
SELECT 从库(轮询/随机) 读操作分流到从库
INSERT/UPDATE/DELETE 主库 写操作必须走主库
事务内的所有操作 主库 防止事务内读到旧数据
/*+ MASTER */ 主库 Hint 强制指定
/*+ SLAVE */ 从库 Hint 强制指定

事务内自动路由主库

@Service
public class OrderService {
@Transactional // 开启事务
public Order createOrder(Long userId, Long productId) {
// 事务内的所有操作都走主库
Order order = new Order(userId, productId);
orderMapper.insert(order);
// 这个 SELECT 也走主库(确保读到刚插入的数据)
Order savedOrder = orderMapper.findById(order.getId());
return savedOrder;
}
}

Hint 强制指定数据源

// 强制读主库
public User getCurrentUser(Long userId) {
HintManager hintManager = HintManager.getInstance();
try {
hintManager.setWriteRouteOnly(); // 强制走主库
return userMapper.findById(userId);
} finally {
hintManager.close(); // 必须关闭
}
}

链式追问三:读写分离的性能与运维

Section titled “链式追问三:读写分离的性能与运维”

Q5:一主多从架构下,如何保证从库高可用?高频

Section titled “Q5:一主多从架构下,如何保证从库高可用?”

从库宕机的影响

架构:1 主库 + 3 从库
master → slave0, slave1, slave2
slave0 宕机:
→ ShardingSphere 自动摘除 slave0
→ 读请求分配到 slave1, slave2(负载增加 50%)
→ 应用层无感知(自动故障转移)
slave0, slave1 同时宕机:
→ 剩余 slave2 承担所有读请求(负载增加 200%)
→ slave2 可能被打挂 → 雪崩

高可用方案

方案一:冗余从库
部署 5 个从库,允许 2 个宕机
→ 成本高,但可靠性高
方案二:从库自动摘除 + 告警
ShardingSphere 配置健康检查:
spring:
shardingsphere:
rules:
readwrite-splitting:
data-sources:
myds:
props:
health-check-enabled: true # 开启健康检查
health-check-interval: 30000 # 检查间隔 30s
max-healthy-read-retry-count: 3 # 重试次数
→ 自动摘除不健康从库
→ 告警通知 DBA
方案三:读主库兜底
所有从库宕机时,读请求自动路由到主库
→ 主库压力增大,但保证可用性

主库宕机的处理

问题:主库宕机 → 无法写入
方案:主从切换(MHA / Orchestrator)
MHA(Master High Availability)流程:
1. 监控主库状态
2. 主库宕机 → 从剩余从库中选一个提升为主库
3. 其他从库切换到新主库
4. VIP(虚拟 IP)漂移到新主库
5. 应用通过 VIP 连接,无感知切换
切换时间:10-30 秒

Q6:如何评估读写分离的性能提升?实战

Section titled “Q6:如何评估读写分离的性能提升?”

性能测试数据

测试环境:
主库:16核 32GB 内存 SSD
从库:16核 32GB 内存 SSD(2个)
压测场景:80% 读 + 20% 写
单库性能:
QPS:2000(读) + 500(写) = 2500
CPU:80%
响应时间:100ms(P99)
一主两从性能:
QPS:4000(读,分流到 2 个从库) + 500(写) = 4500
主库 CPU:50%(只承担写)
从库 CPU:40%(每个从库承担 2000 读)
响应时间:50ms(P99)
性能提升:80%(读请求并发度提升)

性能监控指标

-- 主库状态
SHOW STATUS LIKE 'Questions'; -- 总查询数
SHOW STATUS LIKE 'Com_select'; -- SELECT 次数
SHOW STATUS LIKE 'Com_insert'; -- INSERT 次数
SHOW STATUS LIKE 'Com_update'; -- UPDATE 次数
SHOW STATUS LIKE 'Com_delete'; -- DELETE 次数
-- 计算读写比例
读写比例 = Com_select / (Com_insert + Com_update + Com_delete)
-- 从库延迟
SHOW SLAVE STATUS\G
Seconds_Behind_Master

何时收益不明显

场景一:写多读少(写:读 = 8:2)
→ 读写分离收益小,主库仍然是瓶颈
场景二:从库配置低
→ 从库成为瓶颈,读请求反而更慢
场景三:主从延迟大
→ 从库读到的数据过旧,用户体验差,最终还是要读主库
场景四:单条查询很慢
→ 读写分离不能解决慢查询问题,需要优化 SQL 和索引

本质一句话:读写分离适合「读多写少」场景,性能提升来自于「读请求并发度提升」,但无法解决「慢查询」和「主库写瓶颈」。