Sharding-JDBC分库分表原理

2,752 阅读11分钟

介绍

Sharding-JDBC是当当网研发的开源分布式数据库中间件,从3.0开始Sharding-JDBC被包含在Sharding-Sphere中,之后该项目进入Apache孵化器,4.0版本之后正式为Apache版本。 Sharding-JDBC的核心功能是数据分片(也就是分库分表)、读写分离以及分布式事务。通过Sharding-JDBC,应用可以透明的使用jdbc访问已经分库分表、读写分离的多个数据源,无需关心数据源的数量以及数据是如何分布的。 Sharding-JDBC被定位为轻量级Java框架,在Java的JDBC层提供额外服务,使用客户端直连数据库,以jar包形式提供服务,无需额外部署和依赖,可理解为增强版的JDBC驱动,完全兼容JDBC和各种ORM框架。

  • 适用于任何基于JDBC的ORM框架,如:JPA、Hibernate、Mybatis,SpringJDBCTemplate或直接使用JDBC。
  • 支持任何第三方的数据库连接池,如:DBCP,C3P0,Druid,HikariCP等。
  • 支持任意实现JDBC规范的数据库,目前支持Mysql、Oracle、SQLServer、PostgreSQL以及任何遵循SQL92标准的数据库。

image.png

概念&功能

数据分片

背景

传统的将数据集中存储在单一节点的解决方案,在性能、可用性和运维成本三方面已经难于满足互联网的海量数据场景。 数据分片是指按照某个维度将存在在单一数据库中的数据分散的存放到多个数据库或表中以达到提升性能瓶颈以及可用性的效果。数据分片的有效手段是对关系型数据库进行分库和分表。分库和分表均可以有效的避免由数据量超过可承受阈值而产生的查询瓶颈。除此之外,分库还能够用于有效的分散对数据库单点的访问量。分表虽然无法缓解数据库压力,但能够提供尽量将分布式事务转化为本地事务的可能,一旦涉及到跨库的更新操作,分布式事务往往会使问题变得复杂。 通过分库和分表进行数据拆分使得各个表的数据量保持在阈值以下,以及对流量进行疏导应对高并发量,是应对高并发和海量数据系统的有效手段。数据分片的拆分方式又分为垂直拆分和水平拆分。

垂直分片

按照业务拆分方式成为垂直分片,又称纵向拆分,核心理念是专库专用。拆分前一个数据库由多个数据表构成,每个表对应不同业务。拆分后,按照业务对表进行归类,分布到不同的数据库中。下图就是根据业务将用户表和订单表垂直分片到不同数据库的方案。

image.png

垂直拆分往往需要对架构和设计进行调整,通常是来不及应对需求快速变化的,而且也无法真正解决单点瓶颈,垂直拆分可以缓解数据量和访问量带来的麻烦,但无法根治。

水平分片

水平分片又称横向拆分,它不再将数据根据业务逻辑分类,而是通过某个字段,根据某种规则将数据分散到多个库或表中,每个分片仅包含数据的一部分,例如:根据主键分片,偶数主键的记录放入0库(表),奇数主键的记录放入1库(或表),如下图所示:

image.png

水平分片从理论上突破了单机数据量处理的瓶颈,并且扩展相对自由,是分库分表的标准解决方案。

挑战

虽然数据分片解决了性能、可用性以及单点备份恢复等问题,但是面对散乱的分库分表之后的数据,应用开发工程师和DBA对数据库的操作变得异常繁重,需要知道数据需要从哪个具体的数据库的分表获取。另一个挑战就是能够正确运行在单点中的SQL,在分片之后的数据库中不一定能够正常运行。例如,分表导致表名称修改,或者分页、排序、聚合分组等操作的不正确处理。 跨库事务也是分布式的数据库集群要面对的。合理采用分表,可以降低单表数据量的情况下,尽量使用本地事务,善于使用同库不同表可有效避免分布式事务带来的麻烦。在不能避免跨库事务的场景,有些业务仍然需要保持事务一致性,而基于XA的分布式事务由于在并发度高的场景下性能无法满足需要,并未被互联网巨头大规模使用,大多采用最终一致性的柔性事务代替强一致性事务。

