首页 > 应用开发 > Java > 一文快速入门分库分表中间件 Sharding-JDBC (必修课)
Attempts to establish a connection with the data source that * this {@code DataSource} object represents. * @return a connection to the data source */ Connection getConnection() throws SQLException; /** *
Attempts to establish a connection with the data source that * this {@code DataSource} object represents. * @param username the database user on whose behalf the connection is * being made * @param password the user's password */ Connection getConnection(String username, String password) throws SQLException; } ``` 其中 `CommonDataSource` 是定义数据源的根接口这很好理解,而 `Wrapper` 接口则是拓展 JDBC 分片功能的关键。 由于数据库厂商的不同,他们可能会各自提供一些超越标准 JDBC API 的扩展功能,但这些功能非 JDBC 标准并不能直接使用,而 `Wrapper` 接口的作用就是把一个由第三方供应商提供的、非 JDBC 标准的接口包装成标准接口,也就是`适配器模式`。 既然讲到了适配器模式就多啰嗦几句,也方便后边的理解。 >适配器模式个种比较常用的设计模式,它的作用是将某个类的接口转换成客户端期望的另一个接口,使原本因接口不匹配(或者不兼容)而无法在一起工作的两个类能够在一起工作。 比如用耳机听音乐,我有个圆头的耳机,可手机插孔却是扁口的,如果我想要使用耳机听音乐就必须借助一个转接头才可以,这个转接头就起到了适配作用。 举个栗子:假如我们 `Target` 接口中有 `hello()` 和 `word()` 两个方法。 ```javascript public interface Target { void hello(); void world(); } ``` 可由于接口版本迭代`Target` 接口的 `word()` 方法可能会被废弃掉或不被支持,`Adaptee` 类的 `greet()`方法将代替`hello()` 方法。 ```javascript public class Adaptee { public void greet(){ } public void world(){ } } ``` 但此时旧版本仍然有大量 `word()` 方法被使用中,解决此事最好的办法就是创建一个适配器`Adapter`,这样就适配了 `Target` 类,解决了接口升级带来的兼容性问题。 ```javascript public class Adapter extends Adaptee implements Target { @Override public void world() { } @Override public void hello() { super.greet(); } @Override public void greet() { } } ``` 而 `Sharding-JDBC` 提供的正是非 JDBC 标准的接口,所以它也提供了类似的实现方案,也使用到了 `Wrapper` 接口做数据分片功能的适配。除了 DataSource 之外,Connection、Statement、ResultSet 等核心对象也都继承了这个接口。 下面我们通过 `ShardingDataSource` 类源码简单看下实现过程,下图是继承关系流程图。  `ShardingDataSource` 类它在原 `DataSource` 基础上做了功能拓展,初始化时注册了分片SQL路由包装器、SQL重写上下文和结果集处理引擎,还对数据源类型做了校验,因为它要同时支持多个不同类型的数据源。到这好像也没看出如何适配,那接着向上看 `ShardingDataSource ` 的继承类 `AbstractDataSourceAdapter ` 。 ```javascript @Getter public class ShardingDataSource extends AbstractDataSourceAdapter { private final ShardingRuntimeContext runtimeContext; /** * 注册路由、SQl重写上下文、结果集处理引擎 */ static { NewInstanceServiceLoader.register(RouteDecorator.class); NewInstanceServiceLoader.register(SQLRewriteContextDecorator.class); NewInstanceServiceLoader.register(ResultProcessEngine.class); } /** * 初始化时校验数据源类型 并根据数据源 map、分片规则、数据库类型得到一个分片上下文,用来获取数据库连接 */ public ShardingDataSource(final Map dataSourceMap, final ShardingRule shardingRule, final Properties props) throws SQLException { super(dataSourceMap); checkDataSourceType(dataSourceMap); runtimeContext = new ShardingRuntimeContext(dataSourceMap, shardingRule, props, getDatabaseType()); } private void checkDataSourceType(final Map dataSourceMap) { for (DataSource each : dataSourceMap.values()) { Preconditions.checkArgument(!(each instanceof MasterSlaveDataSource), "Initialized data sources can not be master-slave data sources."); } } /** * 数据库连接 */ @Override public final ShardingConnection getConnection() { return new ShardingConnection(getDataSourceMap(), runtimeContext, TransactionTypeHolder.get()); } } ``` `AbstractDataSourceAdapter ` 抽象类内部主要获取不同类型的数据源对应的数据库连接对象,实现 `AutoCloseable ` 接口是为在使用完资源后可以自动将这些资源关闭(调用 `close `方法),那再看看继承类 `AbstractUnsupportedOperationDataSource` 。 ```javascript @Getter public abstract class AbstractDataSourceAdapter extends AbstractUnsupportedOperationDataSource implements AutoCloseable { private final Map dataSourceMap; private final DatabaseType databaseType; public AbstractDataSourceAdapter(final Map dataSourceMap) throws SQLException { this.dataSourceMap = dataSourceMap; databaseType = createDatabaseType(); } public AbstractDataSourceAdapter(final DataSource dataSource) throws SQLException { dataSourceMap = new HashMap<>(1, 1); dataSourceMap.put("unique", dataSource); databaseType = createDatabaseType(); } private DatabaseType createDatabaseType() throws SQLException { DatabaseType result = null; for (DataSource each : dataSourceMap.values()) { DatabaseType databaseType = createDatabaseType(each); Preconditions.checkState(null == result || result == databaseType, String.format("Database type inconsistent with '%s' and '%s'", result, databaseType)); result = databaseType; } return result; } /** * 不同数据源类型获取数据库连接 */ private DatabaseType createDatabaseType(final DataSource dataSource) throws SQLException { if (dataSource instanceof AbstractDataSourceAdapter) { return ((AbstractDataSourceAdapter) dataSource).databaseType; } try (Connection connection = dataSource.getConnection()) { return DatabaseTypes.getDatabaseTypeByURL(connection.getMetaData().getURL()); } } @Override public final Connection getConnection(final String username, final String password) throws SQLException { return getConnection(); } @Override public final void close() throws Exception { close(dataSourceMap.keySet()); } } ``` `AbstractUnsupportedOperationDataSource` 实现`DataSource` 接口并继承了 `WrapperAdapter` 类,它内部并没有什么具体方法只起到桥接的作用,但看着是不是和我们前边讲适配器模式的例子方式有点相似。 ```javascript public abstract class AbstractUnsupportedOperationDataSource extends WrapperAdapter implements DataSource { @Override public final int getLoginTimeout() throws SQLException { throw new SQLFeatureNotSupportedException("unsupported getLoginTimeout()"); } @Override public final void setLoginTimeout(final int seconds) throws SQLException { throw new SQLFeatureNotSupportedException("unsupported setLoginTimeout(int seconds)"); } } ``` `WrapperAdapter` 是一个包装器的适配类,实现了 JDBC 中的 `Wrapper` 接口,其中有两个核心方法 `recordMethodInvocation` 用于添加需要执行的方法和参数,而 `replayMethodsInvocation` 则将添加的这些方法和参数通过反射执行。仔细看不难发现两个方法中都用到了 `JdbcMethodInvocation`类。 ```javascript public abstract class WrapperAdapter implements Wrapper { private final Collection jdbcMethodInvocations = new ArrayList<>(); /** * 添加要执行的方法 */ @SneakyThrows public final void recordMethodInvocation(final Class> targetClass, final String methodName, final Class>[] argumentTypes, final Object[] arguments) { jdbcMethodInvocations.add(new JdbcMethodInvocation(targetClass.getMethod(methodName, argumentTypes), arguments)); } /** * 通过反射执行 上边添加的方法 */ public final void replayMethodsInvocation(final Object target) { for (JdbcMethodInvocation each : jdbcMethodInvocations) { each.invoke(target); } } } ``` `JdbcMethodInvocation` 类主要应用反射通过传入的 `method` 方法和 `arguments` 参数执行对应的方法,这样就可以通过 JDBC API 调用非 JDBC 方法了。 ```javascript @RequiredArgsConstructor public class JdbcMethodInvocation { @Getter private final Method method; @Getter private final Object[] arguments; /** * Invoke JDBC method. * * @param target target object */ @SneakyThrows public void invoke(final Object target) { method.invoke(target, arguments); } } ``` 那 `Sharding-JDBC` 拓展 JDBC API 接口后,在新增的分片功能里又做了哪些事情呢? 一张表经过分库分表后被拆分成多个子表,并分散到不同的数据库中,在不修改原业务 SQL 的前提下,`Sharding-JDBC` 就必须对 SQL进行一些改造才能正常执行。 大致的执行流程:`SQL 解析` -> `执⾏器优化` -> `SQL 路由` -> `SQL 改写` -> `SQL 执⾏` -> `结果归并` 六步组成,一起瞅瞅每个步骤做了点什么。  ### SQL 解析 SQL解析过程分为词法解析和语法解析两步,比如下边这条查询用户订单的SQL,先用词法解析将SQL拆解成不可再分的原子单元。在根据不同数据库方言所提供的字典,将这些单元归类为关键字,表达式,变量或者操作符等类型。 ```sql SELECT order_no,price FROM t_order_ where user_id = 10086 and order_status > 0 ``` 接着语法解析会将拆分后的SQL转换为抽象语法树,通过对抽象语法树遍历,提炼出分片所需的上下文,上下文包含查询字段信息(`Field`)、表信息(`Table`)、查询条件(`Condition`)、排序信息(`Order By`)、分组信息(`Group By`)以及分页信息(`Limit`)等,并标记出 SQL中有可能需要改写的位置。  ### 执⾏器优化 执⾏器优化对SQL分片条件进行优化,处理像关键字 `OR`这种影响性能的坏味道。 ### SQL 路由 SQL 路由通过解析分片上下文,匹配到用户配置的分片策略,并生成路由路径。简单点理解就是可以根据我们配置的分片策略计算出 SQL该在哪个库的哪个表中执行,而SQL路由又根据有无分片健区分出 `分片路由` 和 `广播路由`。  有分⽚键的路由叫分片路由,细分为直接路由、标准路由和笛卡尔积路由这3种类型。 #### 标准路由 标准路由是最推荐也是最为常⽤的分⽚⽅式,它的适⽤范围是不包含关联查询或仅包含绑定表之间关联查询的SQL。 当 SQL分片健的运算符为 `=` 时,路由结果将落⼊单库(表),当分⽚运算符是` BETWEEN` 或` IN` 等范围时,路由结果则不⼀定落⼊唯⼀的库(表),因此⼀条逻辑SQL最终可能被拆分为多条⽤于执⾏的真实SQL。 ```sql SELECT * FROM t_order where t_order_id in (1,2) ``` SQL路由处理后 ```sql SELECT * FROM t_order_0 where t_order_id in (1,2) SELECT * FROM t_order_1 where t_order_id in (1,2) ``` #### 直接路由 直接路由是通过使用 `HintAPI` 直接将 SQL路由到指定⾄库表的一种分⽚方式,而且直接路由可以⽤于分⽚键不在SQL中的场景,还可以执⾏包括⼦查询、⾃定义函数等复杂情况的任意SQL。 比如根据 `t_order_id ` 字段为条件查询订单,此时希望在不修改SQL的前提下,加上 `user_id`作为分片条件就可以使用直接路由。 #### 笛卡尔积路由 笛卡尔路由是由⾮绑定表之间的关联查询产生的,查询性能较低尽量避免走此路由模式。 --- 无分⽚键的路由又叫做广播路由,可以划分为全库表路由、全库路由、 全实例路由、单播路由和阻断路由这 5种类型。 #### 全库表路由 全库表路由针对的是数据库 `DQL `和 `DML`,以及 `DDL`等操作,当我们执行一条逻辑表 `t_order` SQL时,在所有分片库中对应的真实表 `t_order_0` ··· `t_order_n` 内逐一执行。 #### 全库路由 全库路由主要是对数据库层面的操作,比如数据库 `SET` 类型的数据库管理命令,以及 TCL 这样的事务控制语句。 对逻辑库设置 `autocommit` 属性后,所有对应的真实库中都执行该命令。 ```sql SET autocommit=0; ``` #### 全实例路由 全实例路由是针对数据库实例的 DCL 操作(设置或更改数据库用户或角色权限),比如:创建一个用户 order ,这个命令将在所有的真实库实例中执行,以此确保 order 用户可以正常访问每一个数据库实例。 ```sql CREATE USER order@127.0.0.1 identified BY '程序员内点事'; ``` #### 单播路由 单播路由用来获取某一真实表信息,比如获得表的描述信息: ```sql DESCRIBE t_order; ``` `t_order` 的真实表是 `t_order_0` ···· `t_order_n`,他们的描述结构相完全同,我们只需在任意的真实表执行一次就可以。 #### 阻断路由 ⽤来屏蔽SQL对数据库的操作,例如: ```sql USE order_db; ``` 这个命令不会在真实数据库中执⾏,因为 `ShardingSphere` 采⽤的是逻辑 Schema(数据库的组织和结构) ⽅式,所以无需将切换数据库的命令发送⾄真实数据库中。 ### SQL 改写 将基于逻辑表开发的SQL改写成可以在真实数据库中可以正确执行的语句。比如查询 `t_order` 订单表,我们实际开发中 SQL是按逻辑表 `t_order` 写的。 ```sql SELECT * FROM t_order ``` 但分库分表以后真实数据库中 `t_order` 表就不存在了,而是被拆分成多个子表 `t_order_n` 分散在不同的数据库内,还按原SQL执行显然是行不通的,这时需要将分表配置中的逻辑表名称改写为路由之后所获取的真实表名称。 ```sql SELECT * FROM t_order_n ``` ### SQL执⾏ 将路由和改写后的真实 SQL 安全且高效发送到底层数据源执行。但这个过程并不是简单的将 SQL 通过JDBC 直接发送至数据源执行,而是平衡数据源连接创建以及内存占用所产生的消耗,它会自动化的平衡资源控制与执行效率。 ### 结果归并 将从各个数据节点获取的多数据结果集,合并成一个大的结果集并正确的返回至请求客户端,称为结果归并。而我们SQL中的排序、分组、分页和聚合等语法,均是在归并后的结果集上进行操作的。 ## 四、快速实践 下面我们结合 `Springboot` + `mybatisplus` 快速搭建一个分库分表案例。 ### 1、准备工作 先做准备工作,创建两个数据库 `ds-0`、`ds-1`,两个库中分别建表 `t_order_0`、`t_order_1`、`t_order_2` 、`t_order_item_0`、`t_order_item_1`、`t_order_item_2`,`t_config`,方便后边验证广播表、绑定表的场景。  表结构如下: `t_order_0` 订单表 ```sql CREATE TABLE `t_order_0` ( `order_id` bigint(200) NOT NULL, `order_no` varchar(100) DEFAULT NULL, `create_name` varchar(50) DEFAULT NULL, `price` decimal(10,2) DEFAULT NULL, PRIMARY KEY (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC; ``` `t_order_0` 与 `t_order_item_0` 互为关联表 ```sql CREATE TABLE `t_order_item_0` ( `item_id` bigint(100) NOT NULL, `order_no` varchar(200) NOT NULL, `item_name` varchar(50) DEFAULT NULL, `price` decimal(10,2) DEFAULT NULL, PRIMARY KEY (`item_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC; ``` 广播表 `t_config` ```sql `id` bigint(30) NOT NULL, `remark` varchar(50) CHARACTER SET utf8 DEFAULT NULL, `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `last_modify_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=latin1; ``` `ShardingSphere` 提供了4种分片配置方式: - Java 代码配置 - Yaml 、properties 配置 - Spring 命名空间配置 - Spring Boot配置 为让代码看上去更简洁和直观,后边统一使用 `properties` 配置的方式,引入 `shardingsphere` 对应的 `sharding-jdbc-spring-boot-starter` 和 `sharding-core-common` 包,版本统一用的 4.0.0-RC1。 ### 2、分片配置 ```xml org.apache.shardingsphere sharding-jdbc-spring-boot-starter 4.0.0-RC1 org.apache.shardingsphere sharding-core-common 4.0.0-RC1 ``` 准备工作做完( mybatis 搭建就不赘述了),接下来我们逐一解读分片配置信息。 我们首先定义两个数据源 `ds-0`、`ds-1`,并分别加上数据源的基础信息。 ```sql # 定义两个全局数据源 spring.shardingsphere.datasource.names=ds-0,ds-1 # 配置数据源 ds-0 spring.shardingsphere.datasource.ds-0.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.ds-0.driverClassName=com.mysql.jdbc.Driver spring.shardingsphere.datasource.ds-0.url=jdbc:mysql://127.0.0.1:3306/ds-0?useUnicode=true&characterEncoding=utf8&tinyInt1isBit=false&useSSL=false&serverTimezone=GMT spring.shardingsphere.datasource.ds-0.username=root spring.shardingsphere.datasource.ds-0.password=root # 配置数据源 ds-1 spring.shardingsphere.datasource.ds-1.type=com.alibaba.druid.pool.DruidDataSource spring.shardingsphere.datasource.ds-1.driverClassName=com.mysql.jdbc.Driver spring.shardingsphere.datasource.ds-1.url=jdbc:mysql://127.0.0.1:3306/ds-1?useUnicode=true&characterEncoding=utf8&tinyInt1isBit=false&useSSL=false&serverTimezone=GMT spring.shardingsphere.datasource.ds-1.username=root spring.shardingsphere.datasource.ds-1.password=root ``` 配置完数据源接下来为表添加分库和分表策略,使用 `sharding-jdbc` 做分库分表需要我们为每一个表单独设置分片规则。 ```sql # 配置分片表 t_order # 指定真实数据节点 spring.shardingsphere.sharding.tables.t_order.actual-data-nodes=ds-$->{0..1}.t_order_$->{0..2} ``` `actual-data-nodes` 属性指定分片的真实数据节点,`$`是一个占位符,{0..1}表示实际拆分的数据库表数量。 `ds-$->{0..1}.t_order_$->{0..2} ` 表达式相当于 6个数据节点 - ds-0.t_order_0 - ds-0.t_order_1 - ds-0.t_order_2 - ds-1.t_order_0 - ds-1.t_order_1 - ds-1.t_order_2 ```sql ### 分库策略 # 分库分片健 spring.shardingsphere.sharding.tables.t_order.database-strategy.inline.sharding-column=order_id # 分库分片算法 spring.shardingsphere.sharding.tables.t_order.database-strategy.inline.algorithm-expression=ds-$->{order_id % 2} ``` 为表设置分库策略,上边讲了 `sharding-jdbc` 它提供了四种分片策略,为快速搭建我们先以最简单的行内表达式分片策略来实现,在下一篇会介绍四种分片策略的详细用法和使用场景。 ` database-strategy.inline.sharding-column` 属性中 ` database-strategy` 为分库策略,` inline` 为具体的分片策略,` sharding-column` 代表分片健。 ` database-strategy.inline.algorithm-expression` 是当前策略下具体的分片算法,` ds-$->{order_id % 2} ` 表达式意思是 对 `order_id `字段进行取模分库,2 代表分片库的个数,不同的策略对应不同的算法,这里也可以是我们自定义的分片算法类。 ```sql # 分表策略 # 分表分片健 spring.shardingsphere.sharding.tables.t_order.table-strategy.inline.sharding-column=order_id # 分表算法 spring.shardingsphere.sharding.tables.t_order.table-strategy.inline.algorithm-expression=t_order_$->{order_id % 3} # 自增主键字段 spring.shardingsphere.sharding.tables.t_order.key-generator.column=order_id # 自增主键ID 生成方案 spring.shardingsphere.sharding.tables.t_order.key-generator.type=SNOWFLAKE ``` 分表策略 和 分库策略 的配置比较相似,不同的是分表可以通过 `key-generator.column` 和 `key-generator.type` 设置自增主键以及指定自增主键的生成方案,目前内置了`SNOWFLAKE` 和 `UUID` 两种方式,还能自定义的主键生成算法类,后续会详细的讲解。 ```sql # 绑定表关系 spring.shardingsphere.sharding.binding-tables= t_order,t_order_item ``` 必须按相同分片健进行分片的表才能互为成绑定表,在联合查询时就能避免出现笛卡尔积查询。 ```sql # 配置广播表 spring.shardingsphere.sharding.broadcast-tables=t_config ``` 广播表,开启 SQL解析日志,能清晰的看到 SQL分片解析的过程 ```sql # 是否开启 SQL解析日志 spring.shardingsphere.props.sql.show=true ``` ### 3、验证分片 分片配置完以后我们无需在修改业务代码了,直接执行业务逻辑的增、删、改、查即可,接下来验证一下分片的效果。 我们同时向 `t_order`、`t_order_item` 表插入 5条订单记录,并不给定主键 `order_id` ,`item_id` 字段值。 ```javascript public String insertOrder() { for (int i = 0; i < 4; i++) { TOrder order = new TOrder(); order.setOrderNo("A000" + i); order.setCreateName("订单 " + i); order.setPrice(new BigDecimal("" + i)); orderRepository.insert(order); TOrderItem orderItem = new TOrderItem(); orderItem.setOrderId(order.getOrderId()); orderItem.setOrderNo("A000" + i); orderItem.setItemName("服务项目" + i); orderItem.setPrice(new BigDecimal("" + i)); orderItemRepository.insert(orderItem); } return "success"; } ``` 看到订单记录被成功分散到了不同的库表中, `order_id` 字段也自动生成了主键ID,基础的分片功能就完成了。  那向广播表 `t_config` 中插入一条数据会是什么效果呢? ```javascript public String config() { TConfig tConfig = new TConfig(); tConfig.setRemark("我是广播表"); tConfig.setCreateTime(new Date()); tConfig.setLastModifyTime(new Date()); configRepository.insert(tConfig); return "success"; } ``` 发现所有库中 `t_config` 表都执行了这条SQL,广播表和 MQ广播订阅的模式很相似,所有订阅的客户端都会收到同一条消息。  简单SQL操作验证没问通,接下来在试试复杂一点的联合查询,前边我们已经把 `t_order` 、`t_order_item` 表设为绑定表,直接联表查询执行一下。  通过控制台日志发现,逻辑表SQL 经过解析以后,只对 `t_order_0` 和 `t_order_item_0` 表进行了关联产生一条SQL。  那如果不互为绑定表又会是什么情况呢?去掉 `spring.shardingsphere.sharding.binding-tables`试一下。 发现控制台解析出了 3条真实表SQL,而去掉 `order_id` 作为查询条件再次执行后,结果解析出了 9条SQL,进行了笛卡尔积查询。所以相比之下绑定表的优点就不言而喻了。  ## 五、总结 以上对分库分表中间件 `sharding-jdbc` 的基础概念做了简单梳理,快速的搭建了一个分库分表案例,但这只是实践分库分表的第一步,下一篇我们会详细的介绍四种分片策略的具体用法和使用场景(必知必会),后边将陆续讲解自定义分布式主键、分布式数据库事务、分布式服务治理,数据脱敏等。 >案例 `GitHub` 地址:https://github.com/chengxy-nds/Springboot-Notebook/tree/master/springboot-sharding-jdbc 整理了几百本各类技术电子书,送给小伙伴们。关注公号回复【`666`】自行领取。和一些小伙伴们建了一个技术交流群,一起探讨技术、分享技术资料,旨在共同学习进步,如果感兴趣就加入我们吧! 
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/69961615/viewspace-2729695/ ,如需转载,请注明出处,否则将追究法律责任。
注册时间:2020-02-17