MySQL设计与实现
参考文档:
MySQL是开源的,广泛使用的RDBMS。今天我们从各个维度去扒一扒MySQL的设计和实现。
MySQL的逻辑架构
- 连接器:
- 与客户端建立连接,获取权限,维持和管理连接;
- 建立连接成功后,权限修改也不会影响已存在的权限
- 执行过程中临时使用的内存是管理在连接对象里面的。这些资源会在连接断开的时候才释放。所以,当大量连接不会被释放,可能会导致OOM,被系统杀掉。
- 分析器:
- 对SQL进行语法分析,主要有词法分析和语法分析。语法错误或者词法错误会在这一阶段提示。
- 优化器:
- 执行计划在这一阶段生成。
- 表里索引比较多时,决定使用哪一个索引;关联查询时,决定查询顺序。
- 执行器:
- 操作引擎,返回执行结果。
InnoDB架构
内存结构
缓冲池(buffer pool):缓冲池用于InnoDB在访问表和索引数据。冲池允许直接从内存访问经常使用的数据,从而加快处理速度。在专用服务器上,多达 80% 的物理内存通常分配给缓冲池。
为了提高大量读取操作的效率,缓冲池被划分为可能包含多行的页。为了缓存管理的效率,缓冲池被实现为页的链表;使用LRU 算法优化内存使用。
变更缓冲(change buffer):Change buffer的主要目的是将对二级索引的数据操作缓存下来,以此减少二级索引的随机IO,并达到操作合并的效果。
日志缓冲(log buffer):日志缓冲区是保存要写入磁盘上日志文件的数据的内存区域。
自适应哈希索引(Adaptive Hash Index):InnoDB存储引擎会监控对表上索引的查找,如果观察到建立哈希索引可以带来速度的提升,则建立哈希索引,所以称之为自适应(adaptive) 的。自适应哈希索引通过缓冲池的B+树构造而来,因此建立的速度很快。而且不需要将整个表都建哈希索引,InnoDB存储引擎会自动根据访问的频率和模式 来为某些页建立哈希索引。
硬盘结构
表
索引
表空间
双写缓冲
redo log
undo log
MySQL的高可用部署方案
存储系统的常见高可用方案:主备,主从,分片。
主备复制:主备架构一般是主机承担所有任务,如读写操作,执行操作。当主机宕机时,需要人工恢复主机或者备机作为新主机。
主从复制:主机提供读写,从机只提供读操作。当主机宕机时,从机还能继续支撑一段时间的读任务。
双机切换:在上面两个方案的基础上增加“切换”功能,即系统自动决定主机角色,并完成角色切换。在判断决策这块有两个问题点:
1.如何判断状态:是相互连接判断(Redis哨兵)还是交给3方(zookeeper) ,需要检测节点的哪些内容(主观下线还是客观下线)
2.如何做切换决策:什么时候备机升级成主机,怎么选择新主机(多机)?原主机恢复后怎么办?是否需要人工介入?新旧主机存在数据冲突怎么办?
主主复制:两台都是主机,不存在切换的概念;客户端无需区分角色,读写请求随便发给哪台。但是这个问题也很明显,数据无法双向复制。
数据同步的方案分为两个维度:1.复制格式:命令,数据,文件;2.复制方式:同步,异步,半同步,多数同步。
复制格式里,复制命令的方式实现简单,适合增量复制的场景但是可能出现数据不一致的情况。复制数据的方式虽然能保证数据的一致性,但是流量可能会比较大。文件复制更适合全量复制,流量可能也会大一些。
复制方式里,同步复制更适合主备/主从架构,能保证最强一致性,写实写入性能比较低;异步复制故障容忍度高,但是容易出现数据不一致的情况,更适合集群的复制;半同步的方式是上面两种的折中方案,也同样适合集群复制;多数复制让我想起了Redlock,他的数据一致性强,故障容忍度高,一般应用在分布式一致性,分布式协同里面。
MySQL通过复制binlog的方式传递数据,而binlog有两种格式,分别是statement(命令)和row(数据)。在真正数据传输时,MySQL允许使用mixed模式,也就是statement和row的混合模式。从复制方式上,MySQL支持异步和半同步复制。
顺便说下Redis,他支持命令和RDB(文件)以及混合模式,复制方式也支持异步和wait(半同步)的方式。
我们再来看看集群(3台以上)的存储系统高可用方式。
1.数据集中式
主从,主备,主从从这种,数据只在主节点写,读可以在主从备节点上执行(或者读写分离)。
如:zookeeper,Redis Sentinel(就当Sentinel算吧)。
数据集中式的方案,要考虑到数据复制,状态检测和主机切换。
有状态的服务一般水平扩展有一定的瓶颈。
2.数据分散式
数据分散式指多个节点都各自存储一部分数据。同时,每个节点又会备份一部分数据。
如:Kafka,Elastic,Redis Cluster,MySQL的垂直分表和分库。
数据分散式的方案要重点考虑任务分配,尽量避免数据倾斜。所以,均衡性,容错性(节点故障切换访问路由等),伸缩性(扩展时算法将部分数据自动移动到新节点以保证均衡性)。
分散集群和集中集群主要区别是,分散集群每个节点都可以处理读写请求,不存在集中式只负责写的角色。
但是数据分散集群中的数据分配算法必须有一台独立的或者集群中的一个来执行。
如Redis Cluster分配Slot。
路由策略一般有SDK式(Redis)的和节点Proxy(Oracle dblink)式。
服务的状态决策比较常见的有民主式/选举式和独裁式。
独裁式的决策逻辑简单,但是决策者角色要做到高可用。
民主式的决策过程复杂,决策逻辑也复杂,一般有标准的算法,如Raft,ZAB,Paxos。但是他可用性高,数据一致性强,但是有脑裂问题。
民主式决策一般用来选举Leader/Master节点,独裁式一般作为决策角色对外提供服务(微服务的注册中心)。
我们来看看MySQL的高可用方案。
MySQL主备复制 / replication
MySQL 主备复制见:https://dev.mysql.com/doc/refman/8.0/en/replication.html
MySQL binlog复制相关见:https://dev.mysql.com/doc/refman/8.0/en/replication-configuration.html
MySQL默认异步复制,半同步复制见:https://dev.mysql.com/doc/refman/8.0/en/replication-semisync.html
MySQL延迟复制见:https://dev.mysql.com/doc/refman/8.0/en/replication-delayed.html
MySQL数据格式复制见:https://dev.mysql.com/doc/refman/8.0/en/replication-formats.html
MySQL数据一致性见:https://dev.mysql.com/doc/refman/8.0/en/replication-gtids.html
MySQL复制的实现见:https://dev.mysql.com/doc/refman/8.0/en/replication-implementation.html
MySQL多主 / group
InnoDB Cluster
一个 InnoDB Cluster 至少由三个 MySQL Server 实例组成,它提供高可用性和扩展特性。InnoDB Cluster 使用以下 MySQL 技术:
- MySQL Shell,它是MySQL的高级客户端和代码编辑器。
- MySQL Server 和Group Replication,它使一组 MySQL 实例能够提供高可用性。InnoDB Cluster 提供了一种替代的、易于使用的编程方式来使用 Group Replication。
- MySQL Router,一个轻量级的中间件,它在你的应用程序和 InnoDB 集群之间提供透明的路由。
MySQL的事务与实现方式
事务:原子性,持久性,隔离性,一致性
持久性
定义:事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
所以,只要 redo log 和 binlog 保证持久化到磁盘,就能确保 MySQL 异常重启后,数据可以恢复。
binlog的写入机制:
事务执行过程中,先把日志写到 binlog cache,事务提交的时候,再把 binlog cache 写到 binlog 文件中。
一个事务的binlog不可拆分。系统为每个线程分配了binlog chache,如果大于分配内存,会暂存磁盘。在事务提交时写入磁盘并清空cache。
类似于Redis的AOF写入机制,binlog的写入分为写入pagecache,写入磁盘和先写pagecache,在N个事务后写入磁盘。
关于更多binlog内容见:
redolog的写入机制:
事务执行过程中,先修改数据内存,把日志写入redo log buffer中,事务提交把日志写入redo log中。
redolog提供了3中写入策略:只写到redo log buffer;写到page cache;写入磁盘。注意这里是redo log的写入机制。
redo log与数据的关系如下:
写完redo log不会主动刷盘更新数据。
1.正常运行的实例提交完数据后会产生脏页,触发条件后刷脏页。
2.在崩溃恢复后,InnoDB会判定数据页丢失了数据或者没有更新,会把这个数据也加载到内存,然后让redolog更新内存数据。然后这个数据页就变成了脏页。
redo log是有大小限制的,他是循环(顺序)写的方式,超过限制会强行刷redolog。
关于更多redolog内容见:
这里区分一个概念,
binlog是作为归档用的,MySQL的server层依赖binlog,切换了引擎或者高可用的数据同步都需要binlog。这里放一下关于binlog的官方文档:https://dev.mysql.com/doc/refman/8.0/en/mysqlbinlog.html
redolog是恢复崩溃用的,是InnoDB保证持久化的一个手段。
这里引官方的一句话:The redo log is a disk-based data structure used during crash recovery to correct data written by incomplete transactions.
机翻为:重做日志是基于磁盘的数据结构,在崩溃恢复期间用于纠正不完整事务写入的数据。
另外提一下WAL机制,Write-Ahead Loggin,预写日志,多个日志一起操作磁盘。他支持in-place的方式更新数据库,减少索引和块列表的修改。
在很多地方都有应用,比如zookeeper。
LSM(Log-Structured merge-tree)的理念也有WAL的影子。
为了保证写入,防宕机,工程师们煞费苦心。。。
DoubleWrite
InnoDB的Page Size一般是16KB,其数据校验也是针对这16KB来计算的,将数据写入到磁盘是以Page为单位进行操作的。当文件系统对一次大数据页写入时,只完成了一部分发生了断电/on crash ,这就是Partial page write问题。
为了解决这个问题,在写数据页之前,先把这个数据页写到一块独立的物理文件位置(ibdata),然后再写到数据页。这样在宕机重启时,如果出现数据页损坏,那么在应用redo log之前,需要通过该页的副本来还原该页,然后再进行redo log重做,这就是double write。
为了解决这个问题,在写数据页之前,先把这个数据页写到一块独立的物理文件位置(ibdata),然后再写到数据页。这样在宕机重启时,如果出现数据页损坏,那么在应用redo log之前,需要通过该页的副本来还原该页,然后再进行redo log重做,这就是double write。
DoubleWrite Buffer就是这块独立的物理文件位置。
都是些磁盘,为什么要先写副本?
我个人理解的是写副本是一次顺序写入。而更新数据则是in-place的,可能会有页分裂,寻址,索引页的修改等等情况,耗时耗力。
两阶段提交
两阶段提交是跨系统维持数据逻辑一致性时常用的一个方案。
prepare 阶段
更新内存数据
写入redo log
commit 阶段
写入binlog
更新redo log为提交状态
提交事务
原子性
定义:一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被恢复(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。
rollback实现的机制主要靠undo log(undo log也是MVCC中一致性视图的实现手段,而MVCC是实现隔离性的手段)。
undolog是逻辑日志,每次增删改语句执行时,会生成对应的undolog。如:insert会生成delete。
关于更多undolog内容见:
隔离性
定义:数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交(Read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(Serializable)。
RR与RC的实现
MVCC,全称Multi-Version Concurrency Control,多版本并发控制。并发情况下非阻塞式修改数据并且做到数据隔离,还是很神奇的。
MVCC通过一致性读视图( consistent read view),支持 RC(Read Committed,读提交)和 RR(Repeatable Read,可重复读)隔离级别的实现。
可重复读:一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。
这个需求就好像在整个库里做个快照。不像redis的快照,MySQL做一次快照的快照成本是相当的高,一下子“照”出成百上千个G的数据是不现实的。
MVCC用为了实现快照做了个化整为零的方案。
InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。(binlog,redolog,undolog都是靠这个id关联的)
而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。
也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id。
现在我们总结一下,每次事务开启,我们申请一个事务编号。这个事务编号会关联本事务创建的binlog,redolog,undolog。
这样,我们拿到row trx_id,我就可以根据当前的数据和undo log,演算出之前的数据。
那么我们该怎么确定某一行数据的row trx_id是我们需要的呢?
InnoDB为每个事务构造了一个数组。这个数组用来保存本事务启动的瞬间,所有“活跃”的事务ID(什么是活跃的,一时我也想不好怎么表达,后面有时间捡这个坑)。
全库快照的功能就这样实现了。
这个时候再看RR的定义,我只认我事务启动之前的数据。我只需要访问已提交事务的ID,然后通过ID和当前数据反推出我需要的数据就实现了。
读提交的逻辑和可重复读的逻辑类似,它们最主要的区别是:
- 在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;
- 在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。
对于可重复读,查询只承认在事务启动前就已经提交完成的数据;
对于读提交,查询只承认在语句启动前就已经提交完成的数据;
串行化实现
InnoDB将所有普通SELECT 语句隐式转换为SELECT ... FOR SHARE。
只读的话:
- 如果禁用自动提交:InnoDB 将所有普通 SELECT 语句隐式转换为 SELECT ... LOCK IN SHARE MODE。
- 如果启用了自动提交,则 SELECT 是它自己的事务。
因此,它是只读的,如果作为一致(非锁定)读取执行并且不需要阻塞其他事务,则可以序列化。
如果其他事务修改了选定的行,要强制普通 SELECT 阻塞,请禁用自动提交。
这里提一下MySQL中 一些锁的概念。
MySQL 按锁的粒度和级别有行锁,表锁,库锁,有些地方也会说页锁。
行级锁
- 记录锁(record lock)
- for update(X 排他锁),对索引记录的锁定。官网把这个语法叫做记录锁。
- for share(S 共享锁),允许其他线程去读。
- 间隙锁(Gap Locks)id between 0 and 10 forupdate(不允许插入id=5);间隙锁定是对索引记录之间的间隙的锁定,或者是对第一个之前或最后一个索引记录之后的间隙的锁定。
- Next-Key Locks :是record lock 和 gap lock的组合。会锁定当前索引和范围内的索引。
表级锁
- 意向锁(Intention Locks) lock tables … read/write
- 元数据锁(metadata Lock / MDL)不允许其他线程修改表结构
- 自增锁(AUTO-INC Locks)AUTO-INC锁是一种特殊的表级锁,由插入到具有自动增量列的表中的事务使用。在最简单的情况下,如果一个事务正在向表中插入值,则任何其他事务都必须等待自己向该表中插入值,以便第一个事务插入的行接收连续的主键值。
库级锁
- Flush tables with read lock 全局只读
一致性
定义:在事务开始之前和事务结束以后,数据库的完整性没有被破坏。
这表示写入的资料必须完全符合所有的预设规则,这包含资料的精确度、串联性以及后续数据库可以自发性地完成预定的工作。
这里的一致性是数据库的一致性,不是分布式一致性或者一致性哈希(一致性这个词都快用烂了)。
引用知乎里的一段描述:
ACID里的AID都是数据库的特征,也就是依赖数据库的具体实现。实现了AID,也就保证了一致性。
感觉留了好多坑,先写到这,有时间补一下。。。
欢迎关注我的公众号:老张大魔王 >>> 不定时更新哦