Skip to content

读写分离

一、读写分离架构概述

  1. 目标: 分摊主库压力,提升读取性能。
  2. 基本结构: 一主多从 。
  3. 两种实现方式:
    • 3.1 客户端直连负载均衡:
      • 机制: 客户端(连接层)直接选择后端数据库进行查询。
      • 优点:
        • 查询性能稍好(少一层转发)。
        • 整体架构简单,排查问题方便。
      • 缺点:
        • 客户端需了解后端部署细节。
        • 主备切换、库迁移时,客户端需调整连接信息。
        • 缓解: 通常配合服务发现组件 (如 Zookeeper) 管理后端信息,业务端尽量解耦。
    • 3.2 中间代理层 (Proxy):
      • 机制: 客户端只连接 Proxy,Proxy 根据请求类型和上下文决定路由。
      • 优点:
        • 对客户端友好,无需关注后端细节。
        • 连接维护、后端信息维护由 Proxy 完成。
      • 缺点:
        • 对后端维护团队要求更高。
        • Proxy 自身也需要高可用架构。
        • 整体架构相对复杂。
    • 趋势: 目前更倾向于带 Proxy 的架构。

二、读写分离的核心问题:过期读 (Stale Read)

  1. 定义: 由于主从存在延迟,客户端执行完更新后立即查询从库,可能读到更新前的旧数据。
  2. 根源: 主从延迟无法100%避免。
  3. 目标: 客户端希望查询从库的数据结果与查询主库的结果一致。

三、处理过期读的方案

  1. 方案一:强制走主库方案 (Classification of Queries)

    • 机制: 将查询请求分类:
      • 必须最新结果的请求: 强制发往主库 (如:卖家发布商品后立即查看商品状态)。
      • 可接受旧数据的请求: 发往从库 (如:买家浏览商品列表)。
    • 优点: 最常用,简单有效。
    • 缺点:
      • 若所有查询都不能过期读 (如金融业务),则需放弃读写分离,牺牲扩展性。
  2. 方案二:Sleep 方案 (Client-side Delay)

    • 机制: 主库更新后,读从库前先 sleep 一段时间 (如 SELECT SLEEP(1);)。
    • 假设: 大多数情况下主备延迟在 sleep 时间内。
    • 改进版 (前端体验优化):
      • 以卖家发布商品为例:商品发布后,前端Ajax直接用客户端输入内容在页面上模拟“新商品”显示,而非立即查询数据库。
      • 用户刷新页面时(通常已过一段时间),才真正查询从库。
    • 优点: 在特定场景 (如Ajax异步更新) 下能一定程度上解决问题,对用户体验影响较小。
    • 缺点 (不精确):
      • 若0.5秒可同步,仍等待1秒 (资源浪费)。
      • 若延迟超过1秒,仍会过期读。
    • 评价: 看起来“不靠谱”,但有其适用场景。
  3. 方案三:判断主备无延迟方案 (Synchronization Check)

    • 目标: 确保查询前备库已同步完成。
    • 3.1 方法一:判断 seconds_behind_master
      • SHOW SLAVE STATUS; 查看 seconds_behind_master 是否为0。
      • 缺点: 单位是秒,精度可能不够。
    • 3.2 方法二:对比位点 (Log Position)
      • 比较 Master_Log_FileRelay_Master_Log_File
      • 比较 Read_Master_Log_PosExec_Master_Log_Pos
      • 两组值完全相同,则表示接收到的日志已同步完成。
    • 3.3 方法三:对比 GTID 集合
      • Auto_Position=1 (GTID模式)。
      • 比较 Retrieved_Gtid_Set (备库收到的GTID集合) 与 Executed_Gtid_Set (备库已执行的GTID集合)。
      • 两者相同,则表示备库接收到的日志已同步完成。
    • 优点:sleep 方案准确。
    • 缺点 (仍不够精确):
      • 场景: 主库已执行完事务trx3并回复客户端,但trx3的binlog尚未传到备库。
      • 此时,上述判断可能认为“无延迟”(因为备库已收到的都执行完了),但查询仍读不到trx3。
      • 根本原因: 判断的是“备库收到的日志都执行完了”,未考虑“主库已提交但尚未发送到备库的日志”。
  4. 方案四:配合 Semi-Sync 方案 (Semi-Synchronous Replication)

    • Semi-Sync 机制:
      1. 事务提交时,主库将binlog发给从库。
      2. 从库收到binlog后,给主库发送ack。
      3. 主库收到ack后,才给客户端返回“事务完成”确认。
    • 效果: 保证所有已向客户端确认的事务,其binlog至少已送达一个从库。
    • 解决普通异步复制的数据丢失问题: 若主库掉电前binlog未发出,semi-sync可避免此情况。
    • 结合位点/GTID判断:
      • 一主一备: 可以确保避免过期读。
      • 一主多从:
        • 主库只需等到一个从库ack即可。
        • 若查询落到响应ack的从库,能读到最新数据。
        • 若查询落到其他未ack的从库,仍可能过期读。
    • 潜在问题 (同方案三):
      • 业务更新高峰期,位点/GTID更新快,等值判断可能持续不成立,导致从库长时间无法响应查询。
      • 过度等待: 客户端只需保证其自身发起的更新已在从库执行,无需等待主备完全同步。
  5. 方案五:等主库位点方案 (Wait for Master Position)

    • 命令: SELECT master_pos_wait(file, pos[, timeout]); (在从库执行)
      • file, pos: 主库上的文件名和位置。
      • timeout: 可选,最多等待N秒。
      • 返回值:
        • >=0 (正整数M): 从开始到应用完指定位点,执行了M个事务。
        • NULL: 同步线程异常。
        • -1: 超时。
        • 0: 执行命令时已过指定位点。
    • 流程:
      1. 客户端:事务trx1更新完成后,立即在主库执行 SHOW MASTER STATUS; 获取当前File和Position。
      2. 客户端:选定一个从库执行查询。
      3. 从库:执行 SELECT master_pos_wait(File, Position, 1); (假设等待1秒)。
      4. 从库:若返回值 >=0,则在此从库执行查询。
      5. 否则 (超时或错误):退化到主库执行查询 (需做好限流)。
    • 优点:
      • 解决了方案三、四中“过度等待”和一主多从场景下部分从库过期读的问题。
      • 更精确地等待到特定事务同步完成。
    • 缺点:
      • 若所有从库都严重延迟,查询压力可能全部压向主库。
  6. 方案六:等 GTID 方案 (Wait for GTID)

    • 前提: 数据库开启GTID模式。
    • 命令: SELECT wait_for_executed_gtid_set(gtid_set, timeout); (在从库执行)
      • gtid_set: 要等待的GTID集合。
      • timeout: 最多等待N秒。
      • 返回值:
        • 0: 等到指定GTID集合在本库执行完毕。
        • 1: 超时。
    • 获取事务GTID:
      • MySQL 5.7.6+:设置参数 session_track_gtids=OWN_GTID
      • 客户端API:通过 mysql_session_track_get_first 从事务返回包中解析GTID。
    • 流程:
      1. 客户端:事务trx1更新完成后,从返回包直接获取其GTID (记为 gtid1)。
      2. 客户端:选定一个从库执行查询。
      3. 从库:执行 SELECT wait_for_executed_gtid_set(gtid1, 1); (假设等待1秒)。
      4. 从库:若返回值 0,则在此从库执行查询。
      5. 否则 (超时):退化到主库执行查询 (需做好限流)。
    • 优点 (相比等位点方案):
      • 减少一次到主库查询 SHOW MASTER STATUS 的开销。
    • 缺点 (同等位点方案):
      • 若所有从库都严重延迟,查询压力可能全部压向主库。