SQl核心概念

逻辑表

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

真实表

在分片的数据库中真实存在的物理表,即上面的t_order_0到t_order_9

数据节点

数据分片的最小单位。由数据源名称和数据表组成。例如:ds_0.t_order_0。

绑定表

指分片规则一致的主表和子表,例如:t_order表和t_order_item表,均按照order_id分片,则此两张表互为绑定表关系,绑定表之间的多表关联查询不会出现笛卡尔积,关联查询效率将大大提升。例如:

SELECT i.* FROM t_order o JOIN t_order_item i ON o.order_id=i.order_id WHERE o.order_id in (10, 11);

在不配置绑定表关系时,例如分片键的order_id将数值10路由到第0片,将数值11路由到第1片,那么路由后的SQL应该为4条,他们呈现笛卡尔积:

SELECT i.* FROM t_order_0 o JOIN t_order_item_0 i ON o.order_id=i.order_id WHERE o.order_id in (10, 11);

SELECT i.* FROM t_order_0 o JOIN t_order_item_1 i ON o.order_id=i.order_id WHERE o.order_id in (10, 11);

SELECT i.* FROM t_order_1 o JOIN t_order_item_0 i ON o.order_id=i.order_id WHERE o.order_id in (10, 11);

SELECT i.* FROM t_order_1 o JOIN t_order_item_1 i ON o.order_id=i.order_id WHERE o.order_id in (10, 11);

在配置绑定表关系后,路由的SQL只有2条:

SELECT i.* FROM t_order_0 o JOIN t_order_item_0 i ON o.order_id=i.order_id WHERE o.order_id in (10, 11);

SELECT i.* FROM t_order_1 o JOIN t_order_item_1 i ON o.order_id=i.order_id WHERE o.order_id in (10, 11);

其中t_order在From的最左侧,ShardingJDBC会以它作为整个绑定表的主表。所有路由计算将会只使用主表的策略,那么t_order_item的分片计算将会使用t_order的条件,所以绑定表之间的分区键要完全相同。

广播表

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

单表

指所有的分片数据源中只存在唯一一张的表,适用于数据量不大且不需要做任何分片操作的场景。

分片概念

分片键

用于分片的数据库字段,是将数据库(表)水平拆分的关键字段。例:将订单表中的订单主键的尾数取模分片,则订单主键为分片字段。SQL如果没有分片字段,则执行全路由,性能较差。ShardingJDBC也支持多个字段分片。

分片算法

通过分片算法将数据分片,支持通过=、>=、<=、>、<、Between 和IN分片,分片算法需要应用开发者自行实现,可实现的灵活度非常高。目前提供三种分片算法,由于分片算法和业务实现紧密相关,因此并未提供内置分片算法,而是通过分片策略将各种场景提炼出来,提供更高级的抽象,并提供接口让应用开发者自行实现分片算法。

  • 标准分片算法:对应StandardShardingAlgorithm,用于处理单一键作为分片键的=、>=、<=、>、<、Between 和IN进行分片的场景。需配合StandardShardingStrategy使用。
  • 复合分片算法:对应ComplexKeysShardingAlgorithm,用于处理使用多键作为分片键进行分片的场景,包含多个分片键的逻辑较为复杂,需要应用开发者自行处理其中的复杂度,需要配合ComplexShardingStrategy使用。
  • Hint分片算法:对应HintShardingAlgorithm,用于处理使用Hint进行分片的场景。需配合HintShardingStrategy使用。

配置

分片规则

分片规则配置的总入口。包含数据源配置、表配置、绑定表配置以及读写分离配置。

数据源配置

真实数据源列表

表配置

逻辑表名称、数据节点与分片规则的配置

数据节点配置

用于配置逻辑表和真实表的映射关系,分为均匀分布和自定义分布两种形式。 均与分布:数据表在每个数据源中呈现均匀分布

那么数据节点的配置如下:

db0.t_order0, db0.t_order1, db1.t_order0, db1.t_order1

自定义分布:数据表呈现有特定规则的分布,例如

image.png

那么数据节点的配置如下:

db0.t_order0, db0.t_order1, db1.t_order2, db1.t_order3, db1.t_order4

分片策略配置

对于分片策略有数据源分片和表分片两种维度

  • 数据源分片策略:对应DatabaseShardingStrategy,用于配置数据被分配的目标数据源
  • 表分片策略:对应于TableShardingStrategy。用于配置数据被分配的目标表。表分片策略是依赖数据源分片策略的结果的。

自增主键生成策略

通过在客户端生成自增主键替换以数据库原生自增主键的方式,做到分布式主键无重复。

内核剖析

解析引擎

跟解析其他编程语言一样,需要对SQL的语法进行解析

抽象语法树

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

SELECT id, name FROM t_user WHERE status = 'ACTIVE' AND age > 18

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

image.png

最后通过visitor对抽象语法树遍历构造域模型,通过域模型去提炼分片所需的上下文,并标记有可能需要改写的位置。供分片使用的解析上下文包含查询选择项(select items)、表信息(Table)、分片条件(Sharding Condition)、自增主键信息(Auto Increment Primary Key)、排序信息(Order By)、分组信息(Group By)以及分页信息(Limit、Rownum、Top)。SQL的一次解析过程是不可逆的,一个Token按SQL原本的顺序依次进行解析,性能很高。

路由引擎

根据解析上下文匹配数据库和表的分片策略,并生成路由路径。对于携带分片键的SQL,根据分片键的不同可以划分为单片路由(分片键的操作符是等号)、多片路由(分片键的操作符是IN)和范围路由(分片键的操作符是BETWEEN,>,<等)。不携带分片键的SQL则采用广播路由。 分片策略通常采用数据库内置或由用户方配置,数据库内置方案比较简单,内置的分片策略大致分为尾数取模、哈希、范围、标签、时间等。

分片路由

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

直接路由

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

hintManager.setDatabaseShardingValue(3);

假设路由算法为value%2,当一个逻辑库t_order对应2个真实库t_order_0和t_order_1,路由后SQL将在t_order_1上执行,下方是使用API的代码示例:

String sql = "SELECT * FROM t_order";
try (
        HintManager hintManager = HintManager.getInstance();
        Connection conn = dataSource.getConnection();
        PreparedStatement pstmt = conn.prepareStatement(sql)) {
    hintManager.setDatabaseShardingValue(3);
    try (ResultSet rs = pstmt.executeQuery()) {
        while (rs.next()) {
            //...
        }
    }
}
标准路由

标准路由是ShardingJDBC最为推荐的分片方式,适用范围不包含关联查询或仅包含绑定表之间关联查询的SQL。当分片运算符是等号时,路由结果将落入单库(表),当分片运算符是BETWEEN和IN时,则路由结果不一定落入唯一库(表),因此一条逻辑SQL可能被拆分成多条用于执行的真实SQL,例如:按照order_id的奇偶进行数据分片,一个单表查询的SQL如下:

SELECT * FROM t_order WHERE order_id IN (1, 2);

那么路由的结果应为:

SELECT * FROM t_order_0 WHERE order_id IN (1, 2);
SELECT * FROM t_order_1 WHERE order_id IN (1, 2);

绑定表的关联查询与单表查询复杂度和性能相当。

笛卡尔路由

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

SELECT * FROM t_order_0 o JOIN t_order_item_0 i ON o.order_id=i.order_id  WHERE order_id IN (1, 2);
SELECT * FROM t_order_0 o JOIN t_order_item_1 i ON o.order_id=i.order_id  WHERE order_id IN (1, 2);
SELECT * FROM t_order_1 o JOIN t_order_item_0 i ON o.order_id=i.order_id  WHERE order_id IN (1, 2);
SELECT * FROM t_order_1 o JOIN t_order_item_1 i ON o.order_id=i.order_id  WHERE order_id IN (1, 2);

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

广播路由

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

全库表路由

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

SELECT * FROM t_order WHERE good_prority IN (1, 10);

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

SELECT * FROM t_order_0 WHERE good_prority IN (1, 10);
SELECT * FROM t_order_1 WHERE good_prority IN (1, 10);
SELECT * FROM t_order_2 WHERE good_prority IN (1, 10);
SELECT * FROM t_order_3 WHERE good_prority IN (1, 10);
全库路由

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

