分库分表理论篇

Published on with 0 views and 0 comments

1 核心概念

表是透明化数据分片的关键概念,多样化的表类型适配不同场景下的数据分片需求。

1.1 逻辑表

相同结构的水平拆分数据库(表)的逻辑名称,是SQL中表的逻辑标识。 例如:订单数据根据主键尾数拆分为10张表,分别是t_order_0到t_order_9,他们的逻辑表名为t_order。

1.2 真实表

在水平拆分的数据库中真实存在的物理表。 上面提到的t_order_0到t_order_9就是真实表。

1.3 绑定表

指分片规则一致的主表和子表。 使用绑定表进行多表关联查询时,必须使用分片键进行关联,否则会出现笛卡尔积关联或跨库关联,从而影响查询效率。 例如:t_order表和t_order_item表,均按照order_id分片,并且使用order_id进行关联,则此两张表互为绑定表关系。 绑定表之间的多表关联查询不会出现笛卡尔积关联,关联查询效率将大大提升。

1.4 广播表

指所有的分片数据源中都存在的表,表结构及其数据在每个数据库中均完全一致。 适用于数据量不大且需要与海量数据的表进行关联查询的场景,例如:字典表。

1.5 单表

指所有的分片数据源中仅唯一存在的表。 适用于数据量不大且无需分片的表。

2 SQL解析

SQL解析过程分为词法解析语法解析

词法解析器用于将SQL拆解为不可再分的原子符号,称为 Token。并根据不同数据库方言所提供的字典,将其归类为关键字、表达式、字面量和操作符。 再使用语法解析器将词法解析器的输出转换为抽象语法树

例如,以下SQL:

f27d41070b9c46eea861a8a84e688644.png

解析之后的为抽象语法树见下图:

57285f3ef87644359b3bd4d29625077e.png

说明:为了便于理解,抽象语法树中的关键字的Token用绿色表示,变量的Token用红色表示,灰色表示需要进一步拆分

最后,通过 visitor 对抽象语法树遍历构造域模型,通过域模型(SQLStatement)去提炼分片所需的解析上下文,并标记有可能需要改写的位置。 供分片使用的解析上下文包含查询选择项(Select Items)、表信息(Table)、分片条件(Sharding Condition)、自增主键信息(Auto increment Primary Key)、排序信息(Order By)、分组信息(Group By)以及分页信息(Limit、Rownum、Top)。

SQL的一次解析过程是不可逆的,一个个Token按SQL原本的顺序依次进行解析,性能很高。 考虑到各种数据库SQL方言的异同,在解析模块还需提供各类数据库的SQL方言字典。

3 SQL路由

根据解析上下文匹配数据库和表的分片策略,并生成路由路径。

对于携带分片键的SQL,根据分片键的不同可以划分为单片路由(分片键的操作符是等号)、多片路由(分片键的操作符是IN)和范围路由(分片键的操作符是BETWEEN)。 不携带分片键的SQL则采用广播路由

分片策略通常可以采用由数据库内置或由用户方配置:

  • 数据库内置的方案较为简单,内置的分片策略大致可分为尾数取模、哈希、范围、标签、时间等

  • 由用户方配置的分片策略则更加灵活,可以根据使用方需求定制复合分片策略。 如果配合数据自动迁移来使用,可以做到无需用户关注分片策略,自动由数据库中间层分片和平衡数据即可,进而做到使分布式数据库具有的弹性伸缩的能力。

SQL路由整体设计如下图:

a4c5e806dc7b48cfa34e601d74415731.png

3.1 分片路由

用于根据分片键进行路由的场景,又细分为直接路由、标准路由和笛卡尔积路由这3种类型。

3.1.1 直接路由

满足直接路由的条件相对苛刻,它需要通过Hint方式分片,直接指定路由至库表,并且是只分库不分表的前提下,则可以避免SQL解析和之后的结果归并。 因此它的兼容性最好,可以执行包括子查询、自定义函数等复杂情况的任意SQL。直接路由还可以用于分片键不在SQL中的场景

3.1.2 标准路由

标准路由的适用范围是不包含关联查询或仅包含绑定表之间关联查询的SQL

