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

news/2025/1/30 3:43:17/文章来源:https://www.cnblogs.com/wuyuegb2312/p/18685884

前言

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

  1. 为什么mybatis的mapper.xml文件里的sql不需要拼接表名中的分表?

  2. mybatis是如何识别分表位的?

  3. 最近工作中遇到的问题:为什么我的三表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时获取物理表名:

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里的逻辑表改写为物理表,调用栈如下:

用于改写的代码:

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

实现分库分表路由的关键步骤

根据流程图和上面的类,可以总结如下:

  1. 进行数据源的配置,包括库和表的分片规则(算法+来源列),以确保应用启动时组装的Connection包含这些路由信息,从而传递下去。

  2. 执行SQL时,组装的Statement根据逻辑表名找到对应的库表分片规则,从而推算出实际的表名:

    1. 生成LogicSQL,将SQL结构化

    2. 解析LogicSQL的from子句,获取分表相关的参数,并组装逻辑表到物理表的映射RouteUnit(同一个bindingTableRules的表只组装一个)

    3. KernelProcessor将LogicSQL里需要替换的表名按RouteUnit改写成逻辑表名。

问题解答

前言的三个问题

前言中的前两个问题很好解答,通过上面的分析,可以知道原始sql里的逻辑表是怎么转换成物理表的,分片规则是如何和相关的参数共同发挥作用的。

对于第三个问题,我把之前项目中无法找到table_c的第一版三表join查询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,问题解决:

and c.accPackageId = #{accPackageId}

引申问题:如果分表不在同一个分库?

这种情况通过现有项目对应应用验证有点麻烦:库的分片规则所有分表是一致的,这个字段所有分表的逻辑表都有。如果想改,需要改分片规则。

因此我换了一种方式测试:在Navicat中执行等效语句语句,没有报错,且能查到插入的数据:

SELECTa.biz_date,a.type_id,a.voucher_id,a.update_time 
FROMdev_account_0.table_a_1722_2024 aINNER JOIN dev_account_1.table_c_757 c ON a.biz_date= c.biz_dateAND a.type_id= c.type_idAND a.voucher_id= c.voucher_idLEFT JOIN dev_account_0.table_b_1722_2024 b ON a.biz_date= b.biz_dateAND a.type_id= b.type_idAND a.voucher_id= b.voucher_idAND a.schema_id = b.schema_id AND a.sub_year = b.sub_year AND a.company_id = b.company_id 
WHEREa.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。

源码阅读感想

  1. 阅读源码时,最好一边debug一边整理流程图一边去理解。静态地看源码,理解难度很高,原因是:

    1. 使用了大量的反射和代理,debug时很容易陷在其中,读着读着就不知道读到哪里去了。代理类,有一部分是框架中的,也有一部分是日志相关的。

    2. 很多对象都是实例化的子类,只有在debug时才能看到实际的子类是什么。这些子类的继承实现关系也很复杂。

    3. 有一些对象,同时持有了中间数据和中间数据和处理过的数据,如果只盯着中间数据,就不知道到底发生了什么。比如executionContext,它所持有的logicSQL里的SQL文本是不会变的,但是经过一系列路由处理和rewrite后逻辑表替换成物理表的会放在executionContext另一个属性的executionUnits里,并用以执行,有点隐蔽。,并用以执行,有点隐蔽。

  2. 我以前是很反对用lombok的。但是在此之后又过了几年,因为接手老代码的原因工作中不得不用,只能慢慢的接受。这次看到分片算法里有些类也在用(比如org.apache.shardingsphere.sharding.rule.TableRule),不禁哑然失笑。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/876116.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

dell r730xd安装操作系统时遇到的常见问题。

出现这个问题时,需要明确你的系统是已经安装完成并且没有问题的,那么就开机时按F11进入选择引导程序,然后选择指定硬盘的系统进行启动接口。 其他待补充。。。。复制请注明出处,在世界中挣扎的灰太狼