四、方案总结与实践建议

  1. 混合使用: 实际应用中,可组合多种方案。
    • 请求分类: 先区分哪些请求可接受过期读,哪些不能。
    • 精确等待: 对不能接受过期读的,再采用等GTID或等位点方案。
  2. 权衡: 过期读本质上由“一写多读”导致。解决过期读往往需要在读性能、写性能、数据一致性之间做权衡。
  3. 限流策略: 当等待超时或从库普遍延迟导致查询退化到主库时,必须有相应的限流策略,防止主库被打垮。

五、延伸思考:文末问题预告

问题: 采用等GTID方案,对主库大表做DDL,可能出现什么情况?如何避免? * 思考方向 (DDL对GTID等待的影响): * 大表DDL执行时间长,会产生一个GTID。 * 在DDL完成前,后续的查询如果需要等待这个DDL的GTID,会发生什么? * wait_for_executed_gtid_set 是否会长时间阻塞? * 如何优雅地处理这种情况,既保证DDL的执行,又不严重影响依赖GTID等待的读请求?(例如,是否可以分阶段进行,或者有特殊的DDL工具支持)

六、上期问题回顾:GTID模式下,新从库所需binlog已在主库删除的处理

  • 方案总结:
    1. 允许主从不一致:
      • 主库获取 gtid_purged (已删除的GTID集合)。
      • 从库 RESET MASTER;,然后 SET GLOBAL gtid_purged = '主库的gtid_purged值';
      • START SLAVE; (会丢失部分数据)。
    2. 要求主从数据一致 (推荐):
      • 重新搭建从库: 从主库全量备份恢复。
      • 从其他保留全量binlog的从库同步: 先接到这个从库,追上后再切回主库。
      • 从binlog备份恢复: 先在从库应用缺失的binlog备份,再 START SLAVE;