当分片运算符是等于号时,路由结果将落入单库(表),当分片运算符是BETWEEN或IN时,则路由结果不一定落入唯一的库(表),因此一条逻辑SQL最终可能被拆分为多条用于执行的真实SQL。 举例说明,如果按照order_id的奇数和偶数进行数据分片,一个单表查询的SQL如下:

27a9496de48449a3bb25e4329b1ea923.png

那么路由的结果应为:

8fc48bab20db4aecbc43272547121bb1.png

绑定表的关联查询与单表查询复杂度和性能相当。例如一个包含绑定表的关联查询的SQL如下:

ddfaef8305674238b9070fb71e6663f5.png

路由的结果应为:

2bb4ebd3ba0a4e8594169a25211aa8cb.png

可以看到,SQL拆分的数目与单表是一致的。

3.1.3 笛卡尔积路由

笛卡尔路由是最复杂的情况,它无法根据绑定表的关系定位分片规则,因此非绑定表之间的关联查询需要拆解为笛卡尔积组合执行。 如果上个示例中的SQL并未配置绑定表关系,那么路由的结果应为:

d3d78693c4be47778cc074c43fa5bf1e.png

笛卡尔路由查询性能较低,需谨慎使用。

3.2 广播路由

对于不携带分片键的SQL,则采取广播路由的方式。根据SQL类型又可以划分为全库表路由、全库路由、全实例路由、单播路由和阻断路由这 5 种类型。

3.2.1 全库表路由

全库表路由用于处理对数据库中与其逻辑表相关的所有真实表的操作,主要包括不带分片键的DQL和DML,以及DDL 等。例如:

c0d2528f328c48788e110458ca9b8c29.png

则会遍历所有数据库中的所有表,逐一匹配逻辑表和真实表名,能够匹配得上则执行。路由后成为:

4476464bf3e149e0ad82e50b6f2c65ff.png

3.2.2 全库路由

全库路由用于处理对数据库的操作,包括用于库设置的SET类型的数据库管理命令,以及TCL这样的事务控制语句。 在这种情况下,会根据逻辑库的名字遍历所有符合名字匹配的真实库,并在真实库中执行该命令,例如:

b1d1b9ef3bb44838a0aed9aeec54d21a.png

在t_order中执行,t_order有2个真实库。则实际会在t_order_0和t_order_1上都执行这个命令。

3.2.3 全实例路由

全实例路由用于DCL操作,授权语句针对的是数据库的实例。无论一个实例中包含多少个Schema,每个数据库的实例只执行一次。例如:

712103eded9b4dbabb418913519f99b9.png

这个命令将在所有的真实数据库实例中执行,以确保customer用户可以访问每一个实例。

3.2.4 单播路由

单播路由用于获取某一真实表信息的场景,它仅需要从任意库中的任意真实表中获取数据即可。例如:

93e4af328c5c4b77a5bd1bcf52a2334e.png

t_order的两个真实表t_order_0和t_order_1的描述结构相同,所以这个命令在任意真实表上选择执行一次。

3.2.5 阻断路由

阻断路由用于屏蔽SQL对数据库的操作,例如:

917aacb50d614506b63d52769033c5f1.png

这个命令不会在真实数据库中执行,因为分库分表采用的是逻辑Schema的方式,无需将切换数据库Schema的命令发送至数据库中。

4 SQL改写

面向逻辑库与逻辑表书写的SQL,并不能够直接在真实的数据库中执行,SQL改写用于将逻辑SQL改写为在真实数据库中可以正确执行的SQL。 它包括正确性改写和优化改写两部分。

SQL改写的整体设计如下图所示:

162586b816fd4cdb88fda7e4aafd56ec.png

4.1 正确性改写

在包含分表的场景中,需要将分表配置中的逻辑表名称改写为路由之后所获取的真实表名称。仅分库则不需要改写表的名称。除此之外,还包括补列和分页信息修正等内容。

4.1.1 标识符改写

需要改写的标识符包括表名称、索引名称以及Schema名称。

表名称改写是指将找到逻辑表在原始SQL中的位置,并将其改写为真实表的过程。表名称改写是一个典型的需要对SQL进行解析的场景。 从一个最简单的例子开始,若逻辑SQL为:

62fa15f866ff4d32bd62df4802738147.png

假设该SQL配置分片键为order_id,并且order_id=1的情况,将路由至分片表1。那么改写之后的SQL应该为:

