基于你上传的源码文件,我们现在可以像“法医鉴定”一样,精准地拆解这个分库分表组件(db-router-spring-boot-starter)的内部运作机制。

这个组件的实现逻辑非常清晰,遵循了 Spring Boot Starter 的标准开发范式。我将其串联为 “初始化阶段”“运行时阶段” 两个部分来讲解。


第一阶段:初始化阶段(装备库组装)

当你的 Spring Boot 应用启动时,这个组件是如何悄无声息地接管你的数据库连接的?

1. 入口引导 (spring.factories)

一切的起点在于 main/resources/META-INF/spring.factories

  • 作用:Spring Boot 启动时会扫描这个文件,发现了 DataSourceAutoConfig 类,于是开始加载它。

  • 代码org.springframework.boot.autoconfigure.EnableAutoConfiguration=...DataSourceAutoConfig

2. 读取配置与构建数据源 (DataSourceAutoConfig)

这个类是组件的“大管家”,它干了三件大事:

  • 读取配置:通过 EnvironmentAware 接口,它读取了 router.jdbc.datasource.* 下的配置(分库数 dbCount、分表数 tbCount、以及具体的 db01, db02 连接信息)。

  • 兼容性处理:它使用 PropertyUtil 工具类来处理配置属性绑定,为了兼容 Spring Boot 1.x 和 2.x 的 API 差异(通过反射调用 BinderRelaxedPropertyResolver)。

  • 偷天换日(核心)

    • 它创建了一个标准的 JDBC 数据源 Map (targetDataSources)。

    • 关键点:它没有直接返回普通的 DataSource,而是返回了 new DynamicDataSource(),并将解析出的多个真实数据源塞了进去。

    • 注入配置:同时将分库分表数量封装进 DBRouterConfig Bean 中,供后续切面使用。


第二阶段:运行时阶段(路由计算与切换)

当业务代码调用 DAO 方法(如 insertUser)时,组件开始介入。这是一个环环相扣的 “AOP 拦截 计算 存储 切换” 流程。

1. 拦截指令 (@DBRouter & DBRouterJoinPoint)

这是组件的“大脑”。

  • 标记:你在 DAO 接口的方法上打上 @DBRouter(key = "userId") 注解。

  • 拦截DBRouterJoinPoint 切面通过 @Pointcut 锁定所有带此注解的方法。

2. 路由算法 (doRouter 方法)

DBRouterJoinPoint.doRouter 方法中,发生了核心的数学计算:

  1. 提取路由键:通过 getAttrValue 方法,利用反射(BeanUtils)从入参对象中拿到 userId 的值。

  2. 扰动函数(经典算法)

    Java

    // 借鉴 HashMap 源码,让高位也参与运算,减少哈希碰撞
    int idx = (size - 1) & (dbKeyAttr.hashCode() ^ (dbKeyAttr.hashCode() >>> 16));
    
  3. 定位库表

    • 库索引dbIdx = idx / tbCount + 1

    • 表索引tbIdx = idx - tbCount * (dbIdx - 1)

    • 比如 idx=13, tbCount=4 dbIdx=4, tbIdx=1

3. 上下文传递 (DBContextHolder)

算出来是 db04user_01 后,怎么传给 MyBatis 和 DataSource?

  • 使用 ThreadLocal:代码调用 DBContextHolder.setDBKey(...)setTBKey(...)

  • 作用:将这两个值绑定到当前请求线程上,确保线程安全,且后续流程随时可取。

4. 动态切库 (DynamicDataSource)

Spring 准备执行 SQL 获取连接时,会调用 AbstractRoutingDataSource.determineCurrentLookupKey()

  • 代码实现

    Java

    @Override
    protected Object determineCurrentLookupKey() {
        return "db" + DBContextHolder.getDBKey(); // 返回 "db04"
    }
    
  • 结果:Spring 拿着 “db04” 去刚才初始化好的 Map 里找到了真实的 JDBC 连接。

5. 动态切表 (DBRouterBase)

MyBatis 拼装 SQL 时(如 insert into user_${tbIdx})。

  • 实体类继承了 DBRouterBase

  • 调用 getTbIdx() 方法时,直接从 DBContextHolder.getTBKey() 拿值。

  • 结果:SQL 被解析为 insert into user_01

6. 现场清理 (finally)

千万不能忘的一步:在 AOP 的 finally 块中调用 DBContextHolder.clear...

  • 原因:Tomcat 使用线程池复用线程。如果不清理,下一次请求复用这个线程时,可能会带上旧的路由信息,导致数据写错库(非常严重的生产事故)。

总结全景图

整个组件的源码逻辑串联起来就是:

  1. 启动时DataSourceAutoConfig 偷梁换柱,把配置里的 db01, db02 组装成一个 DynamicDataSource 注入 Spring。

  2. 调用时DBRouterJoinPoint 拦路抢劫,算出 userId 对应的库号表号,存入 ThreadLocal

  3. 执行时

    • 找连接DynamicDataSource 偷看 ThreadLocal 里的库号,切换连接。

    • 拼SQL:实体类偷看 ThreadLocal 里的表号,拼接表名。

  4. 结束时:清理 ThreadLocal,毁尸灭迹。

这就是作者源码中实现“无感分库分表”的完整闭环。

这个问题问到了整个组件最“鸡贼”(巧妙)的一个设计点。

确实,从表面看,我们在业务代码里只是 new User(),并没有给它设置过 tbIdx(表下标),那 MyBatis 怎么知道 ${tbIdx} 是多少呢?

秘密藏在 DBRouterBase.java 这个父类里。

核心解密:MyBatis 的反射机制 + 父类方法的“偷梁换柱”