SET autocommit=0;

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

全实例路由

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

CREATE USER customer@127.0.0.1 identified BY '123';
单播路由

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

阻断路由

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

USE order_db;

这个命令不会在真实数据库执行,因为ShardingJDBC采用的是逻辑Schema的方式,无需将切换数据库Schema的命令发送到数据库中。 路由引擎的整体结构划分如下图:

image.png

改写引擎

SQL改写用于将逻辑SQL改写为在真实数据库中可以正确执行的SQL。包括正确性改写和优化改写两部分。

正确性改写

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

标识符改写

需要改写的标识符包括表名称、索引名称和Schema名称。 表名称改写是指找到逻辑表在原始SQL中的位置,并将其改写为真实表的过程。从最简单的例子开始,若逻辑SQL为:

SELECT order_id FROM t_order WHERE order_id=1;

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

SELECT order_id FROM t_order_1 WHERE order_id=1;

在这种最简单的SQL场景汇总,是否将SQL解析为抽象语法树似乎无关紧要,但是下面的场景,就无法通过字符串查找替换来改写了:

SELECT order_id FROM t_order WHERE order_id=1 AND remarks=' t_order xxx';

正确改写后的SQL应为:

SELECT order_id FROM t_order_1 WHERE order_id=1 AND remarks=' t_order xxx';

而不是:

SELECT order_id FROM t_order_1 WHERE order_id=1 AND remarks=' t_order_1 xxx';
补列

需要在查询语句中补列通常由两种情况导致。第一种情况是ShardingJDBC需要在结果归并时获取相应数据,但该数据并未能通过查询的SQL返回,这种情况主要针对Group By和Order By。结果归并时,需要根据Group By和Order By的字段项进行分组和排序,但是如果原始SQL的查询项并没有包含分组项和排序项,则需要改写。

SELECT order_id FROM t_order ORDER BY user_id;

补列之后的SQL:

SELECT order_id, user_id AS ORDER_BY_DERIVED_0 FROM t_order ORDER BY user_id;

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

SELECT AVG(price) FROM t_order WHERE user_id=1;

改写为:

SELECT COUNT(price) AS AVG_DERIVED_COUNT_0, SUM(price) AS AVG_DERIVED_SUM_0 FROM t_order WHERE user_id=1;
分页修正

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

SELECT score FROM t_score ORDER BY score DESC LIMIT 1, 2;

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

image.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,取出所有前两页的数据,在结合排序条件计算出正确的数据。下图展示了进行SQL改写之后的分页执行结果

image.png

批量拆分

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

INSERT INTO t_order (order_id, xxx) VALUES (1, 'xxx'), (2, 'xxx'), (3, 'xxx');

假设数据库仍然是按照order_id的奇偶值分为两片的,仅将这条SQL的表名进行修改,然后发送至数据库完成SQL的执行,则这两个分片都会写入相同的记录,所以需要将SQL改写为:

INSERT INTO t_order_0 (order_id, xxx) VALUES (2, 'xxx');
INSERT INTO t_order_1 (order_id, xxx) VALUES (1, 'xxx'), (3, 'xxx');

优化改写

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

单节点优化

路由至单节点的SQL,则无需优化改写。当获得一次查询的路由结果后,如果是路由至唯一的数据节点,则无需涉及到结果归并,因此补列和分页信息等改写都没有必要进行。

流式归并优化

仅为包含Group By的SQL增加Order By以及和分组项相同的排序项和排序顺序,用于将内存归并转化为流式归并。

image.png

执行引擎

ShardingJDBC采一套自动化的执行引擎,负责将路由和改写完成之后的真实SQL安全且高效的发送到底层数据源执行,更关注平衡数据源连接创建和内存占用所产生的消耗,以及最大限度的合理利用并发等问题。执行引擎的目标是自动化的平衡资源控制和执行效率。

连接模式

