读《OceanBase 数据库源码解析》
这本书才出来几个月的时间,刚好看到微信读书上已经有了,这两天就花一点时间把这本书读完,话不多说,开始。
这本书整个有九章,各章节如下:
- OceanBase 概述
- OceanBase 的架构
- OBServer
- 存储引擎
- SQL 引擎
- 事务引擎
- 高可用
- 多租户
- 安全管理
我们一章一章过一遍。
p.s. 这本书内容真的很多,值得对着代码精读一遍。
OceanBase 概述
OceanBase不需要过多赘述,大家都很熟悉了,下面是 OB的整个发展时间脉络:
里面有几个比较有意思的时间节点,一个是 14 到 15 年加入了 Paxos,另一个是 16到 18 年做了多租户,可以发现,这么一个复杂系统也是从最简单的基础功能实现开始的。
OB 的特性如下:
- 透明可扩展: OB整体做了存算分离,在总控的帮助下可以按需伸缩,伸缩后自动做负载均衡。
- 极致高可用: 通过Paxos来实现的高可用,可以保证数据的一致性和完整性。支持单机房、双机房、两地三中心、三地务中心部署。支持基于日志复制的主备库
- 混合事务和分析处理:同时支持 OLAP 和 OLTP
- 多租户:支持单集群多租户,基于租户来实现资源隔离
- 高兼容性:支持 MySQL和 Oracle 语法
- 完整自助知识产权:好
- 高性能:准内存数据库,基于 LSM Tree 实现读写性能优化,支持强一致性事务
- 安全性: 支持各类安全需求。
第一章剩下的部分基本就是介绍怎么编译安装 OB 的,这里就不多赘述了
OceanBase 的架构
OB整体采用 Shared-Nothing 的架构,集群中节点互相独立,具有比较高的扩展性。
几个关键概念:
- 分区(Partition):数据分布的基本单元,即数据分片,OB 可以基于范围、Hash、列表分区,也可以组合分区。
- 副本(Replica):分区的副本,同一个分区的多个副本共同组成 Paxos 复制组,其中有一个副本是主副本,负责所有的写操作。
- OBServer:逻辑服务器,一台物理机上可以部署一台或多个 OBServer,每个 OBServer 都包含 SQL 引擎、事务引擎和存储引擎
- 可用区(Zone):其实是 Availability Zone,一个 OB 集群由多个可用区组成,一个可用区多个 OBServer,为了提高可用性,一般会把同一分区的不同副本放在不同的可用区,以提高可用性。总控服务负责整个集群的资源调度,一般由多个可用区提供总控服务(一主多备)。
- 地域(Region):一个 Region 包含一个或多个可用区,不同 Region 相距较远。这里提到两地三中心的部署模式,主城市两个机房,每个机房各两个副本,副城市一个机房,维护一个副本,这样的好处是可以在城市内完成 Paxos 的共识
OB 集群架构如下图:
两地三中心如下图:
OB的源码文件目录结构如下:
主要的内核代码都在 src
目录下,包括 election(分布式选举)、clog(事务日志、Paxos)、archive(日志归档)、rootserver(总控)、share(公共组件)、sql(SQL 引擎)、storage(存储引擎)、observer(OBServer 的主干过程)
OBServer工作目录下通常有admin、bin、etc、etc2、etc3、lib、log、run、store这九个目录,但这九个目录并非都是必须安装的。在启动OBServer时必须要确保存在的是etc、log、run、store,同时store下应该有clog、ilog、slog、sstable这四个子目录。其中需要注意的是 store 目录,包含 clog(事务日志)、ilog(日志索引)、slog(存储日志)、sstable(基线数据)
OceanBase Database Proxy(ODP) 是 OB 的代理服务器,主要负责接收并转发 SQL 请求、路由管理、连接管理等功能。
OBServer
OBServer由 OBD(OceanBase Deployer)负责启动,启动后会基于传入参数进行选主,OBServer 的 main
函数主要执行以下启动工作:
- 解析命令行参数。
- 创建运行状态、日志、配置目录,即2.3节所述的数据目录下run、log、etc子目录。
- 调用start_daemon函数将OBServer进程从一个普通进程转变成守护进程,并将守护进程的PID(进程号)写入运行状态目录下的observer.pid文件。
- 设置运行日志管理器OB_LOGGER,日志文件是log下的observer.log,还会根据解析的命令行参数设置日志级别,日志的最大文件大小为256×1024×1024,之后便开始记录运行日志。
- 如果守护进程启动成功,则开始启动OBServer的实例(线程)。
- ObServer类的get_instance方法得到一个ObServer实例。
- 调用init方法根据命令行参数、日志配置初始化ObServer。
- 调用start方法运行OBServer进程。
- 调用wait方法等待停止信号,发现停止信号后调用stop方法停止ObServer中各个子系统。
- 如果wait结束,调用destroy方法关闭OBServer进程。
OBServer中各个子系统的生命周期也和OBServer本身一样由初始化(init方法)、启动(start方法)、等待停止(wait方法)、停止(stop方法)组成,它们分别会在OBServer的相应阶段被调用。
OBServer 的子系统如下图所示:
网络子系统的各种初始化内容不多赘述,比较重要的内容是其中初始化了几个不同的请求队列,负责缓存和处理请求:
- 租约队列leasequeue:接收租约相关的请求。
- DDL队列ddlqueue:接收DDL请求。
- MySQL队列mysqlqueue:接收除DDL之外的MySQL请求。
- 诊断队列diagnosequeue:接收诊断相关的请求。
与之对应的还有四个队列守护进程,线程名分别为:LeaseQueueTh、DDLQueueTh、MysqlQueueTh和DiagnoseQueueTh。队列守护线程的工作循环里会不断地弹出队列中的请求,然后用与队列绑定的请求Handler来处理请求。这种处理有效控制流量,避免大量请求把系统打崩。
多租户环境的资源隔离通过线程管理来实现,首先基于系统中 CPU 核数和配置信息得到工作线程的初始数量:(可用的核心数+服务器租户的虚拟核心数)×默认每核心线程数+为系统级租户预留的线程数。工作线程的最大数量是在在初始数量的基础上加上可用CPU核心数的16倍之后与硬上限 4096 取较小值。除了系统级租户所占用的线程外,OB 会产生一个叫做MultiTenant
的守护线程,用于定时对租户列表进行遍历,调用租户的 timeup 方法,该方法可以简称租户拥有的工作线程,当工作线程不足时就会从工作线程池中为租户获取工作线程。
OB中的线程整体可以分为三种类型:
- I/O线程用在网络子系统中,用于取下MySQL或RPC端口上到达的包,然后包装成内部的请求形式供工作线程消费,I/O线程及其管理机制在网络子系统初始化时建立。
- 工作线程代表各租户处理各种请求,例如SQL请求和RPC请求,工作线程及其管理机制在多租户环境初始化时建立。
- 后台线程是各个子系统执行特定任务的代理,例如用于分区的转储、合并、迁移等任务的DAG线程,它们随着各个子系统的初始化而产生。
线程的关键属性和方法如下:
start()
:启动线程的方法,在这个方法中会调用pthread_create函数创建这个对象所代表的线程,同时将现成的入口函数指向__th_start方法。__th_start()
:所属线程的入口函数,在其中会通过系统调用获取当前进程的进程ID以及线程ID并保存在pid和tid两个属性中,最后会调用runnable_属性中对象的run接口来执行线程的自定义主函数。get_pid()
:从pid_属性中返回当前线程进程ID的方法。get_tid()
:从tid_属性中返回当前线程ID的方法。pid_
:保存已启动线程所属的进程ID。tid_
:保存已启动线程的线程ID。runnable_
:用于嵌入一个Runnable对象(也可能是其子类对象),runnable_中的对象可以在Thread对象实例化或者用start方法启动线程时作为参数传入,以实现线程主函数的可定制化。
OB 的 handler 主要分两种:ObMySQLHandler和ObRpcHandler,分别执行用户请求和内部请求,这部分具体实现不多赘述。
OB 的 session 管理基于 session ID实现,具体方式如下:
- 每一个会话都会得到一个全局唯一的会话ID,客户端将保持该会话ID。
- 会话信息保存在服务器端,并以会话ID为标识。
- 客户端发送请求时会携带其会话ID,OBServer根据请求中的会话ID将请求在相应的会话“环境”中执行。
总控服务主要负责元数据管理、集群资源管理、版本合并管理、执行管理命令等功能。
- 元数据管理:RS通过心跳机制监控集群中各个OBServer的存活状态,并同步更新系统表,以及进行异常处理。同时也通过心跳向其他OBServer传输配置变更、模式变化等多种信息。all_root_table存放所有系统表的分区信息,all_tenant_meta_table存放所有用户表的分区信息,这些信息也由RS统一管理,其他OBServer执行请求时可以通过RS服务获取这些信息来定位要操纵的数据。
- 集群资源管理:集群资源管理包括Leader管理、分区负载均衡、资源单元(Resource Unit)负载均衡等任务。
- 版本合并管理:不同于小版本冻结(转储)由各个OBServer自行处理,大版本冻结(合并)由RS协调发起,是一个由RS和所有分区Leader组成的两阶段分布式事务。某个分区无主会导致大版本冻结失败。合并可以由业务写入(转储达到一定的次数,由全局参数控制)触发,也可以定时触发(例如每日合并,一般设置于业务低峰期)或手动触发。
- 执行管理命令:RS是管理命令执行的入口,包括BOOTSTRAP命令、ALTER SYSTEM命令和其他DDL命令。BOOTSTRAP是系统的自举过程,主要用于创建系统表、初始化系统配置等。DDL是指创建表、创建索引、删除表等动作,DDL不会被优化器处理,而是作为命令直接发送到RS,DDL产生的模式变更保存于系统表并更新到内存,然后产生新的版本号通知所有在线的OBServer,OBServer再刷新获得新版本的模式。
OceanBase支持了一套检测机制,通过该机制发现RS的异常,并通过运维命令强制切换RS来恢复。RS异常包括RS无主、RS上任失败、RS线程卡住、快照点回收异常、配置项异常、工作线程满、DDL线程满等,主要分为两类:一类是RS可以正常服务请求;另一类是RS不可服务。前一类可以通过停服的方式隔离异常RS并将RS切到其他机器来解决;后一类包括RS上任失败、队列满等异常,可以通过外部工具来强制切换RS服务,新RS上任后再隔离原来的异常机器。
配置子系统不多赘述,主要负责管理各种配置项。
存储引擎
先看图, OBServer 的存储引擎架构如下图所示:
这里用了典型的 LSM tree 的实现,老生常谈就不多说了。
为了提高对数据的访问速度,OceanBase存储引擎构建了两种缓存:行缓存(Row Cache)和块缓存(Block Cache)。基线数据的I/O单位是块(Block),存储引擎采用块缓存提高I/O利用率。对于行的操作会利用块缓存中的块和增量数据构造出“行”形式的数据,为了避免重复施行这种构造,构造形成的行会被放在行缓存中重复使用。除缓存的数据形式不同之外,两种缓存的使用场景也有所不同。OLTP业务大部分操作为小查询,存储引擎优先使用行缓存来回答查询,从而避免了解析整个数据块的开销,达到了接近内存数据库的性能。而对于涉及数据量很大的OLAP业务,则优先使用块缓存。
OB的元数据存储在系统表中,系统表统一存放在系统(SYS)租户中,未区分不同租户产生的元数据,这些系统表中都有一个tenant_id列用来标识该条元数据的归属,这些系统表的名称都以“all”为前缀。同时,为了方便各租户使用自己的元数据,在各租户中也定义有一些从系统表导出的视图,这些视图的名称都以“tenant”为前缀。很明显,这些视图都是系统表的简单行列子集视图,可以直接在其上进行修改操作,因此它们也被归入到“系统表”的类别之中。此外,为了方便对元数据的查看,OceanBase还提供了一些比较复杂的只读视图,它们被称为“虚拟表”,其名称以“all_virtual”或者“tenant_virtual”为前缀。
系统表初始化过程在 OB的初次启动时执行,一个OceanBase集群第一次被启动时,需要首先进行自举操作(Bootstrap)形成初始的系统表结构并且将集群中各个服务器节点加入到集群之中,通常这一动作是由OBD发起。
OBD在启动集群时会通过检查节点数据目录的clog子目录是否存在来判断是否需要进行自举动作,如果需要进行自举,则OBD会向集群发送一系列的SQL命令完成自举:
- OBD首先会发送一个BOOTSTRAP命令进行基本的自举
- 基本自举完成后,OBD还会发出若干ADD SERVER命令将RS列表中的多个节点注册到集群中
BOOTSTRAP 命令中第一个目标节点接收到请求后,会调用ObService::bootstrap()
方法进行处理,方法分为预备阶段和自举阶段,预备阶段主要是为了创建一号表(__all_core_table),自举阶段姿势负责创建其他系统表,由ObBootstrap::execute_bootstrap()
接手处理。ObBootstrap::execute_bootstrap()
如下图所示:
OB 提供多版本模式服务,各节点上都缓存有模式数据的副本,但对于模式的修改则由RootService所在的节点实施,在完成模式修改之后由RootService将新的模式版本通知其他节点,它们将会刷新各自的模式缓存。由于系统运行中会由于DDL操作导致模式版本发生变化,不同时刻开始的操作(事务)将会看到(需要)不同版本的模式信息,这套模式服务准确来说应该被称为“多版本模式服务”。
为了实现对模式的修改,OceanBase在多版本模式服务中提供了ObSchemaService
类作为DDL命令操纵模式的接口。ObSchemaService
是一个接口类,目前它仅有一个实现:ObSchemaServiceSQLImpl
类。ObSchemaServiceSQLImpl
的作用是根据外部模块的调用,返回操纵相应数据库对象的SQL服务类的对象,外部模块再利用SQL服务对象的方法完成DDL操作。
模式数据是整个数据库系统运行期间会频繁访问的信息,为了避免反复地从持久化存储中读出系统表数据,OceanBase在多版本模式服务中设置了模式缓存,被访问过的模式被驻留在位于内存中的缓存区域用于加速后续的模式访问。由于OceanBase的分布式数据库特性,集群中每个节点上都会有访问模式数据的需求,因此每个节点上都有自己的模式缓存。虽然多个节点上的模式缓存形成了多副本,但整个集群中只有RootService节点才能通过执行DDL语句修改模式信息,这些缓存之间实际是一主多从的关系,非RootService节点上的缓存会随着RootService节点的缓存变化而刷新,因此不会出现缓存不一致的问题。
模式缓存的刷新主要分为主动刷新和被动刷新。RootServer执行完DDL操作并且更新自身的模式缓存时,会产生新的模式版本号。模式版本号可以看成是一种流水号,新的模式版本号是从前一个版本号加1形成。产生新的模式版本号之后,RootServer并不采用广播的方式通知其他节点,而是等待其他节点报告心跳(续租)时随着响应信息返回给这些节点。当其他模块想要获取完整模式(会指定其模式版本)时,如果所在节点模式缓存中无法找到对应版本的完整模式,会实时触发SQL从系统表构造指定版本的完整模式,并放入到当前节点的模式缓存中。
OceanBase在物理上将数据划分成多种粒度层次进行组织。如下图所示,该层次中最粗的管理粒度是SSTable,每台OBServer上的SSTable仅对应一个物理文件block_file,这意味着OceanBase的存储引擎会将当前节点上所有表中的数据都“塞”进这个物理文件中。SSTable由若干宏块(MacroBlock)组成,宏块又由若干体积更小的微块(MicroBlock)构成,微块中则包含着数据行(Row),这也就是关系数据库操纵数据的基本单元。
数据行有两种组织结构:稀疏格式和平面格式。
稀疏格式如下,头部有各种标志位、索引位置、事务 ID、行的状态 等各种信息。在访问行中某列的时候,会先找到该列的索引位置,然后局域索引位置来读取该列的数据。
平面格式如下,平面格式的行被用于持久化存储即SSTable中,可以认为稀疏行被从MemTable转存到SSTable后就变成了平面格式的行。相对于稀疏格式,平面格式的行中没有列ID数组。这是因为稀疏格式的行中并不一定会保存所有的列值,因此需要附加列ID数组来表明行中存放了哪些列值。而平面格式的行中会包含所有的列值,故而不需要用列ID数组标记列值的存在性。正因为这种“全部包含”的特性,平面格式的行历史上也被称为稠密行。
微块是OceanBase进行读取的最小单位,由头部信息、行数组和行索引三部分构成。其中头部信息由通用块头部和微块头部组成。通用块头部存储的是数据块长度、校验信息之类的数据,微块头部存储的是微块中行列的各种信息。微块的中间部分是连续的稠密行,其中每一行的具体位置由微块最后的行索引数组确定。
多个微块组成了宏块,宏块同样包含头部信息、数据区(多个微块)、微块索引三大部分,不过在宏块的最后还可能存在一段填充区域,用于将宏块的尺寸对齐成固定的2MB。相对于微块一般不超过16KB的大小,宏块显得很大,这是因为OceanBase将宏块作为写数据的最小的单元,较大的宏块尺寸有利于更多地累积数据修改,更好地发挥磁盘的吞吐性能。
SSTable由若干个宏块组成,它表示表在某台服务器上的基线数据,也可以理解为表落在这台服务器上的分区(也可能是分区的副本),每个逻辑节点上的SSTable和表之间是一对一的关系,但同一个表由于分区的关系可能会在集群中多个节点上都拥有SSTable。SSTable仍然是一个对数据进行分组的逻辑单位,最终一个节点上的所有SSTable(来自不同的表)都集中存放于一个物理文件block_file中。SSTable在存储引擎中由ObSSTable类表达,其结构如下图所示:
位于SSTable中的是相对静态的数据(称为基线数据),按多版本并发控制(MVCC)的说法,SSTable中是数据行的旧版本。对数据行进行修改(插入、更新、删除)产生的新版本数据行则会首先被放入MemTable中,等到此类修改累积到一定程度后再从MemTable中逐渐转移到SSTable变成新的基线数据。这里其实存在一个读放大的问题,在读取某一行数据的时候,要先从 SSTable 中读取基线数据,接着再去 MemTable 中读取更新的数据,好处是基线数据只在事务启动时读取一次,之后可以完全从 MemTable 中读取和写入。
MemTable 中有两个索引:B-Tree 和 Hash表,索引中保存的是指向行的新版本数据的指针。MemTable中这两种索引有各自的优势,因此服务于不同的操作:
- 在B-Tree中进行查找时需要经历一条从根节点到叶子节点的路径,对于单点查找来说可能代价较高。不过,由于B-Tree中索引的数据是有序的,能够提高搜索的局部性,因此只有在进行范围查找时,才会使用B-Tree作为支撑。
- 在Hash表中的查找需要针对搜索键值计算出Hash桶号才能从桶中找到目标数据,因此Hash表仅适合等值查询(单点查找)。MemTable中涉及的操作也有这类查询的用武之地:①插入一行数据的时候,需要先检查此行数据是否已经存在,检查冲突时会使用Hash表;②事务在插入或者更新一行数据时,需要找到此行并对其进行上锁,防止其他事务修改此行,此时也会使用Hash表。
在SSTable和MemTable的基础之上,OceanBase的存储引擎还引入一些更高层的存储组织概念:分区组、表组等,这些存储组织单位之间的关系如下图所示:
表组(Table Group)是一种介于表的逻辑分组和物理分组之间的组织结构,它被用来将相关联的经常要联合在一起查询的表(例如有外键关联的表)集合起来。之所以说它是一种逻辑分组,是因为表组并没有在物理上用一个文件或者一个目录将表组中的表数据组织在一起,它的存在仅仅表明同一个表组中的表之间存在比较强的关联查询需求。而表组的物理分组特点则体现在它确实会影响表中数据的物理分布位置:由于表组内的表经常会被用来进行关联查询(连接查询),因此为了避免经常进行跨节点的数据交换,会将表组中的表按照统一的分区方式(典型的是按照连接键)进行分区,这样表组内多个表中相关联的行都会存放在位于同一个节点的分区中。
而分区组(Partition Group)则是由表组和节点两个维度交叉产生的一种逻辑分组,即一个表组内的表在同一个节点上的所有分区就形成一个分区组。
OceanBase-CE支持对数据的压缩,压缩是以微块为单位进行的。OB支持 LZ4、Snappy、zlib、zstd 这几种压缩算法。
OceanBase的存储引擎采用了LSM Tree的设计思想,将数据划分成基线(SSTable)和增量(MemTable)两部分。基线数据是静止状态且位于空间易于扩展的外部存储设备(磁盘)上,但增量数据是动态变化的且位于空间难以扩展的内存中。尽管MemTable仅存储了数据的变化,在一个繁忙的系统中增量数据的体量仍会以可观的速度增加,如果不对其做一些控制,MemTable会很快耗尽节点的内存。
因此,当MemTable的内存使用达到一定阈值(由freeze_trigger_percentage控制)时,就需要将MemTable中的数据存储到磁盘上以释放内存空间,这个过程称为转储(Minor Compaction)。在转储之前首先需要保证将要被转储的MemTable不再进行新的数据写入,这个过程就是所谓的冻结(Freeze)。冻结会阻止当前活跃的MemTable进行新的写入,同时会生成新的活动MemTable。冻结MemTable的动作可能会发生多次,因此对于一个表分区来说,始终会有唯一的活动MemTable,但是可能会有多个已冻结MemTable。已冻结MemTable仍然占据着内存空间,因此当内存消耗较大时,有必要腾空这些已冻结MemTable,这个操作会将已冻结MemTable转储到文件中形成Mini SSTable。
基线数据和增量数据分离的设计还会带来另一个问题:如果任由MemTable中的数据一直累积(包括被转储的部分),那么行的操作链就变得很长,获取最新版本行的操作就需要在基线版本的基础上应用更多的修改操作,这显然会使得操作行的代价变大。为了解决这类问题,OceanBase会在合适的时间执行合并操作,包括Minor Compaction和Major Compaction。前者是将转储形成的Mini SSTable合并形成Minor SSTable,后者是将Minor SSTable合并到最终的基线SSTable中,两者的最大区别是:Minor Compaction是节点级别的操作,仅影响本节点上的内存数据和转储数据;Major Compaction是集群级别的操作,它会导致集群上所有的节点都把转储的数据合并到基线数据上。通过这两种Compaction可以尽量缩短行操作链,提高行操作的性能。
由于SSTable外加多版本的MemTable设计,数据行的读取路径可能会变得很长,为了提高基线行数据的读取效率,OceanBase在SSTable的上层引入了多层Cache机制。
设计Cache的目的是缓存SSTable中频繁访问的数据,分为:
- Block Cache(块缓存):用于缓存SSTable中的原始数据块。
- Block Index Cache(块索引缓存):用于缓存微块索引。
- Row Cache(行缓存):用于缓存一个个完整的数据行。
- Bloom Filter Cache(布隆过滤器缓存):用于缓存布隆过滤器,布隆过滤器可以快速回答那些结果为空集的查询。
由于SSTable在非合并期间都是只读的,所以不用担心Cache失效的问题。当对行的读请求来临时,首先尝试通过缓存中的布隆过滤器检查目标行是否真正存在,对于存在的行将尝试从Row Cache中获取行,如果没有命中则利用块索引缓存计算目标行所在的微块;然后尝试从Block Cache中获取这个微块,如果没有命中微块就会通过存储引擎利用磁盘I/O读取微块数据并放入Block Cache中。通过这样多级缓存的设计,绝大部分单行操作在基线数据中只需要一次缓存查找就能确定能否找到,性能相对较高。
SQL 引擎
和其他数据库一样,SQL 引擎分为解析器、优化器、执行器三个部分。当SQL引擎接收到了SQL请求后,经过语法解析、语义分析、查询重写、查询优化等一系列过程后,再由执行器来负责执行。与集中式数据库系统不同的是,分布式数据库系统的查询优化器会依据数据分布生成分布式执行计划。查询优化其实就是老生常谈了,算子下推、智能连接、分区裁剪,还有并行处理、任务拆分、动态分区、流水调度、任务裁剪、子任务结果合并、并发限制等优化。SQL 引擎的结构如下:
解析器构建语法树,基于语法树查询计划缓存,没查到就去做语义分析、查询重写、查询优化生成执行计划,生成好计划后将其加入计划缓存,然后执行器执行计划。各种数据库都是大同小异,就不多说了。
计划缓存有两种淘汰方式,一是当计划缓存占用的内存达到了需要淘汰计划的内存上限时,对计划缓存中的执行计划自动进行淘汰,二是通过发出SQL命令的方式手动删除缓存中的指定计划。
缓存计划失效有以下几种情况,一是数据库对象的模式发生变化,二是计划中涉及的数据库对象的统计信息重新被收集,那么该计划就会失效。OceanBase会在SSTable合并时统一进行统计信息的收集,因此每次进行合并后,实际上计划缓存中所有计划都会失效。还有一种缓存计划的失效场景,它发生在缓存计划被执行后进行执行统计信息更新时,其基本思想是将同一个SQL语句的缓存计划中性能表现较差的设置为失效,但这些设置为失效的计划并没有被立刻从缓存中清除,而是等到下次从缓存中获取同一SQL语句的计划时顺便将它们清除。这种基于计划的实际运行性能进行优胜劣汰的做法也是业界SQL计划管理器(SQL Plan Management,SPM)技术的一种典型做法。基于性能将执行计划设置为失效(过期)的情况有以下几种:
- 计划执行超时或者执行计划的会话被杀死。
- 计划平均执行时间超过5ms且不稳定,即计划满足以下情况之一:
- 计划的速度低于同类计划(属于同一个SQL语句且使用相同的参数值)的平均速度。
- 本地计划访问的行数超过同类计划的平均访问行数。
- 本地计划的平均访问行数增长太快(超过第一次执行时的10倍)。
- 分布式计划的平均查询执行时间增长太快(超过第一次执行时的2倍)。
- 计划的本次访问行数超过了一个阈值EXPIRED_PLAN_TABLE_ROW_THRESHOLD(100行)。
- 计划第一次执行访问的行数为0,但本次执行访问的行数不为0。
- 计划第一次执行访问的行数不为0,且本次执行访问的行数超过了第一次的2倍。
查询重写主要按照以下步骤来执行:
- 简化。
- 子查询合并。
- ANY/ALL优化。
- 集合操作重写。
- 视图合并。
- WHERE条件子查询提升。
- 半连接转换为内连接。
- 查询下推。
- 消除外连接。
- 连接消除。
- 外连接LIMIT下推。
- 全外连接优化。
- OR扩展优化。
- 聚集改写为窗口函数。
- 窗口GROUP BY处理。
- 聚集处理。
- 投影剪枝。
- 谓词移动。
上面的步骤没啥好解释的,各家数据库都是大差不差。
OceanBase的计划生成可以分为两个阶段:第一阶段(将产生一个初始的逻辑计划,第二阶段则在初始逻辑计划的基础上进行改造,重点是考虑逻辑计划涉及的表数据的位置分布信息,将初始逻辑计划改造为一种可以在多个节点上并行执行的形式。
在逻辑计划的生成过程中,选取路径是一个必不可少的环节。访问路径决定了查询涉及的基表数据是以何种顺序和方向汇聚并形成最终的结果关系,可以说访问路径表达了逻辑计划中数据的流动方向和提取(访问)数据的方式,例如对查询“SELECT*FROM A,B,C,D”来说,先连接A和C然后再连接B最后连接D就体现了访问路径的数据流向。总体来说,访问路径就是逻辑计划的主干,逻辑计划是在访问路径的基础之上进行了更细致的包装形成的数据结构。
优化器会使用ObSelectLogPlan::generate_raw_plan()
方法来为一个SELECT查询生成初始的计划,该过程总体可以分为三个主要步骤:
ObSelectLogPlan::generate_plan_for_set()
:如果查询中涉及集合操作,则递归调用generate_raw_plan方法为各分支子查询生成计划。ObSelectLogPlan::generate_plan_for_plain_select()
:为查询中的单表生成路径,然后基于单表的路径用动态规划或者线性算法生成连接路径。ObLogPlan::get_current_best_plan()
:根据代价模型和排序要求选择最优路径形成计划。
整体上,优化器生成路径的方法都是自底向上的构建方法,即先构造出查询中涉及的基表的单表路径,然后逐步将它们两两连接起来形成越来越“大”的连接路径,最终形成最上层的连接表。因此,优化器首先会生成所有的单表路径。
连接路径的生成有动态规划(Dynamic Programming)和线性(Linear)两种算法,如果连接中涉及的表的数目不超过10个(由DEFAULT_SEARCH_SPACE_RELS定义),则采用动态规划算法生成连接路径,否则采用线性算法生成连接路径。
OceanBase的连接路径生成采用了System-R的动态规划算法,考虑到的因素包括每一个表可能的访问路径、查询感兴趣的顺序(例如ORDER BY要求的顺序)、可能的连接算法(NESTED-LOOP,BLOCK-BASED NESTED-LOOP,SORT-MERGE等)以及不同表之间的连接选择率等。由于要考虑连接类型、连接顺序等多方面的可能性,动态规划算法产生的访问路径数量会比较大,而且随着参与连接的基表数目增加,需要考虑的访问路径的数量会呈几何增长。因此,当参与连接的基表数目比较多时,动态规划算法需要耗费比较多的时间才能选择出一个性能比较好的连接路径。如果将优化所消耗的时间考虑在内,这个性能较好的连接路径带来的性能优势可能也会被抵消殆尽。所以,在参与连接的基表数目很多时(超过十个),更需要的是一种能更快选出较优路径方法,即便它产生的路径不是那么优秀,这种方法就是接下来的线性算法。
连接路径生成的线性算法的最大不同在于其预处理步骤ObLogPlan::preprocess_for_linear()
,在预处理步骤中会遵从查询语句中对表的连接要求,将具有连接关系的基表先连接起来构成连接表。经过这种预处理之后,后续的连接路径生成过程中需要考虑的可能性就会减少,之后仍然会采用类似动态规划算法的方式从基表以及预处理步骤中创建的连接表出发构造更高层的连接表,但由于很多低层的连接表已经在预处理步骤中遵循连接要求“定向”生成了,花费在构造这些低层连接表上的时间就被大大降低。
OceanBase将优化器的优化过程分成串行优化与并行优化两个阶段。
在生成计划的过程中,经常需要计算路径的代价,用于淘汰没有优势的路径,这些代价计算都按照OceanBase代价模型的假设进行。OceanBase的代价模型考虑了CPU代价(例如处理一个谓词的CPU开销)和I/O代价(例如顺序、随机读取宏块和微块的代价)。当然,作为一种分布式数据库,OceanBase的代价模型中还会考虑跨节点数据传输导致的代价,将一条路径中所有的CPU代价、I/O代价和网络传输代价累加起来就得到该路径的总代价。
屋里计划的执行入口在ObMPQuery::response_result
方法中,里面区分了三种情况:DML语句的同步执行、DML语句(增删查改)的远程异步执行、非DML语句执行。
- DML语句的同步执行方式是本地执行计划的执行方式,这包括非分布式计划以及查询执行器中执行的分布式计划片段。在这种执行方式下,ObMPQuery::response_result会建立一个同步计划驱动(ObSyncPlanDriver)来执行计划,然后由同步计划驱动的response_result方法采用同步的方式推进执行计划的执行,调用ObResultSet的get_next_row方法一个个地取出执行计划的行并返回。
- DML语句的远程异步执行是分布式执行计划的执行方式:由一个ObServer实例作为查询控制器(Query Controller),分布式计划被分发给集群中其他OBServer(称作查询执行器,Query Executor)执行,然后它们将自己的局部结果传送给控制器,控制器将收到的结果行返回给需求者。在这种方式下,控制器将分布式计划发布出去后,会注册一些回调函数,当远程执行器的结果到达时将触发回调函数来完成最后的结果返回。对于每一个远端执行器来说,其执行收到的分布式计划的过程和DML语句的同步执行方式类似,不同之处在于其产生的结果行需要返回给查询控制器。
- 非DML语句执行方式用于执行CREATE TABLE之类的非DML语句,OceanBase内部也把这类语句称为命令(Command,CMD)。这类语句的执行特点是返回的结果并不是标准的行集合,例如可能是一种简单的字符串。这意味着这类语句的执行是一次调用,然后获得整个语句的执行结果。因此,ObMPQuery::response_result会建立一个同步命令驱动(Ob-SyncCmdDriver)或者异步命令驱动(ObAsyncCmdDriver)来执行命令语句。
当用户提交的SQL语句需要访问的数据位于两个及以上节点时,就会启用并行执行,会执行如下步骤:
- 用户所连接的这个节点(会话所在地)将承担查询协调者(Query Coordinator,QC)的角色。
- QC预约足够的线程资源。
- QC将需要并行的计划拆成多个子计划(Data Flow Operation,DFO),每个DFO包含若干个串行执行的操作符。例如,一个DFO里包含扫描分区、聚集、发送操作符,另外一个DFO里包含收集、聚集操作符等。
- QC按照一定的逻辑顺序将DFO调度到合适的节点上执行,该节点会临时启动一个辅助协调者(Sub Query Coordinator,SQC),SQC负责在所在节点上为各个DFO申请执行资源、构造执行上下文环境等,然后启动DFO在相应节点上并行执行。
- 当各个DFO都执行完毕,QC会串行执行剩余部分的计算。例如,一个并行的COUNT聚集,最终需要QC将各个节点上的计算结果做一个SUM运算。
- QC所在线程将结果返回给客户端。
并行执行框架的运作过程如下图所示,计划进入执行阶段后,本地调度器会先按照 Volcano 模型从计划树的顶层操作符开始迭代执行,每次迭代都会从顶层操作符返回一个结果行,在计划树执行过程中,如果遇到与并行执行相关的操作符,就会触发并行执行,由一个叫做 QC 的线程来执行。并行执行操作符会将其下的子计划通过 RPC 发送到相关节点上,子节点上会产生一个 SQC来协调执行本地的子计划。
在OceanBase中用并行度(Degree Of Parallelism,DOP)的概念指定用多少个线程来执行一个DFO,参与执行DFO的线程被称为并行执行工作者(Parallel Executing Worker,PX Worker)。目前OceanBase可以通过“parallel”这个Hint来指定并行度。DOP是个集群级的概念,因此还需要将DOP确定的线程数分配到参与执行DFO的多个节点上。
为了将这种划分能力进行抽象和封装,OceanBase的并行执行框架引入了Granule的概念。每个扫描任务称为一个Granule,这个扫描任务既可以是扫一整个分区,也可以是扫分区中的一个范围。
分区的划分需要一个合适的粒度,既不能太大也不能太小。粒度过大,容易出现PX Worker工作量不均衡;粒度过小,会多次出现前一个Granule执行完毕后切换到下一个Granule的动作,这样做的累积开销比较大。目前OceanBase中使用了一个经验值来实现对分区的划分:每个PX Worker可以拿到13个Granule是最合适的。划分形成的Granule被串成一个链表,由SQC产生的PX Worker将从链表上抢Granule任务执行。
在一对有关联关系的DFO中,子DFO作为生产者分配了M个PX Worker线程,父DFO作为消费者分配了N个PX Worker线程,那么它们之间的数据传输就需要用到M×N个网络通道。
为了对这种网络通信进行抽象,OceanBase中引入了数据传输层(Data Transfer Layer,DTL)的概念,将任意两点(PX Worker)之间的通信连接用通道(Channel)的概念来描述。通道分为发送端和接收端,为了防止发送端无节制地向通道中发送数据导致接收端累积的数据占据过多内存,DTL中加入了流量控制逻辑对发送端向通道中发送数据的行为加以限制:每个通道的接收端预留了三个槽位来保存接收到的数据,当槽位被数据占满时会通知发送端暂停发送数据,当有接收端数据被消费腾出空闲槽位时也会通知发送端继续发送。
并行框架实现的关键点在于:1. PX 操作符的实现,即 QC 的实现;2. SQC 的实现;3. PX Worker 的实现。
首先是 PX 操作符的实现。在PX框架中,QC以操作符的形式出现,因此要弄清楚并行执行的起点,就需要理解PX操作符的实现方式。在OceanBase目前的PX框架中,有两种PX操作符:ObPxFifoCoordOp和ObPxMSCoordOp,它们都是ObPxCoordOp的子类。两种PX操作符的主要差异在于它们接收数据的策略不同,ObPxFifoCoordOp顾名思义是采用先进先出(First In First Out,FIFO)的策略,而ObPxMSCoordOp名字中的MS表示Merge Sort,即它会先对输入完成排序再向上层节点输出行。
接着是 SQX 的实现。在OceanBase的并行执行框架中,SQC是所在节点上执行DFO的管理者和调度器,该节点上参与DFO执行的所有PX Worker都在它的管理之下。SQC实际上并不是一个独立的线程,而是以一个对象的形式出现。之所以不将SQC实现为一个单独的线程,是因为虽然SQC是调度器,但是当它将PX Worker驱动起来以后其实工作并不繁重,如果让SQC独占一个线程会让它在大部分时间里都处于空转的状态,既占用了RPC处理线程,又长时间不退出(因为PX Worker有大量数据需要返回),这可能会导致RPC处理线程被耗尽。
SQC的所有动作都是由消息驱动的。QC以RPC的方式发出一个DFO到某个节点后,其上的RPC处理器就会初始化SQC并尝试驱动PX Worker开始执行任务。初始化SQC的消息将由ObInitSqcP::process()
来处理,其处理步骤包括:
- 建立一个ObPxSqcHandler对象来操纵SQC。
- 调用SQC的pre_acquire_px_worker方法获取所需的PX Worker线程数量并准备管理它们的数据结构。
- 真正建立起所需的通道将这个SQC和其QC联系起来。
最后是 PX Worker 的实现。SQC所在的节点上会有一个线程池(ObPxPool),对于需要线程数大于1的情况,Ob-PxSubCoord::dispatch_tasks()
会通过ObPxSubCoord::dispatch_task_to_thread_pool()
把要运行的任务包装成一个ObPxThreadWorker
对象,然后调用该对象的run方法将这个任务包装成一个PxWorkerFunctor
,在提交到线程池同时也启动了对应的PX Worker。
在由PxWorkerFunctor
表达的任务进入到线程池后,线程池会调度一个空闲的线程充当PX Worker来执行该任务,即执行PxWorkerFunctor::operator()
方法来运行指定的任务。
事务引擎
分布式事务同样需要维持事务的四大特性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability),简称为ACID特性。
- 原子性:OceanBase数据库是一个分布式系统,分布式事务操作的表或者分区可能分布在不同机器上,OceanBase数据库采用两阶段提交协议保证事务的原子性,确保多台机器上的事务要么都提交成功要么都回滚。
- 一致性:事务必须是使数据库从一个一致性状态变到另一个一致性状态,一致性与原子性是密切相关的。OceanBase中事务一致性的问题要比集中式数据库系统更复杂:由于OceanBase采用了多副本的架构,数据的修改只要在多数副本上得到确认即可认为成功,因此在少数派副本和多数派副本之间可能在一定时间窗口内存在不一致。为了提高性能,OceanBase允许应用(用户)在自己的事务中主动降低一些一致性要求以便去读取那些少数派副本上的数据,从而能提高读取的性能。
- 隔离性:OceanBase-CE天然运行在MySQL兼容模式下。在MySQL模式下,事务能够采用读已提交(Read Committed,RC)隔离级别和可重复读(Repeatable Read,RR)隔离级别。结合前述的一致性要求,事务可以实现相当灵活的数据版本读取需求。
- 持久性:对于单个机器来说,OceanBase通过REDO日志记录了数据的修改,通过WAL机制保证在宕机重启之后能够恢复出来。事务一旦提交成功,事务数据一定不会丢失。对于整个集群来说,OceanBase数据库通过Paxos协议将数据同步到多个副本,只要多数派副本存活数据就一定不会丢失。
事务是与会话密切关联在一起的,每次从一个客户端连接到OceanBase开始到这个连接从OceanBase断开为止,其间发生在该连接上的所有数据库操作(命令)被称为一个“会话”。在一个会话中,客户端可能会执行多个事务,不过在会话存续的任一时刻仅有一个事务运行(称为活跃事务)。反过来,一个事务必定也仅存在于一个会话中。事务被进一步分解为一个或者多个语句(Statement),事务和语句之间是一种一对多的关系,一个事务可以包含多个语句,而每一个语句则仅属于一个事务。OceanBase管理事务的核心就是维持会话、事务、语句的状态以及它们之间的关联,并根据SQL语句在各个节点上的执行情况适时推进它们的状态变化。在会话描述类ObBasicSessionInfo中有多个属性共同描述了会话中进行着的事务:
trans_consistency_type_
属性描述当前事务的一致性类型,其有两种类型:CURRENT_READ
和BOUNDED_STALENESS_READ
。CUR-RENT_READ
会使得事务在进行数据操作时在目标数据所在分区的Leader节点上,由于OceanBase的架构设计要求数据修改操作一定是在Leader上完成后再同步到其他多数派副本上,因此从Leader上读到的肯定是最新版本的可见数据。BOUNDED_STALENESS_READ
类型顾名思义是指“有界脏读”,即按照某种限制允许事务读取旧版本的数据。例如在创建索引时就可以允许使用这种一致性类型,这种一致性类型另外一种可能的用途是用来实现闪回查询,即查询过去某个时间点的数据,这种服务在很多基于 MVCC 的数据库产品中都有提供。
consistency_level_
属性描述当前事务的一致性级别,分为强一致性(STRONG)和弱一致性(WEAK)两种,所谓弱一致性是指在某些场景下用户可以接受查询到一些并不太具有一致性的数据,这些不一致的数据是由于OceanBase多副本之间同步的时间差导致的,但是最终这些副本之间还是会达到一致。很明显,只有在只读的场景下,弱一致性事务才可行,涉及写操作或者可能涉及写操作的事务一定会使用强一致性事务完成,这样才能保证前述的多副本间最终的一致性。事实上BOUNDED_STALENESS_READ类型的读操作也属于一种弱一致性读,因此只有在一致性级别为WEAK时才可能发生。
trans_flags_
属性中用一个整数的多个位描述了当前事务的一些标志,例如:IS_IN_TRANSACTION
表示会话正在运行一个事务,即有活跃事务存在;而HAS_HOLD_ROW_LOCK
表示事务持有行锁。trans_result_
属性描述了会话中当前已执行过的事务的结果状态,尤其是分布式事务中在其他节点上执行的局部事务的执行情况,以便于在事务结束后及时释放各参与节点上占用的资源。
会话中有关事务最核心的部分是transdesc属性,它是一个ObTransDesc类的对象,它描述了会话中活跃事务的详情,其结构如上图所示:
trans_id_
:事务的ID,由事务所在的服务器(ObAddr
,包括IP地址和端口号)以及一个本地流水号(整数)组合而成。trans_param_
:事务的参数,包括事务的隔离级别(isolation_
)、访问模式(access_mode_
,表示只读或者读写)、是否自动提交(autocommit_
)、一致性类型(consistency_type_
属性)。participants_
:事务的参与分区构成的数组,每一个元素都代表一个ObPartitionKey
(包括表的ID、分区的ID、二级分区ID等)。stmt_participants_
:和participants_
类似,但保存的是当前语句的参与分区信息participants_pla_
:参与分区的Leader数组,每一个元素对应于某个参与分区的Leader所在节点(ObAddr
),与participants_
相互呼应。stmt_participants_pla_
:和participants_pla_
类似,但保存的是当前语句的参与分区的Leader信息。trans_expired_time_
:事务超时时间,OceanBase没有分布式死锁检测机制,因此对于死锁的消除只能依靠事务超时时间,当事务超时时会自动中止事务,由事务的发起者自动重试。sql_no_
:当前执行语句的内部编号,这个SQL编号是一个流水号,每开始执行一个新的语句都会使得这个编号加1,而且事务中的保存点技术也相当依赖这个字段。need_rollback_
:指示客户端尽快回滚。session_id_
:当前会话的ID,由于分布式事务的存在,事务的状态并非仅由协调者节点上的事务信息组成,还包括分布在其他节点上的局部事务状态,这些组成部分之间需要用会话的ID以及事务的ID关联起来,因此每个事务的数据结构中需要记录所属的会话ID。can_elr_
:表示当前事务是否可以采用提前解行锁(Early Lock Release,ELR)技术。is_local_trans_
:标志着整个事务是否为一个纯粹的本地事务,即所有参与分区的Leader都在事务的发起节点上,这种事务不涉及分布式事务的部分,因此可以更快速地完成提交。is_fast_select_
:表示当前事务是一个快速SELECT,主要服务于实现游标(Cursor
)读操作。trans_type_
:是一个TransType
类的实例,表示事务的类型,可能的值有:SP_TRANS
:单分区(Single Partition)事务,并不是说所有仅涉及一个分区的事务都是单分区事务,只有该分区的Leader和事务的发起者在同一个节点的事务才能称为单分区事务,否则就会被认为是分布式事务。MINI_SP_TRANS
:属于单分区事务的一种特殊情况,即事务中仅涉及一个点查询(仅涉及一个目标行的查询)且autocommit
配置参数被设置为1。DIST_TRANS
:其他非以上两种特殊情况的事务都被认为是分布式事务。
is_all_select_stmt_
:表示当前事务所执行过的语句是否都是SELECT(只读)语句,用于事务提交时的优化。session_
:指向所属会话结构的指针。last_end_stmt_ts_
:记录上一个语句完成执行的时间戳,结合事务超时技术防止事务长期空闲占用资源。trx_idle_timeout_
:事务的超时时长,结合last_end_stmt_ts_
字段的值就能判断一个事务当前是否已经闲置过长时间,如果发现此类情况则强制回滚事务节省系统资源。
事务的管理和控制本质上是通过在会话、事务、语句等状态数据结构上进行修改来实现。SQL引擎在执行一个SQL语句时可以区分为非事务控制语句和事务控制语句。非事务控制语句(即常规的DML语句、DDL语句以及DCL语句)被SQL引擎执行时对于事务的影响取决于系统中的自动提交设置(autocommit参数)以及当前会话所处的事务状态。对于打开了自动提交的会话,每一个非事务控制语句都会运行在一个单独的事务(称为隐式事务)中,事务的生命周期和对应的SQL语句相同。而对于自动提交关闭的会话,则需要使用显式事务,即用事务开始(START TRANSACTION或BEGIN)语句和事务结束(END/COMMIT/ROLLBACK)语句标记出事务的边界。这种情况下,如果没有标记事务的开始位置,则认为上一个事务结束之后的位置就是新事务的开始。这些事务边界标定语句称为事务控制语句,它们被用于显式控制事务的状态或者决定事务对数据的修改是否最终生效。另外,显式事务会覆盖自动提交设置,即在自动提交打开的情况下,通过BEGIN开始的显式事务中的SQL语句不会在执行完成时立刻提交,而是在显式事务结束时随整个事务一并提交或者回滚。
事务启动主要有两种方式,显式事务启动和隐式事务启动。对于显式事务启动来说,BEGIN
语句的执行将会启动事务。由于BEGIN
这类事务控制语句用于操纵显式事务,因此ObSqlTransControl
类中服务于BEGIN语句执行的方法是explicit_start_trans
。ObSqlTransControl
中有另一个重载过的explicit_start_trans
方法,两者存在调用关系。总体上,两个explicit_start_trans
方法合作为事务准备好ObStart-TransParam
信息,然后在确保当前会话没有处于事务中(检查IS_IN_TRANSACTION
标志)的前提下,通过ObSqlTransControl::start_trans()
启动事务,启动事务成功后将会话设置为处于事务中。
当自动提交设置打开且没有使用事务控制语句启动显式事务时,每一个被执行的SQL语句都会作为一个单语句的隐式事务执行。DML语句导致隐式事务启动的过程如下,当每一个DML语句被执行时(与是否隐式事务无关),都会调用ObResultSet::open_plan()
开始执行计划,在该方法中首先会使用ObResultSet::auto_start_plan_trans()
确保该DML语句运行在一个事务中,即如果会话当前已经处于一个运行事务中则继续使用该事务(不开启新事务而是加入现有事务),否则启动一个隐式事务。完成事务启动(加入)之后才真正调用执行器的execute_plan
方法执行DML语句对应的执行计划。计划执行结束之后,如果执行计划还涉及其他节点上的分区,则调用ObResultSet::start_participant()
启动相应节点上的局部事务。
语句被作为单一语句事务需要同时满足以下主要条件:
- SELECT语句且不是SELECT FOR UPDATE;
- 会话不处于运行事务中,即事务ID无效且没有IS_IN_TRANSACTION标志;
- 自动提交设置打开
在事务发起节点(协调者)启动其本地语句(事务)之后,会调用一系列start_participant
方法通知语句所涉及的各个参与者在各自掌握的分区数据上开启局部事务,这些局部事务联合协调者节点上的全局事务一起完成整个分布式事务的工作。
OceanBase支持语句级的原子性,即一条语句的操作要么都成功要么都失败,不会存在部分成功部分失败的情况。
当一条语句执行过程中没有报错,那么该语句所做的修改都是成功的,如果一条语句执行过程中报错,那么该语句执行的操作都会被回滚,这种情况称为语句级回滚。语句级回滚有如下特点:
- 语句回滚时仅回滚本语句的修改,不会影响当前事务该语句之前语句所做的修改:例如,一个事务有两条UPDATE语句,第一条UPDATE语句执行成功,第二条UPDATE语句执行失败,则只有第二条UPDATE语句会发生语句级回滚,第一条UPDATE语句所做的修改会被保留。
- 语句级回滚的效果等价于语句没有被执行过,即语句执行过程中涉及的全局索引、触发器、行锁等均属于语句的操作,语句回滚需要将这些操作都回滚到语句开启之前的状态。
常见的语句级回滚有以下几种:
- INSERT操作出现主键冲突
- 单条语句执行时间过长导致超时
- 多个事务存在行锁冲突导致思索,事务被死锁检测机制强制结束
这里同时还聊到了全局时间戳,可以说全局时间戳是分布式事务的核心。OceanBase的MVCC设计严重依赖于各种版本信息:事务的提交版本、快照版本等,这些版本实际上就是一个个时间戳。为了保持版本之间的可比较性,大部分情况下,这些时间戳的获取渠道是一致的(从同一个时钟获取),在OceanBase中这个渠道通过全局时间戳服务实现。
OceanBase会为系统中的每一个租户(系统租户除外)启动一个全局时间戳服务(Global Timestamp Service,GTS),事务提交时通过本租户的GTS服务获取事务版本号,保证全局的事务顺序。系统租户使用的是本地时间戳服务(Local Timestamp Service,LTS),因此OceanBase不推荐外部操作使用系统租户。
全局时间戳由一个三副本的 GTS provider 提供,全局时间戳来源于leader的 本地时间戳。Leader 发生改选时,会将其已经授权的最大时间戳同步到心得 leader,以免发生回退。同时通过 lease 也能够保证新旧 leader 之间无重叠。GTS 的优化主要有两种,一是对于单节点事务不去获取全局时间戳,二是可以将多个事务获取全局时间戳的操作合并,批量获取。
Save Point 也是事务实现中的重要部分。OceanBase中对提交和回滚的支持依赖于存储引擎中MemTable与SSTable相结合的设计:事务对数据行的修改作为增量保留在内存中,在事务最终提交或者回滚之前,这些修改都处于一种“待定”的状态,事务提交或者回滚的工作实际上就是将属于相应事务的修改的状态“确定化”。如果事务最终提交,那么属于该事务的“待定”修改都被加上该事务的提交版本号,表示这些修改已经生效;如果事务最终被回滚,那么归属于该事务的“待定”修改可以直接从MemTable中移除。而回滚到保存点的操作效果则介于事务的提交和回滚之间:回滚到某个保存点需要放弃该保存点之后的所有修改,但需要保留保存点之前做出的修改,即部分回滚事务。因此,回滚到保存点被实现为从内存中(MemTable)清除在该保存点之后产生的所有“待定”修改,但是在该保存点之前发生的修改仍处于“待定”状态,因为它们还可能被后面的ROLLBACK TO语句、COMMIT语句或者事务级ROLLBACK语句进一步处理。
在OceanBase的实现中,事务执行过程中有一个SQL序列(SQL Sequence),它根本上就是一个整数值,该值在事务执行过程中是递增的,每一个新开始的语句都会导致该序列向前推进一步,同时该语句中会保留执行该语句时的序列值,该值被称为SQL编号(通常写作sqlno)。每一个语句执行过程中产生的修改数据也会被关联上该语句的SQL编号,而在事务中定义的保存点也会获得一个SQL编号,这样根据要回滚到的保存点的SQL编号,就能找到MemTable中哪些修改是在该保存点之后产生的,在执行回滚时仅需要将这些修改从MemTable中移除即可。
Redo日志是OceanBase用于宕机恢复以及维护多副本数据一致性的关键组件。Redo日志是一种物理日志,它记录了数据库对于数据的全部修改历史,具体地说记录的是一次写操作后的结果。从某个持久化的数据版本开始逐条回放Redo日志可以还原出数据的最新版本。
OceanBase的Redo日志有两个主要作用:
- 宕机恢复:与大多数主流数据库相同,OceanBase遵循WAL(Write-Ahead Logging)原则,在事务提交前将Redo日志持久化,保证事务的原子性和持久性(ACID中的“A”和“D”)。如果OBServer进程退出或所在的服务器宕机,重启OBServer会扫描并回放本地的Redo日志用于恢复数据。宕机时未持久化的数据会随着Redo日志的回放而重新产生。
- 多副本数据一致性:OceanBase采用Multi-Paxos协议在多个副本间同步Redo日志。对于事务层来说,一次Redo日志的写入只有同步到多数派副本上时才能认为成功。而事务的提交需要所有Redo日志都成功写入。最终,所有副本都会收到相同的一段Redo日志并回放出数据。这就保证了一个成功提交的事务的修改最终会在所有副本上生效并保持一致。Redo日志在多个副本上的持久化使得OceanBase可以提供更强的容灾能力。
OB的 Redo log 主要有两种:
- Clog:全称是提交日志(Commit log),用于记录Redo日志的日志内容,位于store/clog目录下,文件编号从1开始并连续递增,文件ID不会复用,单个日志文件的大小为64MB。这些日志文件记录数据库中的数据所做的更改操作,提供数据持久性保证。
- ilog:全称是索引日志(Index log),用于记录相同分区相同日志ID的已经形成多数派日志的提交日志的位置信息。ilog位于store/ilog目录下,文件编号从1开始并连续递增,文件ID不会复用,单个日志文件的大小非定长。这个目录下的日志文件是Clog的索引,本质上是对日志管理的一种优化,ilog文件删除不会影响数据持久性,但可能会影响系统的恢复时间。ilog文件和Clog文件没有对应关系,由于ilog针对单条日志记录的内容会比Clog少很多,因此一般场景下ilog文件数目也比Clog文件数目少很多。
OceanBase的分布式事务中存在三种组成部分:
- 调度器(Scheduler):OceanBase分布式事务的事务协调器不一定运行在发起相应分布式事务的节点上,用户或者应用所连接的节点被称为调度器或者调度者。可以认为调度者在全局事务运行的前期大约等效于一个传统意义上的“事务协调器”,因为在全局事务结束(提交或者回滚)前各局部事务的信息都由调度器持有和维护。当然,由于调度器知晓参与到全局事务中各节点的位置,这些信息也使得调度器能在全局事务执行期间根据收到的SQL语句向各参与节点发出控制指令。
- 协调器(Coordinator):OceanBase分布式事务的协调器在全局事务结束时发挥作用,例如它会在全局事务提交时,担负起两阶段提交协议中的协调者角色。总体来说,调度器和协调器共同完成了全局事务层面的事务控制工作。
- 参与者(Participant):OceanBase分布式事务的参与者和前述的概念一致,即运行局部事务的节点都被称为参与者,由于调度器和协调器所在的节点也包含数据,因此它们也可能同时充当了参与者的角色,甚至有可能同一个节点同时承担三种角色。
MVCC的基本原则是:数据被修改时,修改并不是就地(In-place Update)进行,即修改不是直接应用在目标数据上,而是将目标数据标记为已删除,然后插入一个更新后的新数据,被标记删除的数据被称为“旧版本”,新插入的数据则称为“新版本”。通常来说,MVCC机制下产生的同一个数据的新旧版本之间在逻辑上或者物理上构成一个按产生顺序排列的版本链,每一个版本上也会关联产生/删除该版本的事务信息(例如事务提交版本号)。在MVCC机制中版本链的支持下,当并发事务访问同一个数据时,不同的事务将会看到版本链中的不同数据版本,每个事务看起来都运行在自己“专属”的版本上,因此不会发生冲突。
在OceanBase的MVCC机制中,可能的三种基本并发控制包括:读读、读写以及写写并发。
- 读读并发控制: 读读并发是指两个并发事务对同一个数据的访问方式都是读操作,由于数据不会被它们修改,所以即便没有MVCC机制的支持,读读并发的事务之间也不会产生冲突。在OceanBase的访问控制机制中,没有对读读并发做特殊的处理。
- 读写并发控制: 读写并发是指两个并发事务访问同一个数据,其中一个事务对该数据执行读操作,而另一个事务对该数据执行写操作。在MVCC机制中,执行写操作的事务会把该数据的当前版本标记为删除然后插入一个新版本,旧版本在物理上依然存在,执行读操作的事务仍然能够读取该数据的当前版本完成自己的执行。因此在OceanBase的访问控制机制中,读写并发是不会产生冲突的。不过,由于还要考虑写写并发控制,因此OceanBase中的写操作事务需要对要写的数据加上排他锁。OceanBase的读操作对访问的数据是不加锁的,所以读不会阻塞写。不过如果用了SELECT…FOR UPDATE语法,则不会是快照读,会尝试加锁(共享锁),直到事务提交或者回滚才释放,这个时候就跟并发写有冲突。
- 写写并发控制: OceanBase中的写操作会先在数据上申请加排他锁(即行锁),如果已经有其他锁存在则需要在队列里等待。行锁保证了每个时刻最多只能有一个事务修改这个行,行锁释放的时候通知等待队列里的第一个事务,这个队列避免了锁等待的争抢。同时,根据6.2.1节所述,OceanBase会在行上维护一个链表,记录历史修改和提交版本信息。当然,处于锁等待队列中的事务不会无限制等待下去,每个SQL语句执行有个超时机制,由变量ob_query_timeout控制,默认为10s。如果事务为了执行一个SQL语句等待了过长时间导致超过这个时间限制,事务会报告“lock wait timeout”。这个报错信息是取自MySQL,在MySQL里变量innodb_lock_wait_timeout会控制锁等待超时时间。
OceanBase的锁机制使用了以数据行为级别的锁粒度。同一行不同列之间的修改会导致同一把锁上的互斥;而不同行的修改是不同的两把锁,因此是无关的。OceanBase的读取操作是不加锁的,因此可以做到读写不互斥,从而提高用户读写事务的并发能力。为了避免在内存中维护大量的锁信息,OceanBase的行锁被实现为存储在行本身中的方式。不过,行锁的等待队列仍然需要被维持在内存中,这样才能在锁被释放的时候唤醒等待队列中的事务。需要注意的是,虽然OceanBase的并发控制机制中读写不冲突,但是在事务提交过程中,为了维护事务的一致性快照,会有短暂的读写互斥,称之为Lock for Read。
OceanBase的锁存储在行上,从而减少内存中单独维护锁信息所需要的开销。事务对行加锁时,行应该是存在于MemTable中。在每一行的ObMvccRow实例中,都有一个row_lock_属性(ObLatch)用于对该行加锁。ObLatch可以认为是一种低层次的锁,其是latch_属性中的lock_属性(uint32_t),lock_属性的值包含多方面的信息:
1)最高位(WAIT_MASK所标记的位):是否有事务在等待这个锁,如果为1则ObLatch中的等待队列里有事务正在等待当前这个锁。
2)次高位(WRITE_MASK所标记的位):是否加上了写锁,如果为1表示已经有事务持有了这个数据上的写锁。
3)低30位:除两个标志位之外,其他位表示的是持锁事务,但并非事务的ID,而是事务产生的MVCC上下文(ObMvccCtx)的ID。
在内存中,当事务获取到行锁时,该事务就是所谓的行锁持有者。当事务尝试获取行锁时,会通过对应的事务标记发现自己不是行锁持有者而放弃并等待或发现自己是行锁持有者后获得行的使用权利。当事务释放行锁后,就会在所有事务涉及的行上解除对应的事务标记,从而允许之后的事务继续尝试获取行锁。
当数据被转储到SSTable中后,在宏块内部的数据上记录着对应的事务标记。其余事务依旧需要通过事务标识来辨识是否可以允许访问对应的数据。与内存中的锁机制不同的是,由于SSTable不可变的特性,无法在事务释放行锁后,立即清除宏块内部的数据上的事务标记。当然依旧可以通过事务标识找到对应的事务信息,进而确认事务是否已经解锁。
OceanBase当前主要依赖超时回滚机制来解决死锁问题,目前存在三种超时机制:
- 锁超时机制:依赖于锁超时时间,它由配置参数ob_trx_lock_timeout设置,默认与语句超时时间相同。若事务等待锁的时间超过锁超时时间,则会回滚对应的语句,并返回锁超时对应的错误码。此时,由于某一个循环依赖中的资源依赖已经消失,因此就不再存在死锁。以事务T2获取资源A超时为例,只要事务T2结束,那么资源B上的锁就会被释放,事务T1就可以获取到对应的资源B完成执行。
- 语句超时机制:依赖于语句超时时间,它由配置参数ob_query_timeout设置,默认为10s。若语句执行的时间超过语句超时时间,则会回滚对应的语句,并返回语句超时对应的错误码。此时,由于某一个循环依赖中的资源依赖已经消失,因此就不再存在死锁。
- 事务超时机制:依赖于事务超时时间,它由配置参数ob_trx_timeout设置,默认为100s。若事务执行的时间超过事务超时时间,则会回滚对应的事务,并返回事务超时对应的错误码。此时,由于某一个循环依赖中的资源依赖已经消失,因此就不再存在死锁。
OceanBase支持两种隔离级别:读已提交和可串行化。读已提交的功能和问题不再赘述,这里再细说一下可串行化隔离级别。可串行化的定义是让并发的事务执行的效果跟按某种顺序串行执行效果一样。通常的实现方法就是事务期间访问的数据全程加锁(共享锁或排他锁),以防止事务期间访问的数据被其他事务修改了。这样做的并发太低,所以OceanBase在实现可串行化隔离级别的时候实际选用了快照读的策略,整个事务访问的数据是同一个快照版本。这样由于减少了读写并发冲突,整体并发的能力提上去了。不过OceanBase的可串行化级别可能有写偏序(Write Skew)问题,因此OceanBase的可串行化级别并不是真正的可串行化,而是快照隔离级别(Snapshot Isolation)。
高可用
在OceanBase中,数据的高可靠机制主要有多副本的容灾复制、回收站和备份恢复等,备份恢复是保护用户数据的最后手段。
简单来说 OB Server 的集群中,数据是以分区为单位存储并提供高可用的能力。一个分区有多个副本,不同副本在不同区中,其中一个主副本来接收写操作。主从副本之间通过 multi-paxos 来实现副本之间的数据一致性。
分区的多个副本通过选举协议选择其中一个作为主副本(Leader),在集群重新启动时或者主副本出现故障时,都会进行这样的选举。选举服务依赖集群中各台机器时钟的一致性,每台机器之间的时钟误差不能超过200ms,集群的每台机器应部署NTP或其他时钟同步服务以保证时钟一致。选举服务有优先级机制保证选择更优的副本作为主副本,优先级机制会考虑用户指定的Primary Zone以及机器的异常状态等。
Paxos 没什么好说的,基本满世界都是 Paxos 相关的文章。
除了各种软硬件异常导致的失效,数据库受损的另一大主因是误操作。OceanBase对此提供了对象闪回机制作为防护手段,即当数据库对象被删除时并不是从物理上直接删除,而是被转移到回收站中。如果之后发现数据库对象是被误删除,那么就可以从回收站中将数据库对象重新恢复。这种从回收站中恢复被删除数据库对象的操作称为对象闪回(Flashback)。这种操作其实也是业界常用操作,数据删除一般等一段时间后才完全生效,以避免用户误删的情况。
OceanBase内建支持集群级别的物理备份以及租户级别的恢复。物理备份由基线数据、日志归档数据两种数据组成,因此物理备份由日志归档和数据备份两个功能组合而成:
- 日志归档指的是日志数据的自动归档功能,OBServer会定期自动将日志数据归档到指定的备份路径;
- 数据备份指的是备份基线数据的功能,该功能分为全量备份和增量备份两种:
- 全量备份是指备份所有的需要基线的宏块:如第4章4.2节所述,OceanBase将数据切分为大小为2MB的宏块,宏块是数据文件I/O的基本单位,一个SSTable就由若干个宏块构成。
- 增量备份是指备份上一次备份以后新增和修改过的宏块。
多租户
从租户用途来看,OceanBase中的租户可以分成系统租户和普通租户两种。
系统租户: 系统租户也称为SYS租户,是OceanBase的系统内置租户。系统租户主要有以下几个功能:
- 系统租户承载了所有租户的元信息存储和管理服务。例如,系统租户下存储了所有普通租户系统表的对象元数据信息和位置信息。
- 系统租户是分布式集群集中式策略的执行者。例如,只有在系统租户下,才可以执行轮转合并、删除或创建普通租户、修改系统配置项、资源负载均衡、自动容灾处理等操作。
- 系统租户负责管理和维护集群资源。例如,系统租户下存储了集群中所有OBServer的信息和Zone的信息。
系统租户在集群自举过程中创建,系统租户信息和资源的管理都是在RootService服务(RS)上完成,RS位于系统租户下__all_core_table表的主副本上。
普通租户
普通租户可以看作是一个数据库实例代名词,其中仅包括用户级别的数据。普通租户由管理员通过系统租户根据业务需要来创建,普通租户具备一个实例所应该具有的所有特性:- 可以创建自己的用户。
- 可以创建数据库(Database)、表(Table)等各种数据库对象。
- 有自己独立的系统表和系统视图。
- 有自己独立的系统变量。
- 数据库实例所具备的其他特性。
OceanBase中,使用资源单元(Resource Unit)、资源池(Resource Pool)和资源单元配置(Resource Unit Config)三个概念,对各租户的可用资源进行定义。
- 资源单元(Resource Unit)。资源单元是一个容器。实际上,副本是存储在资源单元之中的,所以资源单元是副本的容器。每个资源单元描述了位于一个节点上的一组计算和存储资源,可以视为一个轻量级虚拟机,包括若干CPU、内存、磁盘资源等。资源单元是为租户分配资源的最小单位,一个租户在同一个节点上最多有一个资源单元。资源单元也不能跨节点,每个资源单元一定会被放置在资源足以容纳它的节点上。同时资源单元也是集群负载均衡的一个基本单位,当集群内的节点下线前,其上的资源单元必须迁移到其他节点上,如果集群内其他节点的资源不足以容纳这些资源单元,会导致节点下线无法成功。
- 资源池(Resource Pool)。资源池是资源单元的集合,一个资源池由具有相同资源单元配置(Resource Unit Config)的若干个资源单元组成。一个资源池只能属于一个租户,一个租户可以拥有若干个资源池,这些资源池的集合描述了这个租户所能使用的所有资源。资源池中会定义资源池属于哪些Zone以及在每个Zone上的资源单元数量,OceanBase系统会在Zone内根据负载为每个资源单元选择一个节点放置,受制于一个节点最多承载一个租户的一个资源单元,因此资源池定义的每Zone单元数不能超过Zone中的节点数。
- 资源单元配置(Resource Unit Config)。资源单元配置是对资源单元中所拥有资源的描述,包含资源单元所属的资源池信息、使用资源的租户信息、资源单元的配置信息(如CPU核数和内存资源)等。修改资源单元配置可以动态调整资源单元的计算资源,进而调整对应租户的资源。
在系统运行过程中,当资源单元出现变化(包括创建新的资源单元以及服务器上资源使用不均衡时)导致需要进行系统资源均衡时,需要考虑资源占用率。当系统中有多种资源需要进行均衡时,仅使用其中一种资源的占用率去进行均衡不可能准确,也很难达到较好的均衡效果。为此,OceanBase在多种资源(CPU资源和内存资源)均衡和分配时,使用了如下的资源占用评估方法,即为参与分配和均衡的每种资源分配一个权重,作为计算OBServer总的资源占用率时该资源所占的百分比,每种资源使用得越多,其权重就越高。
创建一个新的资源单元时,需要为该资源单元选择一个OBServer宿主机,分配宿主机所采用的方法是:先根据上面多资源占用率的计算规则,计算出每一个OBServer的资源占用率,然后选取资源占用率最小的那台OBServer,作为新建资源单元的宿主机。
资源单元均衡是通过在OBServer间迁移资源单元的方式使得各OBServer的资源占用率相差尽量小,使用上述多种资源占用率的算法,可以计算出每台OBServer的资源占用率,并尝试不断迁移资源单元,使得迁移资源单元完成后各OBServer之间的资源占用率比迁移资源单元前更小,即完成了资源单元的均衡。
例如,某集群中总的CPU资源为50个CPU,资源单元共占用20个CPU,则CPU总的占用率为40%。该集群中总的内存资源为1000GB,资源单元共占用内存资源100GB,则内存占用率为10%,集群中没有其他资源参与均衡。归一化后,CPU和内存资源的权重分配为80%和20%,各OBServer根据该权重计算各自的资源占用率,然后再通过迁移降低各OBServer之间的资源占用率差值。
安全管理
OceanBase的安全体系主要包括身份鉴别、访问控制和安全审计,这些就没什么好说了。