1. 正常的 JavaBean 逻辑

通常我们定义一个 Bean,Getter 方法是返回字段值的:

Java

public class User {
    private String name;
    public String getName() {
        return this.name; // 返回自己存的值
    }
}

2. DBRouterBase 的“作弊”逻辑

但是,请看你上传的 DBRouterBase.java 源码,它的 getTbIdx 方法并没有返回自己的字段值,而是直接去 ThreadLocal 里“偷”了:

Java

public class DBRouterBase {

    private String tbIdx;

    // 重点在这里!!!
    // 当外界调用 getTbIdx() 时,它压根不看 this.tbIdx 是什么
    // 而是直接去 DBContextHolder (ThreadLocal) 里拿刚才 AOP 算好的值
    public String getTbIdx() {
        return DBContextHolder.getTBKey(); 
    }

}

完整的数据流向(案发现场还原)

我们可以把这个过程慢放一遍:

  1. AOP 阶段(计算并存储)

    • 代码执行 DBRouterJoinPoint

    • 算出来 tbIdx = 03

    • 存入:DBContextHolder.setTBKey("03")

  2. DAO 调用阶段(传参)

    • 你调用 userDao.insertUser(user)

    • 此时这个 user 对象里的 tbIdx 字段其实是 null(因为你没设置过)。

  3. MyBatis 解析阶段(拼 SQL)

    • MyBatis 拿到 Mapper XML 里的 SQL:insert into user_${tbIdx} ...

    • MyBatis 发现占位符 ${tbIdx},它会尝试去参数对象(User)里找这个属性。

    • 关键动作:MyBatis 通过反射调用 user.getTbIdx() 方法。

  4. 偷天换日阶段(触发机关)

    • 因为 User 继承了 DBRouterBase,所以调用的是父类的 getTbIdx()

    • getTbIdx() 方法执行:return DBContextHolder.getTBKey();

    • 它直接把 AOP 存在 ThreadLocal 里的 "03" 返回给了 MyBatis。

  5. 最终结果

    • MyBatis 以为这是 User 对象自己的属性,于是把 SQL 拼成了 insert into user_03 ...

总结

你之所以没看出来,是因为这个 Entity 类并不诚实

它利用了 Java 的继承特性和 Getter 方法的重写逻辑,打通了“实体对象”和“ThreadLocal 上下文”之间的通道。这样做的最大好处是:业务代码(User类)完全不需要关心分表逻辑,也不用手动去 setTbIdx,一切全自动完成。

宾果!完全正确! 💯

这就是我刚才提到的 “2.0 版本(拦截器模式)” 的核心代码实现。

正是因为有了这个 DynamicMybatisPlugin,你的实体类(如 RaffleActivityAccount)才得以从复杂的继承关系中解脱出来,变成纯净的 POJO。

我来帮你拆解一下这段代码的**“魔法”**是如何生效的,以及它是怎么替代掉 DBRouterBase 的:

1. 核心逻辑拆解

这段代码做了 4 件关键的事情,完美实现了“偷天换日”:

① 埋伏拦截 (@Intercepts)

Java

@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", ...)})
  • 动作:它像一个路障,拦住了 MyBatis 发送 SQL 给数据库之前的最后一步(prepare 阶段)。

  • 时机:此时 SQL 已经生成了,但是还没发出去。

② 识别指令 (DBRouterStrategy)

Java

DBRouterStrategy dbRouterStrategy = clazz.getAnnotation(DBRouterStrategy.class);
if (null == dbRouterStrategy || !dbRouterStrategy.splitTable()){ ... }
  • 动作:它检查当前的 Mapper 接口(比如 IRaffleActivityAccountDao)上有没有打 @DBRouterStrategy(splitTable = true) 注解。

  • 目的精准打击。不是所有表都需要分表,只有打标的 Dao 才处理,防止误伤其他普通表。

③ 动态整容 (Regex Replace)

Java

// 正则查找:找到 'from 表名' 或 'into 表名'
Matcher matcher = pattern.matcher(sql);
// 核心替换:把 "from user" 变成 "from user_03"
String replaceSql = matcher.replaceAll(tableName + "_" + DBContextHolder.getTBKey());
  • 关键点:它直接从 DBContextHolder(ThreadLocal)里拿到了 _03

  • 对比旧版:旧版是 MyBatis 通过反射去调用 PO.getTbIdx() 拿;新版是拦截器直接去 DBContextHolder 拿。中间商(PO对象)被踢掉了!

④ 暴力植入 (Reflection)

Java

Field field = boundSql.getClass().getDeclaredField("sql");
field.set(boundSql, replaceSql);
  • 动作:利用 Java 反射机制,强行把 MyBatis 里的 SQL 字符串改成了我们拼接好的新 SQL。

2. 总结:新旧方案对比

这张表能让你彻底明白为什么现在不用继承了:

特性1.0 继承模式 (User extends DBRouterBase)2.0 拦截器模式 (DynamicMybatisPlugin)
获取分表下标MyBatis 找参数对象要 (user.getTbIdx())拦截器直接找 ThreadLocal 要 (DBContextHolder)
SQL 写法必须显式写后缀:from user_${tbIdx}完全无感from user (拦截器自动加后缀)
POJO 纯净度 (必须继承框架父类,有侵入) (纯净的 @Data 对象,无侵入)
技术核心继承 + OGNL 表达式MyBatis Plugin + 正则表达式 + 反射

所以,你在新代码里看到的 RaffleActivityAccount 干干净净,完全是因为在这个拦截器里,脏活累活全被干完了。这就是架构演进带来的优雅性!