从资源控制角度看,业务方访问数据库的连接数量应该有所限制。 从执行效率看,为每个分片查询维持一个独立的数据库连接,可以更加有效的利用多线程提升执行效率,为每个数据库连接开启独立的线程,可以将IO产生的消耗并行处理,还能避免过早将查询结果加载至内存。独立的数据库连接,能够持有查询结果集游标位置的引用,在需要获取相应数据时移动游标即可。 以结果集游标下移进行结果归并的方式,称为流式归并,它无需将结果数据全数加载到内存,可以有效节省内存资源。当无法保证每个分片查询持有一个独立的数据库连接时,则需要在复用该数据库连接获取下一章分表的查询结果之前,将当前的查询结果全数加载到内存,流式归并将退化为内存归并。 一方面是对数据库连接资源的控制保护,一方面是采用更优的归并模式达到对中间件内存资源的节省,如何处理好两者之间的关系,是ShardingSphere执行引擎需要解决的问题。比如,一条SQL在经过分片后,需要操作数据库实例的200张表,那么是创建200个连接并行执行还是创建一个连接串行执行呢?针对这个问题,提出了连接模式的概念,将其划分为内存限制模式和连接限制模式。

内存限制模式

使用此模式的前提是,ShardingJDBC对一次操作所耗费的数据库连接数量不做限制,并且在SQL满足条件的情况下,优先流式归并。

连接限制模式

使用此模式的前提是,ShardingJDBC严格控制一次操作的数据库连接数量。如果是对一个数据库实例的200张表操作,那么只会创建唯一一个数据库连接。如果一次操作的分片落在不同数据库,那么会为每个数据库操作创建一个连接,执行内存归并。

自动化执行引擎

自动化执行引擎将连接模式的选择细化到每一次SQL操作,针对每一次SQL请求,自动化执行引擎将根据路由结果,进行实时演算和权衡,并采用恰当的连接模式执行。针对自动化的执行引擎,用户只需配置maxConnectionSizePerQuery,该参数表示一次查询每个数据库所允许使用的最大连接数。执行引擎分为准备和执行两个阶段

准备阶段

准备执行的数据,分为结果集分组和执行单元创建两个步骤。结果集分组是实现内化连接模式的关键。执行引擎根据maxConnectionSizePerQuery配置项,结合当前结果路由,选择恰当的连接模式,具体步骤如下:

  • 将SQL的路由结果按照数据源分组
  • 通过下图的公式,可以获得每个数据库实例在maxConnectionSizePerQuery的允许范围内,每个连接需要执行的SQL路由结果组,并计算本次请求的最优连接模式

image.png

执行阶段

该阶段为真正执行SQL,分为分组执行和归并结果集生成两个步骤。 分组执行将准备阶段生成的执行单元分组下发到底层并发执行引擎,并针对执行过程中的每个关键步骤发送事件。ShardingJDBC通过在执行准备节点的获取的连接模式,生成内存归并结果或流式归并结果集,并将其传递至结果归并引擎,以进行下一步的工作。

image.png

归并引擎

将各个数据节点获取的多数据结果集组合成一个结果集并正确的返回到客户端,称为结果归并。ShardingJDBC支持的结果归并从功能上分为遍历、排序、分组、分页和聚合5种类型,他们是组合而非互斥的关系。从结构上分为流式归并、内存归并和装饰者归并。流式归并和内存归并是互斥的,装饰者归并可以在流式归并和内存归并上做进一步的处理。

  • 流式归并是指每一次从结果集中获取到的数据,都能够通过逐条获取的方式返回正确的单条数据,它与数据库原生的返回结果集的方式最为契合。遍历、排序和流式分组都属于流式归并的一种。
  • 内存归并是需要将结果集的所有数据都遍历并存储在内存中,再通过统一的分组、排序和聚合等计算后,再将其封装成逐条访问的数据结果集返回。
  • 装饰者归并是对所有的结果集归并进行统一的功能增强,目前装饰者归并有分页归并和聚合归并两种类型。

遍历归并

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

排序归并