ee78db8e393b460bb8030fb34b17ac0d.png

在这种最简单的SQL场景中,是否将SQL解析为抽象语法树似乎无关紧要,只要通过字符串查找和替换就可以达到SQL改写的效果。 但是下面的场景,就无法仅仅通过字符串的查找替换来正确的改写SQL了:

17708e89f502419285df97263dc6f6b3.png

正确改写的SQL应该是:

c1364d5a94d04d4f90bda6a7f25848a7.png

而非:

5dcf280c2f3d4595bb22a8423961c172.png

由于表名之外可能含有表名称的类似字符,因此不能通过简单的字符串替换的方式去改写SQL

下面再来看一个更加复杂的SQL改写场景:

1985fd361d69473fbc27323e826182c2.png

上面的SQL将表名作为字段的标识符,因此在SQL改写时需要一并修改:

9833711c2ab7409c883d9a478f820ac2.png

而如果SQL中定义了表的别名,则无需连同别名一起修改,即使别名与表名相同亦是如此。例如:

b621adb23a29456fa942867bbe6ac668.png

SQL改写则仅需要改写表名称就可以了:

b775bf4aafcd4a51bf5d41ae2ad3b48f.png

索引名称是另一个有可能改写的标识符。 在某些数据库中(如MySQL、SQLServer),索引是以表为维度创建的,在不同的表中的索引是可以重名的; 而在另外的一些数据库中(如PostgreSQL、Oracle),索引是以数据库为维度创建的,即使是作用在不同表上的索引,它们也要求其名称的唯一性。

4.1.2 补列

需要在SQL中补列通常由3种情况导致。

第一种情况是需要在结果归并时获取相应数据,但该数据并未能通过查询的SQL返回。 这种补列情况主要是针对 GROUP BYORDER BY。结果归并时,需要根据 GROUP BYORDER BY 的字段项进行分组和排序,但如果原始SQL的选择项中若并未包含分组项或排序项,则需要对原始SQL进行改写。 先看一下原始SQL中带有结果归并所需信息的场景:

928244d05f8d414cb52ae539f72142dc.png

由于使用user_id进行排序,在结果归并中需要能够获取到user_id的数据,而上面的SQL是能够获取到user_id数据的,因此无需补列。

如果选择项中不包含结果归并时所需的列,则需要进行补列,如以下SQL:

81932f724448431a84b349d9803f9af5.png

由于原始SQL中并不包含需要在结果归并中需要获取的user_id,因此需要对SQL进行补列改写。补列之后的SQL如下:

fbc555e604d047f6ae096dfeb03b1fb0.png

值得一提的是,补列只会补充缺失的列,不会全部补充,而且在SELECT语句中包含 * 的SQL,也会根据表的元数据信息选择性补列。下面是一个较为复杂的SQL补列场景:

7de91485bfb34b20b062db789a79c3e0.png

我们假设只有t_order_item表中包含order_item_id列,那么根据表的元数据信息可知,在结果归并时,排序项中的user_id是存在于t_order表中的,无需补列;order_item_id 并不在t_order中,因此需要补列。 补列之后的SQL是:

ac913a329cdc473a8bcdcefe2055d151.png

补列的另一种情况是使用 AVG 聚合函数。在分布式的场景中,使用 avg1 + avg2 + avg3 / 3 计算平均值并不正确,需要改写为 (sum1 + sum2 + sum3) / (count1 + count2 + count3)。 这就需要将包含AVG的SQL改写为SUM和COUNT,并在结果归并时重新计算平均值。例如以下SQL:

90e089da56de42d6aa3a59544d03309e.png

需要改写为:

04ddbe17472e40eeb5846eecfc427c63.png

然后才能够通过结果归并正确的计算平均值。

最后一种补列是在执行INSERT的SQL语句时,如果使用数据库自增主键,是无需写入主键字段的。 但数据库的自增主键是无法满足分布式场景下的主键唯一的,因此需要提供分布式自增主键的生成策略,并且可以通过补列,让使用方无需改动现有代码,即可将分布式自增主键透明的替换数据库现有的自增主键。举例说明,假设表t_order的主键是order_id,原始的SQL为:

ad8866d6b5314010a6ec4c7b9ae3960c.png

