读写分离深度解析
面试官:读写分离了解吗?实现原理是什么?
你:读写分离基于 MySQL 主从复制,主库处理写操作,从库处理读操作,通过中间件(ShardingSphere/MyCat)或应用层(动态数据源)路由 SQL。
面试官:主从复制有延迟,读从库可能读到旧数据,如何解决?
这个追问考察你对「一致性 vs 性能」权衡的理解。能说清多种方案及适用场景的候选人,才是高阶选手。
链式追问一:主从复制原理
Section titled “链式追问一:主从复制原理”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.cnflog_bin = mysql-binbinlog_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.comfi链式追问二:读写分离的一致性问题
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) // 事务内自动走主库@Transactionalpublic 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 = ONrpl_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)直接去主库读
@Servicepublic 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 级别:同一用户的操作在同一个数据库连接上,始终读主库
@Controllerpublic 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: trueQ4: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 强制指定事务内自动路由主库:
@Servicepublic 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\GSeconds_Behind_Master何时收益不明显:
场景一:写多读少(写:读 = 8:2) → 读写分离收益小,主库仍然是瓶颈
场景二:从库配置低 → 从库成为瓶颈,读请求反而更慢
场景三:主从延迟大 → 从库读到的数据过旧,用户体验差,最终还是要读主库
场景四:单条查询很慢 → 读写分离不能解决慢查询问题,需要优化 SQL 和索引本质一句话:读写分离适合「读多写少」场景,性能提升来自于「读请求并发度提升」,但无法解决「慢查询」和「主库写瓶颈」。