需求背景
数据库路由的需求背景主要来自于业务体量的增加,让原有的技术设计和实现不能承载现有增加的业务规模和体量,因此需要设计分库分表。
最终无论你的应用程序是在最初设计时就开量要分库分表,还是因为业务体量的增加而分库分表,都需要考虑运用什么组件、使用什么方式,来分库分表。可能你会想到 ShardingSphere、MyCat 类似这样的组件。
本文只是个人学习简单设计一个路由组件。
方案设计
- 数据库连接池的配置,分库分表需要按需配置数据库连接源,在这些连接池的集合中进行动态切换操作。
- AbstractRoutingDataSource,是用于动态切换数据源的 Spring 服务类,它提供了获取数据源的抽象方法 determineCurrentLookupKey
- 关于路由的计算。在路由计算中需要获取用于分库分表的字段,通过哈希值的计算以及扰动最终达到尽可能的散列,让数据均匀分散到各个库表中。
技术实现
自定义注解实现标注需要分库分表的方法
注解增强实现分库分表标识
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE,ElementType.METHOD})
public @interface DBRouter {
// 通过配置切面拦截当前注解然后对dao进行增强动态选择datasource
String key() default "";
}
DBRouterStrategy注解
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE,ElementType.METHOD})
public @interface DBRouterStrategy {
boolean splitTable() default false;
}
使用方式:
在dao中将需要进行分库分表的方法加入注解
@Mapper
@DBRouterStrategy(splitTable = true)
public interface IRaffleActivityOrderDao {
@DBRouter(key = "userId")
void insert(RaffleActivityOrder raffleActivityOrder);
@DBRouter
List<RaffleActivityOrder> queryRaffleActivityOrderByUserId(String userId);
}
获取配置的多数据源
在中间件的主配置类上实现EnvironmentAware注解读取配置在application.yml中的多数据源
读取之后存入到本地对象中然后创建动态数据源的bean
DynamicDataSource 实现 AbstractRoutingDataSource来进行多数据源配置
public class DynamicDataSource extends AbstractRoutingDataSource {
// 返回数据源标识
@Override
protected Object determineCurrentLookupKey() {
return "db" + DBContextHolder.getDBKey();
}
}
其中determineCurrentLookupKey决定了动态选择哪一个数据源,关联到的是一个ThreadLocal封装类,关于这个类中值的设定就是通过第一步中标注注解的切面类实现的下面介绍注解的切面类。
注解对应的切面
DBRouterJoinPoint实现@Aspect
通过拦截当前注解的方法对dao中的方法进行增强,具体的增强也就是设置上一步中用于分库分表的threadlocal中的值。
@Pointcut("@annotation(com.rzp.middleware.db.router.annotation.DBRouter)")
public void aopPoint() {
}
@Around("aopPoint() && @annotation(dbRouter)")
public Object doRouter(ProceedingJoinPoint jp, DBRouter dbRouter) throws Throwable {
String dbKey = dbRouter.key();
if (StringUtils.isBlank(dbKey) && StringUtils.isBlank(dbRouterConfig.getRouterKey())) {
throw new RuntimeException("annotation DBRouter key is null!");
}
dbKey = StringUtils.isNotBlank(dbKey) ? dbKey : dbRouterConfig.getRouterKey();
// 路由属性
String dbKeyAttr = getAttrValue(dbKey, jp.getArgs());
// 路由策略
dbRouterStrategy.doRouter(dbKeyAttr);
// 返回结果
try {
return jp.proceed();
} finally {
dbRouterStrategy.clear();
}
}
具体的分库分表值的计算这里借鉴HashMap中的实现方式也就是 h ^ (h >>> 16) & (size - 1)
这里使用到了Strategy模式方便拓展。
@Override
public void doRouter(String dbKeyAttr) {
// size = 4; 现在备选一个库,一个库里要分表的存在4张表
// dbCount: 1, tbCount: 4.
int size = dbRouterConfig.getDbCount() * dbRouterConfig.getTbCount();
// 扰动函数 使用hashMap的扰动函数.
int idx = (size - 1) & (dbKeyAttr.hashCode()) ^ (dbKeyAttr.hashCode() >>> 16);
// 库表索引
int dbIdx = idx / dbRouterConfig.getTbCount() + 1;
int tbIdx = idx / dbRouterConfig.getTbCount() * (dbIdx - 1);
// 设置到 ThreadLocal
DBContextHolder.setDBKey(String.format("%02d", dbIdx));
DBContextHolder.setTBKey(String.format("%03d", tbIdx));
logger.debug("数据库路由 dbIdx:{} tbIdx:{}", dbIdx, tbIdx);
}
解释:
X % 2^n = X & (2^n - 1) 为什么呢?
首先举例说明当n = 3时,
- 对于右边来说 2^3 - 1 就相当于是 8 -1 也就是7, 8的二进制表示为 1000,7为0111,也就是将低位全部变为1然后 & X,就相当于取X的低三位。
- 对于左边来说,我们先思考X / 2^3 其实就相当于 X >>> 3位,那么X % 2^n 其实也就相当于获取X的低三位。
- 因为&是二进制直接计算效率比十进制取模高!!。
为什么要进行 (h ^ h >>> 16)呢为什么不是直接 h & (size - 1)?
考虑当前size很小时,因为size很小所以size - 1的二进制表示高位基本全为0,那么在和h进行&操作时很难考虑到h二进制中的高位,因此增加一层扰动让h ^ h >>> 16充分融合低位和高位的值,再进行 & 操作。
因此对于这个自研的组件来说也必须保证数据库数量 * 表的size = 2 ^ n;
Mybatis插件拦截进行具体执行
通过使用Mybatis提供的插件拦截sql,因为已经设置了动态数据源,所以还需要动态的去拦截sql,用户在使用时仍然只需要配置一个xml文件,mybatis插件拦截sql,然后重新组装成我们计算好的数据库名称。
通过实现Interceptor接口来进行插件开发。
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 在intercept方法中,首先获取到被拦截的StatementHandler对象和相关的元数据信息。
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
MetaObject metaObject = MetaObject.forObject(statementHandler, SystemMetaObject.DEFAULT_OBJECT_FACTORY, SystemMetaObject.DEFAULT_OBJECT_WRAPPER_FACTORY, new DefaultReflectorFactory());
MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
String id = mappedStatement.getId();
// 拿到当前dao类对象的名字
String className = id.substring(0, id.lastIndexOf("."));
Class<?> clazz = Class.forName(className);
// 查看当前dao对象是否加了自定义的注解
DBRouterStrategy dbRouterStrategy = clazz.getAnnotation(DBRouterStrategy.class);
// 如果使用了DBRouterStrategy注解并且splitTable属性为true,则获取当前SQL语句。
if (null == dbRouterStrategy || !dbRouterStrategy.splitTable()){
return invocation.proceed();
}
BoundSql boundSql = statementHandler.getBoundSql();
String sql = boundSql.getSql();
Matcher matcher = pattern.matcher(sql);
String tableName = null;
if (matcher.find()) {
tableName = matcher.group().trim();
}
assert null != tableName;
// 将匹配到的表名与分表键值拼接,生成新的表名。
String replaceSql = matcher.replaceAll(tableName + "_" + DBContextHolder.getTBKey());
Field field = boundSql.getClass().getDeclaredField("sql");
field.setAccessible(true);
field.set(boundSql, replaceSql);
field.setAccessible(false);
// 最后调用invocation.proceed()方法继续执行原始的数据库操作。
return invocation.proceed();
}
提供事务管理
@Bean
public TransactionTemplate transactionTemplate(DataSource dataSource){
DataSourceTransactionManager dataSourceTransactionManager = new DataSourceTransactionManager();
dataSourceTransactionManager.setDataSource(dataSource);
TransactionTemplate transactionTemplate = new TransactionTemplate();
transactionTemplate.setTransactionManager(dataSourceTransactionManager);
transactionTemplate.setPropagationBehaviorName("PROPAGATION_REQUIRED");
return transactionTemplate;
}