2.3.2 以PolarDB-M为代表的Share Everything集群
Share Everything集群解决方案曾经是众多老牌厂商的看家法宝,比如Oracle基于共享存储的RAC,底层文件系统依赖Oracle ASM;再比如前面提到的,Microsoft基于Windows Failover Cluster的SQL Server FCI技术,文件系统基于Cluster+NTFS。这些解决方案有一个无法绕过的设备,那就是共享存储设备。对于这个设备,最有名的厂商要数EMC(如今被DELL收购)。这个设备非常昂贵,所以Share Everything集群解决方案实实在在是一个烧钱的方案。
于是,著名的“去IOE”运动走上历史舞台,即号召行业内不再使用IBM的小型机、Oracle数据库和EMC存储设备,改而使用开源平台(即Linux)的廉价主机和本地存储设备。一时间,基于日志复制的Share Nothing结构成为主流,主从高可用成为首选高可用方案。
时间来到云计算时代,对于云计算厂商来说,存储设备并不是非常高的门槛,大量的云化IaaS、PaaS业务,底层使用的均是虚拟化后的存储设备。以阿里云为例,阿里云底层的飞天系统,已经足够实现共享存储所需的所有虚拟化前置条件,关键问题在于能否有一个配套的文件系统,来支撑数据库业务在云环境中的共享存储访问管控。PolarFS(Polar File System)就是在这个背景下走上历史舞台的,如图2-18所示。
图2-18 阿里云PolarDB集群实现
搞定了文件系统,还需要搞定数据库的计算和存储分离。
相比于前文所阐述的数据仓库的计算和存储分离,OLTP系统强调线上数据的一致性,所以计算节点如何做好高效一致性,并且还能做好共享存储的持久化,成为一个重要课题。PostgreSQL先天具备物理复制的特性,所以升级到PolarDB的架构显得顺理成章,于是PolarDB-P(兼容PGSQL)、PolarDB-O(兼容Oracle)正式面世。
计算和存储分离,除了各个节点能够更加专注做自己擅长的领域,而且从弹性的角度来看,也是有不可忽略的优势的。传统的MySQL和PGSQL,无论是物理复制还是逻辑复制,如果本地资源耗尽需要升配,则不可避免地要进行数据搬迁。这时候就需要通过物理备份,加上apply log的方式进行数据搬迁,这已经是最快的搬迁方法了。比如一个1TB的数据库,可能要搬迁一整天。而计算和存储分离后,计算节点的扩容和存储解耦,无论是垂直扩展还是水平扩展都非常快,只需要应用一小段Redo Cache即可。存储节点的扩容同样依托存储设备,其热扩容能力显然强于本地SSD(固态硬盘)。由此可见,分离后的弹性明显强于传统物理机版本实例。
所以准确地说,PolarDB不能算标准的Share Everything集群,虽然能够看到明显的共享思想在里面,但它其实是共享存储。分片的位置,决定了其不同的能力特点,如图2-19所示。
图2-19 共享存储数据库分片选择
前面2.3.1节我们讲解了Share Nothing的分片方式,在整个MySQL最上层,即服务层之前进行了一次分片,所以表都是分开的。
Share Everything集群,究竟在哪里做分片是非常有讲究的。Spanner选择在存储引擎上游做分片,这对兼容性是有非常大的牺牲的,但能获得更好的扩展性;Aurora选择在redo日志上做分片,这种“redo即数据”的思想,主要是为了减少网络开销;PolarDB-M选择在Disk前做分片,兼容性是最好的。
2.3.2.1 PolarDB-M的物理复制
MySQL的逻辑复制,在计算和存储分离后还可靠吗?笔者相信业内肯定会有基于binlog的计算和存储分离方案。而我们担心的是另外一个问题,即:binlog是否是MySQL的性能瓶颈之一?
前面我们讨论了,binlog是MySQL原生在服务层实现的逻辑复制日志,而非InnoDB存储引擎所必需的。两者为了相互配合,实现了两阶段提交模型。但binlog有一个非常大的I/O瓶颈,即每次binlog同步到磁盘时都非常慢。
下面让我们详细介绍一下binlog的写文件过程。
binlog文件并没有被预分配大小,它是自动增长的。
这会有问题吗?答案是会有影响。
我们来对比一下PGSQL和SQL Server。PGSQL的WAL文件是预分配16MB大小的,SQL Server的事务日志是可以控制扩展长度的,但实际上,调优好的SQL Server系统日志文件是不会扩大的,它会不断地被日志备份清理。
从文件系统层面来说,如果一个I/O要访问的地址是已经预分配好的,那么文件系统几乎不用做额外的维护,这类I/O被称为Replace I/O; 而如果一个I/O要访问的地址是一个虚拟地址,并没有分配实际的物理空间,那么文件系统需要更改自己的元数据,记录匹配关系,这类I/O被称为Append I/O。
大部分主流文件系统,目前都使用稀疏文件(Sparse File)来管理元数据,这就意味着文件系统非常担心有元数据碎片。在Windows的NTFS下,通常不推荐SQL Server使用64GB以上的文件。Linux早期的文件系统Ext2 也只能支持单文件32GB大小(比如Oracle经常会以32GB作为一个数据文件的大小)。
所以,即便现在都使用Ext3/Ext4文件系统了,Append I/O导致文件系统压力过大的问题也依然存在,最严重时,甚至会发现在D进程里,文件系统的进程如jbd2:dm-x-x赫然在列,文件系统的卡顿直接对线上I/O产生影响。这也是为什么突然删除一个大文件,I/O会抖动的原因,因为对于文件系统来说,它们都是一个非常大的元数据维护作业。
搞明白这一点,我们认识到binlog对MySQL本身的性能限制。很多人都在问,几乎所有的关系型数据库都支持物理复制,那MySQL是否支持?
PolarDB MySQL彻底推翻了传统MySQL基于binlog的复制原理,改造了redo的格式,改用redo日志进行复制。在共享存储的环境中,计算节点分离,如何才能尽可能快地传递复制信息到对方的计算节点,而非通过存储节点转发,成为一个重要课题。这时候,物理复制的日志小,物理页的操作就体现出它的优势了。
让我们来看一个例子,PolarDB-M如何通过redo的方式实现物理复制。
假设RW节点的一个数据行,从1改成了2,此时提交,redo日志被LGWR刷到了Log文件里,但在Data文件里并没有更新这个值(因为还没有刷脏)。RW节点的Msg Sender会同步给RO节点,当前RW最新的LSN号是多少,RO节点的Msg Receiver也会同步自己读取到的最新LSN号是多少。而这当中的GAP,会被保存在RO节点的Redo Cache中。
假设在RO节点的Buffer Pool(缓冲池)中刚好存在这条记录,但显然已经不是最新的副本了,如果有请求需要访问RO节点的这条记录,那么RO节点会使用内存页的值,即1,去应用redo日志(apply redo log),得到2,再返回给客户端。
如果在RO节点的Buffer Pool中没有这条记录,当RO被请求到这条记录时,则会去冷读Data文件,读到1,再应用redo日志,得到2,返回给客户端,如图2-20所示。
图2-20 阿里云PolarDB-M缓存融合(Cache Fusion)
通过这样的方式,保证了没有刷脏的数据(存储节点异步)依然可以在计算节点上实现一致性。而磁盘刷脏后,RO节点也会相应地清理Redo Cache中的冗余记录。
这虽然看上去是做复制,但实际上更像是缓存融合的功能。只不过在Oracle RAC中,缓存融合依赖多套组件进行节点之间的数据块复制(Page/Block Copy)和锁的共享,PolarDB-M使用的是基于redo复制的内存融合。
从计算层面来看都没问题了,但依然存在一个很残酷的问题,就是锁的问题。
2.3.2.2 PolarDB-M锁的实现
正常的单机数据库,锁是由事务引擎提供的,在MySQL中则由InnoDB来负责实现。而在逻辑复制的主从结构中,锁是由节点本身自行实现和控制的,因为两者的锁不会产生交集。而在物理复制的主从结构中,锁是绕不过去的话题,在前文所阐述的PGSQL物理复制的场景中,我们也讨论了PGSQL在特殊的几类场景中,锁还会对物理复制性能有影响。SQL Server的物理复制虽然没有因为锁而影响到性能,但实际上它是做了行为优化的。
PolarDB-M和PGSQL的物理复制有相似的地方,即DML锁并不会影响到整个主从结构,或者说整个集群,它的生命周期和其他节点不会有交集。真正有交集的是DDL语句,DDL语句会改变元数据,不同于PGSQL的元数据MVCC问题,PolarDB-M的数据文件逻辑上只有一份,更不用说元数据了。因此,如何在DDL语句执行时给元数据完整加上排他锁,就显得非常重要,否则会因为元数据不一致,引发一系列数据问题。
PolarDB-M的DDL解决方案是用来传递MDL锁的。众所周知,锁是不会被写入事务日志的,比较好的情况是有些数据块会把锁的信息写入错误日志中,而不是事务日志中,如PGSQL。为了实现通过事务日志传递MDL锁,我们针对redo日志进行了一定程度的改造,让redo日志具备了可以传递MDL锁的能力。有了这个能力,我们就可以在DDL语句执行时,在RW节点上记录需要锁定的MDL锁,广播到所有RO节点,停止访问这个元数据,实现元数据更改的一致性。
DDL在准备(Prepare)、实现(Perform)和提交(Commit)三个阶段,执行过程大致如下:
在DDL准备阶段,Master节点拿到元数据MDL EX锁(排他锁),会通知其他RO节点获取MDL EX锁,如果这个时候有RO节点正在访问相关数据,持有元数据MDL SH锁(共享锁),则会导致广播被阻塞,直到所有RW和RO节点都获得MDL EX锁;执行实现阶段并释放MDL EX锁,等到提交阶段有需要MDL EX锁时,会再进行一次广播。
PolarDB-M的8.0和5.6版本略有一些差异,但基本上是MySQL原生的结构差异,比如8.0版本中引入了数据字典(DD)的概念,并且有了innodb_ddl_log,所以在步骤上会多一步post-ddl的操作。在MDL锁传递的过程中,则没有太大的区别,如图2-21和图2-22所示。
图2-21 阿里云PolarDB-M 5.6 DDL实现过程
图2-22 阿里云PolarDB-M 8.0 DDL实现过程
这个方案的优点是,通过事务日志传递MDL锁的巧妙方法,有效控制了元数据的一致性读/写。但既然是广播方案,广播的缺点也会被继承。还记得2.1.1.1节讲到Redis的注意事项,就是要减少广播命令如keys,这里也是一样的。一旦RW节点发起MDL广播,如果有一个RO节点的MDL锁获取比较慢,就会导致其他所有RW和RO节点等待,这无疑是一种指定对象(Object)的可用性降级。所以,和其他关系型数据库一样,我们建议谨慎规划和执行DDL语句。而在PolarDB-M中,我们还要额外注意这个MDL锁的广播影响面。
针对这块的瓶颈,我们的长期方案是通过多版本元数据来彻底解决全局MDL锁带来的等待问题。
2.3.2.3 PolarDB-M优化器亮点并发查询的实现
在MySQL 8.0中,我们欣喜地发现,MySQL首次增加了并发读特性(Parallel Query,以下简称PQuery,即并发查询)。然而,遗憾的是,这个并发特性只能用于PK。换言之,几乎只有select count (*)的场景,才能享受到这个特性。
在Oracle和SQL Server中,并发扫描是家常便饭。Oracle有一个快速全索引扫描(Fast Full Index Scan),即多线程扫描一个对象。而SQL Server的并行索引扫描(Parallel Index Scan)同样允许多线程协作扫描某个大任务。MySQL一直以来不具备多线程能力,哪怕遇到再大的单表,也只能一个线程扫描,所以吞吐能力受到限制。
并发查询,从本质上说,就是以CPU使用率换取时间的策略,这在数据库乃至计算机领域都是非常常用的一种思路。当然,CPU不可能完美地把一个任务切分成多份,在并发运行时,在等待事件中时常能看到并发等待锁,比如SQL Server的CXPackets等待事件。
要想实现这种并发查询,需要服务层和存储引擎层都实现才行。存储引擎层主要是进行I/O子通道的协调,在技术上难度没有那么大。真正高难度的,是如何在优化器中搞定这个切分执行。
这时可能有读者会问,不就是分发任务吗?使用MapReduce思想有什么难度吗?
实际上,这当中包含多种场景,比如select * from table;,确实只需要分发,然后聚合。再比如select * from table where id =n order by id desc;,是先排序还是先分发任务呢?如果先分发任务,那么order by子句是否需要在每个分片中执行?
事实上,我们使用的是Leader+Worker这样的Exchange结构,首先依托Leader对数据进行分区,然后每个分区都由不同的Worker来完成任务,如图2-23所示。如果有order by、group by子句等,也在本分区中完成执行,然后再聚合。
图2-23 阿里云PolarDB-M并发查询架构示意图
我们在performance_schema下已经设置了相关监控项,可以这样打开:
如图2-24所示,从这个结果集可以看到,哪些SQL语句执行了并发查询。
图2-24 阿里云PolarDB-M的performance_schema对应的视图
此外,在执行explain时,也可以看到并发查询的具体计划。
2.3.2.4 PolarDB-M集群访问的实现
在一个集群中,因为不同节点的状态不同,所以实际的数据整合方式也不同。前面介绍了只读复制逻辑,本节将介绍基于只读的PolarDB-M集群访问的实现原理。
1. PolarDB-M Proxy的一致性实现
阿里云PolarDB-M Proxy的一致性实现原理示意图如图2-25所示。
图2-25 阿里云PolarDB-M Proxy的一致性实现原理示意图
(1)最终一致性
RW和RO节点是异步复制关系,RW节点写入一条记录,在同一时间,并不是所有RO节点都已经应用这条记录。在同一个会话中,如果多次执行select请求,那么它可能会被分发到不同的RO节点,但某些RO节点因为复制延迟的关系,在当前时间点还查询不到。几秒后,重新执行select,就能够查询到了。这就是所谓的最终能查询到一致的结果,称作最终一致性。
(2)会话一致性
会话一致性是最终一致性的升级版本。在同一个会话中,假如RW节点写入一条记录,这个时候redo日志会有一个序列号(LSN),比如33,如果这个会话紧接着请求(select)这条记录,那么Proxy会判断哪些RO节点的当前LSN大于33,并路由到这些符合条件的RO节点上,这样就能保证会话层面的一致性。
(3)全局一致性
全局一致性是会话一致性的升级版本。会话一致性只能保证在同一个会话中,将SQL请求正确路由到符合LSN范围要求的RO节点上。假如多个会话之间存在因果关系,并且有强一致性要求,那么就需要实现全局一致性。
但是全局一致性的实现非常复杂,所以对于强一致性要求,我们通常不会这样去处理,而是使用主节点入口,从主实例进入,这样读/写都发生在RW节点上,就能够满足一致性要求了。
2. Proxy读/写分离和负载均衡
Proxy除了能够按照不同的一致性要求去路由SQL请求,更重要的是,它也可以实现读/写分离和负载均衡。应该说,读/写分离是负载均衡的一个方法论。
读/写分离的技术基础,就是前面讲到的复制。PolarDB-M的复制优势就是物理复制带来的低延迟、稳定性,复制的性能和稳定性是读/写分离最重要的指标。读/写分离还有一个质量指标是一致性。上面已经讲解了Proxy关于一致性的保证,下面介绍SQL路由的两个基本思想。
(1)完全由应用层面隔离
最典型的例子是把AP系统、报表系统挂到指定的只读节点上进行只读查询,而生产业务直连主节点。
这种实现方式对应用的要求比较高,如果应用的耦合做得比较好,则确实能控制不同模块的请求类型。但大部分应用使用的是混合访问模型,既有读/写请求,也有只读请求。
(2)完全由Proxy层面路由
完全由Proxy层面路由,也是一个常用思路,由Proxy决定分发给哪个节点来处理。最简单的分流思路就是单纯按照命令,将select请求统一分发给只读节点,将DML语句统一分发给读/写节点。
这当中有两个难点,其中一个难点是前文阐述的一致性问题,另一个难点是对事务的处理。
PolarDB-M的Proxy按照真实事务设计,虽然使用了BEGIN关键词启动显式事务,但真正开始事务的是第一条DML语句。所以,只有从第一条DML语句到事务结束的语句,才是最小一致性单元,我们会将其统一分发到RW节点,如图2-26所示。
图2-26 阿里云PolarDB-M事务的具体路由