可以看到,上述SQL中并未包含自增主键,是需要数据库自行填充的。如果配置自增主键后,SQL将改写为:

06b62e1c5da346c78faa7f92b5e6ab2e.png

改写后的SQL将在 INSERT FIELDINSERT VALUE 的最后部分增加主键列名称以及自动生成的自增主键值。上述SQL中的 xxxxx 表示自动生成的自增主键值。

如果INSERT的SQL中并未包含表的列名称,例如:

afaadd6893024e6dbbf1f1f6d673c87c.png

改写的SQL将只在主键所在的列顺序处增加自增主键即可:

7226cb1b290c4655af7db2a2beaba80e.png

自增主键补列时,如果使用占位符的方式书写SQL,则只需要改写参数列表即可,无需改写SQL本身。

4.1.3 分页修正

从多个数据库获取分页数据与单数据库的场景是不同的。 假设每10条数据为一页,取第2页数据。在分片环境下获取 LIMIT 10,10,归并之后再根据排序条件取出前10条数据是不正确的。 举例说明:

5bee605948d64f039ffc77242f034841.png

下图展示了不进行SQL的改写的分页执行结果:

8b139fb988d84646bb29a5320e4f1ec3.png

通过图中所示,想要取得两个表中共同的按照分数排序的第2条和第3条数据,应该是95和90。 由于执行的SQL只能从每个表中获取第2条和第3条数据,即从t_score_0表中获取的是90和80;从t_score_1表中获取的是85和75。 因此进行结果归并时,只能从获取的90、80、85和75之中进行归并,那么结果归并无论怎么实现,都不可能获得正确的结果。

正确的做法是将分页条件改写为 LIMIT 0,3(公式:limit M offset N 改写成 limit M+N offset 0),取出所有前两页数据,再结合排序条件计算出正确的数据。 下图展示了进行SQL改写之后的分页执行结果:

65af842695544cf0be13001097a8ccb8.png

越获取偏移量位置靠后数据,使用LIMIT分页方式的效率就越低。 有很多方法可以避免使用LIMIT进行分页。比如构建行记录数量与行偏移量的二级索引,或使用上次分页数据结尾ID作为下次查询条件的分页方式等。

分页信息修正时,如果使用占位符的方式书写SQL,则只需要改写参数列表即可,无需改写SQL本身。

4.1.4 批量拆分

在使用批量插入的SQL时,如果插入的数据是跨分片的,那么需要对SQL进行改写来防止将多余的数据写入到数据库中。 插入操作与查询操作的不同之处在于,查询语句中即使用了不存在于当前分片的分片键,也不会对数据产生影响;而插入操作则必须将多余的分片键删除。 举例说明,如下SQL:

1b5f5f78e9224525a9572cd7270ca1ed.png

假设数据库仍然是按照order_id的奇偶值分为两片的,仅将这条SQL中的表名进行修改,然后发送至数据库完成SQL的执行 ,则两个分片都会写入相同的记录。 虽然只有符合分片查询条件的数据才能够被查询语句取出,但存在冗余数据的实现方案并不合理。因此需要将SQL改写为:

d20a0b5523bd402f9cae14d10891d870.png

使用IN的查询与批量插入的情况相似,不过IN操作并不会导致数据查询结果错误。通过对IN查询的改写,可以进一步的提升查询性能。如以下SQL:

3c509678a4e14f538f8ba1d7be74ae35.png

改写为:

825a3c0550f04b2482a65b129d0d6a64.png

可以进一步的提升查询性能。

4.2 优化改写

优化改写的目的是在不影响查询正确性的情况下,对性能进行提升的有效手段。它分为单节点优化和流式归并优化。

4.2.1 单节点优化

路由至单节点的SQL,则无需优化改写。 当获得一次查询的路由结果后,如果是路由至唯一的数据节点,则无需涉及到结果归并。因此补列和分页信息等改写都没有必要进行。 尤其是分页信息的改写,无需将数据从第 1 条开始取,大量的降低了对数据库的压力,并且节省了网络带宽的无谓消耗。

4.2.2 流式归并优化

流式归并优化仅为包含 GROUP BY 的SQL增加 ORDER BY 以及和分组项相同的排序项和排序顺序,用于将内存归并转化为流式归并。 在结果归并的部分中,将对流式归并和内存归并进行详细说明。

