今天我们介绍一下Sharding-JDBC框架和快速的搭建一个分库分表案例,为讲解后续功能点准备好环境。
一、Sharding-JDBC简介
最早是当当网内部使用的一款分库分表框架,到年的时候才开始对外开源,这几年在大量社区贡献者的不断迭代下,功能也逐渐完善,现已更名为ShardingSphere,年正式成为Apache软件基会的顶级项。
随着版本的不断更迭的核心功能也变得多元化起来。从最开始Sharding-JDBC1.0版本只有数据分片,到Sharding-JDBC2.0版本开始支持数据库治理(注册中心、配置中心等等),再到Sharding-JDBC3.0版本又加分布式事务(支持Atomikos、Narayana、Bitronix、Seata),如今已经迭代到了Sharding-JDBC4.0版本。
现在的ShardingSphere不单单是指某个框架而是一个生态圈,这个生态圈、Sharding-Proxy和Sharding-Sidecar这三款开源的分布式数据库中间件解决方案所构成。
的前身就是,所以它是整个框架中最为经典、成熟的组件,我们先从框架入手学习分库分表。
二、核心概念
在开始分库分表具体实战之前,我们有必要先了解分库分表的一些核心概念。
分片
一般我们在提到分库分表的时候,大多是以水平切分模式(水平分库、分表)为基础来说的,数据分片将原本一张数据量较大的表t_order拆分生成数个表结构完全一致的小数据量表t_order_0、t_order_1、···、t_order_n,每张表只存储原大表中的一部分数据,当执行一条SQL时会通过分库策略、分片策略将数据分散到不同的数据库、表内。
数据节点
数据节点是分库分表中一个不可再分的最小数据单元(表),它由数据源名称和数据表组成,例如上图中order_db_1.t_order_0、order_db_2.t_order_1就表示一个数据节点。
逻辑表
逻辑表是指一组具有相同逻辑和数据结构表的总称。比如我们将订单表拆分成···t_order_9等10张表。此时我们会发现分库分表以后数据库中已不在有这张表,取而代之的是,但我们在代码中写依然按来写。此时就是这些拆分表的逻辑表。
真实表
真实表也就是上边提到的数据库中真实存在的物理表。
分片键
用于分片的数据库字段。我们将表分片以后,当执行一条SQL时,通过对字段order_id取模的方式来决定,这条数据该在哪个数据库中的哪个表中执行,此时字段就是表的分片健。
这样以来同一个订单的相关数据就会存在同一个数据库表中,大幅提升数据检索的性能,不仅如此sharding-jdbc还支持根据多个字段作为分片健进行分片。
分片算法
上边我们提到可以用分片健取模的规则分片,但这只是比较简单的一种,在实际开发中我们还希望用=、=、、、BETWEEN和IN等条件作为分片规则,自定义分片逻辑,这时就需要用到分片策略与分片算法。
从执行SQL的角度来看,分库分表可以看作是一种路由机制,把SQL语句路由到我们期望的数据库或数据表中并获取数据,分片算法可以理解成一种路由规则。
咱们先捋一下它们之间的关系,分片策略只是抽象出的概念,它是由分片算法和分片健组合而成,分片算法做具体的数据分片逻辑。
分库、分表的分片策略配置是相对独立的,可以各自使用不同的策略与算法,每种策略中可以是多个分片算法的组合,每个分片算法可以对多个分片健做逻辑判断。
注意:sharding-jdbc并没有直接提供分片算法的实现,需要开发者根据业务自行实现。
提供了4种分片算法:
1、精确分片算法
精确分片算法(PreciseShardingAlgorithm)用于单个字段作为分片键,SQL中有=与等条件的分片,需要在标准分片策略(StandardShardingStrategy)下使用。
2、范围分片算法
范围分片算法(RangeShardingAlgorithm)用于单个字段作为分片键,SQL中有BETWEENAND、、、、)下使用。
3、复合分片算法
复合分片算法(ComplexKeysShardingAlgorithm)用于多个字段作为分片键的分片操作,同时获取到多个分片健的值,根据多个字段处理业务逻辑。需要在复合分片策略(ComplexShardingStrategy)下使用。
4、Hint分片算法
Hint分片算法(HintShardingAlgorithm)稍有不同,上边的算法中我们都是解析语句提取分片键,并设置分片策略进行分片。但有些时候我们并没有使用任何的分片键和分片策略,可还想将SQL路由到目标数据库和表,就需要通过手动干预指定SQL的目标数据库和表信息,这也叫强制路由。
分片策略
上边讲分片算法的时候已经说过,分片策略是一种抽象的概念,实际分片操作的是由分片算法和分片健来完成的。
1、标准分片策略
标准分片策略适用于单分片键,此策略支持PreciseShardingAlgorithm和RangeShardingAlgorithm两个分片算法。
其中是必选的,用于处理和的分片。是可选的,用于处理,,,,条件分片,如果不配置,SQL中的条件等将按照全库路由处理。
2、复合分片策略
复合分片策略,同样支持对SQL语句中的,,,,,和的分片操作。不同的是它支持多分片键,具体分配片细节完全由应用开发者实现。
3、行表达式分片策略
行表达式分片策略,支持对SQL语句中的和的分片操作,但只支持单分片键。这种策略通常用于简单的分片,不需要自定义分片算法,可以直接在配置文件中接着写规则。
t_order_$-{t_order_id%4}代表对其字段t_order_id取模,拆分成4张表,而表名分别是到t_order_3。
4、Hint分片策略
Hint分片策略,对应上边的Hint分片算法,通过指定分片健而非从中提取分片健的方式进行分片的策略。
分布式主键
数据分后,不同数据节点成全局唯主键是常棘的问题,同个逻辑表()内的不同真实表()之间的增键由于法互相感知而产重复主键。
尽管可通过设置增主键初始值和步的式避免ID碰撞,但这样会使维护成本加大,乏完整性和可扩展性。如果后去需要增加分片表的数量,要逐一修改分片表的步长,运维成本非常高,所以不建议这种方式。
实现分布式主键成器的方式很多,具体可以百度,网上有很多
为了让上手更加简单,ApacheShardingSphere内置了UUID、SNOWFLAKE两种分布式主键成器,默认使雪花算法(snowflake)成64bit的整型数据。不仅如此它还抽离出分布式主键成器的接口,便我们实现定义的增主键成算法。
广播表
广播表:存在于所有的分片数据源中的表,表结构和表中的数据在每个数据库中均完全一致。一般是为字典表或者配置表t_config,某个表一旦被配置为广播表,只要修改某个数据库的广播表,所有数据源中广播表的数据都会跟着同步。
绑定表
绑定表:那些分片规则一致的主表和子表。比如:订单表和t_order_item订单服务项目表,都是按字段分片,因此两张表互为绑定表关系。
那绑定表存在的意义是啥呢?
通常在我们的业务中都会使用和等表进行多表联合查询,但由于分库分表以后这些表被拆分成N多个子表。如果不配置绑定表关系,会出现笛卡尔积关联查询,将产生如下四条。
SELECT*FROMt_order_0oJOINt_order_item_0iONo.order_id=i.order_idSELECT*FROMt_order_0oJOINt_order_item_1iONo.order_id=i.order_idSELECT*FROMt_order_1oJOINt_order_item_0iONo.order_id=i.order_idSELECT*FROMt_order_1oJOINt_order_item_1iONo.order_id=i.order_id
而配置绑定表关系后再进行关联查询时,只要对应表分片规则一致产生的数据就会落到同一个库中,那么只需和t_order_item_0表关联即可。
注意:在关联查询时它作为整个联合查询的主表。所有相关的路由计算都只使用主表的策略,表的分片相关的计算也会使用的条件,所以要保证绑定表之间的分片键要完全相同。
三、和JDBC的猫腻
从名字上不难看出,和JDBC有很大关系,我们知道JDBC是一种Java语言访问关系型数据库的规范,其设计初衷就是要提供一套用于各种数据库的统一标准,不同厂家共同遵守这套标准,并提供各自的实现方案供应用程序调用。
但其实对于开发人员而言,我们只关心如何调用JDBCAPI来访问数据库,只要正确使用DataSource、Connection、Statement、ResultSet等API接口,直接操作数据库即可。所以如果想在JDBC层面实现数据分片就必须对现有的API进行功能拓展,而Sharding-JDBC正是基于这种思想,重写了JDBC规范并完全兼容了JDBC规范。
对原有的、等接口扩展成ShardingDataSource、ShardingConnection,而对外暴露的分片操作接口与JDBC规范中所提供的接口完全一致,只要你熟悉JDBC就可以轻松应用Sharding-JDBC来实现分库分表。
因此它适用于任何基于的ORM框架,如:JPA,Hibernate,Mybatis,SpringJDBCTemplate或直接使用的JDBC。完美兼容任何第三方的数据库连接池,如:DBCP,C3P0,BoneCP,Druid,HikariCP等,几乎对主流关系型数据库都支持。
那又是如何拓展这些接口的呢?想知道答案我们就的从源码入手了,下边我们以JDBCAPI中的为例看看它是如何被重写扩展的。
数据源接口的核心作用就是获取数据库连接对象,我们看其内部提供了两个获取数据库连接的方法,并且继承了CommonDataSource和Wrapper两个接口。
publicinterfaceDataSourceextendsCommonDataSource,Wrapper{/***pAttemptstoestablishaconnectionwiththedatasourcethat*this{
codeDataSource}objectrepresents.*returnaconnectiontothedatasource*/ConnectiongetConnection()throwsSQLException;/***paramusernamethedatabaseuseronwhosebehalftheconnectionis*beingmade*parampasswordtheuserspassword*/ConnectiongetConnection(Stringusername,Stringpassword)throwsSQLException;}其中是定义数据源的根接口这很好理解,而接口则是拓展JDBC分片功能的关键。
由于数据库厂商的不同,他们可能会各自提供一些超越标准JDBCAPI的扩展功能,但这些功能非JDBC标准并不能直接使用,而接口的作用就是把一个由第三方供应商提供的、非JDBC标准的接口包装成标准接口,也就是适配器模式。
既然讲到了适配器模式就多啰嗦几句,也方便后边的理解。
适配器模式个种比较常用的设计模式,它的作用是将某个类的接口转换成客户端期望的另一个接口,使原本因接口不匹配(或者不兼容)而无法在一起工作的两个类能够在一起工作。比如用耳机听音乐,我有个圆头的耳机,可手机插孔却是扁口的,如果我想要使用耳机听音乐就必须借助一个转接头才可以,这个转接头就起到了适配作用。举个栗子:假如我们Target接口中有hello()和word()两个方法。
publicinterfaceTarget{voidhello();voidworld();}
可由于接口版本迭代接口的方法可能会被废弃掉或不被支持,Adaptee类的greet()方法将代替方法。
publicclassAdaptee{publicvoidgreet(){}publicvoidworld(){}}
但此时旧版本仍然有大量方法被使用中,解决此事最好的办法就是创建一个适配器Adapter,这样就适配了类,解决了接口升级带来的兼容性问题。
publicclassAdapterextendsAdapteeimplementsTarget{
Overridepublicvoidworld(){}Overridepublicvoidhello(){super.greet();}Overridepublicvoidgreet(){}}而提供的正是非JDBC标准的接口,所以它也提供了类似的实现方案,也使用到了接口做数据分片功能的适配。除了DataSource之外,Connection、Statement、ResultSet等核心对象也都继承了这个接口。
下面我们通过类源码简单看下实现过程,下图是继承关系流程图。
类它在原基础上做了功能拓展,初始化时注册了分片SQL路由包装器、SQL重写上下文和结果集处理引擎,还对数据源类型做了校验,因为它要同时支持多个不同类型的数据源。到这好像也没看出如何适配,那接着向上看的继承类AbstractDataSourceAdapter。
GetterpublicclassShardingDataSourceextends{privatefinalShardingRuntimeContextruntimeContext;/***注册路由、SQl重写上下文、结果集处理引擎*/static{NewInstanceServiceLoader.register(RouteDecorator.class);.register(SQLRewriteContextDecorator.class);.register(ResultProcessEngine.class);}/***初始化时校验数据源类型并根据数据源map、分片规则、数据库类型得到一个分片上下文,用来获取数据库连接*/publicShardingDataSource(finalMapString,DataSourcedataSourceMap,finalShardingRuleshardingRule,finalPropertiesprops)throwsSQLException{super(dataSourceMap);checkDataSourceType(dataSourceMap);runtimeContext=new(dataSourceMap,shardingRule,props,getDatabaseType());}privatevoidcheckDataSourceType(finalMapString,DataSourcedataSourceMap){for(DataSourceeach:dataSourceMap.values()){Preconditions.checkArgument(!(eachinstanceofMasterSlaveDataSource),"Initializeddatasourcescannotbemaster-slavedatasources.");}}/***数据库连接*/
OverridepublicfinalShardingConnectiongetConnection(){returnnewShardingConnection(getDataSourceMap(),runtimeContext,TransactionTypeHolder.get());}}AbstractDataSourceAdapter抽象类内部主要获取不同类型的数据源对应的数据库连接对象,实现AutoCloseable接口是为在使用完资源后可以自动将这些资源关闭(调用close方法),那再看看继承类AbstractUnsupportedOperationDataSource。
GetterpublicabstractclassAbstractDataSourceAdapterextendsAbstractUnsupportedOperationDataSourceimplementsAutoCloseable{privatefinalMapString,DataSourcedataSourceMap;privatefinalDatabaseTypedatabaseType;publicAbstractDataSourceAdapter(finalMapString,DataSourcedataSourceMap)throwsSQLException{this.dataSourceMap=dataSourceMap;databaseType=createDatabaseType();}publicAbstractDataSourceAdapter(finalDataSourcedataSource)throwsSQLException{dataSourceMap=newHashMap(1,1);dataSourceMap.put("unique",dataSource);databaseType=createDatabaseType();}privateDatabaseTypecreateDatabaseType()throwsSQLException{DatabaseTyperesult=null;for(DataSourceeach:dataSourceMap.values()){DatabaseTypedatabaseType=createDatabaseType(each);Preconditions.checkState(null==result
result==databaseType,String.format("Databasetypeinconsistentwith%sand%s",result,databaseType));result=databaseType;}returnresult;}/***不同数据源类型获取数据库连接*/privateDatabaseTypecreateDatabaseType(finalDataSourcedataSource)throwsSQLException{if(dataSourceinstanceofAbstractDataSourceAdapter){return((AbstractDataSourceAdapter)dataSource).databaseType;}try(Connectionconnection=dataSource.getConnection()){returnDatabaseTypes.getDatabaseTypeByURL(connection.getMetaData().getURL());}}
OverridepublicfinalConnectiongetConnection(finalStringusername,finalStringpassword)throwsSQLException{returngetConnection();}Overridepublicfinalvoidclose()throwsException{close(dataSourceMap.keySet());}}AbstractUnsupportedOperationDataSource实现DataSource接口并继承了WrapperAdapter类,它内部并没有什么具体方法只起到桥接的作用,但看着是不是和我们前边讲适配器模式的例子方式有点相似。
publicabstractclassAbstractUnsupportedOperationDataSourceextendsWrapperAdapterimplementsDataSource{
OverridepublicfinalintgetLoginTimeout()throwsSQLException{thrownewSQLFeatureNotSupportedException("unsupportedgetLoginTimeout()");}OverridepublicfinalvoidsetLoginTimeout(finalintseconds)throwsSQLException{thrownewSQLFeatureNotSupportedException("unsupportedsetLoginTimeout(intseconds)");}}WrapperAdapter是一个包装器的适配类,实现了JDBC中的Wrapper接口,其中有两个核心方法recordMethodInvocation用于添加需要执行的方法和参数,而replayMethodsInvocation则将添加的这些方法和参数通过反射执行。仔细看不难发现两个方法中都用到了JdbcMethodInvocation类。
publicabstractclassWrapperAdapterimplementsWrapper{privatefinalCollectionJdbcMethodInvocationjdbcMethodInvocations=newArrayList();/***添加要执行的方法*/
SneakyThrowspublicfinalvoidrecordMethodInvocation(finalClass?targetClass,finalStringmethodName,finalClass?[]argumentTypes,finalObject[]arguments){jdbcMethodInvocations.add(newJdbcMethodInvocation(targetClass.getMethod(methodName,argumentTypes),arguments));}/***通过反射执行上边添加的方法*/publicfinalvoidreplayMethodsInvocation(finalObjecttarget){for(JdbcMethodInvocationeach:jdbcMethodInvocations){each.invoke(target);}}}JdbcMethodInvocation类主要应用反射通过传入的method方法和arguments参数执行对应的方法,这样就可以通过JDBCAPI调用非JDBC方法了。
RequiredArgsConstructorpublicclassJdbcMethodInvocation{
GetterprivatefinalMethodmethod;GetterprivatefinalObject[]arguments;/***InvokeJDBCmethod.**paramtargettargetobject*/SneakyThrowspublicvoidinvoke(finalObjecttarget){method.invoke(target,arguments);}}那Sharding-JDBC拓展JDBCAPI接口后,在新增的分片功能里又做了哪些事情呢?
一张表经过分库分表后被拆分成多个子表,并分散到不同的数据库中,在不修改原业务SQL的前提下,Sharding-JDBC就必须对SQL进行一些改造才能正常执行。
大致的执行流程:SQL解析-执器优化-SQL路由-SQL改写-SQL执-结果归并六步组成,一起瞅瞅每个步骤做了点什么。
SQL解析
SQL解析过程分为词法解析和语法解析两步,比如下边这条查询用户订单的SQL,先用词法解析将SQL拆解成不可再分的原子单元。在根据不同数据库方言所提供的字典,将这些单元归类为关键字,表达式,变量或者操作符等类型。
SELECTorder_no,priceFROMt_order_whereuser_id=andorder_status0
接着语法解析会将拆分后的SQL转换为抽象语法树,通过对抽象语法树遍历,提炼出分片所需的上下文,上下文包含查询字段信息(Field)、表信息(Table)、查询条件(Condition)、排序信息(OrderBy)、分组信息(GroupBy)以及分页信息(Limit)等,并标记出SQL中有可能需要改写的位置。
执器优化
执器优化对SQL分片条件进行优化,处理像关键字OR这种影响性能的坏味道。
SQL路由
SQL路由通过解析分片上下文,匹配到用户配置的分片策略,并生成路由路径。简单点理解就是可以根据我们配置的分片策略计算出SQL该在哪个库的哪个表中执行,而SQL路由又根据有无分片健区分出分片路由和广播路由。
有分键的路由叫分片路由,细分为直接路由、标准路由和笛卡尔积路由这3种类型。
标准路由
标准路由是最推荐也是最为常的分式,它的适范围是不包含关联查询或仅包含绑定表之间关联查询的SQL。
当SQL分片健的运算符为=时,路由结果将落单库(表),当分运算符是BETWEEN或IN等范围时,路由结果则不定落唯的库(表),因此条逻辑SQL最终可能被拆分为多条于执的真实SQL。
SELECT*FROMt_orderwheret_order_idin(1,2)
SQL路由处理后
SELECT*FROMt_order_0wheret_order_idin(1,2)SELECT*FROMt_order_1wheret_order_idin(1,2)
直接路由
直接路由是通过使用HintAPI直接将SQL路由到指定库表的一种分方式,而且直接路由可以于分键不在SQL中的场景,还可以执包括查询、定义函数等复杂情况的任意SQL。
比如根据t_order_id字段为条件查询订单,此时希望在不修改SQL的前提下,加上user_id作为分片条件就可以使用直接路由。
笛卡尔积路由
笛卡尔路由是由绑定表之间的关联查询产生的,查询性能较低尽量避免走此路由模式。
无分键的路由又叫做广播路由,可以划分为全库表路由、全库路由、全实例路由、单播路由和阻断路由这5种类型。
全库表路由
全库表路由针对的是数据库DQL和DML,以及DDL等操作,当我们执行一条逻辑表t_orderSQL时,在所有分片库中对应的真实表t_order_0···t_order_n内逐一执行。
全库路由
全库路由主要是对数据库层面的操作,比如数据库SET类型的数据库管理命令,以及TCL这样的事务控制语句。
对逻辑库设置auto