Sharding-JDBC 问题收集
简介
Sharding-JDBC
最早是当当网内部使用的一款分库分表框架,到2017年的时候才开始对外开源,这几年在大量社区贡献者的不断迭代下,功能也逐渐完善,现已更名为 ShardingSphere
,2020年4⽉16⽇正式成为 Apache
软件基⾦会的顶级项⽬。
随着版本的不断更迭 ShardingSphere
的核心功能也变得多元化起来。从最开始 Sharding-JDBC 1.0 版本只有数据分片,到 Sharding-JDBC 2.0 版本开始支持数据库治理(注册中心、配置中心等等),再到 Sharding-JDBC 3.0版本又加分布式事务 (支持 Atomikos
、Narayana
、Bitronix
、Seata
)
现在的 ShardingSphere 不单单是指某个框架而是一个生态圈,这个生态圈 Sharding-JDBC
、Sharding-Proxy
和 Sharding-Sidecar
这三款开源的分布式数据库中间件解决方案所构成。
ShardingSphere
的前身就是 Sharding-JDBC
,所以它是整个框架中最为经典、成熟的组件。
核心概念
在开始 Sharding-JDBC
分库分表具体实战之前,我们有必要先了解分库分表的一些核心概念。
分片
一般我们在提到分库分表的时候,大多是以水平切分模式(水平分库、分表)为基础来说的,数据分片将原本一张数据量较大的表 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
拆分成 t_order_0
··· t_order_9
等 10张表。此时我们会发现分库分表以后数据库中已不在有 t_order
这张表,取而代之的是 t_order_n
,但我们在代码中写 SQL
依然按 t_order
来写。此时 t_order
就是这些拆分表的逻辑表
。
真实表
真实表也就是上边提到的 t_order_n
数据库中真实存在的物理表。
分片键
用于分片的数据库字段。我们将 t_order
表分片以后,当执行一条SQL时,通过对字段 order_id
取模的方式来决定,这条数据该在哪个数据库中的哪个表中执行,此时 order_id
字段就是 t_order
表的分片健。
这样以来同一个订单的相关数据就会存在同一个数据库表中,大幅提升数据检索的性能,不仅如此 sharding-jdbc
还支持根据多个字段作为分片健进行分片。
分片算法
上边我们提到可以用分片健取模的规则分片,但这只是比较简单的一种,在实际开发中我们还希望用 >=
、<=
、>
、<
、BETWEEN
和 IN
等条件作为分片规则,自定义分片逻辑,这时就需要用到分片策略与分片算法。
从执行 SQL 的角度来看,分库分表可以看作是一种路由机制,把 SQL 语句路由到我们期望的数据库或数据表中并获取数据,分片算法可以理解成一种路由规则。
咱们先捋一下它们之间的关系,分片策略只是抽象出的概念,它是由分片算法和分片健组合而成,分片算法做具体的数据分片逻辑。
分库、分表的分片策略配置是相对独立的,可以各自使用不同的策略与算法,每种策略中可以是多个分片算法的组合,每个分片算法可以对多个分片健做逻辑判断。
注意:sharding-jdbc 并没有直接提供分片算法的实现,需要开发者根据业务自行实现。
sharding-jdbc
提供了4种分片算法:
1、精确分片算法
精确分片算法(PreciseShardingAlgorithm)用于单个字段作为分片键,SQL中有 =
与 IN
等条件的分片,需要在标准分片策略(StandardShardingStrategy
)下使用。
2、范围分片算法
范围分片算法(RangeShardingAlgorithm)用于单个字段作为分片键,SQL中有 BETWEEN AND
、>
、<
、>=
、<=
等条件的分片,需要在标准分片策略(StandardShardingStrategy
)下使用。
3、复合分片算法
复合分片算法(ComplexKeysShardingAlgorithm)用于多个字段作为分片键的分片操作,同时获取到多个分片健的值,根据多个字段处理业务逻辑。需要在复合分片策略(ComplexShardingStrategy
)下使用。
4、Hint分片算法
Hint分片算法(HintShardingAlgorithm)稍有不同,上边的算法中我们都是解析SQL
语句提取分片键,并设置分片策略进行分片。但有些时候我们并没有使用任何的分片键和分片策略,可还想将 SQL 路由到目标数据库和表,就需要通过手动干预指定SQL的目标数据库和表信息,这也叫强制路由。
分片策略
上边讲分片算法的时候已经说过,分片策略是一种抽象的概念,实际分片操作的是由分片算法和分片健来完成的。
1、标准分片策略
标准分片策略适用于单分片键,此策略支持 PreciseShardingAlgorithm
和 RangeShardingAlgorithm
两个分片算法。
其中 PreciseShardingAlgorithm
是必选的,用于处理 =
和 IN
的分片。RangeShardingAlgorithm
是可选的,用于处理BETWEEN AND
, >
, <
,>=
,<=
条件分片,如果不配置RangeShardingAlgorithm
,SQL中的条件等将按照全库路由处理。
2、复合分片策略
复合分片策略,同样支持对 SQL语句中的 =
,>
, <
, >=
, <=
,IN
和 BETWEEN AND
的分片操作。不同的是它支持多分片键,具体分配片细节完全由应用开发者实现。
3、行表达式分片策略
行表达式分片策略,支持对 SQL语句中的 =
和 IN
的分片操作,但只支持单分片键。这种策略通常用于简单的分片,不需要自定义分片算法,可以直接在配置文件中接着写规则。
t_order_$->{t_order_id % 4}
代表 t_order
对其字段 t_order_id
取模,拆分成4张表,而表名分别是t_order_0
到 t_order_3
。
4、Hint分片策略
Hint分片策略,对应上边的Hint分片算法,通过指定分片健而非从 SQL
中提取分片健的方式进行分片的策略。
分布式主键
数据分⽚后,不同数据节点⽣成全局唯⼀主键是⾮常棘⼿的问题,同⼀个逻辑表(t_order
)内的不同真实表(t_order_n
)之间的⾃增键由于⽆法互相感知而产⽣重复主键。
尽管可通过设置⾃增主键 初始值
和 步⻓
的⽅式避免ID碰撞,但这样会使维护成本加大,乏完整性和可扩展性。如果后去需要增加分片表的数量,要逐一修改分片表的步长,运维成本非常高,所以不建议这种方式。
实现分布式主键⽣成器的方式很多,可以参考我之前写的《9种分布式ID生成方式》。
为了让上手更加简单,ApacheShardingSphere 内置了UUID
、SNOWFLAKE
两种分布式主键⽣成器,默认使⽤雪花算法(snowflake
)⽣成64bit的⻓整型数据。不仅如此它还抽离出分布式主键⽣成器的接口,⽅便我们实现⾃定义的⾃增主键⽣成算法。
广播表
广播表:存在于所有的分片数据源中的表,表结构和表中的数据在每个数据库中均完全一致。一般是为字典表或者配置表 t_config
,某个表一旦被配置为广播表,只要修改某个数据库的广播表,所有数据源中广播表的数据都会跟着同步。
绑定表
绑定表:那些分片规则一致的主表和子表。比如:t_order
订单表和 t_order_item
订单服务项目表,都是按 order_id
字段分片,因此两张表互为绑定表关系。
那绑定表存在的意义是啥呢?
通常在我们的业务中都会使用 t_order
和 t_order_item
等表进行多表联合查询,但由于分库分表以后这些表被拆分成N多个子表。如果不配置绑定表关系,会出现笛卡尔积关联查询,将产生如下四条SQL
。
SELECT * FROM t_order_0 o JOIN t_order_item_0 i ON o.order_id=i.order_id
SELECT * FROM t_order_0 o JOIN t_order_item_1 i ON o.order_id=i.order_id
SELECT * FROM t_order_1 o JOIN t_order_item_0 i ON o.order_id=i.order_id
SELECT * FROM t_order_1 o JOIN t_order_item_1 i ON o.order_id=i.order_id
而配置绑定表关系后再进行关联查询时,只要对应表分片规则一致产生的数据就会落到同一个库中,那么只需 t_order_0
和 t_order_item_0
表关联即可。
SELECT * FROM t_order_0 o JOIN t_order_item_0 i ON o.order_id=i.order_id
SELECT * FROM t_order_1 o JOIN t_order_item_1 i ON o.order_id=i.order_id
注意:在关联查询时
t_order
它作为整个联合查询的主表。 所有相关的路由计算都只使用主表的策略,t_order_item
表的分片相关的计算也会使用t_order
的条件,所以要保证绑定表之间的分片键要完全相同。
与JDBC的关联
从名字上不难看出,Sharding-JDBC
和 JDBC
有很大关系,我们知道 JDBC 是一种 Java
语言访问关系型数据库的规范,其设计初衷就是要提供一套用于各种数据库的统一标准,不同厂家共同遵守这套标准,并提供各自的实现方案供应用程序调用。
但其实对于开发人员而言,我们只关心如何调用 JDBC API 来访问数据库,只要正确使用 DataSource
、Connection
、Statement
、ResultSet
等 API 接口,直接操作数据库即可。所以如果想在 JDBC 层面实现数据分片就必须对现有的 API 进行功能拓展,而 Sharding-JDBC 正是基于这种思想,重写了 JDBC 规范并完全兼容了 JDBC 规范。
对原有的 DataSource
、Connection
等接口扩展成 ShardingDataSource
、ShardingConnection
,而对外暴露的分片操作接口与 JDBC 规范中所提供的接口完全一致,只要你熟悉 JDBC 就可以轻松应用 Sharding-JDBC 来实现分库分表。
因此它适用于任何基于 JDBC
的 ORM
框架,如:JPA
, Hibernate
,Mybatis
,Spring JDBC Template
或直接使用的 JDBC。完美兼容任何第三方的数据库连接池,如:DBCP
, C3P0
, BoneCP
,Druid
, HikariCP
等,几乎对主流关系型数据库都支持。
那 Sharding-JDBC
又是如何拓展这些接口的呢?想知道答案我们就的从源码入手了,下边我们以 JDBC API 中的 DataSource
为例看看它是如何被重写扩展的。
数据源 DataSource
接口的核心作用就是获取数据库连接对象 Connection
,我们看其内部提供了两个获取数据库连接的方法 ,并且继承了 CommonDataSource
和 Wrapper
两个接口。
public interface DataSource extends CommonDataSource, Wrapper {
/**
* <p>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;
/**
* <p>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()
两个方法。
public interface Target {
void hello();
void world();
}
可由于接口版本迭代Target
接口的 word()
方法可能会被废弃掉或不被支持,Adaptee
类的 greet()
方法将代替hello()
方法。
public class Adaptee {
public void greet(){
}
public void world(){
}
}
但此时旧版本仍然有大量 word()
方法被使用中,解决此事最好的办法就是创建一个适配器Adapter
,这样就适配了 Target
类,解决了接口升级带来的兼容性问题。
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
。
@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<String, DataSource> 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<String, DataSource> 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
。
@Getter
public abstract class AbstractDataSourceAdapter extends AbstractUnsupportedOperationDataSource implements AutoCloseable {
private final Map<String, DataSource> dataSourceMap;
private final DatabaseType databaseType;
public AbstractDataSourceAdapter(final Map<String, DataSource> 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
类,它内部并没有什么具体方法只起到桥接的作用,但看着是不是和我们前边讲适配器模式的例子方式有点相似。
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
类。
public abstract class WrapperAdapter implements Wrapper {
private final Collection<JdbcMethodInvocation> 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 方法了。
@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拆解成不可再分的原子单元。在根据不同数据库方言所提供的字典,将这些单元归类为关键字,表达式,变量或者操作符等类型。
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,根据分片键的不同可以划分为单片路由(分片键的操作符是等号)、多片路由(分片键的操作符是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 [email protected] identified BY '123';
单播路由
单播路由用于获取一个真实表信息的场景,它仅需要从任意库的任意真实表中获取数据即可。例如:
阻断路由
阻断路由拥有屏蔽SQL对数据库的操作,例如:
USE order_db;
这个命令不会在真实数据库执行,因为ShardingJDBC采用的是逻辑Schema的方式,无需将切换数据库Schema的命令发送到数据库中。
改写引擎
正确性改写
在包含分表的场景中,需要将分表配置中的逻辑表名称改写为路由之后所获取的真实表名称。仅分库则不需要表名称的改写。除此之外,还包括补列和分页信息修正等内容。
标志符改写
需要改写的标识符包括表名称、索引名称以及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;
补列
需要在查询语句中补列通常由两种情况导致。 第一种情况是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;
补列的另一种情况是使用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条数据为一页,取第2页数据。在分片环境下获取LIMIT 10, 10,归并之后再根据排序条件取出前10条数据是不正确的。
举例说明,若SQL为:
SELECT score FROM t_score ORDER BY score DESC LIMIT 1, 2;
正确的做法是将分页条件改写为LIMIT 0, 3,取出所有前两页数据,再结合排序条件计算出正确的数据。 下图展示了进行SQL改写之后的分页执行结果。
SELECT score FROM t_score ORDER BY score DESC LIMIT 0, 3;
越获取偏移量位置靠后数据,使用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);
可以进一步的提升查询性能。
优化改写
优化改写的目的是在不影响查询正确性的情况下,对性能进行提升的有效手段,分为单节点优化和流式归并优化。
单节点优化
路由至单节点的SQL,则无需优化改写。当获得一次查询的路由结果后,如果是路由至唯一的数据节点,则无需涉及到结果归并,因此补列和分页信息等改写都没有必要进行。
流式归并优化
仅为包含Group By的SQL增加Order By以及和分组项相同的排序项和排序顺序,用于将内存归并转化为流式归并。
执行引擎
ShardingJDBC采一套自动化的执行引擎,负责将路由和改写完成之后的真实SQL安全且高效的发送到底层数据源执行,更关注平衡数据源连接创建和内存占用所产生的消耗,以及最大限度的合理利用并发等问题。执行引擎的目标是自动化的平衡资源控制和执行效率。
连接模式
从资源控制角度看,业务方访问数据库的连接数量应该有所限制。 从执行效率看,为每个分片查询维持一个独立的数据库连接,可以更加有效的利用多线程提升执行效率,为每个数据库连接开启独立的线程,可以将IO产生的消耗并行处理,还能避免过早将查询结果加载至内存。独立的数据库连接,能够持有查询结果集游标位置的引用,在需要获取相应数据时移动游标即可。 以结果集游标下移进行结果归并的方式,称为流式归并,它无需将结果数据全数加载到内存,可以有效节省内存资源。当无法保证每个分片查询持有一个独立的数据库连接时,则需要在复用该数据库连接获取下一章分表的查询结果之前,将当前的查询结果全数加载到内存,流式归并将退化为内存归并。 一方面是对数据库连接资源的控制保护,一方面是采用更优的归并模式达到对中间件内存资源的节省,如何处理好两者之间的关系,是ShardingSphere执行引擎需要解决的问题。比如,一条SQL在经过分片后,需要操作数据库实例的200张表,那么是创建200个连接并行执行还是创建一个连接串行执行呢?针对这个问题,提出了连接模式的概念,将其划分为内存限制模式和连接限制模式。
内存限制模式
使用此模式的前提是,ShardingJDBC对一次操作所耗费的数据库连接数量不做限制,并且在SQL满足条件的情况下,优先流式归并。
连接限制模式
使用此模式的前提是,ShardingJDBC严格控制一次操作的数据库连接数量。如果是对一个数据库实例的200张表操作,那么只会创建唯一一个数据库连接。如果一次操作的分片落在不同数据库,那么会为每个数据库操作创建一个连接,执行内存归并。
自动化执行引擎
自动化执行引擎将连接模式的选择细化到每一次SQL操作,针对每一次SQL请求,自动化执行引擎将根据路由结果,进行实时演算和权衡,并采用恰当的连接模式执行。针对自动化的执行引擎,用户只需配置maxConnectionSizePerQuery,该参数表示一次查询每个数据库所允许使用的最大连接数。执行引擎分为准备和执行两个阶段
准备阶段
准备执行的数据,分为结果集分组和执行单元创建两个步骤。结果集分组是实现内化连接模式的关键。执行引擎根据maxConnectionSizePerQuery配置项,结合当前结果路由,选择恰当的连接模式,具体步骤如下:
- 将SQL的路由结果按照数据源分组
- 通过下图的公式,可以获得每个数据库实例在maxConnectionSizePerQuery的允许范围内,每个连接需要执行的SQL路由结果组,并计算本次请求的最优连接模式
执行阶段
该阶段为真正执行SQL,分为分组执行和归并结果集生成两个步骤。 分组执行将准备阶段生成的执行单元分组下发到底层并发执行引擎,并针对执行过程中的每个关键步骤发送事件。ShardingJDBC通过在执行准备节点的获取的连接模式,生成内存归并结果或流式归并结果集,并将其传递至结果归并引擎,以进行下一步的工作。
归并引擎
将各个数据节点获取的多数据结果集组合成一个结果集并正确的返回到客户端,称为结果归并。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 弹出队列,并且将其数据结果集游标指向的值返回至客户端,并下移游标,继续加入队列排队,以此类推。 当一个结果集中已经没有数据了,则无需再次加入队列。
可以看到,对于每个数据结果集的数据是有序的,而多个结果集整体无序的情况下,ShardingJDBC无需将所有数据加载至内存即可排序,使用流式归并的方式,每次next仅获取唯一正确的一条数据,极大的节省了内存消耗。
分组归并
分组归并的情况最为复杂,分为流式分组归并和内存分组归并。流式分组归并要求SQL的排序项与分组项的字段以及排序类型必须保持一致,否则只能通过内存归并才能保证数据的正确性。 举例:假设根据科目分片,表结构中包含考生的姓名(为了简单起见,不考虑重名的情况)和分数。通过 SQL 获取每位考生的总分,可通过如下SQL
SELECT name, SUM(score) FROM t_score GROUP BY name ORDER BY name;
在分组项与排序项一致的情况下,取的数据是连续的,分组所需的数据全数存在于各个数据结果集的当前游标所指向的数据值,因此可以采用流式归并
进行归并时,逻辑与排序归并类型,下图展示了进行next调用时,流式分组归并是如何进行的。
通过图中我们可以看到,当进行第一次 next 调用时,排在队列首位的 t_score_java 将会被弹出队列,并且将分组值同为 “Jerry” 的其他结果集中的数据一同弹出队列。 在获取了所有的姓名为 “Jetty” 的同学的分数之后,进行累加操作,那么,在第一次 next 调用结束后,取出的结果集是 “Jetty” 的分数总和。 与此同时,所有的数据结果集中的游标都将下移至数据值 “Jetty” 的下一个不同的数据值,并且根据数据结果集当前游标指向的值进行重排序。 因此,包含名字顺着第二位的 “John” 的相关数据结果集则排在的队列的前列。
流式分组归并和排序归并的区别仅仅在于两点:
- 1、它会一次性将多个数据结果集中的分组项相同的数据全数取出。
- 2、它需要根据聚合函数的类型进行聚合计算。 对于分组项和排序项不一致的情况,由于需要获取分组的相关数据值并非连续的,因此无法使用流式归并,需要将所有的结果集数据加载进内存进行分组和聚合。
聚合归并
无论是流式分组归并还是内存分组归并,对聚合函数的处理都是一致的,除了分组的SQL外,不进行分组的SQL也可以使用聚合函数。因此,聚合归并是在之前介绍的归并类上追加的归并能力,即装饰者模式。聚合函数可以归类为比较、累加和求平均值三种类型。 比较类型的聚合函数是指 MAX
和 MIN
。它们需要对每一个同组的结果集数据进行比较,并且直接返回其最大或最小值即可。
累加类型的聚合函数是指 SUM
和 COUNT
。它们需要将每一个同组的结果集数据进行累加。
求平均值的聚合函数只有 AVG
。它必须通过 SQL 改写的 SUM
和 COUNT
进行计算,相关内容已在 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;
ShardingJDBC与其他组件的对比
常见框架
网上常见的分库分表框架有:当当网ShardingJDBC,alibaba的cobar(是阿里巴巴B2B部门开发的)、MyCAT(基于阿里开源的Cobar产品而研发),蚂蚁金服的ZDAL,蘑菇街的TSharding。除了这些,还有很多各自公司提出的框架,但是根据用户量较高的为以上几种。自从出现了基于Cobar的MyCAT,zdal,也很少有人用Cobar了。ZDAL虽然也是开源,但是很少文章和使用反馈,不支持MongoDB,交流活跃度也比较低。
对比概览
主要指标 | Sharding-JDBC | Mycat | zdal |
---|---|---|---|
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没有限制
OLAP,OLTP
数据处理大致可以分成两大类:联机事务处理OLTP(on-line transaction processing)、联机分析处理OLAP(On-Line Analytical Processing)。
OLTP是传统的关系型数据库的主要应用,主要是基本的、日常的事务处理,例如银行交易。OLAP是数据仓库系统的主要应用,支持复杂的分析操作,侧重决策支持,并且提供直观易懂的查询结果。
OLTP 系统强调数据库内存效率,强调内存各种指标的命令率,强调绑定变量,强调并发操作;
OLAP 系统则强调数据分析,强调SQL执行市场,强调磁盘I/O,强调分区等。
OLTP与OLAP之间的比较:
聚合分组查询
不适合 OLAP 的场景。虽然 Sharding-JDBC 也能做聚合分组查询,但大量的 OLAP 场景,仍然会比较慢,而且复杂的 SQL(如子查询等)目前还没有支持。这种查询不太适合大数据和高并发的互联网 online 数据库,建议使用合理的 OLTP 查询
由于时间和精力有限,目前无法做到全 SQL 的兼容。我们现阶段的目标是尽量支持 OLTP 最常用的 80% 的 SQL。目前支持聚合、分组、排序等查询。暂时不支持 distinct,对 or 的支持也不是特别完善,但是 distinct 和 group by 可以互换,or 也可以用 in 代替。因此绝大部分 SQL 经过修改是可以使用的。
对于过于复杂的 SQL,如子查询等,不一定适合在大数据量的分片数据库中使用,也许需要重新梳理。
关联查询
切分之后数据分摊在不同的节点上,JOIN比较麻烦了,因此切分之后尽量避免JOIN。
- 全局表
这种很好理解,对于一些全局需要关联的表可以在每个数据节点上都存储一份,一般是一些数据字典表。
全局表在Sharding-JDBC称之为广播表
- 字段冗余
这是一种典型的反范式设计,为了避免关联JOIN,可以将一些冗余字段保存,比如订单表保存userId时,可以将userName也一并保存,这样就避免了和User表的关联JOIN了。
字段冗余这种方案存在数据一致性问题
- 数据组装
这种还是比较好理解的,直接不使用JOIN关联,分两次查询,从第一次的结果集中找出关联数据的唯一标识,然后再次去查询,最后对得到的数据进行组装
需要进行手动组装,数据很大的情况对CPU、内存有一定的要求
- 绑定表
对于相互关联的数据节点,通过分片规则将其切分到同一个库中,这样就可以直接使用SQL的JOIN 进行关联查询。
Sharding-JDBC中称之为绑定表,比如订单表和用户表的绑定
排序查询--分页、排序、函数
流式处理+归并排序
首先,Sharding-JDBC采用流式处理 + 归并排序的方式来避免内存的过量占用。Sharding-JDBC的SQL改写,不可避免的占用了额外的带宽,但并不会导致内存暴涨。 与直觉不同,大多数人认为Sharding-JDBC会将1000010*2记录全部加载至内存,进而占用大量内存而导致内存溢出。 但由于每个结果集的记录是有序的,因此Sharding-JDBC每次比较仅获取各个分片的当前结果集记录,驻留在内存中的记录仅为当前路由到的分片的结果集的当前游标指向而已。 对于本身即有序的待排序对象,归并排序的时间复杂度仅为O(n),性能损耗很小。
其次,Sharding-JDBC对仅落至单分片的查询进行进一步优化。落至单分片查询的请求并不需要改写SQL也可以保证记录的正确性,因此在此种情况下,Sharding-JDBC并未进行SQL改写,从而达到节省带宽的目的。
关键字筛选
limit这种分页方式,并非最佳实践,越获取偏移量位置靠后数据,使用LIMIT分页方式的效率就越低。
使用id进行分页是比较好的方案:
SELECT * FROM t_order WHERE id > 100000 AND id <= 100010 ORDER BY id;
或通过记录上次查询结果的最后一条记录的ID进行下一页的查询,例如:
SELECT * FROM t_order WHERE id > 10000000 LIMIT 10;
目前市面上分表下针对分页常见的集中解决方案
分表解决方案
解决方案 | skip<=100 | skip<10000 | skip>10000 | 优点 | 缺点 |
---|---|---|---|---|---|
内存分表 | 速度快O(n),n=skip*分表数 | 速度快O(n),n=skip*分表数,内存暴涨 | O(n),n=skip*分表数,内存爆炸,速度越来越慢 | 实现简单,支持分库 | skip过大内存暴涨 |
union all | 速度快 | 速度快 | 速度越来越慢 | 实现简单 | 不支持分库,不好优化,索引可能会失效 |
流式分表 | 速度快O(n),n=skip | 速度快O(n),n=skip | O(n),n=skip 速度越来越慢 | 支持分库 | 实现复杂,网络流量随着N增大 |
内存分页
顾名思义就是将各个表的结果集合并到内存中进行排序后分页
union all
使用的是数据库本身的聚合操作,用过匿名表来实现和操作当前表一样无感知
流式分表
和名字一样就是通过next来一次一次获取,和datareader类似只有在next后才可以获取到客户端
通过上面的简单对照我们可以清楚地发现,其实我们可以选择的基本上就内存分表和流式分表而已,又以为内存分表的限制其实最优解就是流式分表。
跨库事务
目前 Sharding-JDBC 的事务支持两种,一种是弱 XA,另一种是柔性事务(BASE)。
分布式事务这块,我们认为 XA 多阶段提交的方式,虽然对分布式数据的完整性有比较好的保障,但会极大的降影响应用性能,并未考虑采用。我们采用的是两种方式,一种称之为弱 XA,另一种是柔性事务,即 BASE。
弱 XA 就是分库之后的数据库各自负责自己事务的提交和回滚,没有统一的调度器集中处理。这样做的好处是天然就支持,对性能也没有影响。但一旦出问题,比如两个库的数据都需要提交,一个提交成功,另一个提交时断网导致失败,则会发生数据不一致的问题,而且这种数据不一致是永久存在的。
XA事务
X/Open了定义了规范和API接口,由这个厂商进行具体的实现,这个标准提出了使用二阶段提交(2PC –Two-Phase-Commit)来保证分布式事务的完整性。后来J2EE也遵循了X/OpenDTP规范,设计并实现了java里的分布式事务编程接口规范-JTA,如下图,表示一个X/Open DTP模型。
X/Open DTP模型定义了三个角色和两个协议,其中三个角色分别如下:
- AP(Application Program),表示应用程序,也可以理解成使用DTP模型的程序
- RM(Resource Manager),资源管理器,这个资源可以是数据库, 应用程序通过资源管理器对资源进行控制,资源管理器必须实现XA定义的接口
- TM(Transaction Manager),表示事务管理器,负责协调和管理全局事务,事务管理器控制整个全局事务,管理事务的生命周期,并且协调资源。
两个协议分别是:
-
XA协议: XA 是X/Open DTP定义的资源管理器和事务管理器之间的接口规范,TM用它来通知和协调相关RM事务的开始、结束、提交或回滚。目前Oracle、Mysql、DB2都提供了对XA的支持; XA接口是双向的系统接口,在事务管理器(TM ) 以及多个资源管理器之间形成通信的桥梁(XA不能自动 提交)XA协议的语法,主流的数据库都支持 XA协议,从而能够实现跨数据库事务。
-
TX协议: 全局事务管理器与资源管理器之间通信的接口。在分布式系统中,每一个机器节点虽然都能够明确知道自己在进行事务操作过程中的结果是成功还是失败,但却无法直接获取到其他分布式节点的操作结果。因此当一个事务操作需要跨越多个分布式节点的时候,为了保持事务处理的ACID特性,就需要引入一个“协调者”(TM)来统一调度所有分布式节点的执行逻辑,这些被调度的分布式节点被称为AP。TM负责调度AP的行为,并最终决定这些AP是否要把事务真正进行提交到(RM)
XA二阶段提交
在X/OpenDTP模型中,一个分布式事务所涉及的SQL逻辑都执行完成,并到了(RM)要最后提交事务的关键时刻,为了避免分布式系统所固有的不可靠性导致提交事务意外失败,TM 果断决定实施两步走的方案,这个就称为二阶提交。
二阶段提交,是计算机网络尤其是在数据库领域内,为了使基于分布式系统架构下的所有节点在进行事务处理过程中能够保持原子性和一致性而设计的一种算法。通常,二阶段提交协议也被认为是一种一致性协议,用来保证分布式系统数据的一致性。目前,绝大部分的关系型数据库都是采用二阶段提交协议来完成分布式事务处理的,利用该协议能够非常方便地完成所有分布式事务AP的协调,统一决定事务的提交或回滚,从而能够有效保证分布式数据一致性,因此2pc也被广泛运用在许多分布式系统中。
-- 启动一个XA事务 (xid 必须是一个唯一值; [JOIN|RESUME] 字句不被支持)
mysql> XA START 'xatest';
Query OK, 0 rows affected (0.00 sec)
mysql> INSERT INTO mytable (i) VALUES(10);
Query OK, 1 row affected (0.04 sec)
-- 结束一个XA事务 ( [SUSPEND [FOR MIGRATE]] 字句不被支持)
mysql> XA END 'xatest';
Query OK, 0 rows affected (0.00 sec)
-- 准备 此动作会把这个事务的redo日志写入innodb redo log,只要这一阶段是成功的,那么后续 XACommit一定会成功
mysql> XA PREPARE 'xatest';
Query OK, 0 rows affected (0.00 sec)
--提交XA事务
mysql> XA COMMIT 'xatest';
Query OK, 0 rows affected (0.00 sec)
XA事务存在的问题:
基于XA协议的全局事务,是属于强一致性事务,因为在全局事务中,只要有任何一个RM出现异常,都会导致全局事务回滚。同时,本地事务在Prepare阶段锁定资源时,如果有其他事务要要修改相同的数据,就必须要等待前面的事务完成,这本身是无可厚非的设计,但是由于多个RM节点是跨网络,一旦出现网络延迟,就导致该事务一直占用资源使得整体性能下降。
Sharding-JDBC 是否支持读写分离?
Sharding-JDBC 从 1.3.0 开始支持读写分离。其功能包括:
- 根据配置区分写库和多个读库,目前暂时只有轮训策略选取读库,可以配合分库分表使用。
- 通过 Hint 强制指定某次查询走写库。
- 如果在同一线程且同一数据库连接中有发现 DML 语句,则该 DML 之后的查询都从写库查询,DML 之前的 DQL 语句不受影响,仍然查询读库。其目的是保持同一用户线程的数据一致性。
但限于 Sharding-JDBC 本身设计的考虑,数据库层面的主从切换以及主从数据同步,Sharding-JDBC 并不负责。Sharding-JDBC 定位仍然是轻量级的增强版数据库驱动。因此由于主库和从库同步延迟导致的数据不一致,并不是 Sharding-JDBC 的处理范畴。
另外由于 Sharding-JDBC 本身是分库分表中间件,读写分离也是后加入的功能,因此可以支持分库分表+读写分离,但是仅读写分离目前还不容易配置,未来也会将读写分离提炼出来作为独立的 API 使用。
分库分表使用 like 查询,是否能查询出来?性能如何?会去查询所有的库和表吗?
- 分库分表使用 like 查询是有限制的。目前 Shariding-JDBC 不支持 like 语句中包含分片键,但不包含分片键的 like 语句可以正确执行。
- 至于 like 性能问题,是与数据库相关的,Shariding-JDBC 仅仅是解析 SQL 以及路由至正确的数据源而已。
- 是否会查询所有的库和表是根据分片键决定的,如果 SQL 中不包括分片键,就会查询所有库和表,这个和是否有 like 没有关系。
分库分表的中间件有两种方案选取
- 类似 Sharding-JDBC 及 TDDL 的增强版 JDBC 驱动思想
- 类似 Mycat 增加中间层,然后在中间层进行分库分表思想
JDBC 驱动版的优点:
- 轻量,范围更加容易界定,只是 JDBC 增强,不包括 HA、事务以及数据库元数据管理
- 开发的工作量较小,无需关注 nio,各个数据库协议等
- 运维无需改动,无需关注中间件本身的 HA
- 性能高,JDBC 直连数据库,无需二次转发
- 可支持各种基于 JDBC 协议的数据库,如:MySQL,Oralce,SQLServer
Proxy 版的优点:
- 可以负责更多的内容,将数据迁移,分布式事务等纳入 Proxy 的范畴
- 更有效的管理数据库的连接
- 整合大数据思路,将 OLTP 和 OLAP 分离处理
因此两种方式互相可以互补,建议使用 Java 的团队,且仅 OLTP 的互联网前端操作。有可能会使用多种数据库的情况,可以选择 JDBC 层的中间件;如果需要 OLAP 和 OLTP 混合,加以重量级的操作,如数据迁移,分布式事务等,可以考虑 Proxy 层的中间件。但目前开源的数据迁移和分布式事务的完善解决方案还不常见。
参考:
深度认识 Sharding-JDBC:做最轻量级的数据库中间层
Java猿社区—ShardingSphere之广播表与绑定表
Amazon Aurora分库分表架构下集成Sharding-JDBC实现分布式事务
一文快速入门分库分表中间件 Sharding-JDBC (必修课)
标签:归并,JDBC,收集,分片,id,SQL,Sharding,order From: https://www.cnblogs.com/hongdada/p/16975044.html