分库分表之 Sharding-JDBC 中间件(上)
前言
这是一篇将“介绍 Sharding-JDBC 基本使用方法”作为目标的文章,但笔者却把大部分文字放在对 Sharding-JDBC 的工作原理的描述上,因为笔者认为原理是每个 IT 打工人学习技术的归途。
使用框架、中间件、数据库、工具包等公共组件来组装出应用系统是我们这一代 IT 打工人工作的常态。对于这些公共组件——比如框架——的学习,有些人的方法是这样的:避开复杂晦涩的框架原理,仅仅关注它的各种配置、API、注解,在尝试了这个框架的常用配置项、API、注解的效果之后,就妄称自己学会了这个框架。这种对技术的肤浅的认知既经不起实践的考验,也经不起面试官的考验,甚至连自己使用这些配置项、API、注解在干什么都没有明确的认知。
所以,打工人们,还是多学点原理,多看点源码,让优秀的设计思想、算法和编程风格冲击一下自己的大脑吧 :-)
因为 Sharding-JDBC 的设计细节实在太多,因此本文不可能对 Sharding-JDBC 进行面面俱到的讲解。笔者在本文中仅仅保留了对 Sharding-JDBC 的核心特性、核心原理的讲解,并尽量使用简单生动的文字进行表达,使读者阅读本文后对 Sharding-JDBC 的基本原理和使用有清晰的认知。为了使这些文字尽量摆脱枯燥的味道,文章采用了第一人称的讲述方式,让 Sharding-JDBC 现身说法,进行自我剖析,希望给大家一个更好的阅读体验。
但是,妄图不动脑子就能对某项技术产生深度认知是绝不可能的,你思考得越多,你得到的越多。这就印证了那句话:“我变秃了,也变强了。”
1. 我的出生和我的家族
我是 Sharding-JDBC,一个关系型数据库中间件,我的全名是 Apache ShardingSphere JDBC,我被冠以 Apache 这个贵族姓氏是 2020 年 4 月的事情,这意味着我进入了代码世界的“体制内”。但我还是喜欢别人称呼我的小名,Sharding-JDBC。
我的创造者在我诞生之后给我讲了我的身世:
“
你的诞生是一个必然的结果。
在你诞生之前,传统软件的存储层架构将所有的业务数据存储到单一数据库节点,在性能、可用性和运维成本这三方面已经难于满足互联网的海量数据场景。
从性能方面来说,由于关系型数据库大多采用 B+树类型的索引,在数据量逐渐增大的情况下,索引深度的增加也将使得磁盘访问的 IO 次数增加,进而导致查询性能的下降;同时,高并发访问请求也使得集中式数据库成为系统的最大瓶颈。
从可用性的方面来讲,应用服务器节点能够随意水平拓展(水平拓展就是增加应用服务器节点数量)以应对不断增加的业务流量,这必然导致系统的最终压力都落在数据库之上。而单一的数据库节点,或者简单的主从架构,已经越来越难以承担众多应用服务器节点的数据查询请求。数据库的可用性,已成为整个系统的关键。
从运维成本方面考虑,随着数据库实例中的数据规模的增大,DBA 的运维压力也会增加,因为数据备份和恢复的时间成本都将随着数据量的增大而愈发不可控。
这样看来关系型数据库似乎难以承担海量记录的存储。
然而,关系型数据库当今依然占有巨大市场,是各个公司核心业务的基石。在传统的关系型数据库无法满足互联网场景需要的情况下,将数据存储到原生支持分布式的 NoSQL 的尝试越来越多。但 NoSQL 对 SQL 的不兼容性以及生态圈的不完善,使得它们在与关系型数据库的博弈中处于劣势,关系型数据库的地位却依然不可撼动,未来也难于撼动。
我们目前阶段更加关注在原有关系型数据库的基础上做增量,使之更好适应海量数据存储和高并发查询请求的场景,而不是要颠覆关系型数据库。
分库分表方案就是这种增量,它的诞生解决了海量数据存储和高并发查询请求的问题。
但是,单一数据库被分库分表之后,繁杂的库和表使得编写持久层代码的工程师的思维负担翻了很多倍,他们需要考虑一个业务 SQL 应该去哪个库的哪个表里去查询,查询到的结果还要进行聚合,如果遇到多表关联查询、排序、分页、事务等等问题,那简直是一个噩梦。
于是我们创造了你。你可以让工程师们以像查询单数据库实例和单表那样来查询被水平分割的库和表,我们称之为透明查询。
你是水平分片世界的神。
”
这使我感到骄傲。
我被定位为一个轻量级 Java 框架,我在 Java 的 JDBC 层提供的额外服务,可以说是一个增强版的 JDBC 驱动,完全兼容 JDBC 和各种 ORM 框架。
- 我适用于任何基于 JDBC 的 ORM 框架,如:JPA, Hibernate, Mybatis, Spring JDBC Template 或直接使用 JDBC。
- 我支持任何第三方的数据库连接池,如:DBCP, C3P0, BoneCP, Druid, HikariCP 等。
- 我支持任意实现 JDBC 规范的数据库,目前支持 MySQL,Oracle,SQLServer,PostgreSQL 以及任何遵循 SQL92 标准的数据库。
我的创造者起初只创造了我一个独苗,后来为了我的家族的兴盛,我的两个兄弟——Apache ShardingSphere Proxy、Apache ShardingSphere Sidecar 又被创造了出来。前者被定位为透明化的数据库代理端,提供封装了数据库二进制协议的服务端版本,⽤于完成对异构语⾔的支持;后者被定位为 Kubernetes 的云原⽣数据库代理,以 Sidecar 的形式代理所有对数据库的访问。通过无中心、零侵⼊的⽅案提供与数据库交互的的啮合层,即 Database Mesh,又可称数据库⽹格。
因此,我们这个家族叫做 Apache ShardingSphere,旨在在分布式的场景下更好利用关系型数据库的计算和存储能力,而并非实现一个全新的关系型数据库。我们三个既相互独立,又能配合使用,均提供标准化的数据分片、分布式事务和数据库治理功能。
2. 我统治的世界和我的职责
我是 Sharding-JDBC,我生活在一个数据水平分片的世界,我统治着这个世界里被水平拆分后的数据库和表。
在分片的世界里,数据分片有两种法则:垂直拆分和水平拆分。
按照业务拆分的方式称为垂直分片,又称为纵向拆分,它的核心理念是专库专用。在拆分之前,一个数据库由多个数据表构成,每个表对应着不同的业务。而拆分之后,则是按照业务将表进行归类,分布到不同的数据库中,从而将压力分散至不同的数据库。下图展示了根据业务需要,将用户表和订单表垂直分片到不同的数据库的方案。
垂直分片往往需要对架构和设计进行调整。通常来讲,是来不及应对互联网业务需求快速变化的;而且,它也并无法真正的解决单点瓶颈。如果垂直拆分之后,表中的数据量依然超过单节点所能承载的阈值,则需要水平分片来进一步处理。
水平分片又称为横向拆分。相对于垂直分片,它不再将数据根据业务逻辑分类,而是通过某个字段(或某几个字段),根据某种规则将数据分散至多个库或表中,每个分片仅包含数据的一部分。例如:根据主键分片,偶数主键的记录放入 0 库(或表),奇数主键的记录放入 1 库(或表),如下图所示。
水平分片从理论上突破了单机数据量处理的瓶颈,并且扩展相对自由,是分库分表的标准解决方案。我管辖的就是水平分片世界。
通过分库和分表进行数据的拆分来使得各个表的数据量保持在阈值以下,是应对高并发和海量数据系统的有效手段。此外,使用多主多从的分片方式,可以有效的避免数据单点,从而提升数据架构的可用性。
其实,水平分库本质上还是在分表,因为被水平拆分后的库中,都有相同的表分片。
分库和分表这项工作并不是我来做,我虽然是神,但我还没有神到能理解你们这些工程师的业务设计和架构设计,从而自动把你们的业务数据库和业务表进行分片。对哪部分进行分片、怎样分片、分多少份,这些工作全部由这些工程师进行。当这些分库分表的工作被完成后,你们只需要在我的配置文件中或者通过我的 API 告诉我这些拆分规则(这就是后文要提到的分片策略)即可,剩下的事情,交给我去做。
我是 Sharding-JDBC,我的职责是尽量透明化水平分库分表所带来的影响,让使用方尽量像使用一个数据库一样使用水平分片之后的数据库集群,或者像使用一个数据表一样使用水平分片之后的数据表。由于我的治理,每个服务器节点只能看到一个逻辑上的数据库节点,和其中的多个逻辑表,它们看不到真正存在于物理世界中的被水平分割的多个数据库分片和被水平分割的多个数据表分片。服务器节点看到的简单的持久层结构,其实是我苦心营造的幻象。
而为了营造这种幻象,我在幕后付出了很多。
当一个 Java 应用服务器节点将一个查询 SQL 交给我之后,我要做下面几件事:
1)SQL 解析:解析分为词法解析和语法解析。我先通过词法解析器将这句 SQL 拆分为一个个不可再分的单词,再使用语法解析器对 SQL 进行理解,并最终提炼出解析上下文。简单来说就是我要理解这句 SQL,明白它的构造和行为,这是下面的优化、路由、改写、执行和归并的基础。
2)SQL 路由:我根据解析上下文匹配用户对这句 SQL 所涉及的库和表配置的分片策略(关于用户配置的分片策略,我后文会慢慢解释),并根据分片策略生成路由后的 SQL。路由后的 SQL 有一条或多条,每一条都对应着各自的真实物理分片。
3)SQL 改写:我将 SQL 改写为在真实数据库中可以正确执行的语句(逻辑 SQL 到物理 SQL 的映射,例如把逻辑表名改成带编号的分片表名)。
4)SQL 执行:我通过多线程执行器异步执行路由和改写之后得到的 SQL 语句。
5)结果归并:我将多个执行结果集归并以便于通过统一的 JDBC 接口输出。
如果你连读这段工作流程都很困难,那你就能明白我在这个水平分片的世界里有多辛苦。关于这段工作流程,我会在后文慢慢说给你听。
3. 召唤我的方式
我是 Sharding-JDBC,我被定位为一个轻量级数据库中间件,当你们召唤我去统治水平拆分后的数据库和数据表时,只需要做下面几件事:
1)引入依赖包。
maven 是统治依赖包世界的神,在他诞生之后,一切对 jar 包的引用就变得简单了。向 maven 获取我的 jar 包,咒语是:
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core</artifactId>
<version>${latest.release.version}</version>
</dependency>
于是,我就出现在了这个项目中!
如果你们构建的项目已经被 Springboot 统治了(Springboot 是 Spring 的继任者,Spring 是统治对象世界的神,Springboot 继承了 Spring 的统治法则,并简化了 Spring 的配置),那么就可以向 maven 获取我的 springboot starter jar 包,咒语是:
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-spring-boot-starter</artifactId>
<version>${shardingsphere.version}</version>
</dependency>
这样,我就能和 Springboot 神共存于同一个项目。
2)进行水平分片规则配置。
你们要把水平分片规则配置告诉我,这样我才能知道你们是怎样水平拆分数据库和数据表的。你们可以通过我提供的 Java API,或者配置文件告诉我分片规则。
如果是以 Java API 的方式进行配置,示例如下:
// 配置真实数据源
Map<String, DataSource> dataSourceMap = new HashMap<>();
// 配置第 1 个数据源
BasicDataSource dataSource1 = new BasicDataSource();
dataSource1.setDriverClassName("com.mysql.jdbc.Driver");
dataSource1.setUrl("jdbc:mysql://localhost:3306/ds0");
dataSource1.setUsername("root");
dataSource1.setPassword("");
dataSourceMap.put("ds0", dataSource1);
// 配置第 2 个数据源
BasicDataSource dataSource2 = new BasicDataSource();
dataSource2.setDriverClassName("com.mysql.jdbc.Driver");
dataSource2.setUrl("jdbc:mysql://localhost:3306/ds1");
dataSource2.setUsername("root");
dataSource2.setPassword("");
dataSourceMap.put("ds1", dataSource2);
// 配置 t_order 表规则
ShardingTableRuleConfiguration orderTableRuleConfig
= new ShardingTableRuleConfiguration(
"t_order",
"ds${0..1}.t_order${0..1}"
);
// 配置 t_order 被拆分到多个子库的策略
orderTableRuleConfig.setDatabaseShardingStrategy(
new StandardShardingStrategyConfiguration(
"user_id",
"dbShardingAlgorithm"
)
);
// 配置 t_order 被拆分到多个子表的策略
orderTableRuleConfig.setTableShardingStrategy(
new StandardShardingStrategyConfiguration(
"order_id",
"tableShardingAlgorithm"
)
);
// 省略配置 t_order_item 表规则...
// ...
// 配置分片规则
ShardingRuleConfiguration shardingRuleConfig = new ShardingRuleConfiguration();
shardingRuleConfig.getTables().add(orderTableRuleConfig);
// 配置 t_order 被拆分到多个子库的算法
Properties dbShardingAlgorithmrProps = new Properties();
dbShardingAlgorithmrProps.setProperty(
"algorithm-expression",
"ds${user_id % 2}"
);
shardingRuleConfig.getShardingAlgorithms().put(
"dbShardingAlgorithm",
new ShardingSphereAlgorithmConfiguration("INLINE", dbShardingAlgorithmrProps)
);
// 配置 t_order 被拆分到多个子表的算法
Properties tableShardingAlgorithmrProps = new Properties();
tableShardingAlgorithmrProps.setProperty(
"algorithm-expression",
"t_order${order_id % 2}"
);
shardingRuleConfig.getShardingAlgorithms().put(
"tableShardingAlgorithm",
new ShardingSphereAlgorithmConfiguration("INLINE", tableShardingAlgorithmrProps)
);
这段配置代码中涉及的 t_order 表(存储订单的基本信息)的表结构为:
order_id | user_id | create_time | remarks | total_price |
t_order_item 表(存储订单的商品和价格明细信息)的结构为:
order_id | production_code | count | price | discount |
这段配置代码描述了对 t_order 表进行的如下图所示的数据表水平分片(对 t_order_item 表也要进行类似的水平分片,但是这部分配置省略了):
在这段配置中,或许你们注意到了一些奇怪的表达式:
ds$->{0..1}.t_order$->{0..1}
ds_${user_id % 2}
t_order_${order_id % 2}
这些表达式被称为 Groovy 表达式,它们的含义很容易识别:
1)对 t_order 进行两种维度的拆分:数据库维度和表维度数;
2)在数据库维度,t_order.user_id % 2 == 0 的记录全部落到 ds0,t_order.user_id % 2 == 1 的记录全部落到 ds1;(有人称这一过程为水平分库,其实它的本质还是在水平地分表,只不过依据表中 user_id 的不同把拆分的后的表放入两个数据库实例。)
3)在表维度,t_order.order_id% 2 == 0 的记录全部落到 t_order0,t_order.order_id% 2 == 1 的记录全部落到 t_order1。
4)对记录的读和写都按照这种方向进行,“方向”,就是分片方式,就是路由。
我允许你们这些工程师使用这种简洁的 Groovy 表达式告诉我你们设置的分片策略和分片算法。但是这种方式所能表达的含义是有限的。因此,我提供了分片策略接口和分片算法接口让你们利用 Java 代码尽情表达更为复杂的分片策略和分片算法。关于这一点,我将在《我的特性和工作方法》这一章详述。
而且在这里我要先告诉你,分片算法是分片策略的组成部分,分片策略设置=分片键设置+分片算法设置。上述配置里使用的策略是 Inline 类型的分片策略,使用的算法是 Inline 类型的行表达式算法,你或许不清楚我现在讲的这些术语,不要着急,我会在《我的特性和工作方法》这一章详述。
如果是以配置文件的方式进行配置,示例如下(这里以我的 springboot starter 包的 properties 配置文件为例):
# 配置真实数据源
spring.shardingsphere.datasource.names=ds0,ds1
# 配置第 1 个数据源
spring.shardingsphere.datasource.ds0.type=org.apache.commons.dbcp2.BasicDataSource
spring.shardingsphere.datasource.ds0.driver-class-name=com.mysql.jdbc.Driver
spring.shardingsphere.datasource.ds0.url=jdbc:mysql://localhost:3306/ds0
spring.shardingsphere.datasource.ds0.username=root
spring.shardingsphere.datasource.ds0.password=
# 配置第 2 个数据源
spring.shardingsphere.datasource.ds1.type=org.apache.commons.dbcp2.BasicDataSource
spring.shardingsphere.datasource.ds1.driver-class-name=com.mysql.jdbc.Driver
spring.shardingsphere.datasource.ds1.url=jdbc:mysql://localhost:3306/ds1
spring.shardingsphere.datasource.ds1.username=root
spring.shardingsphere.datasource.ds1.password=
# 配置 t_order 表规则
spring.shardingsphere.rules.sharding.tables.t_order.actual-data-nodes=ds$->{0..1}.t_order$->{0..1}
# 配置 t_order 被拆分到多个子库的策略
spring.shardingsphere.rules.sharding.tables.t_order.database-strategy.standard.sharding-column=user_id
spring.shardingsphere.rules.sharding.tables.t_order.database-strategy.standard.sharding-algorithm-name=database_inline
# 配置 t_order 被拆分到多个子表的策略
spring.shardingsphere.rules.sharding.tables.t_order.table-strategy.standard.sharding-column=order_id
spring.shardingsphere.rules.sharding.tables.t_order.table-strategy.standard.sharding-algorithm-name=table_inline
# 省略配置 t_order_item 表规则...
# ...
# 配置 t_order 被拆分到多个子库的算法
spring.shardingsphere.rules.sharding.sharding-algorithms.database_inline.type=INLINE
spring.shardingsphere.rules.sharding.sharding-algorithms.database_inline.props.algorithm-expression=ds_${user_id % 2}
# 配置 t_order 被拆分到多个子表的算法
spring.shardingsphere.rules.sharding.sharding-algorithms.table_inline.type=INLINE
spring.shardingsphere.rules.sharding.sharding-algorithms.table_inline.props.algorithm-expression=t_order_${order_id % 2}
这段配置文件的语义和上面的 Java 配置代码同义。
3)创建数据源。
若使用上文所示的 Java API 进行配置,则可以通过 ShardingSphereDataSourceFactory 工厂创建数据源,该工厂产生一个 ShardingSphereDataSource 实例,ShardingSphereDataSource 实现自 JDBC 的标准接口 DataSource(所以 ShardingSphereDataSource 实例也是接口 DataSource 的实例)。之后,就可以通过 dataSource 调用原生 JDBC 接口来执行 SQL 查询,或者将 dataSource 配置到 JPA,MyBatis 等 ORM 框架来执行 SQL 查询。
// 创建 ShardingSphereDataSource
DataSource dataSource = ShardingSphereDataSourceFactory.createDataSource(
dataSourceMap,
Collections.singleton(shardingRuleConfig, new Properties())
);
若使用上文所示的基于 springboot starter 的 properties 配置文件进行分片配置,则可以直接通过 Spring 提供的自动注入的方式获得数据源实例 dataSource(同样,这也是一个 ShardingSphereDataSource 实例)。之后,就可以通过 dataSource 调用原生 JDBC 接口来执行 SQL 查询,或者将 dataSource 配置到 JPA,MyBatis 等 ORM 框架来执行 SQL 查询。
/**
* 注入一个 ShardingSphereDataSource 实例
*/
@Resource
private DataSource dataSource;
有了 dataSource(以上两种方式产生的 dataSource 没有区别,都是 ShardingSphereDataSource 的一个实例,业务代码将 SQL 交给这个 dataSource,也就是交到了我的手中),就可以执行 SQL 查询了。
4)执行 SQL。这里给出 dataSource 调用原生 JDBC 接口来执行 SQL 查询的示例:
String sql = "SELECT i.* FROM t_order o JOIN t_order_item i ON o.order_id=i.order_id WHERE o.user_id=? AND o.order_id=?";
try (
Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)
) {
ps.setInt(1, 10);
ps.setInt(2, 1000);
try (
ResultSet rs = preparedStatement.executeQuery()
) {
while(rs.next()) {
// ...
}
}
}
在这个示例中,Java 代码调用 dataSource 的 JDBC 接口时,只感觉自己在对一个逻辑库中的两个逻辑表进行关联查询,并没有意识到物理分片的存在。而背后是我在进行 SQL 语句的解析、路由、改写、执行和结果归并!
本文转载自公众号:码海