5 SQL执行

SQL执行的目标是自动化的平衡资源控制与执行效率。它不是简单地将SQL通过JDBC直接发送至数据源执行;也并非直接将执行请求放入线程池去并发执行。它更关注平衡数据源连接创建以及内存占用所产生的消耗,以及最大限度地合理利用并发等问题。

执行引擎的整体结构划分如下图所示:

3820e50152584f459e2bfc6dd109a6cf.png

5.1 连接模式

从资源控制的角度看,业务方访问数据库的连接数量应当有所限制。 它能够有效地防止某一业务操作过多的占用资源,从而将数据库连接的资源耗尽,以致于影响其他业务的正常访问。 特别是在一个数据库实例中存在较多分表的情况下,一条不包含分片键的逻辑SQL将产生落在同库不同表的大量真实SQL,如果每条真实SQL都占用一个独立的连接,那么一次查询无疑将会占用过多的资源。

从执行效率的角度看,为每个分片查询维持一个独立的数据库连接,可以更加有效的利用多线程来提升执行效率。 为每个数据库连接开启独立的线程,可以将 I/O 所产生的消耗并行处理。为每个分片维持一个独立的数据库连接,还能够避免过早的将查询结果数据加载至内存。 独立的数据库连接,能够持有查询结果集游标位置的引用,在需要获取相应数据时移动游标即可。

以结果集游标下移进行结果归并的方式,称之为流式归并,它无需将结果数据全数加载至内存,可以有效的节省内存资源,进而减少垃圾回收的频次。 当无法保证每个分片查询持有一个独立数据库连接时,则需要在复用该数据库连接获取下一张分表的查询结果集之前,将当前的查询结果集全数加载至内存。 因此,即使可以采用流式归并,在此场景下也将退化为内存归并。

一方面是对数据库连接资源的控制保护,一方面是采用更优的归并模式达到对中间件内存资源的节省,如何处理好两者之间的关系,是执行引擎需要解决的问题。 具体来说,如果一条SQL在经过分片后,需要操作某数据库实例下的200张表。 那么,是选择创建200个连接并行执行,还是选择创建一个连接串行执行呢?效率与资源控制又应该如何抉择呢?

针对上述场景,产生了一种解决思路。 它提出了连接模式(Connection Mode)的概念,将其划分为内存限制模式(MEMORY_STRICTLY)和连接限制模式(CONNECTION_STRICTLY)这两种类型。

5.1.1 内存限制模式

使用内存限制模式的前提是,对一次操作所耗费的数据库连接数量不做限制。 如果实际执行的SQL需要对某数据库实例中的200张表做操作,则对每张表创建一个新的数据库连接,并通过多线程的方式并发处理,以达成执行效率最大化。 并且在SQL满足条件情况下,优先选择流式归并,以防止出现内存溢出或避免频繁垃圾回收情况。

内存限制模式适用于OLAP操作,可以通过放宽对数据库连接的限制提升系统吞吐量。

5.1.2 连接限制模式

使用连接限制模式的前提是,严格控制对一次操作所耗费的数据库连接数量。 如果实际执行的SQL需要对某数据库实例中的200张表做操作,那么只会创建唯一的数据库连接,并对其200张表串行处理。 如果一次操作中的分片散落在不同的数据库,仍然采用多线程处理对不同库的操作,但每个库的每次操作仍然只创建一个唯一的数据库连接。 这样即可以防止对一次请求对数据库连接占用过多所带来的问题。连接限制模式始终选择内存归并

连接限制模式适用于OLTP操作,OLTP通常带有分片键,会路由到单一的分片,因此严格控制数据库连接,以保证在线系统数据库资源能够被更多的应用所使用,是明智的选择。

5.2 自动化执行引擎

自动化执行引擎将连接模式的选择粒度细化至每一次SQL的操作。 针对每次SQL请求,自动化执行引擎都将根据其路由结果,进行实时的演算和权衡,并自主地采用恰当的连接模式执行,以达到资源控制和效率的最优平衡。 简言之,用户无需了解所谓的内存限制模式和连接限制模式是什么,而是交由执行引擎根据当前场景自动选择最优的执行方案

执行引擎分为准备和执行两个阶段。

5.2.1 准备阶段

顾名思义,此阶段用于准备执行的数据。它分为结果集分组和执行单元创建两个步骤。

