English 简体中文 繁體中文 한국 사람 日本語 Deutsch русский بالعربية TÜRKÇE português คนไทย french
查看: 9|回复: 0

深入理解Mybatis分库分表执行原理

[复制链接]
查看: 9|回复: 0

深入理解Mybatis分库分表执行原理

[复制链接]
查看: 9|回复: 0

355

主题

0

回帖

1067

积分

金牌会员

积分
1067
pda设备

355

主题

0

回帖

1067

积分

金牌会员

积分
1067
2025-2-5 13:41:41 | 显示全部楼层 |阅读模式
前言

工作多年,分库分表的场景也见到不少了,但是我仍然对其原理一知半解。趁着放假前时间比较富裕,我想要解答三个问题:

  • 为什么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),不禁哑然失笑。
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

355

主题

0

回帖

1067

积分

金牌会员

积分
1067

QQ|智能设备 | 粤ICP备2024353841号-1

GMT+8, 2025-3-10 15:23 , Processed in 1.304044 second(s), 30 queries .

Powered by 智能设备

©2025

|网站地图