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

前言

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

  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时获取物理表名:

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

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

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

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

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

    1. 生成LogicSQL,将SQL结构化

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

    3. 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。

源码阅读感想

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

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

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

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

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