结果集分组是实现内化连接模式概念的关键。执行引擎根据 maxConnectionSizePerQuery 配置项(该参数表示一次查询时每个数据库所允许使用的最大连接数),结合当前路由结果,选择恰当的连接模式。 具体步骤如下:

  1. 将SQL的路由结果按照数据源的名称进行分组。

  2. 通过下图的公式,可以获得每个数据库实例在maxConnectionSizePerQuery的允许范围内,每个连接需要执行的SQL路由结果组,并计算出本次请求的最优连接模式。
    571fa12d9db544588a587edd92299fd5.png

在maxConnectionSizePerQuery允许的范围内,当一个连接需要执行的请求数量大于1时,意味着当前的数据库连接无法持有相应的数据结果集,则必须采用内存归并; 反之,当一个连接需要执行的请求数量等于1时,意味着当前的数据库连接可以持有相应的数据结果集,则可以采用流式归并。

每一次的连接模式的选择,是针对每一个物理数据库的。也就是说,在同一次查询中,如果路由至一个以上的数据库,每个数据库的连接模式不一定一样,它们可能是混合存在的形态

通过上一步骤获得的路由分组结果创建执行的单元。 当数据源使用数据库连接池等控制数据库连接数量的技术时,在获取数据库连接时,如果不妥善处理并发,则有一定几率发生死锁。 在多个请求相互等待对方释放数据库连接资源时,将会产生饥饿等待,造成交叉的死锁问题。

举例说明,假设一次查询需要在某一数据源上获取两个数据库连接,并路由至同一个数据库的两个分表查询。 则有可能出现查询A已获取到该数据源的1个数据库连接,并等待获取另一个数据库连接;而查询B也已经在该数据源上获取到的一个数据库连接,并同样等待另一个数据库连接的获取。 如果数据库连接池的允许最大连接数是2,那么这2个查询请求将永久的等待下去。下图描绘了死锁的情况:

ea562cf8d6f14b0d8b9df0d2a4faf78b.png

为了避免死锁的出现,在获取数据库连接时需要进行同步处理。 在创建执行单元时,以原子性的方式一次性获取本次SQL请求所需的全部数据库连接,杜绝了每次查询请求获取到部分资源的可能。 由于对数据库的操作非常频繁,每次获取数据库连接时时都进行锁定,会降低并发。因此,在这里可以进行2点优化:

  1. 避免锁定一次性只需要获取1个数据库连接的操作。因为每次仅需要获取1个连接,则不会发生两个请求相互等待的场景,无需锁定。 对于大部分OLTP的操作,都是使用分片键路由至唯一的数据节点,这会使得系统变为完全无锁的状态,进一步提升了并发效率。 除了路由至单分片的情况,读写分离也在此范畴之内。

  2. 仅针对内存限制模式时才进行资源锁定。在使用连接限制模式时,所有的查询结果集将在装载至内存之后释放掉数据库连接资源,因此不会产生死锁等待的问题。

5.2.2 执行阶段

该阶段用于真正的执行SQL,它分为分组执行归并结果集生成两个步骤:

  1. 分组执行将准备执行阶段生成的执行单元分组下发至底层并发执行引擎,并针对执行过程中的每个关键步骤发送事件。 如:执行开始事件、执行成功事件以及执行失败事件。执行引擎仅关注事件的发送,它并不关心事件的订阅者。

  2. 通过在执行准备阶段的获取的连接模式,生成内存归并结果集或流式归并结果集,并将其传递至结果归并引擎,以进行下一步的工作。

6 结果归并

将从各个数据节点获取的多数据结果集,组合成为一个结果集并正确的返回至请求客户端,称为结果归并。

结果归并从功能上分为遍历、排序、分组、分页和聚合5种类型,它们是组合而非互斥的关系。 从结构划分,可分为流式归并、内存归并和装饰者归并。流式归并和内存归并是互斥的,装饰者归并可以在流式归并和内存归并之上做进一步的处理。

由于从数据库中返回的结果集是逐条返回的,并不需要将所有的数据一次性加载至内存中,因此,在进行结果归并时,沿用数据库返回结果集的方式进行归并,能够极大减少内存的消耗,是归并方式的优先选择。

