Apache ShardingSphere 的核心概念是连接, 增量和可插拔
ShardingSphere 生态包含三款开源分布式数据库中间件解决方案
Sharding-Sphere 的整体架构
定位为轻量级 Java 框架, 在 Java 的 JDBC 层提供的额外服务. 它使用客户端直连数据库, 以 jar 包形式提供服务, 无需额外部署和依赖, 可理解为增强版的 JDBC 驱动, 完全兼容 JDBC 和各种 ORM 框架
Sharding-Proxy 是提供了数据库层面的代理, 应用是直连 Sharding-Proxy, 然后 Sharding-Proxy 通过处理之后再转发到 mysql 中, 开发人员不需要感知到分库分表的存在, 相当于正常访问 mysql
定位为 Kubernetes 的云原生数据库代理, 以 Sidecar 的形式代理所有对数据库的访问. 通过无中心, 零侵入的方案提供与数据库交互的啮合层, 即 Database Mesh, 又可称数据库网格
Database Mesh 的关注重点在于如何将分布式的数据访问应用与数据库有机串联起来, 它更加关注的是交互, 是将杂乱无章的应用与数据库之间的交互进行有效地梳理. 使用 Database Mesh, 访问数据库的应用和数据库终将形成一个巨大的网格体系, 应用和数据库只需在网格体系中对号入座即可, 它们都是被啮合层所治理的对象
ShardingSphere-JDBC | ShardingSphere-Proxy | ShardingSphere-Sidecar | |
---|---|---|---|
数据库 | 任意 | MySQL/PostgreSQL | MySQL/PostgreSQL |
连接消耗数 | 高 | 低 | 高 |
异构语言 | 仅 Java | 任意 | 任意 |
性能 | 损耗低 | 损耗略高 | 损耗低 |
无中心化 | 是 | 否 | 是 |
静态入口 | 无 | 有 | 无 |
ShardingSphere 的 3 个产品的数据分片主要流程是完全一致的, 按照是否进行查询优化, 可以分为 Standard 内核流程和 Federation 执行引擎流程. Standard 内核流程由 SQL 解析=> SQL 路由=> SQL 改写=> SQL 执行=> 结果归并组成, 主要用于处理标准分片场景下的 SQL 执行. Federation 执行引擎流程由 SQL 解析=> 逻辑优化=> 物理优化=> 优化执行=> Standard 内核流程组成, Federation 执行引擎内部进行逻辑优化和物理优化, 在优化执行阶段依赖 Standard 内核流程, 对优化后的逻辑 SQL 进行路由, 改写, 执行和归并
相对于其他编程语言, SQL 是比较简单的. 不过, 它依然是一门完善的编程语言, 因此对 SQL 的语法进行解析, 与解析其他编程语言 (如: Java 语言, C 语言, Go 语言等) 并无本质区别.
解析过程分为词法解析和语法解析. 词法解析器用于将 SQL 拆解为不可再分的原子符号, 称为 Token. 并根据不同数据库方言所提供的字典, 将其归类为关键字, 表达式, 字面量和操作符. 再使用语法解析器将词法解析器的输出转换为抽象语法树. 例如, 以下 SQL:
SELECT id, name FROM t_user WHERE status = 'ACTIVE' AND age > 18
解析之后的为抽象语法树见下图
为了便于理解, 抽象语法树中的关键字的 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 方言字典.
根据解析上下文匹配数据库和表的分片策略, 并生成路由路径. 对于携带分片键的 SQL, 根据分片键的不同可以划分为单片路由 (分片键的操作符是等号), 多片路由 (分片键的操作符是 IN) 和范围路由 (分片键的操作符是 BETWEEN). 不携带分片键的 SQL 则采用广播路由
分片策略通常可以采用由数据库内置或由用户方配置. 数据库内置的方案较为简单, 内置的分片策略大致可分为尾数取模, 哈希, 范围, 标签, 时间等. 由用户方配置的分片策略则更加灵活, 可以根据使用方需求定制复合分片策略. 如果配合数据自动迁移来使用, 可以做到无需用户关注分片策略, 自动由数据库中间层分片和平衡数据即可, 进而做到使分布式数据库具有的弹性伸缩的能力. 在 ShardingSphere 的线路规划中, 弹性伸缩将于 4.x 开启
用于根据分片键进行路由的场景, 又细分为 3 种类型
满足直接路由的条件相对苛刻, 它需要通过 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()) {
//...
}
}
}
标准路由是 ShardingSphere 最为推荐使用的分片方式, 它的适用范围是不包含关联查询或仅包含绑定表之间关联查询的 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 o JOIN t_order_item i ON o.order_id=i.order_id WHERE order_id IN (1, 2);
那么路由的结果应为:
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_1 o JOIN t_order_item_1 i ON o.order_id=i.order_id WHERE order_id IN (1, 2);
可以看到, SQL 拆分的数目与单表是一致的
笛卡尔路由是最复杂的情况, 它无法根据绑定表的关系定位分片规则, 因此非绑定表之间的关联查询需要拆解为笛卡尔积组合执行. 如果上个示例中的 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 类型又可以划分为
全库表路由用于处理对数据库中与其逻辑表相关的所有真实表的操作, 主要包括不带分片键的 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';
这个命令将在所有的真实数据库实例中执行, 以确保 customer 用户可以访问每一个实例
单播路由用于获取某一真实表信息的场景, 它仅需要从任意库中的任意真实表中获取数据即可. 例如
DESCRIBE t_order;
t_order 的两个真实表 t_order_0, t_order_1 的描述结构相同, 所以这个命令在任意真实表上选择执行一次
阻断路由用于屏蔽 SQL 对数据库的操作, 例如
USE order_db;
这个命令不会在真实数据库中执行, 因为 ShardingSphere 采用的是逻辑 Schema 的方式, 无需将切换数据库 Schema 的命令发送至数据库中
工程师面向逻辑库与逻辑表书写的 SQL, 并不能够直接在真实的数据库中执行, SQL 改写用于将逻辑 SQL 改写为在真实数据库中可以正确执行的 SQL. 它包括正确性改写和优化改写两部分
在包含分表的场景中, 需要将分表配置中的逻辑表名称改写为路由之后所获取的真实表名称. 仅分库则不需要表名称的改写. 除此之外, 还包括补列和分页信息修正等内容
需要改写的标识符包括表名称, 索引名称以及 Schema 名称. 表名称改写是指将找到逻辑表在原始 SQL 中的位置, 并将其改写为真实表的过程. 表名称改写是一个典型的需要对 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 解析为抽象语法树似乎无关紧要, 只要通过字符串查找和替换就可以达到 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';
由于表名之外可能含有表名称的类似字符, 因此不能通过简单的字符串替换的方式去改写 SQL
下面再来看一个更加复杂的 SQL 改写场景
SELECT t_order.order_id FROM t_order WHERE t_order.order_id=1 AND remarks=' t_order xxx';
上面的 SQL 将表名作为字段的标识符, 因此在 SQL 改写时需要一并修改
SELECT t_order_1.order_id FROM t_order_1 WHERE t_order_1.order_id=1 AND remarks=' t_order xxx';
而如果 SQL 中定义了表的别名, 则无需连同别名一起修改, 即使别名与表名相同亦是如此. 例如
SELECT t_order.order_id FROM t_order AS t_order WHERE t_order.order_id=1 AND remarks=' t_order xxx';
SQL 改写则仅需要改写表名称就可以了
SELECT t_order.order_id FROM t_order_1 AS t_order WHERE t_order.order_id=1 AND remarks=' t_order xxx';
索引名称是另一个有可能改写的标识符. 在某些数据库中 (如 MySQL, SQLServer), 索引是以表为维度创建的, 在不同的表中的索引是可以重名的; 而在另外的一些数据库中 (如 PostgreSQL, Oracle), 索引是以数据库为维度创建的, 即使是作用在不同表上的索引, 它们也要求其名称的唯一性
在 ShardingSphere 中, 管理 Schema 的方式与管理表如出一辙, 它采用逻辑 Schema 去管理一组数据源. 因此, ShardingSphere 需要将用户在 SQL 中书写的逻辑 Schema 替换为真实的数据库 Schema
ShardingSphere 目前还不支持在 DQL 和 DML 语句中使用 Schema. 它目前仅支持在数据库管理语句中使用 Schema, 例如
SHOW COLUMNS FROM t_order FROM order_ds;
Schema 的改写指的是将逻辑 Schema 采用单播路由的方式, 改写为随机查找到的一个正确的真实 Schema
需要在查询语句中补列通常由两种情况导致. 第一种情况是 ShardingSphere 需要在结果归并时获取相应数据, 但该数据并未能通过查询的 SQL 返回. 这种情况主要是针对 GROUP BY 和 ORDER BY. 结果归并时, 需要根据 GROUP BY 和 ORDER BY 的字段项进行分组和排序, 但如果原始 SQL 的选择项中若并未包含分组项或排序项, 则需要对原始 SQL 进行改写. 先看一下原始 SQL 中带有结果归并所需信息的场景
SELECT order_id, user_id FROM t_order ORDER BY user_id;
由于使用 user_id 进行排序, 在结果归并中需要能够获取到 user_id 的数据, 而上面的 SQL 是能够获取到 user_id 数据的, 因此无需补列
如果选择项中不包含结果归并时所需的列, 则需要进行补列, 如以下 SQL
SELECT order_id FROM t_order ORDER BY user_id;
由于原始 SQL 中并不包含需要在结果归并中需要获取的 user_id, 因此需要对 SQL 进行补列改写. 补列之后的 SQL 是
SELECT order_id, user_id AS ORDER_BY_DERIVED_0 FROM t_order ORDER BY user_id;
值得一提的是, 补列只会补充缺失的列, 不会全部补充, 而且, 在 SELECT 语句中包含* 的 SQL, 也会根据表的元数据信息选择性补列. 下面是一个较为复杂的 SQL 补列场景
SELECT o.* FROM t_order o, t_order_item i WHERE o.order_id=i.order_id ORDER BY user_id, order_item_id;
我们假设只有 t_order_item 表中包含 order_item_id 列, 那么根据表的元数据信息可知, 在结果归并时, 排序项中的 user_id 是存在于 t_order 表中的, 无需补列;order_item_id 并不在 t_order 中, 因此需要补列. 补列之后的 SQL 是
SELECT o.*, order_item_id AS ORDER_BY_DERIVED_0 FROM t_order o, t_order_item i WHERE o.order_id=i.order_id ORDER BY user_id, order_item_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;
然后才能够通过结果归并正确的计算平均值
最后一种补列是在执行 INSERT 的 SQL 语句时, 如果使用数据库自增主键, 是无需写入主键字段的. 但数据库的自增主键是无法满足分布式场景下的主键唯一的, 因此 ShardingSphere 提供了分布式自增主键的生成策略, 并且可以通过补列, 让使用方无需改动现有代码, 即可将分布式自增主键透明的替换数据库现有的自增主键. 分布式自增主键的生成策略将在下文中详述, 这里只阐述与 SQL 改写相关的内容. 举例说明, 假设表 t_order 的主键是 order_id, 原始的 SQL 为:
INSERT INTO t_order (`field1`, `field2`) VALUES (10, 1);
可以看到, 上述 SQL 中并未包含自增主键, 是需要数据库自行填充的. ShardingSphere 配置自增主键后, SQL 将改写为:
INSERT INTO t_order (`field1`, `field2`, order_id) VALUES (10, 1, xxxxx);
改写后的 SQL 将在 INSERT FIELD 和 INSERT VALUE 的最后部分增加主键列名称以及自动生成的自增主键值. 上述 SQL 中的 xxxxx 表示自动生成的自增主键值
如果 INSERT 的 SQL 中并未包含表的列名称, ShardingSphere 也可以根据判断参数个数以及表元信息中的列数量对比, 并自动生成自增主键. 例如, 原始的 SQL 为
INSERT INTO t_order VALUES (10, 1);
改写的 SQL 将只在主键所在的列顺序处增加自增主键即可
INSERT INTO t_order VALUES (xxxxx, 10, 1);
自增主键补列时, 如果使用占位符的方式书写 SQL, 则只需要改写参数列表即可, 无需改写 SQL 本身
从多个数据库获取分页数据与单数据库的场景是不同的. 假设每 10 条数据为一页, 取第 2 页数据. 在分片环境下获取 LIMIT 10, 10, 归并之后再根据排序条件取出前 10 条数据是不正确的. 举例说明, 若 SQL 为
SELECT score FROM t_score ORDER BY score DESC LIMIT 1, 2;
下图展示了不进行 SQL 的改写的分页执行结果
通过图中所示, 想要取得两个表中共同的按照分数排序的第 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 改写之后的分页执行结果
越获取偏移量位置靠后数据, 使用 LIMIT 分页方式的效率就越低. 有很多方法可以避免使用 LIMIT 进行分页. 比如构建行记录数量与行偏移量的二级索引, 或使用上次分页数据结尾 ID 作为下次查询条件的分页方式等
分页信息修正时, 如果使用占位符的方式书写 SQL, 则只需要改写参数列表即可, 无需改写 SQL 本身
在使用批量插入的 SQL 时, 如果插入的数据是跨分片的, 那么需要对 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');
使用 IN 的查询与批量插入的情况相似, 不过 IN 操作并不会导致数据查询结果错误. 通过对 IN 查询的改写, 可以进一步的提升查询性能. 如以下 SQL
SELECT * FROM t_order WHERE order_id IN (1, 2, 3);
改写为
SELECT * FROM t_order_0 WHERE order_id IN (2);
SELECT * FROM t_order_1 WHERE order_id IN (1, 3);
可以进一步的提升查询性能. ShardingSphere 暂时还未实现此改写策略, 目前的改写结果是
SELECT * FROM t_order_0 WHERE order_id IN (1, 2, 3);
SELECT * FROM t_order_1 WHERE order_id IN (1, 2, 3);
虽然 SQL 的执行结果是正确的, 但并未达到最优的查询效率
优化改写的目的是在不影响查询正确性的情况下, 对性能进行提升的有效手段. 它分为单节点优化和流式归并优化
路由至单节点的 SQL, 则无需优化改写. 当获得一次查询的路由结果后, 如果是路由至唯一的数据节点, 则无需涉及到结果归并. 因此补列和分页信息等改写都没有必要进行. 尤其是分页信息的改写, 无需将数据从第 1 条开始取, 大量的降低了对数据库的压力, 并且节省了网络带宽的无谓消耗
它仅为包含 GROUP BY 的 SQL 增加 ORDER BY 以及和分组项相同的排序项和排序顺序, 用于将内存归并转化为流式归并. 在结果归并的部分中, 将对流式归并和内存归并进行详细说明
ShardingSphere 采用一套自动化的执行引擎, 负责将路由和改写完成之后的真实 SQL 安全且高效发送到底层数据源执行. 它不是简单地将 SQL 通过 JDBC 直接发送至数据源执行;也并非直接将执行请求放入线程池去并发执行. 它更关注平衡数据源连接创建以及内存占用所产生的消耗, 以及最大限度地合理利用并发等问题. 执行引擎的目标是自动化的平衡资源控制与执行效率
从资源控制的角度看, 业务方访问数据库的连接数量应当有所限制. 它能够有效地防止某一业务操作过多的占用资源, 从而将数据库连接的资源耗尽, 以致于影响其他业务的正常访问. 特别是在一个数据库实例中存在较多分表的情况下, 一条不包含分片键的逻辑 SQL 将产生落在同库不同表的大量真实 SQL, 如果每条真实 SQL 都占用一个独立的连接, 那么一次查询无疑将会占用过多的资源
从执行效率的角度看, 为每个分片查询维持一个独立的数据库连接, 可以更加有效的利用多线程来提升执行效率. 为每个数据库连接开启独立的线程, 可以将 I/O 所产生的消耗并行处理. 为每个分片维持一个独立的数据库连接, 还能够避免过早的将查询结果数据加载至内存. 独立的数据库连接, 能够持有查询结果集游标位置的引用, 在需要获取相应数据时移动游标即可
以结果集游标下移进行结果归并的方式, 称之为流式归并, 它无需将结果数据全数加载至内存, 可以有效的节省内存资源, 进而减少垃圾回收的频次. 当无法保证每个分片查询持有一个独立数据库连接时, 则需要在复用该数据库连接获取下一张分表的查询结果集之前, 将当前的查询结果集全数加载至内存. 因此, 即使可以采用流式归并, 在此场景下也将退化为内存归并
一方面是对数据库连接资源的控制保护, 一方面是采用更优的归并模式达到对中间件内存资源的节省, 如何处理好两者之间的关系, 是 ShardingSphere 执行引擎需要解决的问题. 具体来说, 如果一条 SQL 在经过 ShardingSphere 的分片后, 需要操作某数据库实例下的 200 张表. 那么, 是选择创建 200 个连接并行执行, 还是选择创建一个连接串行执行呢?效率与资源控制又应该如何抉择呢?
针对上述场景, ShardingSphere 提供了一种解决思路. 它提出了连接模式 (Connection Mode) 的概念, 将其划分为内存限制模式 (MEMORY_STRICTLY) 和连接限制模式 (CONNECTION_STRICTLY) 这两种类型
使用此模式的前提是, ShardingSphere 对一次操作所耗费的数据库连接数量不做限制. 如果实际执行的 SQL 需要对某数据库实例中的 200 张表做操作, 则对每张表创建一个新的数据库连接, 并通过多线程的方式并发处理, 以达成执行效率最大化. 并且在 SQL 满足条件情况下, 优先选择流式归并, 以防止出现内存溢出或避免频繁垃圾回收情况
使用此模式的前提是, ShardingSphere 严格控制对一次操作所耗费的数据库连接数量. 如果实际执行的 SQL 需要对某数据库实例中的 200 张表做操作, 那么只会创建唯一的数据库连接, 并对其 200 张表串行处理. 如果一次操作中的分片散落在不同的数据库, 仍然采用多线程处理对不同库的操作, 但每个库的每次操作仍然只创建一个唯一的数据库连接. 这样即可以防止对一次请求对数据库连接占用过多所带来的问题. 该模式始终选择内存归并
内存限制模式适用于 OLAP 操作, 可以通过放宽对数据库连接的限制提升系统吞吐量; 连接限制模式适用于 OLTP 操作, OLTP 通常带有分片键, 会路由到单一的分片, 因此严格控制数据库连接, 以保证在线系统数据库资源能够被更多的应用所使用, 是明智的选择
ShardingSphere 最初将使用何种模式的决定权交由用户配置, 让开发者依据自己业务的实际场景需求选择使用内存限制模式或连接限制模式
这种解决方案将两难的选择的决定权交由用户, 使得用户必须要了解这两种模式的利弊, 并依据业务场景需求进行选择. 这无疑增加了用户对 ShardingSphere 的学习和使用的成本, 并非最优方案
这种一分为二的处理方案, 将两种模式的切换交由静态的初始化配置, 是缺乏灵活应对能力的. 在实际的使用场景中, 面对不同 SQL 以及占位符参数, 每次的路由结果是不同的. 这就意味着某些操作可能需要使用内存归并, 而某些操作则可能选择流式归并更优, 具体采用哪种方式不应该由用户在 ShardingSphere 启动之前配置好, 而是应该根据 SQL 和占位符参数的场景, 来动态的决定连接模式
为了降低用户的使用成本以及连接模式动态化这两个问题, ShardingSphere 提炼出自动化执行引擎的思路, 在其内部消化了连接模式概念. 用户无需了解所谓的内存限制模式和连接限制模式是什么, 而是交由执行引擎根据当前场景自动选择最优的执行方案
自动化执行引擎将连接模式的选择粒度细化至每一次 SQL 的操作. 针对每次 SQL 请求, 自动化执行引擎都将根据其路由结果, 进行实时的演算和权衡, 并自主地采用恰当的连接模式执行, 以达到资源控制和效率的最优平衡. 针对自动化的执行引擎, 用户只需配置 maxConnectionSizePerQuery 即可, 该参数表示一次查询时每个数据库所允许使用的最大连接数
执行引擎分为准备和执行两个阶段
顾名思义, 此阶段用于准备执行的数据. 它分为结果集分组和执行单元创建两个步骤
结果集分组是实现内化连接模式概念的关键. 执行引擎根据 maxConnectionSizePerQuery 配置项, 结合当前路由结果, 选择恰当的连接模式. 具体步骤如下:
在 maxConnectionSizePerQuery 允许的范围内, 当一个连接需要执行的请求数量大于 1 时, 意味着当前的数据库连接无法持有相应的数据结果集, 则必须采用内存归并; 反之, 当一个连接需要执行的请求数量等于 1 时, 意味着当前的数据库连接可以持有相应的数据结果集, 则可以采用流式归并
每一次的连接模式的选择, 是针对每一个物理数据库的. 也就是说, 在同一次查询中, 如果路由至一个以上的数据库, 每个数据库的连接模式不一定一样, 它们可能是混合存在的形态
通过上一步骤获得的路由分组结果创建执行的单元. 当数据源使用数据库连接池等控制数据库连接数量的技术时, 在获取数据库连接时, 如果不妥善处理并发, 则有一定几率发生死锁. 在多个请求相互等待对方释放数据库连接资源时, 将会产生饥饿等待, 造成交叉的死锁问题
举例说明, 假设一次查询需要在某一数据源上获取两个数据库连接, 并路由至同一个数据库的两个分表查询. 则有可能出现查询 A 已获取到该数据源的 1 个数据库连接, 并等待获取另一个数据库连接;而查询 B 也已经在该数据源上获取到的一个数据库连接, 并同样等待另一个数据库连接的获取. 如果数据库连接池的允许最大连接数是 2, 那么这 2 个查询请求将永久的等待下去. 下图描绘了死锁的情况
ShardingSphere 为了避免死锁的出现, 在获取数据库连接时进行了同步处理. 它在创建执行单元时, 以原子性的方式一次性获取本次 SQL 请求所需的全部数据库连接, 杜绝了每次查询请求获取到部分资源的可能. 由于对数据库的操作非常频繁, 每次获取数据库连接时时都进行锁定, 会降低 ShardingSphere 的并发. 因此, ShardingSphere 在这里进行了 2 点优化:
该阶段用于真正的执行 SQL, 它分为分组执行和归并结果集生成两个步骤
分组执行将准备执行阶段生成的执行单元分组下发至底层并发执行引擎, 并针对执行过程中的每个关键步骤发送事件. 如: 执行开始事件, 执行成功事件以及执行失败事件. 执行引擎仅关注事件的发送, 它并不关心事件的订阅者. ShardingSphere 的其他模块, 如: 分布式事务, 调用链路追踪等, 会订阅感兴趣的事件, 并进行相应的处理
ShardingSphere 通过在执行准备阶段的获取的连接模式, 生成内存归并结果集或流式归并结果集, 并将其传递至结果归并引擎, 以进行下一步的工作
将从各个数据节点获取的多数据结果集, 组合成为一个结果集并正确的返回至请求客户端, 称为结果归并
ShardingSphere 支持的结果归并从功能上分为遍历, 排序, 分组, 分页和聚合 5 种类型, 它们是组合而非互斥的关系. 从结构划分, 可分为流式归并, 内存归并和装饰者归并. 流式归并和内存归并是互斥的, 装饰者归并可以在流式归并和内存归并之上做进一步的处理
由于从数据库中返回的结果集是逐条返回的, 并不需要将所有的数据一次性加载至内存中, 因此, 在进行结果归并时, 沿用数据库返回结果集的方式进行归并, 能够极大减少内存的消耗, 是归并方式的优先选择
流式归并是指每一次从结果集中获取到的数据, 都能够通过逐条获取的方式返回正确的单条数据, 它与数据库原生的返回结果集的方式最为契合. 遍历, 排序以及流式分组都属于流式归并的一种
内存归并则是需要将结果集的所有数据都遍历并存储在内存中, 再通过统一的分组, 排序以及聚合等计算之后, 再将其封装成为逐条访问的数据结果集返回
装饰者归并是对所有的结果集归并进行统一的功能增强, 目前装饰者归并有分页归并和聚合归并这 2 种类型
它是最为简单的归并方式. 只需将多个数据结果集合并为一个单向链表即可. 在遍历完成链表中当前数据结果集之后, 将链表元素后移一位, 继续遍历下一个数据结果集即可
由于在 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 弹出队列, 并且将其数据结果集游标指向的值返回至客户端, 并下移游标, 继续加入队列排队, 以此类推. 当一个结果集中已经没有数据了, 则无需再次加入队列
可以看到, 对于每个数据结果集中的数据有序, 而多数据结果集整体无序的情况下, ShardingSphere 无需将所有的数据都加载至内存即可排序. 它使用的是流式归并的方式, 每次 next 仅获取唯一正确的一条数据, 极大的节省了内存的消耗
从另一个角度来说, ShardingSphere 的排序归并, 是在维护数据结果集的纵轴和横轴这两个维度的有序性. 纵轴是指每个数据结果集本身, 它是天然有序的, 它通过包含 ORDER BY 的 SQL 所获取. 横轴是指每个数据结果集当前游标所指向的值, 它需要通过优先级队列来维护其正确顺序. 每一次数据结果集当前游标的下移, 都需要将该数据结果集重新放入优先级队列排序, 而只有排列在队列首位的数据结果集才可能发生游标下移的操作
分组归并的情况最为复杂, 它分为流式分组归并和内存分组归并. 流式分组归并要求 SQL 的排序项与分组项的字段以及排序类型 (ASC 或 DESC) 必须保持一致, 否则只能通过内存归并才能保证其数据的正确性
举例说明, 假设根据科目分片, 表结构中包含考生的姓名 (为了简单起见, 不考虑重名的情况) 和分数. 通过 SQL 获取每位考生的总分, 可通过如下 SQL
SELECT name, SUM(score) FROM t_score GROUP BY name ORDER BY name;
在分组项与排序项完全一致的情况下, 取得的数据是连续的, 分组所需的数据全数存在于各个数据结果集的当前游标所指向的数据值, 因此可以采用流式归并. 如下图所示
进行归并时, 逻辑与排序归并类似. 下图展现了进行 next 调用的时候, 流式分组归并是如何进行的
通过图中我们可以看到, 当进行第一次 next 调用时, 排在队列首位的 t_score_java 将会被弹出队列, 并且将分组值同为“Jerry” 的其他结果集中的数据一同弹出队列. 在获取了所有的姓名为“Jerry” 的同学的分数之后, 进行累加操作, 那么, 在第一次 next 调用结束后, 取出的结果集是“Jerry” 的分数总和. 与此同时, 所有的数据结果集中的游标都将下移至数据值“Jerry” 的下一个不同的数据值, 并且根据数据结果集当前游标指向的值进行重排序. 因此, 包含名字顺着第二位的“John” 的相关数据结果集则排在的队列的前列
流式分组归并与排序归并的区别仅仅在于两点:
对于分组项与排序项不一致的情况, 由于需要获取分组的相关的数据值并非连续的, 因此无法使用流式归并, 需要将所有的结果集数据加载至内存中进行分组和聚合. 例如, 若通过以下 SQL 获取每位考生的总分并按照分数从高至低排序:
SELECT name, SUM(score) FROM t_score GROUP BY name ORDER BY score DESC;
那么各个数据结果集中取出的数据与排序归并那张图的上半部分的表结构的原始数据一致, 是无法进行流式归并的
当 SQL 中只包含分组语句时, 根据不同数据库的实现, 其排序的顺序不一定与分组顺序一致. 但由于排序语句的缺失, 则表示此 SQL 并不在意排序顺序. 因此, ShardingSphere 通过 SQL 优化的改写, 自动增加与分组项一致的排序项, 使其能够从消耗内存的内存分组归并方式转化为流式分组归并方案
无论是流式分组归并还是内存分组归并, 对聚合函数的处理都是一致的. 除了分组的 SQL 之外, 不进行分组的 SQL 也可以使用聚合函数. 因此, 聚合归并是在之前介绍的归并类的之上追加的归并能力, 即装饰者模式. 聚合函数可以归类为比较, 累加和求平均值这 3 种类型
比较类型的聚合函数是指 MAX 和 MIN. 它们需要对每一个同组的结果集数据进行比较, 并且直接返回其最大或最小值即可
累加类型的聚合函数是指 SUM 和 COUNT. 它们需要将每一个同组的结果集数据进行累加
求平均值的聚合函数只有 AVG. 它必须通过 SQL 改写的 SUM 和 COUNT 进行计算, 相关内容已在 SQL 改写的内容中涵盖, 不再赘述
上文所述的所有归并类型都可能进行分页. 分页也是追加在其他归并类型之上的装饰器, ShardingSphere 通过装饰者模式来增加对数据结果集进行分页的能力. 分页归并负责将无需获取的数据过滤掉
ShardingSphere 的分页功能比较容易让使用者误解, 用户通常认为分页归并会占用大量内存. 在分布式的场景中, 将 LIMIT 10000000, 10 改写为 LIMIT 0, 10000010, 才能保证其数据的正确性. 用户非常容易产生 ShardingSphere 会将大量无意义的数据加载至内存中, 造成内存溢出风险的错觉. 其实, 通过流式归并的原理可知, 会将数据全部加载到内存中的只有内存分组归并这一种情况. 而通常来说, 进行 OLAP 的分组 SQL, 不会产生大量的结果数据, 它更多的用于大量的计算, 以及少量结果产出的场景. 除了内存分组归并这种情况之外, 其他情况都通过流式归并获取数据结果集, 因此 ShardingSphere 会通过结果集的 next 方法将无需取出的数据全部跳过, 并不会将其存入内存
但同时需要注意的是, 由于排序的需要, 大量的数据仍然需要传输到 ShardingSphere 的内存空间. 因此, 采用 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;
表分为以下几个类型
相同结构的水平拆分数据库 (表) 的逻辑名称, 是 SQL 中表的逻辑标识. 例: 订单数据根据主键尾数拆分为 10 张表, 分别是 t_order_0 到 t_order_9, 他们的逻辑表名为 t_order
在水平拆分的数据库中真实存在的物理表. 例如从 t_order_0 到 t_order_9 的表
指分片规则一致的主表和子表. 使用绑定表进行多表关联查询时, 必须使用分片键进行关联, 否则会出现笛卡尔积关联或跨库关联, 从而影响查询效率. 例如: t_order 表和 t_order_item 表, 均按照 order_id 分片, 并且使用 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);
在配置绑定表关系, 并且使用 order_id 进行关联后, 路由的 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 的最左侧, ShardingSphere 将会以它作为整个绑定表的主表. 所有路由计算将会只使用主表的策略, 那么 t_order_item 表的分片计算将会使用 t_order 的条件. 因此, 绑定表间的分区键需要完全相同
指所有的分片数据源中都存在的表, 表结构及其数据在每个数据库中均完全一致. 适用于数据量不大且需要与海量数据的表进行关联查询的场景, 例如: 字典表
指所有的分片数据源中仅唯一存在的表. 适用于数据量不大且无需分片的表
分片键用于将数据库 (表) 水平拆分的数据库字段. 例: 将订单表中的订单主键的尾数取模分片, 则订单主键为分片字段. SQL 中如果无分片字段, 将执行全路由, 性能较差. 除了对单分片字段的支持, Apache ShardingSphere 也支持根据多个字段进行分片
分片算法用于将数据分片的算法, 支持=, >=, <=, >, <, BETWEEN 和 IN 进行分片. 分片算法可由开发者自行实现, 也可使用 Apache ShardingSphere 内置的分片算法语法糖, 灵活度非常高
分片算法语法糖, 用于便捷的托管所有数据节点, 使用者无需关注真实表的物理分布. 包括取模, 哈希, 范围, 时间等常用分片算法的实现
提供接口让应用开发者自行实现与业务实现紧密相关的分片算法, 并允许使用者自行管理真实表的物理分布. 自定义分片算法又分为
采用 UUID.randomUUID() 的方式产生分布式主键
在分片规则配置模块可配置每个表的主键生成策略, 默认使用雪花算法 (snowflake) 生成 64bit 的长整型数据
使用雪花算法生成的主键, 二进制表示形式包含 4 部分, 从高位到低位分表为: 1bit 符号位, 41bit 时间戳位, 10bit 工作进程位以及 12bit 序列号位
Math.pow(2, 41) / (365 * 24 * 60 * 60 * 1000L)
结果约等于 69.73 年Apache ShardingSphere 分布式事务的实现原理
XAShardingSphereTransactionManager 为 Apache ShardingSphere 的分布式事务的 XA 实现类. 它主要负责对多数据源进行管理和适配, 并且将相应事务的开启, 提交和回滚操作委托给具体的 XA 事务管理器
收到接入端的 set autoCommit=0 时, XAShardingSphereTransactionManager 将调用具体的 XA 事务管理器开启 XA 全局事务, 以 XID 的形式进行标记
XAShardingSphereTransactionManager 将数据库连接所对应的 XAResource 注册到当前 XA 事务中之后, 事务管理器会在此阶段发送 XAResource.start 命令至数据库. 数据库在收到 XAResource.end 命令之前的所有 SQL 操作, 会被标记为 XA 事务
XAResource1.start ## Enlist阶段执行
statement.execute("sql1"); ## 模拟执行一个分片SQL1
statement.execute("sql2"); ## 模拟执行一个分片SQL2
XAResource1.end ## 提交阶段执行
上述的 sql1 和 sql2 将会被标记为 XA 事务
XAShardingSphereTransactionManager 在接收到接入端的提交命令后, 会委托实际的 XA 事务管理进行提交动作, 事务管理器将收集到的当前线程中所有注册的 XAResource, 并发送 XAResource.end 指令, 用以标记此 XA 事务边界. 接着会依次发送 prepare 指令, 收集所有参与 XAResource 投票. 若所有 XAResource 的反馈结果均为正确, 则调用 commit 指令进行最终提交; 若有任意 XAResource 的反馈结果不正确, 则调用 rollback 指令进行回滚. 在事务管理器发出提交指令后, 任何 XAResource 产生的异常都会通过恢复日志进行重试, 以保证提交阶段的操作原子性, 和数据强一致性
XAResource1.prepare ## ack: yes
XAResource2.prepare ## ack: yes
XAResource1.commit
XAResource2.commit
XAResource1.prepare ## ack: yes
XAResource2.prepare ## ack: no
XAResource1.rollback
XAResource2.rollback
整合 Seata AT 事务时, 需要将 TM, RM 和 TC 的模型融入 Apache ShardingSphere 的分布式事务生态中. 在数据库资源上, Seata 通过对接 DataSource 接口, 让 JDBC 操作可以同 TC 进行远程通信. 同样, Apache ShardingSphere 也是面向 DataSource 接口, 对用户配置的数据源进行聚合. 因此, 将 DataSource 封装为基于 Seata 的 DataSource 后, 就可以将 Seata AT 事务融入到 Apache ShardingSphere 的分片生态中
包含 Seata 柔性事务的应用启动时, 用户配置的数据源会根据 seata.conf 的配置, 适配为 Seata 事务所需的 DataSourceProxy, 并且注册至 RM 中
TM 控制全局事务的边界, TM 通过向 TC 发送 Begin 指令, 获取全局事务 ID, 所有分支事务通过此全局事务 ID, 参与到全局事务中;全局事务 ID 的上下文存放在当前线程变量中
处于 Seata 全局事务中的分片 SQL 通过 RM 生成 undo 快照, 并且发送 participate 指令至 TC, 加入到全局事务中. 由于 Apache ShardingSphere 的分片物理 SQL 采取多线程方式执行, 因此整合 Seata AT 事务时, 需要在主线程和子线程间进行全局事务 ID 的上下文传递
提交 Seata 事务时, TM 会向 TC 发送全局事务的提交或回滚指令, TC 根据全局事务 ID 协调所有分支事务进行提交或回滚
在定义的命名空间下, rules, props 和 metadata 节点以 YAML 格式存储配置, 可通过修改节点来实现对于配置的动态管理. nodes 存储数据库访问对象运行节点, 用于区分不同数据库访问实例
namespace
├──rules # 全局规则配置
├──props # 属性配置
├──metadata # Metadata 配置
├ ├──${schema_1} # Schema 名称1
├ ├ ├──dataSources # 数据源配置
├ ├ ├──rules # 规则配置
├ ├ ├──tables # 表结构配置
├ ├ ├ ├──t_1
├ ├ ├ ├──t_2
├ ├──${schema_2} # Schema 名称2
├ ├ ├──dataSources # 数据源配置
├ ├ ├──rules # 规则配置
├ ├ ├──tables # 表结构配置
├──nodes
├ ├──compute_nodes
├ ├ ├──online
├ ├ ├ ├──proxy
├ ├ ├ ├ ├──${your_instance_ip_a}@${your_instance_port_x}
├ ├ ├ ├ ├──${your_instance_ip_b}@${your_instance_port_y}
├ ├ ├ ├ ├──....
├ ├ ├ ├──jdbc
├ ├ ├ ├ ├──${your_instance_ip_a}@${your_instance_pid_x}
├ ├ ├ ├ ├──${your_instance_ip_b}@${your_instance_pid_y}
├ ├ ├ ├ ├──....
├ ├ ├──attributies
├ ├ ├ ├──${your_instance_ip_a}@${your_instance_port_x}
├ ├ ├ ├ ├──status
├ ├ ├ ├ ├──label
├ ├ ├ ├──${your_instance_ip_b}@${your_instance_pid_y}
├ ├ ├ ├ ├──status
├ ├ ├ ├──....
├ ├──storage_nodes
├ ├ ├──disable
├ ├ ├ ├──${schema_1.ds_0}
├ ├ ├ ├──${schema_1.ds_1}
├ ├ ├ ├──....
├ ├ ├──primary
├ ├ ├ ├──${schema_2.ds_0}
├ ├ ├ ├──${schema_2.ds_1}
├ ├ ├ ├──....
全局规则配置, 可包括访问 ShardingSphere-Proxy 用户名和密码的权限配置
- !AUTHORITY
users:
- root@%:root
- [email protected]:sharding
provider:
type: ALL_PRIVILEGES_PERMITTED
属性配置, 详情请参见配置手册
kernel-executor-size: 20
sql-show: true
多个数据库连接池的集合, 不同数据库连接池属性自适配 (例如: DBCP, C3P0, Druid, HikariCP)
ds_0:
initializationFailTimeout: 1
validationTimeout: 5000
maxLifetime: 1800000
leakDetectionThreshold: 0
minimumIdle: 1
password: root
idleTimeout: 60000
jdbcUrl: jdbc:mysql://127.0.0.1:3306/ds_0?serverTimezone=UTC&useSSL=false
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
maximumPoolSize: 50
connectionTimeout: 30000
username: root
poolName: HikariPool-1
ds_1:
initializationFailTimeout: 1
validationTimeout: 5000
maxLifetime: 1800000
leakDetectionThreshold: 0
minimumIdle: 1
password: root
idleTimeout: 60000
jdbcUrl: jdbc:mysql://127.0.0.1:3306/ds_1?serverTimezone=UTC&useSSL=false
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
maximumPoolSize: 50
connectionTimeout: 30000
username: root
poolName: HikariPool-2
规则配置, 可包括数据分片, 读写分离, 数据加密, 影子库压测等配置
- !SHARDING
xxx
- !READWRITE_SPLITTING
xxx
- !ENCRYPT
xxx
表结构配置, 每个表使用单独节点存储, 暂不支持动态修改
name: t_order # 表名
columns: # 列
id: # 列名
caseSensitive: false
dataType: 0
generated: false
name: id
primaryKey: trues
order_id:
caseSensitive: false
dataType: 0
generated: false
name: order_id
primaryKey: false
indexs: # 索引
t_user_order_id_index: # 索引名
name: t_user_order_id_index
数据库访问对象运行实例信息, 子节点是当前运行实例的标识. 运行实例标识由运行服务器的 IP 地址和 PORT 构成. 运行实例标识均为临时节点, 当实例上线时注册, 下线时自动清理. 注册中心监控这些节点的变化来治理运行中实例对数据库的访问等
可以治理读写分离从库, 可动态添加删除以及禁用
考虑到 Apache ShardingSphere 的弹性伸缩模块的几个挑战, 目前的弹性伸缩解决方案为: 临时地使用两个数据库集群, 伸缩完成后切换的方式实现
优点
缺点
弹性伸缩模块会通过解析旧分片规则, 提取配置中的数据源, 数据节点等信息, 之后创建伸缩作业工作流, 将一次弹性伸缩拆解为 4 个主要阶段
在准备阶段, 弹性伸缩模块会进行数据源连通性及权限的校验, 同时进行存量数据的统计, 日志位点的记录, 最后根据数据量和用户设置的并行度, 对任务进行分片
执行在准备阶段拆分好的存量数据迁移作业, 存量迁移阶段采用 JDBC 查询的方式, 直接从数据节点中读取数据, 并使用新规则写入到新集群中
由于存量数据迁移耗费的时间受到数据量和并行度等因素影响, 此时需要对这段时间内业务新增的数据进行同步. 不同的数据库使用的技术细节不同, 但总体上均为基于复制协议或 WAL 日志实现的变更数据捕获功能
这些捕获的增量数据, 同样会由弹性伸缩模块根据新规则写入到新数据节点中. 当增量数据基本同步完成时 (由于业务系统未停止, 增量数据是不断的), 则进入规则切换阶段
在此阶段, 可能存在一定时间的业务只读窗口期, 通过设置数据库只读或 ShardingSphere 的熔断机制, 让旧数据节点中的数据短暂静态, 确保增量同步已完全完成
这个窗口期时间短则数秒, 长则数分钟, 取决于数据量和用户是否需要对数据进行强校验. 确认完成后, Apache ShardingSphere 可通过配置中心修改配置, 将业务导向新规则的集群, 弹性伸缩完成
Apache ShardingSphere 通过对用户输入的 SQL 进行解析, 并依据用户提供的加密规则对 SQL 进行改写, 从而实现对原文数据进行加密, 并将原文数据 (可选) 及密文数据同时存储到底层数据库. 在用户查询数据时, 它仅从数据库中取出密文数据, 并对其解密, 最终将解密后的原始数据返回给用户. Apache ShardingSphere 自动化& 透明化了数据加密过程, 让用户无需关注数据加密的实现细节, 像使用普通数据那样使用加密数据. 此外, 无论是已在线业务进行加密改造, 还是新上线业务使用加密功能, Apache ShardingSphere 都可以提供一套相对完善的解决方案
加密模块将用户发起的 SQL 进行拦截, 并通过 SQL 语法解析器进行解析, 理解 SQL 行为, 再依据用户传入的加密规则, 找出需要加密的字段和所使用的加解密算法对目标字段进行加解密处理后, 再与底层数据库进行交互. Apache ShardingSphere 会将用户请求的明文进行加密后存储到底层数据库;并在用户查询时, 将密文从数据库中取出进行解密后返回给终端用户. 通过屏蔽对数据的加密处理, 使用户无需感知解析 SQL, 数据加密, 数据解密的处理过程, 就像在使用普通数据一样使用加密数据
在详解整套流程之前, 我们需要先了解下加密规则与配置, 这是认识整套流程的基础. 加密配置主要分为四部分: 数据源配置, 加密算法配置, 加密表配置以及查询属性配置, 其详情如下图所示
如何理解用户想使用哪个列进行 SQL 编写 (logicColumn) ?
我们可以从加密模块存在的意义来理解. 加密模块最终目的是希望屏蔽底层对数据的加密处理, 也就是说我们不希望用户知道数据是如何被加解密的, 如何将明文数据存储到 plainColumn, 将密文数据存储到 cipherColumn. 换句话说, 我们不希望用户知道 plainColumn 和 cipherColumn 的存在和使用. 所以, 我们需要给用户提供一个概念意义上的列, 这个列可以脱离底层数据库的真实列, 它可以是数据库表里的一个真实列, 也可以不是, 从而使得用户可以随意改变底层数据库的 plainColumn 和 cipherColumn 的列名. 或者删除 plainColumn, 选择永远不再存储明文, 只存储密文. 只要用户的 SQL 面向这个逻辑列进行编写, 并在加密规则里给出 logicColumn 和 plainColumn, cipherColumn 之间正确的映射关系即可. 为什么要这么做呢?答案在文章后面, 即为了让已上线的业务能无缝, 透明, 安全地进行数据加密迁移
举例说明, 假如数据库里有一张表叫做 t_user, 这张表里实际有两个字段 pwd_plain, 用于存放明文数据, pwd_cipher, 用于存放密文数据, 同时定义 logicColumn 为 pwd. 那么, 用户在编写 SQL 时应该面向 logicColumn 进行编写, 即 INSERT INTO t_user SET pwd = ‘123’. Apache ShardingSphere 接收到该 SQL, 通过用户提供的加密配置, 发现 pwd 是 logicColumn, 于是便对逻辑列及其对应的明文数据进行加密处理. Apache ShardingSphere 将面向用户的逻辑列与面向底层数据库的明文列和密文列进行了列名以及数据的加密映射转换. 如下图所示
即依据用户提供的加密规则, 将用户 SQL 与底层数据表结构割裂开来, 使得用户的 SQL 编写不再依赖于真实的数据库表结构. 而用户与底层数据库之间的衔接, 映射, 转换交由 Apache ShardingSphere 进行处理
下方图片展示了使用加密模块进行增删改查时, 其中的处理流程和转换逻辑, 如下图所示
业务场景分析: 新上线业务由于一切从零开始, 不存在历史数据清洗问题, 所以相对简单
解决方案说明: 选择合适的加密算法, 如 AES 后, 只需配置逻辑列 (面向用户编写 SQL) 和密文列 (数据表存密文数据) 即可, 逻辑列和密文列可以相同也可以不同. 建议配置如下 (YAML 格式展示)
-!ENCRYPT
encryptors:
aes_encryptor:
type: AES
props:
aes-key-value: 123456abc
tables:
t_user:
columns:
pwd:
cipherColumn: pwd
encryptorName: aes_encryptor
使用这套配置, Apache ShardingSphere 只需将 logicColumn 和 cipherColumn 进行转换, 底层数据表不存储明文, 只存储了密文, 这也是安全审计部分的要求所在. 如果用户希望将明文, 密文一同存储到数据库, 只需添加 plainColumn 配置即可. 整体处理流程如下图所示
业务场景分析
由于业务已经在线上运行, 数据库里必然存有大量明文历史数据. 现在的问题是如何让历史数据得以加密清洗, 如何让增量数据得以加密处理, 如何让业务在新旧两套数据系统之间进行无缝, 透明化迁移
解决方案说明
在提供解决方案之前, 我们先来头脑风暴一下: 首先, 既然是旧业务需要进行加密改造, 那一定存储了非常重要且敏感的信息. 这些信息含金量高且业务相对基础重要. 不应该采用停止业务禁止新数据写入, 再找个加密算法把历史数据全部加密清洗, 再把之前重构的代码部署上线, 使其能把存量和增量数据进行在线加密解密
那么另一种相对安全的做法是: 重新搭建一套和生产环境一模一样的预发环境, 然后通过相关迁移洗数工具把生产环境的存量原文数据加密后存储到预发环境, 而新增数据则通过例如 MySQL 主从复制及业务方自行开发的工具加密后存储到预发环境的数据库里, 再把重构后可以进行加解密的代码部署到预发环境. 这样生产环境是一套以明文为核心的查询修改的环境;预发环境是一套以密文为核心加解密查询修改的环境. 在对比一段时间无误后, 可以夜间操作将生产流量切到预发环境中. 此方案相对安全可靠, 只是时间, 人力, 资金, 成本较高, 主要包括: 预发环境搭建, 生产代码整改, 相关辅助工具开发等
业务开发人员最希望的做法是: 减少资金费用的承担, 最好不要修改业务代码, 能够安全平滑迁移系统. 于是, ShardingSphere 的加密功能模块便应运而生. 可分为 3 步进行
假设系统需要对 t_user 的 pwd 字段进行加密处理, 业务方使用 Apache ShardingSphere 来代替标准化的 JDBC 接口, 此举基本不需要额外改造 (我们还提供了 Spring Boot Starter, Spring 命名空间, YAML 等接入方式, 满足不同业务方需求). 另外, 提供一套加密配置规则, 如下所示
-!ENCRYPT
encryptors:
aes_encryptor:
type: AES
props:
aes-key-value: 123456abc
tables:
t_user:
columns:
pwd:
plainColumn: pwd
cipherColumn: pwd_cipher
encryptorName: aes_encryptor
queryWithCipherColumn: false
依据上述加密规则可知, 首先需要在数据库表 t_user 里新增一个字段叫做 pwd_cipher, 即 cipherColumn, 用于存放密文数据, 同时我们把 plainColumn 设置为 pwd, 用于存放明文数据, 而把 logicColumn 也设置为 pwd. 由于之前的代码 SQL 就是使用 pwd 进行编写, 即面向逻辑列进行 SQL 编写, 所以业务代码无需改动. 通过 Apache ShardingSphere, 针对新增的数据, 会把明文写到 pwd 列, 并同时把明文进行加密存储到 pwd_cipher 列. 此时, 由于 queryWithCipherColumn 设置为 false, 对业务应用来说, 依旧使用 pwd 这一明文列进行查询存储, 却在底层数据库表 pwd_cipher 上额外存储了新增数据的密文数据, 其处理流程如下图所示
新增数据在插入时, 就通过 Apache ShardingSphere 加密为密文数据, 并被存储到了 cipherColumn. 而现在就需要处理历史明文存量数据. 由于 Apache ShardingSphere 目前并未提供相关迁移洗数工具, 此时需要业务方自行将 pwd 中的明文数据进行加密处理存储到 pwd_cipher
新增的数据已被 Apache ShardingSphere 将密文存储到密文列, 明文存储到明文列;历史数据被业务方自行加密清洗后, 将密文也存储到密文列. 也就是说现在的数据库里即存放着明文也存放着密文, 只是由于配置项中的 queryWithCipherColumn = false, 所以密文一直没有被使用过. 现在我们为了让系统能切到密文数据进行查询, 需要将加密配置中的 queryWithCipherColumn 设置为 true. 在重启系统后, 我们发现系统业务一切正常, 但是 Apache ShardingSphere 已经开始从数据库里取出密文列的数据, 解密后返回给用户; 而对于用户的增删改需求, 则依旧会把原文数据存储到明文列, 加密后密文数据存储到密文列
虽然现在业务系统通过将密文列的数据取出, 解密后返回;但是, 在存储的时候仍旧会存一份原文数据到明文列, 这是为什么呢? 答案是: 为了能够进行系统回滚. 因为只要密文和明文永远同时存在, 我们就可以通过开关项配置自由将业务查询切换到 cipherColumn 或 plainColumn. 也就是说, 如果将系统切到密文列进行查询时, 发现系统报错, 需要回滚. 那么只需将 queryWithCipherColumn = false, Apache ShardingSphere 将会还原, 即又重新开始使用 plainColumn 进行查询. 处理流程如下图所示
由于安全审计部门要求, 业务系统一般不可能让数据库的明文列和密文列永久同步保留, 我们需要在系统稳定后将明文列数据删除. 即我们需要在系统迁移后将 plainColumn, 即 pwd 进行删除. 那问题来了, 现在业务代码都是面向 pwd 进行编写 SQL 的, 把底层数据表中的存放明文的 pwd 删除了, 换用 pwd_cipher 进行解密得到原文数据, 那岂不是意味着业务方需要整改所有 SQL, 从而不使用即将要被删除的 pwd 列?还记得我们 Apache ShardingSphere 的核心意义所在吗?
这也正是 Apache ShardingSphere 核心意义所在, 即依据用户提供的加密规则, 将用户 SQL 与底层数据库表结构割裂开来, 使得用户的 SQL 编写不再依赖于真实的数据库表结构. 而用户与底层数据库之间的衔接, 映射, 转换交由 Apache ShardingSphere 进行处理
是的, 因为有 logicColumn 存在, 用户的编写 SQL 都面向这个虚拟列, Apache ShardingSphere 就可以把这个逻辑列和底层数据表中的密文列进行映射转换. 于是迁移后的加密配置即为
-!ENCRYPT
encryptors:
aes_encryptor:
type: AES
props:
aes-key-value: 123456abc
tables:
t_user:
columns:
pwd: # pwd 与 pwd_cipher 的转换映射
cipherColumn: pwd_cipher
encryptorName: aes_encryptor
Apache ShardingSphere 提供了两种加密算法用于数据加密, 这两种策略分别对应 Apache ShardingSphere 的两种加解密的接口, 即 EncryptAlgorithm 和 QueryAssistedEncryptAlgorithm
一方面, Apache ShardingSphere 为用户提供了内置的加解密实现类, 用户只需进行配置即可使用; 另一方面, 为了满足用户不同场景的需求, 我们还开放了相关加解密接口, 用户可依据这两种类型的接口提供具体实现类. 再进行简单配置, 即可让 Apache ShardingSphere 调用用户自定义的加解密方案进行数据加密
该解决方案通过提供 encrypt(), decrypt() 两种方法对需要加密的数据进行加解密. 在用户进行 INSERT, DELETE, UPDATE 时, ShardingSphere 会按照用户配置, 对 SQL 进行解析, 改写, 路由, 并调用 encrypt() 将数据加密后存储到数据库, 而在 SELECT 时, 则调用 decrypt() 方法将从数据库中取出的加密数据进行逆向解密, 最终将原始数据返回给用户
当前, Apache ShardingSphere 针对这种类型的加密解决方案提供了三种具体实现类, 分别是 MD5 (不可逆), AES (可逆), RC4 (可逆), 用户只需配置即可使用这三种内置的方案
相比较于第一种加密方案, 该方案更为安全和复杂. 它的理念是: 即使是相同的数据, 如两个用户的密码相同, 它们在数据库里存储的加密数据也应当是不一样的. 这种理念更有利于保护用户信息, 防止撞库成功
它提供三种函数进行实现, 分别是 encrypt(), decrypt(), queryAssistedEncrypt(). 在 encrypt() 阶段, 用户通过设置某个变动种子, 例如时间戳. 针对原始数据+变动种子组合的内容进行加密, 就能保证即使原始数据相同, 也因为有变动种子的存在, 致使加密后的加密数据是不一样的. 在 decrypt() 可依据之前规定的加密算法, 利用种子数据进行解密
虽然这种方式确实可以增加数据的保密性, 但是另一个问题却随之出现: 相同的数据在数据库里存储的内容是不一样的, 那么当用户按照这个加密列进行等值查询 (SELECT FROM table WHERE encryptedColumnn = ?) 时会发现无法将所有相同的原始数据查询出来. 为此, 我们提出了辅助查询列的概念. 该辅助查询列通过 queryAssistedEncrypt() 生成, 与 decrypt() 不同的是, 该方法通过对原始数据进行另一种方式的加密, 但是针对原始数据相同的数据, 这种加密方式产生的加密数据是一致的. 将 queryAssistedEncrypt() 后的数据存储到数据中用于辅助查询真实数据. 因此, 数据库表中多出这一个辅助查询列
由于 queryAssistedEncrypt() 和 encrypt() 产生不同加密数据进行存储, 而 decrypt() 可逆, queryAssistedEncrypt() 不可逆. 在查询原始数据的时候, 我们会自动对 SQL 进行解析, 改写, 路由, 利用辅助查询列进行 WHERE 条件的查询, 却利用 decrypt() 对 encrypt() 加密后的数据进行解密, 并将原始数据返回给用户. 这一切都是对用户透明化的
当前, Apache ShardingSphere 针对这种类型的加密解决方案并没有提供具体实现类, 却将该理念抽象成接口, 提供给用户自行实现. ShardingSphere 将调用用户提供的该方案的具体实现类进行数据加密
Apache ShardingSphere 通过解析 SQL, 对传入的 SQL 进行影子判定, 根据配置文件中用户设置的影子规则, 路由到生产库或者影子库
影子规则包含影子数据源映射关系, 影子表以及影子算法
以 INSERT 语句为例, 在写入数据时, Apache ShardingSphere 会对 SQL 进行解析, 再根据配置文件中的规则, 构造一条路由链. 在当前版本的功能中, 影子功能处于路由链中的最后一个执行单元, 即, 如果有其他需要路由的规则存在, 如分片, Apache ShardingSphere 会首先根据分片规则, 路由到某一个数据库, 再执行影子路由判定流程, 判定执行 SQL 满足影子规则的配置, 数据路由到与之对应的影子库, 生产数据则维持不变
影子库功能对执行的 SQL 语句进行影子判定. 影子判定支持两种类型算法, 用户可根据实际业务需求选择一种或者组合使用
支持两种算法. 影子判定会首先判断执行 SQL 相关表与配置的影子表是否有交集. 如果有交集, 依次判定交集部分影子表关联的影子算法, 有任何一个判定成功. SQL 语句路由到影子库. 影子表没有交集或者影子算法判定不成功, SQL 语句路由到生产库
仅支持注解影子算法. 在压测场景下, DDL 语句一般不需要测试. 主要在初始化或者修改影子库中影子表时使用. 影子判定会首先判断执行 SQL 是否包含注解. 如果包含注解, 影子规则中配置的 HINT 影子算法依次判定. 有任何一个判定成功. SQL 语句路由到影子库. 执行 SQL 不包含注解或者 HINT 影子算法判定不成功, SQL 语句路由到生产库