三. Redis 基本指令(Redis 快速入门-03)

三. Redis 基本指令(Redis 快速入门-03) @目录三. Redis 基本指令(Redis 快速入门-03)1. Redis 基础操作:2. 对 key(键)操作:3. 对 DB(数据库)操作4. 最后: Reids 指定大全(指令文档): https://www.redis.net.cn/order/Redis 命令十分丰富,包括的命令组有 Cluster、Conne…

在Lazarus下的Free Pascal编程教程——应用程序配置数据的管理与使用

0.前言 我想通过编写一个完整的游戏程序方式引导读者体验程序设计的全过程。我将采用多种方式编写具有相同效果的应用程序,并通过不同方式形成的代码和实现方法的对比来理解程序开发更深层的知识。了解我编写教程的思路,请参阅体现我最初想法的那篇文章中的“1.编程计划”和“…

算法学习笔记:扫描线

前言 之前没什么理解,一做就废,最近集训讲了这个,感觉认识深刻了很多,遂写笔记。 这里讲的扫描线,更精确来说指的是离线二维数点,即用扫描线维护一维,DS 维护另一维。 概念 我们把二维数点放到平面上来,那么一个询问或限制就对应平面上的一个矩形,定义这个矩形的 \(\t…

干掉visio,这个画图神器真的绝了!!!

前言 看过我以往文章的小伙伴可能会发现,我的大部分文章都有很多配图。我的文章风格是图文相结合,更便于大家理解。 最近有很多小伙伴发私信问我:文章中的图是用什么工具画的。他们觉得我画的图风格挺小清新的,能够让人眼前一亮。 先上几张图让大家看看效果:说实话,问我的…

面试题|线程池里有几个线程在运行

本文主要改编自https://www.sohu.com/a/391767502_355142。下面从一道面试题引入本文主题~~ 面试官:"假设有一个线程池,核心线程数为10,最大线程数为20,任务队列长度为100。如果现在来了100个任务,那么线程池里有几个线程在运行?" 粉丝豪:"应该是10吧!&…

开关电源1

EMI(参考链接)从左到右分别是安全电容(X电容),共模电感和安全电容(Y电容),黑色线是火线(L),白色线是零线(N),绿色线是地线(G) 大黄块:安全电容X电容,接在火线和零线之间,用于抑制差模干扰 红白相间线圈:共模电感(4个引脚),绕制方法是双线双向,作用是抑…

deepin 25 Preview 安装及体验

deepin 25 Preview(预览版)近期发布。本文让我们一起体验安装和使用感受吧! 下载下载建议用种子文件下载。作为国内屈指可数的厂商,也不套下CDN,下载也仅2M图片接下来,创建虚拟机。(根据自身情况配置虚拟机性能)选Debian系列 注意,安装磁盘容量至少70G 开始安装root登…

02人工智能创新型教师培育计划(第一期)0126

人工智能创新型教师培育计划(第一期)活动更新(1月24日 15:00更新): 感谢各位老师对本次活动的关注与支持,线上课程即将开始,请各位已报名老师注意以下事项: 1. 直播时间:1月25日 19:30—21:00 1月26日 19:30—21:00 2. 直播内容:课题:大模型赋能3小时入门Pyth…

java基础Day7 面向对象(2)

六、继承 Inheritance 6.1 继承的本质是对某一批类的抽象,从而实现对现实世界更好的建模。 extends:扩展。子类(派生类)是父类(基类)的扩展。 继承是类与类之间的关系。 java中只有单继承,没有多继承:一个儿子只能有一个爸爸,一个爸爸可以有多个儿子。Inheritance>…

java基础Day7 面向对象

六、继承 Inheritance 6.1 继承的本质是对某一批类的抽象,从而实现对现实世界更好的建模。 extends:扩展。子类(派生类)是父类(基类)的扩展。 继承是类与类之间的关系。 java中只有单继承,没有多继承:一个儿子只能有一个爸爸,一个爸爸可以有多个儿子。Inheritance>…