「笔记」MySQL 实战 45 讲 - 实践篇(三)
MySQL 主备
-
MySQL 能够成为现下最流行的开源数据库,binlog 功不可没
- binlog 可以用来归档,也可以用来做主备同步
- 几乎所有的高可用架构,都直接依赖于 binlog
-
MySQL 主备切换流程 — M-S 结构
- 虽然节点 B 没有直接被访问,但依然建议把节点 B(备库)设置成只读(readonly)模式
- 有时候一些运营类的查询语句会被放到备库上去查,设置为只读可以防止误操作
- 防止切换逻辑有 bug,比如切换过程中出现双写,造成主备不一致
- 可以用 readonly 状态,来判断节点的角色
- readonly 设置对超级 (super) 权限用户是无效的,而用于同步更新的线程,就拥有超级权限
- 虽然节点 B 没有直接被访问,但依然建议把节点 B(备库)设置成只读(readonly)模式
-
主备流程图
-
主库接收到客户端的更新请求后,执行内部事务的更新逻辑,同时写 binlog
-
备库 B 跟主库 A 之间维持了一个长连接,主库 A 内部有一个线程专门服务这个长连接
-
一个事务日志同步的完整过程
- 在备库 B 上通过 change master 命令,设置主库 A 的 IP、端口、用户名、密码、位置等
-
在备库 B 上执行 start slave 命令,这时候备库会启动两个线程
- 即上图中的 io_thread 和 sql_thread(io_thread 负责与主库建立连接
- 主库 A 校验完用户名密码后,开始按照备库 B 传过来的位置,从本地读取 binlog并发送
- 备库 B 拿到 binlog 后,写到本地文件,称为中转日志(relay log)
-
sql_thread 读取中转日志,解析出日志里的命令,并执行
- 后来由于多线程复制方案的引入,sql_thread 演化成为了多个线程
-
binlog 的三种格式对比
- statement:binlog 里面记录的是 SQL 语句的原文(占用空间小
- row:binlog 里面记录的是 每一行数据修改的形式(可以避免主备不一致的场景
- 从恢复数据的角度上看,更推荐使用 row( mysqlbinlog 解析出来后直接发给 MySQL
- mixed:集大家之所长,可能导致主备不一致的SQL使用 row 格式,否则使用 statement
-
MySQL 主备切换流程 – 双 M 结构
-
节点 A 和 B 之间总是互为主备关系,切换的时不用修改主备关系
-
如何解决两个节点间的循环复制的问题
- 规定两个库的 server id 必须不同,如果相同,则它们之间不能设定为主备关系
- 备库接到 binlog 并在重放的过程中,生成与原 binlog 的 server id 相同的新的 binlog
- 每个库在收到从自己的主库发过来的日志后,先判断 server id(相同则丢弃
-
通过上述机制依然可能出现死循环的场景
-
在一个主库更新事务后,用命令 set global server_id=x 修改了 server_id ….
-
三节点循环复制
- binlog 上的 server_id 就是 B,binlog 传给节点 A,然后 A 和 A’搭建了双 M 结构…
- 这种三节点复制的场景,做数据库迁移的时候会出现
- 临时解决方式:CHANGE MASTER TO IGNORE_SERVER_IDS=(server_id_of_B);
-
-
如何保证高可用
-
在满足数据可靠性的前提下,MySQL 高可用系统的可用性,是依赖于主备延迟的
- 延迟的时间越小,在主库故障的时候,服务恢复需要的时间就越短,可用性就越高
-
主备延迟
- 数据同步有关的时间点主要包括以下三个
- 主库 A 执行完成一个事务,写入 binlog,我们把这个时刻记为 T1
- 之后传给备库 B,我们把备库 B 接收完这个 binlog 的时刻记为 T2
- 备库 B 执行完成这个事务,我们把这个时刻记为 T3
- 主备延迟:同一个事务在备库执行完成的时间和主库执行完成的时间之间的差值,也就是 T3-T1
- 执行 show slave status 命令后,显示的 seconds_behind_master 用于表示当前备库延迟了多少秒
- 计算方式(其实就是 T3-T1,时间精度是秒
- 每个事务的 binlog 里面都有一个时间字段,用于记录主库上写入的时间
- 备库取出当前正在执行的事务的时间字段的值,计算它与当前系统时间的差值
- 如果主备库机器的系统时间设置不一致,也不会导致主备延迟的值不准
- 备库连接到主库时会通过执行 SELECT UNIX_TIMESTAMP () 函数获得主库的系统时间
- 若发现与主库时间不一致,则在计算主备延迟的值时自动扣掉这个差值
- 计算方式(其实就是 T3-T1,时间精度是秒
- 主备延迟最直接的表现:是备库消费中转日志(relay log)的速度比主库生产 binlog 的速度要慢
- 数据同步有关的时间点主要包括以下三个
-
主备延迟的来源
- 备库所在机器的性能要比主库所在的机器性能差
- 备库的压力大
- 运营后台需要的分析语句均在备库上跑,查询耗费了大量的 CPU 资源,影响了同步速度
- 解决方案
- 一主多从。除了备库外,可以多接几个从库,让这些从库来分担读的压力
- 通过 binlog 输出到外部系统,如 Hadoop 等,让外部系统提供统计类查询的能力
- 大事务
- 主库上必须等事务执行完成才会写入 binlog,再传给备库
- 如果一个主库上的语句执行 10 分钟,那这个事务很可能就会导致从库延迟 10 分钟
- 控制每个事务更新的数据量,分成多次更新
- 大表 DDL 同样会带来主备延迟问题
- 计划内的 DDL,建议使用 gh-ost 方案
- 主库上必须等事务执行完成才会写入 binlog,再传给备库
-
可靠性优先策略
- 切换流程中存在部分不可用时间(主库 A 和备库 B 都处于 readonly 状态,即不可写
- 不可用时间即为 seconds_behind_master 的值(所以开始需要先确保其足够小 才切换
-
可用性优先策略
-
不等主备数据同步,直接把连接切到备库 B(使其提供读写),那么系统几乎就没有不可用时间
- 可能出现数据不一致的情况
-
可用性优先策略,且 binlog_format=row
- 使用 row 格式的 binlog 时,数据不一致的问题更容易被发现(其他格式则是悄悄地就不一致
- 对于记录操作日志场景(强依赖场景),可以使用,若数据不一致可以通过binlog 来修补
-
并行复制
-
MySQL 5.6 之前只支持单线程复制,由此在主库并发高、TPS 高时就会出现严重的主备延迟问题
-
多线程模型
- coordinator(原 sql_thread), 不过现在它不再直接更新数据,只负责读取中转日志和分发事务
- 真正更新日志的,变成了 worker 线程(参数 slave_parallel_workers 决定个数
- coordinator 在分发的时候,需要满足以下这两个基本要求
- 不能造成更新覆盖。这就要求更新同一行的两个事务,必须被分发到同一个 worker 中
- 同一个事务不能被拆开,必须放到同一个 worker 中
- coordinator(原 sql_thread), 不过现在它不再直接更新数据,只负责读取中转日志和分发事务
-
MySQL 5.5 版本的并行复制策略
- 按表分发策略(非官方
-
按表分发事务的基本思路是,如果两个事务更新不同的表,它们就可以并行
- 如果有跨表的事务,还是要把两张表放在一起考虑的
-
按表并行复制程模型
- 每个 worker 线程对应一个 hash 表,用于保存当前正在这个 worker 的 “执行队列” 里的事务所涉及的表
- key 是 “库名.表名”,value 是一个数字(表示队列中有多少个事务修改这个表
- 每个 worker 线程对应一个 hash 表,用于保存当前正在这个 worker 的 “执行队列” 里的事务所涉及的表
-
分配及发生冲突时的规则
- 若跟所有 worker 都不冲突,coordinator 线程就会把这个事务分配给最空闲的 woker
- 若多于一个 worker 冲突,coordinator 线程就进入等待状态,直至只与一个 worker 冲突
- 若只跟一个 worker 冲突,coordinator 线程就会把这个事务分配给这个存在冲突关系的 worker
-
这个按表分发的方案,在多个表负载均匀的场景里应用效果很好
- 若碰见热点表,则所有事务都会被分配到同一个 worker 中,就变成单线程复制
-
- 按行分发策略(非官方
- 按行复制的核心思路是:如果两个事务没有更新相同的行,它们在备库上可以并行执行
- 这个模式要求 binlog 格式必须是 row
- 基于行的策略,事务 hash 表中不仅需要考虑唯一键的值,而且还需要考虑唯一键
- 即 key 应该是“库名 + 表名 + 索引 a 的名字 +a 的值”
- 栗子:要在表 t1 上执行 update t1 set a=1 where id=2 语句
- key=hash_func (db1+t1+“PRIMARY”+2) , value=2;(主键ID修改前后值不变
- key=hash_func (db1+t1+“a”+2), value=1;
- key=hash_func (db1+t1+“a”+2), value=1;
- 按行分发的策略有两个问题
- 耗费内存( row 格式造成,删除100万数据则要记录100万个项
- 耗费 CPU(解析 binlog,然后计算 hash 值,对于大事务,这个成本还是很高的
- 单个事务如果超过设置的行数阈值(比如单个事务更新10万行)就退化为单线程模式
- coordinator 暂时先 hold 住这个事务
- 等待所有 worker 都执行完成,变成空队列
- coordinator 直接执行这个事务
- 恢复并行模式
- 按表并行分发与按行并行分发均存在的约束条件(遵守的线上使用规范 可以提前规避
- 要能够从 binlog 里面解析出表名、主键值和唯一索引的值(格式必须是 row
- 表必须有主键
- 不能有外键(级联更新的行不会记录在 binlog 中,这样冲突检测就不准确
- 按行复制的核心思路是:如果两个事务没有更新相同的行,它们在备库上可以并行执行
- 按表分发策略(非官方
-
MySQL 5.6 版本的并行复制策略
- 官方 MySQL5.6 版本,支持了并行复制,只是支持的粒度是按库并行
- 用于决定分发策略的 hash 表里,key 就是数据库名
- 这个策略的并行效果,取决于压力模型
- 如果在主库上有多个 DB,并且各个 DB 的压力均衡,使用这个策略的效果会很好
- 如果你的主库上的表都放在同一个 DB 里面,这个策略就没有效果了(业务逻辑库与系统配置库
- 相比于按表和按行分发,这个策略有两个优势
- 构造 hash 值的时候很快,只需要库名(实例上的 DB 数也不会很多
- 不要求 binlog 的格式(任何格式均能拿到 库名
- 官方 MySQL5.6 版本,支持了并行复制,只是支持的粒度是按库并行
-
MariaDB 的并行复制策略
- redo log 组提交 (group commit) 优化 所附属特性
- 能够在同一组里提交的事务,一定不会修改同一行
- 主库上可以并行执行的事务,备库上也一定是可以并行执行的
- MariaDB 利用此特性的做法(模拟主库的并行模式,而不是以往 “分析 binlog,并拆分到 worker”
- 在一组里面一起提交的事务,有一个相同的 commit_id,下一组就是 commit_id+1
- commit_id 直接写到 binlog 里面
- 传到备库应用的时候,相同 commit_id 的事务分发到多个 worker 执行
- 这一组全部执行完成后,coordinator 再去取下一批
- 这个策略有一个问题,它并没有实现 “真正的模拟主库并发度” 这个目标
-
在主库上,一组事务在 commit 的时候,下一组事务是同时处于 “执行中” 状态的
-
主库并行事务
-
MariaDB 并行复制,备库并行效果
- 备库上执行时,要等第一组事务完全执行完成后,第二组事务才能开始执行(吞吐量不够
- 这个方案很容易被大事务拖后腿(若存在一个超大事务,则最后只有一个 worker 工作
-
- redo log 组提交 (group commit) 优化 所附属特性
-
MySQL 5.7 的并行复制策略
-
参数 slave-parallel-type 来控制并行复制策略(方案类似于 MariaDB 并行复制实现
- 配置为 DATABASE,表示使用 MySQL 5.6 版本的按库并行策略;
- 配置为 LOGICAL_CLOCK,表示的就是类似 MariaDB 的策略(针对并行度做了优化
-
两阶段提交细化过程图
- MySQL 5.7 并行复制策略的思想
- 同时处于 prepare 状态的事务,在备库执行时是可以并行的
- 处于 prepare 状态的事务,与处于 commit 状态的事务之间,在备库执行时也是可以并行的
- MySQL 5.7 并行复制策略的思想
-
-
MySQL 5.7.22 的并行复制策略
- 参数 binlog-transaction-dependency-tracking:用来控制是否启用基于 WRITESET 的并行复制
- COMMIT_ORDER,同 MySQL 5.7 的并行复制策略
-
WRITESET:表示的是对于事务涉及更新的每一行,计算 hash 值,组成集合 writeset
- 如果两个事务没有操作相同的行,也就是说它们的 writeset 没有交集,就可以并行
- hash 值是通过 “库名 + 表名 + 索引名 + 值” 计算出来的
- WRITESET_SESSION,是在 WRITESET 的基础上多了一个约束(事务保证相同的向后顺序
- 相对于 MySQL 5.5 版本按行分发策略(非官方)的优势
- writeset 是在主库生成后直接写入到 binlog 里面的,这样在备库执行的时候,不需再次解析
- 不需要把整个事务的 binlog 都扫一遍才能决定分发到哪个 worker,更省内存
- 由于备库的分发策略不依赖于 binlog 内容,所以 binlog 是 statement 格式也是可以的
- 同样,对于 “表上没主键” 和 “外键约束” 的场景,也会暂时退化为单线程模型
- 官方 MySQL5.7 版本新增的备库并行策略修改了 binlog 的内容,即并不是向上兼容的
- 在主备切换、版本升级的时候需要把这个因素也考虑进去
- 参数 binlog-transaction-dependency-tracking:用来控制是否启用基于 WRITESET 的并行复制
一主多从
-
一主多从基本结构
- 虚线箭头表示的是主备关系,也就是 A 和 A’互为主备, 从库 B、C、D 指向的是主库 A
- 一主多从设置一般用于读写分离,主库负责所有的写入和一部分读,其他的读请求则由从库分担
-
一主多从基本结构 – 主备切换
-
一主多从结构在切换完成后,A’会成为新的主库,从库 B、C、D 也要改接到 A’
-
主备切换过程
-
基于位点的主备切换
- 通过 sql_slave_skip_counter 跳过事务和通过 slave_skip_errors 忽略错误
-
基于 GTID 的主备切换
- GTID 的全称是 Global Transaction Identifier,也就是全局事务 ID
- 一个事务在提交的时候生成的,是这个事务的唯一标识(GTID=server_uuid:gno
- 事务中的 transaction_id 与 gno 的区别
- 事务 id 是在事务执行过程中分配的,如果这个事务回滚了,事务 id 也会递增
- gno 是在事务提交的时候才会分配(GTID 往往是连续的
- GTID 启动:启动实例时加上参数 gtid_mode=on 与 enforce_gtid_consistency=on
- GTID 有两种生成方式(使用哪种取决于 session 变量 gtid_next 的值
-
如果 gtid_next=automatic,代表使用默认值
- 记录 binlog 时,先记录 SET @@SESSION.GTID_NEXT=‘server_uuid:gno’
- 把这个 GTID 加入本实例的 GTID 集合
-
如果 gtid_next 是一个指定的 GTID 的值(通过 set gtid_next='current_gtid’指定
- 如果 current_gtid 已经存在于实例的 GTID 集合中,直接忽略这个事务
- 若未存在,就将这个 current_gtid 分配给接下来要执行的事务
- 系统不需要给这个事务生成新的 GTID,因此 gno 也不用加 1
- 一个 current_gtid 只能给一个事务使用,使用后就要重新 set 赋值
-
如果 gtid_next=automatic,代表使用默认值
- 每个 MySQL 实例都维护了一个 GTID 集合,用来对应 “这个实例执行过的所有事务”
- 在基于 GTID 的主备关系里,系统认为只要建立主备关系,就必须保证主库发给备库的日志是完整的
- 如果实例 B 需要的日志已经不存在,A’就拒绝把日志发给 B(返回错误- 不同点:基于位点的主备协议,是由备库决定的
- 备库指定哪个位点,主库就发哪个位点,不做日志的完整性判断
- 不同点:基于位点的主备协议,是由备库决定的
- GTID 的全称是 Global Transaction Identifier,也就是全局事务 ID
-
基于位点的主备切换
-
读写分离
-
读写分离架构(目标都是分摊主库的压力
-
读写分离基本结构
- 客户端(client)主动做负载均衡,这种模式下一般会把数据库的连接信息放在客户端的连接层
- 由客户端来选择后端数据库进行查询
- 客户端(client)主动做负载均衡,这种模式下一般会把数据库的连接信息放在客户端的连接层
-
带 proxy 的读写分离架构
- 在 MySQL 和客户端之间有一个中间代理层 proxy,客户端只连接 proxy
- 由 proxy 根据请求类型和上下文决定请求的分发路由
- 在 MySQL 和客户端之间有一个中间代理层 proxy,客户端只连接 proxy
-
客户端直连和带 proxy 的读写分离架构区别
-
前者因为少了一层 proxy 转发,所以查询性能稍微好一点儿,并且整体架构简单,排查问题更方便
- 后端需要部署细节,所以在出现主备切换、库迁移等操作的时候,客户端都会感知到,并且需要调整数据库连接信息
-
带 proxy 的架构,对客户端比较友好(客户端不需要关注后端细节,连接维护均由 proxy 完成
- 对后端维护团队的要求会更高,同时架构的整体就相对比较复杂
-
前者因为少了一层 proxy 转发,所以查询性能稍微好一点儿,并且整体架构简单,排查问题更方便
-
-
过期读:在从库上会读到系统的一个过期状态
-
由于主从可能存在延迟,客户端执行完一个更新事务后马上发起查询,查到到从库未更新的状态
-
处理过期读的方案汇总
- 强制走主库方案(根据不同的业务逻辑控制路由走主库还是从库,即是否允许过期读
- sleep 方案(select sleep (n) …
-
判断主备无延迟方案(无法杜绝过期读场景
- 判断 seconds_behind_master 是否已经等于 0
- 对比位点确保主备无延迟
- 对比 GTID 集合确保主备无延迟
- 配合 semi-sync 方案
- 等主库位点方案
- 等 GTID 方案
-
配合 semi-sync 方案
- 半同步复制(semi-sync replication)设计
- 事务提交的时候,主库把 binlog 发给从库
- 从库收到 binlog 以后,发回给主库一个 ack,表示收到了
- 主库收到这个 ack 以后,才能给客户端返回 “事务完成” 的确认
- semi-sync 配合前面关于位点的判断,就能够确定在从库上执行的查询请求,可以避免过期读
- semi-sync + 位点判断的方案,只对一主一备的场景是成立的(请求其他从库导致过期读
- 在持续延迟的情况下,可能出现过度等待的问题(更新高峰期时,位点等值判断一直不成立
- 半同步复制(semi-sync replication)设计
-
等主库位点方案
-
命令:select master_pos_wait(file, pos[, timeout]);
- 它是在从库执行的
- 参数 file 和 pos 指的是主库上的文件名和位置
- timeout 可选,设置为正整数 N 表示这个函数最多等待 N 秒
- 返回结果是一个正整数 M
- 表示从命令开始执行,到应用完 file 和 pos 表示的 binlog 位置,执行了多少事务
- 若执行期间发生异常,返回 NULL;等待超时返回 -1;
-
master_pos_wait 方案
-
trx1 完成后马上执行 show master status 得到当前主库执行到的 File 和 Position
-
选定一个从库执行查询语句
-
在从库上执行 select master_pos_wait (File, Position, 1)
-
如果返回值是 >=0 的正整数,则在这个从库执行查询语句
-
否则,到主库执行查询语句(退化机制,因为延迟时间不可控,不能无限等待下去
-
按照我们设定不允许过期读的要求,就只有两种选择(业务开发同学需做好限流策略
- 超时放弃
- 转到主库查询
-
-
-
GTID 方案
-
命令:select wait_for_executed_gtid_set(gtid_set, 1);
- 等待,直到这个库执行的事务中包含传入的 gtid_set,返回 0
- 超时返回 1
-
wait_for_executed_gtid_set 方案
-
trx1 事务更新完成后,从返回包直接获取这个事务的 GTID,记为 gtid1
-
选定一个从库执行查询语句
-
在从库上执行 select wait_for_executed_gtid_set (gtid1, 1)
-
如果返回值是 0,则在这个从库执行查询语句
-
否则,到主库执行查询语句
-
跟等主库位点的方案一样,等待超时后是否直接到主库查,均需要开发同学做限流策略
-
-
-