由于在SQL中存在Order BY语句,因此每个数据结果集自身都是有序的,只需要将数据结果集当前游标指向的数据值进行排序即可。这相当于对多个有序数组进行排序,归并排序是最适合的排序算法。ShardingSphere在对排序的查询进行归并时,将每个结果集的当前数据进行比较(通过实现Java的Comparable接口完成),并将其放入优先级队列。每次获取下一条数据时,只需将队列顶端结果集的游标下移,并根据新游标重新进入优先级排序队列找到自己的位置即可。 通过一个例子来说明 ShardingSphere 的排序归并,下图是一个通过分数进行排序的示例图。 图中展示了 3 张表返回的数据结果集,每个数据结果集已经根据分数排序完毕,但是 3 个数据结果集之间是无序的。 将 3 个数据结果集的当前游标指向的数据值进行排序,并放入优先级队列,t_score_0 的第一个数据值最大,t_score_2 的第一个数据值次之,t_score_1 的第一个数据值最小,因此优先级队列根据 t_score_0,t_score_2 和 t_score_1 的方式排序队列。 下图则展现了进行 next 调用的时候,排序归并是如何进行的。 通过图中我们可以看到,当进行第一次 next 调用时,排在队列首位的 t_score_0 将会被弹出队列,并且将当前游标指向的数据值(也就是 100)返回至查询客户端,并且将游标下移一位之后,重新放入优先级队列。 而优先级队列也会根据 t_score_0 的当前数据结果集指向游标的数据值(这里是 90)进行排序,根据当前数值,t_score_0 排列在队列的最后一位。 之前队列中排名第二的 t_score_2 的数据结果集则自动排在了队列首位。 在进行第二次 next 时,只需要将目前排列在队列首位的 t_score_2 弹出队列,并且将其数据结果集游标指向的值返回至客户端,并下移游标,继续加入队列排队,以此类推。 当一个结果集中已经没有数据了,则无需再次加入队列。

image.png

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

分组归并

分组归并的情况最为复杂,分为流式分组归并和内存分组归并。流式分组归并要求SQL的排序项与分组项的字段以及排序类型必须保持一致,否则只能通过内存归并才能保证数据的正确性。 举例:假设根据科目分片,表结构中包含考生的姓名(为了简单起见,不考虑重名的情况)和分数。通过 SQL 获取每位考生的总分,可通过如下SQL

SELECT name, SUM(score) FROM t_score GROUP BY name ORDER BY name;

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

image.png

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

image.png

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

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

  • 1、它会一次性将多个数据结果集中的分组项相同的数据全数取出。
  • 2、它需要根据聚合函数的类型进行聚合计算。 对于分组项和排序项不一致的情况,由于需要获取分组的相关数据值并非连续的,因此无法使用流式归并,需要将所有的结果集数据加载进内存进行分组和聚合。

聚合归并

无论是流式分组归并还是内存分组归并,对聚合函数的处理都是一致的,除了分组的SQL外,不进行分组的SQL也可以使用聚合函数。因此,聚合归并是在之前介绍的归并类上追加的归并能力,即装饰者模式。聚合函数可以归类为比较、累加和求平均值三种类型。 比较类型的聚合函数是指 MAXMIN。它们需要对每一个同组的结果集数据进行比较,并且直接返回其最大或最小值即可。

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

求平均值的聚合函数只有 AVG。它必须通过 SQL 改写的 SUMCOUNT 进行计算,相关内容已在 SQL 改写的内容中涵盖,不再赘述。

分页归并

分页也是追加在其他归并类型之上的装饰器,ShardingSphere通过装饰者模式增加对数据结果集进行分页的能力。分页归并负责将无需获取的数据过滤掉。在分布式的场景中,将 LIMIT 10000000, 10 改写为 LIMIT 0, 10000010,才能保证其数据的正确性。 用户非常容易产生 ShardingSphere 会将大量无意义的数据加载至内存中,造成内存溢出风险的错觉。 其实,通过流式归并的原理可知,会将数据全部加载到内存中的只有内存分组归并这一种情况。 而通常来说,进行 OLAP 的分组 SQL,不会产生大量的结果数据,它更多的用于大量的计算,以及少量结果产出的场景。 除了内存分组归并这种情况之外,其他情况都通过流式归并获取数据结果集,因此 ShardingSphere 会通过结果集的 next 方法将无需取出的数据全部跳过,并不会将其存入内存。 但同时需要注意,由于排序需要,大量数据仍然需要传输到内存空间,因此采用Limit分页并非最佳实践,由于Limit不能通过索引查询数据,因此如果可以保证ID的连续性,通过ID进行分页是比较好的解决方案。例如:

SELECT * FROM t_order WHERE id > 100000 AND id <= 100010 ORDER BY id;

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

SELECT * FROM t_order WHERE id > 10000000 LIMIT 10;

image.png

ShardingJDBC与其他组件的对比

常见框架

网上常见的分库分表框架有:当当网ShardingJDBC,alibaba的cobar(是阿里巴巴B2B部门开发的)、MyCAT(基于阿里开源的Cobar产品而研发),蚂蚁金服的ZDAL,蘑菇街的TSharding。除了这些,还有很多各自公司提出的框架,但是根据用户量较高的为以上几种。自从出现了基于Cobar的MyCAT,zdal,也很少有人用Cobar了。ZDAL虽然也是开源,但是很少文章和使用反馈,不支持MongoDB,交流活跃度也比较低。

对比概览

主要指标Sharding-JDBCMycatzdal
ORM支持任意任意任意
事务自带弱XA、最大努力送达型柔性事务seata自带弱XA自带弱XA、最大努力送达型柔性事务BASE
分库支持支持支持
分表支持不支持单库分表支持
开发开发成本高,代码侵入大开发成本小,代码侵入小开发成本不高,配置明确
所属公司当当网基于阿里Cobar二次开发,社区维护蚂蚁金服
数据库支持任意Oracle、Sql Server、Myssql、MongoDB不支持MongoDB
活跃度不少的企业在最近几年新项目中使用社区活跃度很高,一些公司已在使用活跃度低
监控
读写分离支持支持
资料资料少,github,官网,网上讨论帖资料多,github、官网、书籍
运维维护成本低维护成本高维护成本低
限制部分JDBC方法不支持,SQL语句限制SQL语句限制
连接池druid版本无要求无要求
配置难度一般复杂比较简单

对比ShardingJDBC与MyCAT

  • 开发与运维成本:ShardingJDBC是一个轻量级框架,不是独立运行的中间件,而是以工程jar形式提供功能,无需额外部署,可以理解为增强版的JDBC驱动。对运维人员无需感知代码与分片规则,只需要维护执行建立表和数据的迁移。它的原理是通过规则改写sql以及对结果进行优化。MyCAT不是jar包,而是独立运行的中间件,所有配置都会交给DBA执行。对于DBA来说,它是一个在mysql server前增加一层代理,mycat本身不存数据,数据都是在Mysql存储的,因此数据可靠性和事务都在Mysql保证。
  • 分库分表能力:ShardingJDBC的优势是分库分表,可以在不需要分库的情况下单库分表。MyCAT不能单库分多表,必须分库。
  • 事务:ShardingJDBC和MyCAT都支持弱XA,弱XA就是分库之后的数据库各自负责自己事务的提交回滚,没有统一的调度器集中处理。好处是对性能没有影响,但是一旦出现问题,比如两个库的数据都需要提交,一个提交成功,另一个提交失败断网导致失败,则会导致数据不一致问题。柔性事务是对弱XA的补充。柔性事务类型很多,ShardingJDBC主要实现的是最大努力送达型,即认为事务经过反复尝试一定能够成功。如果每次事务执行失败,则记录到事务库,并通过异步手段不断尝试,直到事务成功。
  • 监控:Mycat有监控页面,ShardingJDBC没有监控事务是不是最终执行了。
  • 语句限制:Sharding-JDBC分库分表使用like查询是有限制的。目前Sharding-JDBC不支持like语句中包含分片键,但不包含分片键的like语句可以正确执行。至于like性能问题,是与数据库相关的,Sharding-JDBC仅仅是解析SQL以及路由到正确的数据源而已。是否会查询所有的库和表示根据分片键决定的。如果SQL中不包含分片键,就会查询所有库和表,这个和是否有like没有关系。MyCat没有限制

参考资料 shardingsphere.apache.org/document/cu…