基于你上传的源码文件,我们现在可以像“法医鉴定”一样,精准地拆解这个分库分表组件(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 差异(通过反射调用Binder或RelaxedPropertyResolver)。 -
偷天换日(核心):
-
它创建了一个标准的 JDBC 数据源 Map (
targetDataSources)。 -
关键点:它没有直接返回普通的 DataSource,而是返回了
new DynamicDataSource(),并将解析出的多个真实数据源塞了进去。 -
注入配置:同时将分库分表数量封装进
DBRouterConfigBean 中,供后续切面使用。
-
第二阶段:运行时阶段(路由计算与切换)
当业务代码调用 DAO 方法(如 insertUser)时,组件开始介入。这是一个环环相扣的 “AOP 拦截 → 计算 → 存储 → 切换” 流程。
1. 拦截指令 (@DBRouter & DBRouterJoinPoint)
这是组件的“大脑”。
-
标记:你在 DAO 接口的方法上打上
@DBRouter(key = "userId")注解。 -
拦截:
DBRouterJoinPoint切面通过@Pointcut锁定所有带此注解的方法。
2. 路由算法 (doRouter 方法)
在 DBRouterJoinPoint.doRouter 方法中,发生了核心的数学计算:
-
提取路由键:通过
getAttrValue方法,利用反射(BeanUtils)从入参对象中拿到userId的值。 -
扰动函数(经典算法):
Java
// 借鉴 HashMap 源码,让高位也参与运算,减少哈希碰撞 int idx = (size - 1) & (dbKeyAttr.hashCode() ^ (dbKeyAttr.hashCode() >>> 16)); -
定位库表:
-
库索引:
dbIdx = idx / tbCount + 1 -
表索引:
tbIdx = idx - tbCount * (dbIdx - 1) -
比如 idx=13, tbCount=4 → dbIdx=4, tbIdx=1
-
3. 上下文传递 (DBContextHolder)
算出来是 db04 和 user_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 使用线程池复用线程。如果不清理,下一次请求复用这个线程时,可能会带上旧的路由信息,导致数据写错库(非常严重的生产事故)。
总结全景图
整个组件的源码逻辑串联起来就是:
-
启动时:
DataSourceAutoConfig偷梁换柱,把配置里的db01, db02组装成一个DynamicDataSource注入 Spring。 -
调用时:
DBRouterJoinPoint拦路抢劫,算出userId对应的库号表号,存入ThreadLocal。 -
执行时:
-
找连接:
DynamicDataSource偷看ThreadLocal里的库号,切换连接。 -
拼SQL:实体类偷看
ThreadLocal里的表号,拼接表名。
-
-
结束时:清理
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();
}
}
完整的数据流向(案发现场还原)
我们可以把这个过程慢放一遍:
-
AOP 阶段(计算并存储)
-
代码执行
DBRouterJoinPoint。 -
算出来
tbIdx = 03。 -
存入:
DBContextHolder.setTBKey("03")。
-
-
DAO 调用阶段(传参)
-
你调用
userDao.insertUser(user)。 -
此时这个
user对象里的tbIdx字段其实是null(因为你没设置过)。
-
-
MyBatis 解析阶段(拼 SQL)
-
MyBatis 拿到 Mapper XML 里的 SQL:
insert into user_${tbIdx} ...。 -
MyBatis 发现占位符
${tbIdx},它会尝试去参数对象(User)里找这个属性。 -
关键动作:MyBatis 通过反射调用
user.getTbIdx()方法。
-
-
偷天换日阶段(触发机关)
-
因为
User继承了DBRouterBase,所以调用的是父类的getTbIdx()。 -
getTbIdx()方法执行:return DBContextHolder.getTBKey();。 -
它直接把 AOP 存在 ThreadLocal 里的
"03"返回给了 MyBatis。
-
-
最终结果
- MyBatis 以为这是
User对象自己的属性,于是把 SQL 拼成了insert into user_03 ...。
- MyBatis 以为这是
总结
你之所以没看出来,是因为这个 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 干干净净,完全是因为在这个拦截器里,脏活累活全被干完了。这就是架构演进带来的优雅性!