前言
工作多年,分库分表的场景也见到不少了,但是我仍然对其原理一知半解。趁着放假前时间比较富裕,我想要解答三个问题:
-
为什么mybatis的mapper.xml文件里的sql不需要拼接表名中的分表?
-
mybatis是如何识别分表位的?
-
最近工作中遇到的问题:为什么我的三表join会报错找不到表?为了不影响项目上线时间,我不得不在mapper.xml中用${}拼接其中的一个逻辑表对应的物理表表名,引入了SQL注入的风险。
带着问题,我花了不少时间深入了读了一下这部分的源码,终于搞清楚了,借本文分享一下。
本文主要环境是mybatis-plus-boot-starter 3.4.3,不过用的基本上仍然是mybatis的特性。
流程图
以查询为例,可以先看下流程图,大致了解一下整个过程。
关键的类和对象
在流程图里出现了一些类和其实例化的对象,有必要选其中关键的介绍一下。
MappedStatement
类全名org.apache.ibatis.mapping.MappedStatement,是一个final类。**不要被名字误导,它和JDBC API中的java.sql.Statement没有实现关系。**后者用于执行一条静态SQL并返回结果。
MappedStatement用来维护一个mapper中一个方法(对应一个sql)相关的信息,也就是将xml中的sql实例化成一个对象:
生成时机
使用sql的id通过MybatisConfiguration/Configuration获取。后者内部的Map(mappedStatements)会持有所有的mapper中的语句。
BoundSql
类全名:org.apache.ibatis.mapping.BoundSql
主要用于存储 SQL 语句以及该 SQL 语句所需的参数等信息。
如下图中:
-
sql字段是经过处理的sql
-
已经将
${}
直接替换为实际值,这也就会导致注入风险 -
#{}
则使用?
占位
-
-
parameterMappings记录参数的映射方法
-
parameterObject实际的参数值,对应的是java的mapper接口类里的参数。比如接口是9个参数,这里就是18个,其中paramxx是原参数全部用新key存储但是值没变的。这些参数不一定是sql里的
?
占位符所用到的,可能会多一些。
生成时机
Executor通过MapperStatement生成。
Connection
全名:java.sql.Connection,是JDBC API的一个核心接口。它的功能是:
-
建立与数据库的连接
-
创建执行对象,用于执行SQL语句的对象,也就是各种各样的Statement
-
管理事务,包括开启、提交、回滚。本文以查询为例,探讨分表的路由的原理,因此不会对事务相关话题做展开。
应用配置的是分库分表数据源,对应地,实例化的Connection对象是ShardingSphereConnection,如下:
展开dataSourceMap的value,可以看到更多数据源的配置,包括连接超时时间、jdbcUrl,db的用户名和明文的密码。如果运行容器是Springboot,那么这些配置可以在application.properties里看到。
Statement/ShardingSpherePreparedStatement
Statement接口在jdbc中的地位也很重要,它用于执行一条静态SQL并返回结果。
在分库分表的场景,它会生成各种中间过程的上下文Context,比如ExecutionContext、TrafficContext、RouteContext等,流程图中的LogicSQL也可以看作是一种上下文。Logic的sqlStatementContext把原始SQL进行了结构化的解析,比如from、where、group by等。在from字段可以进行多级的join嵌套,比如下图join的left是另一个join,right是一个表(逻辑表):
对于LogicSQL的where属性,其中的参数会用于参与库表的分片计算。
Statement也会借助其他的工具类,如SQLCheckEngine、KernelProcessor做处理,其中最关键的一点是,在KernelProcessor中生成ExecutionContext的方法内,生成RouteContext时获取物理表名:
Java
public ExecutionContext generateExecutionContext(final LogicSQL logicSQL, final ShardingSphereMetaData metaData, final ConfigurationProperties props) {
RouteContext routeContext = route(logicSQL, metaData, props);
SQLRewriteResult rewriteResult = rewrite(logicSQL, metaData, props, routeContext);
ExecutionContext result = createExecutionContext(logicSQL, metaData, routeContext, rewriteResult);
logSQL(logicSQL, props, result);
return result;
}
上面的route()中,也包含了很多步:
- WhereClauseShardingConditionEngine从Logic SQL的from属性获取分表相关的属性和值的代码如下
Java
public List<ShardingCondition> createShardingConditions(final SQLStatementContext<?> sqlStatementContext, final List<Object> parameters) {
if (!(sqlStatementContext instanceof WhereAvailable)) {
return Collections.emptyList();
}
List<ShardingCondition> result = new ArrayList<>();
for (WhereSegment each : ((WhereAvailable) sqlStatementContext).getWhereSegments()) {
result.addAll(createShardingConditions(sqlStatementContext, each.getExpr(), parameters));
}
return result;
}
- 构造condition后,ShardingRouteEngineFactory会把from中和逻辑表名不一致的表剔除掉,这个怎么理解呢?比如SQL里一共涉及3张表table_a、table_b、table_c,其中table_c其中table_c写作
table_c_${}
,直接通过${}
把分表名拼接好了,变成table_c_001。table_c_001这个表名是在分表规则里找不到的,因此也不会应用任何分表规则。
Java
private static ShardingRouteEngine getDQLRoutingEngine(final ShardingRule shardingRule, final ShardingSphereSchema schema, final SQLStatementContext<?> sqlStatementContext,
final ShardingConditions shardingConditions, final ConfigurationProperties props) {
Collection<String> tableNames = sqlStatementContext.getTablesContext().getTableNames();
if (shardingRule.isAllBroadcastTables(tableNames)) {
return sqlStatementContext.getSqlStatement() instanceof SelectStatement ? new ShardingUnicastRoutingEngine(tableNames) : new ShardingDatabaseBroadcastRoutingEngine();
}
if (sqlStatementContext.getSqlStatement() instanceof DMLStatement && shardingConditions.isAlwaysFalse() || tableNames.isEmpty()) {
return new ShardingUnicastRoutingEngine(tableNames);
}
// from子句里的表名,如果不是逻辑表名,不会按照分表处理
Collection<String> shardingLogicTableNames = shardingRule.getShardingLogicTableNames(tableNames);
if (shardingLogicTableNames.isEmpty()) {
return new ShardingIgnoreRoutingEngine();
}
return getDQLRouteEngineForShardingTable(shardingRule, schema, sqlStatementContext, shardingConditions, props, shardingLogicTableNames);
}
-
继续看下去,getDQLRouteEngineForShardingTable如果通过bindingTableRule出要处理的表分片规则一致,那就直接返回,不重复处理。
-
RouteSQLRewriteEngine将SQL里的逻辑表改写为物理表,调用栈如下:
用于改写的代码:
Java
public final String toSQL() {
if (context.getSqlTokens().isEmpty()) {
return context.getSql();
}
Collections.sort(context.getSqlTokens());
StringBuilder result = new StringBuilder();
result.append(context.getSql(), 0, context.getSqlTokens().get(0).getStartIndex());
for (SQLToken each : context.getSqlTokens()) {
result.append(each instanceof ComposableSQLToken ? getComposableSQLTokenText((ComposableSQLToken) each) : getSQLTokenText(each));
result.append(getConjunctionText(each));
}
return result.toString();
}
ShardingRule
用于存放分表和单表的规则,被Statement使用。为了便于叙述,举例如下:
-
一共三个数据源:不分表的ds-master、包括分表的ds0、包括分表的ds1
-
查询的逻辑表名是c_voucher,对应地分表是c_voucher_{companyId}_{subYear},也就是通过companyId和subYear两个参数确定实际的分表。
-
实际使用时有很多分片规则可以采用,比如按userId第几位路由到第几张表、某个字段取哈希值再取模路由。但是由于目前手上的项目找不到这种例子,不在此处剖析。处理方式是类似的,读者可以自行探索。
与application.properties对应关系(部分)
属性 | ShardingRule中的属性名 | application.properties配置 | 备注 |
数据源名称 | dataSourceNames | 属性前缀的一部分,比如 ["ds-0","ds-1"]对应 spring.shardingsphere.datasource.ds-0.xxx=yyy spring.shardingsphere.datasource.ds-1.xxx=zzz | |
分片算法 | shardingAlgorithms (Map) | 既有表的也有库的。 对于表的: spring.shardingsphere.rules.sharding.sharding-algorithms.ts-c-voucher.type=COMPLEX_INLINE spring.shardingsphere.rules.sharding.sharding-algorithms.ts-c-voucher.props.algorithm-expression=c_voucher_-\>{companyId}_->{subYear} 对于库的: spring.shardingsphere.rules.sharding.sharding-algorithms.t-database-inline.type=INLINE spring.shardingsphere.rules.sharding.sharding-algorithms.t-database-inline.props.algorithm-expression=ds-$-> | type定义具体分片算法类型,此处是COMPLEX_INLINE,支持比INLINE更复杂的表达式计算。 可以看出表和库的属性规则不太一样,表是ts-xx,库是t-xx |
全局唯一键生成算法 | keyGenerators (本例中为null) | 本例不涉及 | 雪花算法、UUID等 |
表规则 | tableRules (Map) | 见下一节 | |
绑定表规则 | bindingTableRules | 比如有a~i一共9张表,每3张的分片规则一样 spring.shardingsphere.rules.sharding.binding-tables[0]=a,b,c spring.shardingsphere.rules.sharding.binding-tables[1]=d,e,f spring.shardingsphere.rules.sharding.binding-tables[2]=g,h,i | 将具有关联关系(会进行join)且按照同样的分片规则的表绑定到同一个表规则中,避免笛卡尔积运算 |
其它 | 本例不涉及,略 |
TableRule和application.properties的对应关系
ShardingRule包含了一个TableRule的map,包含了具体表分片的配置。这里单拎出来分析。
属性 | TableRule的属性名 | application.properties配置 | 备注 |
逻辑表名 | logicTable | - | |
实际的数据节点 | actualDataNodes | spring.shardingsphere.rules.sharding.tables.c_vouching_result.actual-data-nodes=ds--\>{0..1}.c_voucher_->{1..2}_$-> | 并不是真实的表名,此处对象中是: ds-0.c_voucher_1_1 ds-0.c_voucher_2_2 ds-0.c_voucher_2_1 ds-0.c_voucher_2_2 ds-1.c_voucher_1_1 ds-1.c_voucher_2_2 ds-1.c_voucher_2_1 ds-1.c_voucher_2_2 |
实际的表 | actualTables | 同上 | 并不是真实的表名,和actualDataNodes类似 |
数据节点的索引map | dataNodeIndexMap | 同上 | 给actualDataNodes按顺序分配一个序号,本例0~7 |
表所在库的分片规则 | databaseShardingStrategyConfig | spring.shardingsphere.rules.sharding.tables.c_voucher.database-strategy.standard.sharding-column=schemaId spring.shardingsphere.rules.sharding.tables.c_voucher.database-strategy.standard.sharding-algorithm-name=t-database-inline | |
表的分片规则 | tableShardingStrategyConfig | spring.shardingsphere.rules.sharding.tables.c_voucher.table-strategy.complex.sharding-columns=companyId,subYear spring.shardingsphere.rules.sharding.tables.c_voucher.table-strategy.complex.sharding-algorithm-name=ts-c-vouching-result | 分片的列、分片算法名称,对应的name是shardingAlgorithms中出现过的拼上前缀的表名 |
生成主键的列 | generateKeyColumn | - | 不涉及,略 |
主键生成器名称 | keyGeneratorName | - | 不涉及,略 |
实际数据源名称 | actualDatasourceNames | - | ds-0,ds-1 |
数据源对应的分表 | datasourceToTablesMap | - | 分表名同actualDataNodes |
实现分库分表路由的关键步骤
根据流程图和上面的类,可以总结如下:
-
进行数据源的配置,包括库和表的分片规则(算法+来源列),以确保应用启动时组装的Connection包含这些路由信息,从而传递下去。
-
执行SQL时,组装的Statement根据逻辑表名找到对应的库表分片规则,从而推算出实际的表名:
-
生成LogicSQL,将SQL结构化
-
解析LogicSQL的from子句,获取分表相关的参数,并组装逻辑表到物理表的映射RouteUnit(同一个bindingTableRules的表只组装一个)
-
KernelProcessor将LogicSQL里需要替换的表名按RouteUnit改写成逻辑表名。
-
问题解答
前言的三个问题
前言中的前两个问题很好解答,通过上面的分析,可以知道原始sql里的逻辑表是怎么转换成物理表的,分片规则是如何和相关的参数共同发挥作用的。
对于第三个问题,我把之前项目中无法找到table_c的第一版三表join查询SQL简化如下:
SQL
SELECT
a.biz_date,
a.type_id,
a.voucher_id,
a.create_time,
a.update_time
FROM
table_a a
INNER JOIN table_c c
ON a.biz_date = c.biz_date
AND a.type_id = c.type_id
AND a.voucher_id = c.voucher_id
LEFT JOIN table_b b
ON a.biz_date = b.biz_date
AND a.type_id = b.type_id
AND a.voucher_id = b.voucher_id
AND a.schema_id = b.schema_id
AND a.sub_year = b.sub_year
AND a.company_id = b.company_id
where a.company_id = #{company_id}
AND a.schema_id = #{schema_id}
AND a.sub_year = #{sub_year}
其中table_a、table_b的分表位是company_id和sub_year,分片规则完全一样;table_c的是accPackageId,分表规则和前两种不一同。
由于第一版运行时会报错,当时没有时间确定具体原因,为了不影响项目进度,我临时将table_c改写成了table_c_${accPackageId}。它在executor里会替换成对应的物理表名,并且也不会找对应的分表规则。项目上线了,能用但是有注入风险。
经过上面的探讨,初版SQL找不到table_c逻辑表的原因其实很简单了:table_c的分表位没有作为一个参数出现在SQL里(尽管它在mapper.java的方法入参里出现)。那么在这个SQL的where中加一行,并把table_c_${accPackageId}
改回table_c
,问题解决:
SQL
and c.accPackageId = #{accPackageId}
引申问题:如果分表不在同一个分库?
这种情况通过现有项目对应应用验证有点麻烦:库的分片规则所有分表是一致的,这个字段所有分表的逻辑表都有。如果想改,需要改分片规则。
因此我换了一种方式测试:在Navicat中执行等效语句语句,没有报错,且能查到插入的数据:
SQL
SELECT
a.biz_date,
a.type_id,
a.voucher_id,
a.update_time
FROM
dev_account_0.table_a_1722_2024 a
INNER JOIN dev_account_1.table_c_757 c ON a.biz_date= c.biz_date
AND a.type_id= c.type_id
AND a.voucher_id= c.voucher_id
LEFT JOIN dev_account_0.table_b_1722_2024 b ON a.biz_date= b.biz_date
AND a.type_id= b.type_id
AND a.voucher_id= b.voucher_id
AND a.schema_id = b.schema_id
AND a.sub_year = b.sub_year
AND a.company_id = b.company_id
WHERE
a.company_id = 1722
AND a.schema_id = 0
AND a.sub_year= 2024
AND c.dev_account_0= 757
那么,既然Mybatis能提供类似的分库分表映射,理论上也是可以达到同样效果的。
当然,我个人并不推荐这样做,因为这种查询有限制:必须对这些分片都有权限。
并且,我观察到,如果table_c没有符合要求的数据,不在同一个库的查询会比在同一个库要慢的非常多,到了极其夸张的程度------分别是50s和0.2s。
源码阅读感想
-
阅读源码时,最好一边debug一边整理流程图一边去理解。静态地看源码,理解难度很高,原因是:
-
使用了大量的反射和代理,debug时很容易陷在其中,读着读着就不知道读到哪里去了。代理类,有一部分是框架中的,也有一部分是日志相关的。
-
很多对象都是实例化的子类,只有在debug时才能看到实际的子类是什么。这些子类的继承实现关系也很复杂。
-
有一些对象,同时持有了中间数据和中间数据和处理过的数据,如果只盯着中间数据,就不知道到底发生了什么。比如executionContext,它所持有的logicSQL里的SQL文本是不会变的,但是经过一系列路由处理和rewrite后逻辑表替换成物理表的会放在executionContext另一个属性的executionUnits里,并用以执行,有点隐蔽。,并用以执行,有点隐蔽。
-
-
我以前是很反对用lombok的。但是在此之后又过了几年,因为接手老代码的原因工作中不得不用,只能慢慢的接受。这次看到分片算法里有些类也在用(比如org.apache.shardingsphere.sharding.rule.TableRule),不禁哑然失笑。