流式归并是指每一次从结果集中获取到的数据,都能够通过逐条获取的方式返回正确的单条数据,它与数据库原生的返回结果集的方式最为契合。遍历、排序以及流式分组都属于流式归并的一种。

内存归并则是需要将结果集的所有数据都遍历并存储在内存中,再通过统一的分组、排序以及聚合等计算之后,再将其封装成为逐条访问的数据结果集返回。

装饰者归并是对所有的结果集归并进行统一的功能增强,目前装饰者归并有分页归并和聚合归并这2种类型。

归并引擎的整体结构划分如下图:

fc7870a674a04162adc540d8fdf1e106.png

6.1 遍历归并

遍历归并是最为简单的归并方式。 只需将多个数据结果集合并为一个单向链表即可。在遍历完成链表中当前数据结果集之后,将链表元素后移一位,继续遍历下一个数据结果集即可。

6.2 排序归并

由于在SQL中存在 ORDER BY 语句,因此每个数据结果集自身是有序的,因此只需要将数据结果集当前游标指向的数据值进行排序即可。 这相当于对多个有序的数组进行排序,归并排序是最适合此场景的排序算法。

在对排序的查询进行归并时,将每个结果集的当前数据值进行比较(Java中通过实现Comparable接口完成),并将其放入优先级队列。 每次获取下一条数据时,只需将队列顶端结果集的游标下移,并根据新游标重新进入优先级排序队列找到自己的位置即可。

通过一个例子来说明排序归并,下图是一个通过分数进行排序的示例图:

8d22ffc2588440eda5782a9fe0a0c063.png

图中展示了3张表返回的数据结果集,每个数据结果集已经根据分数排序完毕,但是3个数据结果集之间是无序的。 将3个数据结果集的当前游标指向的数据值进行排序,并放入优先级队列,t_score_0的第一个数据值最大,t_score_2的第一个数据值次之,t_score_1的第一个数据值最小,因此优先级队列根据t_score_0、t_score_2和t_score_1的方式排序队列。

下图则展现了进行next调用的时候,排序归并是如何进行的:

a05a70892de644f3af058e71d7e8344c.png

通过图中我们可以看到,当进行第一次 next 调用时,排在队列首位的t_score_0将会被弹出队列,并且将当前游标指向的数据值(也就是100)返回至查询客户端,并且将游标下移一位之后,重新放入优先级队列。 而优先级队列也会根据t_score_0的当前数据结果集指向游标的数据值(这里是90)进行排序,根据当前数值,t_score_0排列在队列的最后一位。 之前队列中排名第二的t_score_2的数据结果集则自动排在了队列首位。

在进行第二次next时,只需要将目前排列在队列首位的t_score_2弹出队列,并且将其数据结果集游标指向的值返回至客户端,并下移游标,继续加入队列排队,以此类推。 当一个结果集中已经没有数据了,则无需再次加入队列。

可以看到,对于每个数据结果集中的数据有序,而多数据结果集整体无序的情况下,无需将所有的数据都加载至内存即可排序。 它使用的是流式归并的方式,每次next仅获取唯一正确的一条数据,极大的节省了内存的消耗。

从另一个角度来说,排序归并是在维护数据结果集的纵轴和横轴这两个维度的有序性。 纵轴是指每个数据结果集本身,它是天然有序的,它通过包含 ORDER BY 的SQL所获取。 横轴是指每个数据结果集当前游标所指向的值,它需要通过优先级队列来维护其正确顺序。 每一次数据结果集当前游标的下移,都需要将该数据结果集重新放入优先级队列排序,而只有排列在队列首位的数据结果集才可能发生游标下移的操作。

6.3 分组归并

分组归并的情况最为复杂,它分为流式分组归并和内存分组归并。 流式分组归并要求SQL的排序项与分组项的字段以及排序类型(ASC或DESC)必须保持一致,否则只能通过内存归并才能保证其数据的正确性。

举例说明,假设根据科目分片,表结构中包含考生的姓名(为了简单起见,不考虑重名的情况)和分数。通过SQL获取每位考生的总分,可通过如下SQL:

e6a4712f72114c5ca443eea92488b72d.png

在分组项与排序项完全一致的情况下,取得的数据是连续的,分组所需的数据全数存在于各个数据结果集的当前游标所指向的数据值,因此可以采用流式归并。如下图所示:

3026c8cd65784191883e672ac699e6a0.png

进行归并时,逻辑与排序归并类似。 下图展现了进行next调用的时候,流式分组归并是如何进行的:

d978653a1d1845b28698167f7e61ba74.png

通过图中我们可以看到,当进行第一次next调用时,排在队列首位的t_score_java将会被弹出队列,并且将分组值同为 “Jerry” 的其他结果集中的数据一同弹出队列。 在获取了所有的姓名为 “Jerry” 的同学的分数之后,进行累加操作,那么,在第一次next调用结束后,取出的结果集是 “Jerry” 的分数总和。 与此同时,所有的数据结果集中的游标都将下移至数据值 “Jerry” 的下一个不同的数据值,并且根据数据结果集当前游标指向的值进行重排序。 因此,包含名字顺着第二位的 “John” 的相关数据结果集则排在的队列的前列。

流式分组归并与排序归并的区别仅仅在于两点:

  • 它会一次性的将多个数据结果集中的分组项相同的数据全数取出

  • 它需要根据聚合函数的类型进行聚合计算

对于分组项与排序项不一致的情况,由于需要获取分组的相关的数据值并非连续的,因此无法使用流式归并,需要将所有的结果集数据加载至内存中进行分组和聚合。 例如,若通过以下SQL获取每位考生的总分并按照分数从高至低排序:

1081b7f8076b452e868d100fa7afb471.png

那么各个数据结果集中取出的数据与排序归并那张图的上半部分的表结构的原始数据一致,是无法进行流式归并的。

当SQL中只包含分组语句时,根据不同数据库的实现,其排序的顺序不一定与分组顺序一致。 但由于排序语句的缺失,则表示此SQL并不在意排序顺序。 因此,通过SQL优化的改写,自动增加与分组项一致的排序项,使其能够从消耗内存的内存分组归并方式转化为流式分组归并方案

6.4 聚合归并

无论是流式分组归并还是内存分组归并,对聚合函数的处理都是一致的。 除了分组的SQL之外,不进行分组的SQL也可以使用聚合函数。 因此,聚合归并是在之前介绍的归并类的之上追加的归并能力,即装饰者模式。聚合函数可以归类为比较累加求平均值这 3 种类型:

  • 比较类型的聚合函数是指MAX和MIN。它们需要对每一个同组的结果集数据进行比较,并且直接返回其最大或最小值即可。

  • 累加类型的聚合函数是指SUM和COUNT。它们需要将每一个同组的结果集数据进行累加。

  • 求平均值的聚合函数只有AVG。它必须通过SQL改写的SUM和COUNT进行计算。

6.5 分页归并

上文所述的所有归并类型都可能进行分页。 分页也是追加在其他归并类型之上的装饰器,所以通过装饰者模式来增加对数据结果集进行分页的能力。 分页归并负责将无需获取的数据过滤掉。

分页功能比较容易让使用者误解,用户通常认为分页归并会占用大量内存。 在分布式的场景中,将 LIMIT 10000000,10 改写为 LIMIT 0,10000010,才能保证其数据的正确性。 用户非常容易产生错觉,认为会将大量无意义的数据加载至内存中,造成内存溢出风险。 其实,通过流式归并的原理可知,会将数据全部加载到内存中的只有内存分组归并这一种情况。 而通常来说,进行OLAP的分组SQL,不会产生大量的结果数据,它更多的用于大量的计算,以及少量结果产出的场景。 除了内存分组归并这种情况之外,其他情况都通过流式归并获取数据结果集,因此可以通过结果集的next方法将无需取出的数据全部跳过,并不会将其存入内存。

但同时需要注意的是,由于排序的需要,大量的数据仍然需要传输到内存空间。 因此,采用 LIMIT 这种方式分页,并非最佳实践。 由于LIMIT并不能通过索引查询数据,因此如果可以保证ID的连续性,通过ID进行分页是比较好的解决方案,例如:

b700dd15ae184529b510c29dab07156f.png

或通过记录上次查询结果的最后一条记录的ID进行下一页的查询,例如:

8446b85c1d674284996eccaff9532223.png


标题:分库分表理论篇
作者:yanghao
地址:http://solo.fancydigital.com.cn/articles/2022/01/09/1641721254984.html