|
前言
工作多年,分库分表的场景也见到不少了,但是我仍然对其原理一知半解。趁着放假前时间比较富裕,我想要解答三个问题:
- 为什么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实例化成一个对象:
[attach]https://dcn4mpcv8fl1.feishu.cn/space/api/box/stream/download/asynccode/?code=MjVlNWIxNTk4ZDU1MTc1ZGFkODI0NjkwZDA1YzZhMGFfVkZUdU95WW41bGNXUXFoV0ZUMkFieDJpN242d05pYjRfVG9rZW46Vzh2RGJNTkhab1FGMWZ4WkozR2NyM1Zpbm5nXzE3Mzc5MDA4MTU6MTczNzkwNDQxNV9WNA[/attach]
生成时机
使用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里的?占位符所用到的,可能会多一些。
[attach]https://dcn4mpcv8fl1.feishu.cn/space/api/box/stream/download/asynccode/?code=M2E5ZjcxNWMwNzg0ZGJkODc3MmRmZWNlMjFlNzE0NDdfZnFpNHBnOEpRc2l5ZGJTaWkzNXdQQTlSQW5veFpHcFZfVG9rZW46T2EzdWIxRHEyb280cGt4emFrWmMwU01JbkFnXzE3Mzc5MDA4MTU6MTczNzkwNDQxNV9WNA[/attach]
生成时机
Executor通过MapperStatement生成。
Connection
全名:java.sql.Connection,是JDBC API的一个核心接口。它的功能是:
- 建立与数据库的连接
- 创建执行对象,用于执行SQL语句的对象,也就是各种各样的Statement
- 管理事务,包括开启、提交、回滚。本文以查询为例,探讨分表的路由的原理,因此不会对事务相关话题做展开。
应用配置的是分库分表数据源,对应地,实例化的Connection对象是ShardingSphereConnection,如下:
[attach]https://dcn4mpcv8fl1.feishu.cn/space/api/box/stream/download/asynccode/?code=YTAzYzUxNjdhZGM3NzVjNWZhYTVmYmIzZDA2MDQ4MTBfVWdRakVlME4yREV2MmFFb2N1N0UwVnlzc0JpcnNrZjdfVG9rZW46SmpORWJQRU5ab1ZTemJ4djd4NmNtTENObkdoXzE3Mzc5MDA4MTU6MTczNzkwNDQxNV9WNA[/attach]
展开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是一个表(逻辑表):
[attach]https://dcn4mpcv8fl1.feishu.cn/space/api/box/stream/download/asynccode/?code=ZWQ0YTE4ZTU0ZTRlNjgzYWVmZjgxMDkyYjYwYmQ2MWJfYnQxUnJrZDF0TXFHbzZDRVJxUERBYWRsM0VRYXduVktfVG9rZW46U3V0SWJzeFZEb0hRYTl4T3NJb2NBbEJMbjdKXzE3Mzc5MDA4MTU6MTczNzkwNDQxNV9WNA[/attach]
对于LogicSQL的where属性,其中的参数会用于参与库表的分片计算。
Statement也会借助其他的工具类,如SQLCheckEngine、KernelProcessor做处理,其中最关键的一点是,在KernelProcessor中生成ExecutionContext的方法内,生成RouteContext时获取物理表名:
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属性获取分表相关的属性和值的代码如下
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这个表名是在分表规则里找不到的,因此也不会应用任何分表规则。
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里的逻辑表改写为物理表,调用栈如下:
[attach]https://dcn4mpcv8fl1.feishu.cn/space/api/box/stream/download/asynccode/?code=YTIwN2M2NGNmMzc5YzkyYWRhMmQ5NzkwMGQyYmFhZWZfUHNMMWpIN3BrVnNQaVBJaWRaRlZ6VkNObEZmTGFJZFVfVG9rZW46U0dxNGJPWWlobzd1a3l4SW83ZmNZdk9kblZiXzE3Mzc5MDA4MTU6MTczNzkwNDQxNV9WNA[/attach]
用于改写的代码:
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-实际的数据节点actualDataNodesspring.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类似数据节点的索引mapdataNodeIndexMap同上给actualDataNodes按顺序分配一个序号,本例0~7表所在库的分片规则databaseShardingStrategyConfigspring.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表的分片规则tableShardingStrategyConfigspring.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简化如下:
SELECTa.biz_date,a.type_id,a.voucher_id,a.create_time,a.update_timeFROMtable_a aINNER JOIN table_c cON a.biz_date = c.biz_dateAND a.type_id = c.type_idAND a.voucher_id = c.voucher_idLEFT JOIN table_b bON a.biz_date = b.biz_dateAND a.type_id = b.type_idAND a.voucher_id = b.voucher_idAND a.schema_id = b.schema_idAND a.sub_year = b.sub_yearAND a.company_id = b.company_idwhere 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,问题解决:
and c.accPackageId = #{accPackageId}引申问题:如果分表不在同一个分库?
这种情况通过现有项目对应应用验证有点麻烦:库的分片规则所有分表是一致的,这个字段所有分表的逻辑表都有。如果想改,需要改分片规则。
因此我换了一种方式测试:在Navicat中执行等效语句语句,没有报错,且能查到插入的数据:
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),不禁哑然失笑。
|
|