为什么需要分库分表?
背景
首先,抽奖活动通常会在短时间内吸引大量用户参与,导致系统瞬时请求量激增。同时,活动有明确的时间范围,需要在活动期间完成。然后还有一点是抽奖活动数据不会长期累积,活动结束后数据量相对来说是稳定的。
引入
业务体量较大,数据增长较快,所以需要把用户数据拆分到不同的库表中去,减轻数据库压力
主要操作
垂直拆分
不同表分到不同的数据库中,数据压力分摊不同库,按照业务将表分类,专库专用
水平拆分
同一张表分到不同的数据库,一般是由于单机瓶颈
为什么自研分库分表
现有的路由组件功能虽大,维护成本高;随着版本升级,代码维护复杂性增强。自研组件更轻量,且对当前业务更针对性,不依赖于第三方组件升级。
定制需求更方便,避免引入不必要的依赖。
项目结构
- bugstack
- middleware
- db
- router
- annotation
- DBRouter # 标记需要进行分库分表的方法或类
- DBRouterStrategy # 标记分表策略,指示是否需要进行分表操作
- config
- DataSourceAutoConfig # 自动配置数据源,初始化和管理多个数据源
- dynamic
- DynamicDataSource # 动态数据源,实现数据源的动态切换
- DynamicMybatisPlugin # MyBatis 插件,拦截并修改 SQL 语句
- strategy
- impl
- DBRouterStrategyConsistentHash # 基于一致性哈希的路由策略实现
- DBRouterStrategyHashCode # 基于哈希算法的路由策略实现
- IDBRouterStrategy # 定义路由策略的核心接口
- util
- DBContextHolder # 通过 ThreadLocal 存储当前线程的数据源和分表信息
- DBRouterBase # 提供基础的路由功能
- DBRouterConfig # 存储分库分表的配置信息
- DBRouterJoinPoint # AOP 切面类,拦截带有 @DBRouter 注解的方法,执行路由逻辑
- resources
- META-INF
- spring.factories # Spring Boot 自动配置文件,用于加载自定义的自动配置类
分库分表实现流程
配置数据源
类:
DataSourceAutoConfig
作用:
从配置文件中读取分库分表的配置信息。
从
application.yml
中读取分库分表的配置信息,包括分库数量、分表数量、路由字段等。对应代码
@Override public void setEnvironment(Environment environment) { String prefix = "mini-db-router.jdbc.datasource."; // 读取分库分表配置 dbCount = Integer.parseInt(Objects.requireNonNull(environment.getProperty(prefix + "dbCount"))); tbCount = Integer.parseInt(Objects.requireNonNull(environment.getProperty(prefix + "tbCount"))); routerKey = environment.getProperty(prefix + "routerKey"); // 读取数据源列表 String dataSources = environment.getProperty(prefix + "list"); Map<String, Object> globalInfo = getGlobalProps(environment, prefix + TAG_GLOBAL); for (String dbInfo : dataSources.split(",")) { final String dbPrefix = prefix + dbInfo; Map<String, Object> dataSourceProps = PropertyUtil.handle(environment, dbPrefix, Map.class); injectGlobal(dataSourceProps, globalInfo); dataSourceMap.put(dbInfo, dataSourceProps); } // 读取默认数据源 String defaultData = environment.getProperty(prefix + "default"); defaultDataSourceConfig = PropertyUtil.handle(environment, prefix + defaultData, Map.class); injectGlobal(defaultDataSourceConfig, globalInfo); }
根据配置动态创建和管理多个数据源。
根据从配置文件中读取的配置信息,动态创建多个数据源,并将其封装到
DynamicDataSource
中。对应代码
@Bean("mysqlDataSource") public DataSource createDataSource() { // 创建数据源 Map<Object, Object> targetDataSources = new HashMap<>(); for (String dbInfo : dataSourceMap.keySet()) { Map<String, Object> objMap = dataSourceMap.get(dbInfo); DataSource ds = createDataSource(objMap); targetDataSources.put(dbInfo, ds); } // 设置动态数据源 DynamicDataSource dynamicDataSource = new DynamicDataSource(); dynamicDataSource.setTargetDataSources(targetDataSources); dynamicDataSource.setDefaultTargetDataSource(createDataSource(defaultDataSourceConfig)); return dynamicDataSource; } private DataSource createDataSource(Map<String, Object> attributes) { try { // 初始化 DataSourceProperties DataSourceProperties dataSourceProperties = new DataSourceProperties(); dataSourceProperties.setUrl(attributes.get("url").toString()); dataSourceProperties.setUsername(attributes.get("username").toString()); dataSourceProperties.setPassword(attributes.get("password").toString()); // 设置驱动类名 String driverClassName = attributes.get("driver-class-name") == null ? "com.zaxxer.hikari.HikariDataSource" : attributes.get("driver-class-name").toString(); dataSourceProperties.setDriverClassName(driverClassName); // 设置数据源类型 String typeClassName = attributes.get("type-class-name") == null ? "com.zaxxer.hikari.HikariDataSource" : attributes.get("type-class-name").toString(); DataSource ds = dataSourceProperties.initializeDataSourceBuilder().type((Class<DataSource>) Class.forName(typeClassName)).build(); // 设置连接池属性 MetaObject dsMeta = SystemMetaObject.forObject(ds); Map<String, Object> poolProps = (Map<String, Object>) (attributes.containsKey(TAG_POOL) ? attributes.get(TAG_POOL) : Collections.EMPTY_MAP); for (Map.Entry<String, Object> entry : poolProps.entrySet()) { String key = StringUtils.middleScoreToCamelCase(entry.getKey()); if (dsMeta.hasSetter(key)) { dsMeta.setValue(key, entry.getValue()); } } return ds; } catch (ClassNotFoundException e) { throw new IllegalArgumentException("can not find datasource type class by class name", e); } }
初始化
DynamicDataSource
和DynamicMybatisPlugin
。初始化
DynamicDataSource
和DynamicMybatisPlugin
,以便在分库分表流程中使用。对应代码
@Bean("mysqlDataSource") public DataSource createDataSource() { // 创建数据源 Map<Object, Object> targetDataSources = new HashMap<>(); for (String dbInfo : dataSourceMap.keySet()) { Map<String, Object> objMap = dataSourceMap.get(dbInfo); DataSource ds = createDataSource(objMap); targetDataSources.put(dbInfo, ds); } // 设置动态数据源 DynamicDataSource dynamicDataSource = new DynamicDataSource(); dynamicDataSource.setTargetDataSources(targetDataSources); dynamicDataSource.setDefaultTargetDataSource(createDataSource(defaultDataSourceConfig)); return dynamicDataSource; } @Bean("dbRouterDynamicMybatisPlugin") public Interceptor plugin() { return new DynamicMybatisPlugin(); }
路由策略
类:
IDBRouterStrategy
、DBRouterStrategyHashCode
代码作用:
定义并实现具体的路由策略。
定义一个路由策略接口
IDBRouterStrategy
,用于规范路由策略的实现。public interface IDBRouterStrategy { void doRouter(String dbKeyAttr); void setDBKey(int dbIdx); void setTBKey(int tbIdx); int dbCount(); int tbCount(); void clear(); }
实现一个基于哈希算法的路由策略
DBRouterStrategyHashCode
。public class DBRouterStrategyHashCode implements IDBRouterStrategy { private final Logger logger = LoggerFactory.getLogger(DBRouterStrategyHashCode.class); private final DBRouterConfig dbRouterConfig; public DBRouterStrategyHashCode(DBRouterConfig dbRouterConfig) { this.dbRouterConfig = dbRouterConfig; } @Override public void doRouter(String dbKeyAttr) { //计算库表索引 } @Override public void setDBKey(int dbIdx) { DBContextHolder.setDBKey(String.format("%02d", dbIdx)); } @Override public void setTBKey(int tbIdx) { DBContextHolder.setTBKey(String.format("%03d", tbIdx)); } @Override public int dbCount() { return dbRouterConfig.getDbCount(); } @Override public int tbCount() { return dbRouterConfig.getTbCount(); } @Override public void clear() { DBContextHolder.clearDBKey(); DBContextHolder.clearTBKey(); } }
根据路由字段的值(如
userId
),计算目标数据库和表的索引。在
DBRouterStrategyHashCode
类的doRouter(String dbKeyAttr)
方法中,根据路由字段的值(如userId
),计算目标数据库和表的索引。// 计算总的分库分表数量 int size = dbRouterConfig.getDbCount() * dbRouterConfig.getTbCount(); // 使用扰动函数计算哈希值,确保分布均匀 int idx = (size - 1) & (dbKeyAttr.hashCode() ^ (dbKeyAttr.hashCode() >>> 16)); // 计算目标数据库和表的索引 int dbIdx = idx / dbRouterConfig.getTbCount() + 1; int tbIdx = idx - dbRouterConfig.getTbCount() * (dbIdx - 1);
计算结果存储到
DBContextHolder
中,供后续流程使用。在
DBRouterStrategyHashCode
类的doRouter(String dbKeyAttr)
方法中DBContextHolder.setDBKey(String.format("%02d", dbIdx)); DBContextHolder.setTBKey(String.format("%03d", tbIdx));
动态数据源切换
类:
DynamicDataSource
代码作用:
继承自
AbstractRoutingDataSource
,负责在运行时动态选择数据源。根据
DBContextHolder
中的目标数据库索引,切换到对应的数据源。@Override protected Object determineCurrentLookupKey() { if (null == DBContextHolder.getDBKey()) { return defaultDataSource; } else { return "db" + DBContextHolder.getDBKey(); } }
SQL 修改与执行
类:
DynamicMybatisPlugin
代码作用:
拦截 MyBatis 的 SQL 执行。
拦截
StatementHandler
的prepare
方法,获取 SQL 语句。@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.clas
获取 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(); String className = id.substring(0, id.lastIndexOf(".")); Class<?> clazz = Class.forName(className); DBRouterStrategy dbRouterStrategy = clazz.getAnnotation(DBRouterStrategy.class); //如果注解不存在或 splitTable 为 false,则直接执行原 SQL 语句。 if (null == dbRouterStrategy || !dbRouterStrategy.splitTable()){ return invocation.proceed(); }
获取 SQL 语句
BoundSql boundSql = statementHandler.getBoundSql(); String sql = boundSql.getSql();
根据
DBContextHolder
中的目标表索引,修改 SQL 语句中的表名。使用正则表达式匹配 SQL 语句中的表名
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());
通过反射修改 SQL 语句
Field field = boundSql.getClass().getDeclaredField("sql"); field.setAccessible(true); field.set(boundSql, replaceSql); field.setAccessible(false);
执行修改后的 SQL 语句
return invocation.proceed();
例如,将
user
替换为user_03
。
上下文管理
类:
DBContextHolder
代码作用:
通过
ThreadLocal
存储当前线程的数据源和分表信息。通过 ThreadLocal 存储当前线程的数据源和分表索引
通过 ThreadLocal 获取当前线程的数据源和分表索引
private static final ThreadLocal<String> dbKey = new ThreadLocal<String>(); private static final ThreadLocal<String> tbKey = new ThreadLocal<String>(); public static void setDBKey(String dbKeyIdx){ dbKey.set(dbKeyIdx); } public static void setTBKey(String tbKeyIdx){ tbKey.set(tbKeyIdx); } public static String getDBKey(){ return dbKey.get(); } public static String getTBKey(){ return tbKey.get(); }
确保线程上下文隔离,避免数据污染。
清理操作是必要的,因为
ThreadLocal
中的数据会一直存在,直到线程结束或手动清理,否则可能导致内存泄漏。ThreadLocal
的弱引用。public static void clearDBKey(){ dbKey.remove(); } public static void clearTBKey(){ tbKey.remove(); }
AOP 切面
类:
DBRouterJoinPoint
代码作用:
拦截带有
@DBRouter
注解的方法。使用 @Pointcut 注解定义切点,拦截所有带有 @DBRouter 注解的方法。
@Pointcut("@annotation(cn.bugstack.middleware.db.router.annotation.DBRouter)") public void aopPoint() { }
获取路由字段的值,调用路由策略计算目标库表索引。
使用 @Around 注解定义环绕通知,拦截方法调用。
@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(); } }
在方法执行完成后,清理
DBContextHolder
中的路由信息。在 finally 块中调用 dbRouterStrategy.clear() 方法,清理 DBContextHolder 中的路由信息,避免内存泄漏。
finally { dbRouterStrategy.clear(); }
具体操作流程
调用带有
@DBRouter
注解的插入,查询,删除方法。DBRouterJoinPoint
拦截方法调用,获取路由字段的值(如userId
)。调用
IDBRouterStrategy
的实现类,计算目标数据库和表的索引。DynamicDataSource
根据计算结果,动态切换到目标数据源。DynamicMybatisPlugin
拦截 SQL 语句,修改表名(如将user
替换为user_03
)。在目标数据库和表上执行插入,查询,删除操作。
清理
DBContextHolder
中的路由信息。
业务代码调用方法
配置分库数据yml文件
mini-db-router:
jdbc:
datasource:
dbCount: 2
tbCount: 4
default: db00
routerKey: uId
list: db01,db02
db00:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:13306/lottery?useUnicode=true
username: root
password: 123456
db01:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:13306/lottery_01?useUnicode=true
username: root
password: 123456
db02:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:13306/lottery_02?useUnicode=true
username: root
password: 123456
依赖注入
<dependency>
<groupId>cn.bugstack.middleware</groupId>
<artifactId>db-router-spring-boot-starter</artifactId>
<version>1.0.1</version>
</dependency>
业务层在Dao层调用@DBRouter注解去标明这个执行的库表是分库的
@DBRouter
UserTakeActivityCount queryUserTakeActivityCount(UserTakeActivityCount userTakeActivityCount);
面试问题
简单技术问题
什么是 AOP?在
DB-Router
中如何应用 AOP?AOP是面向切面编程,一种编程范式,用于横切关注点从业务逻辑中分离出来,通过Aspect统一管理
横切关注点:在多个模块或组件中都需要关注并且重复出现的功能,有权限管理、日志记录等
Aspect
:封装横切关注点的模块Pointcut
:定义在哪些方法上应用切面Advice
:在切点处执行的代码逻辑,在拦截到目标方法时,应该执行的具体操作前置通知(Before Advice):在目标方法执行之前执行。
后置通知(After Advice):在目标方法执行之后执行,无论目标方法是否成功。
返回通知(After Returning Advice):在目标方法成功返回之后执行。
异常通知(After Throwing Advice):在目标方法抛出异常之后执行。
环绕通知(Around Advice):在目标方法执行前后都可以执行,可以控制目标方法是否执行。
在
DB-Router
中应用 AOP拦截带有
@DBRouter
注解的方法,根据路由字段的值计算目标库表索引实现方式
使用
@Aspect
注解定义切面类DBRouterJoinPoint
使用
@Pointcut
注解定义切点,拦截所有带有@DBRouter
注解的方法。使用
@Around
注解定义环绕通知,在方法执行前后执行路由逻辑
AbstractRoutingDataSource
是什么?它的作用是什么?什么是
AbstractRoutingDataSource
Spring提供的一个抽象类,实现动态数据源切换
继承自
AbstractDataSource
,并实现了DataSource
接口。它的核心实现原理是通过一个ThreadLocal
变量来存储当前线程的数据源选择键(key
),并根据这个键动态选择对应的数据源。
核心方法
determineCurrentLookupKey()
:返回当前线程使用的数据源键。setTargetDataSources(Map<Object, Object> targetDataSources)
:设置目标数据源集合。
在
DB-Router
中的作用根据
DBContextHolder
中的目标数据库索引,切换到对应的数据源实现
继承
AbstractRoutingDataSource
,重写determineCurrentLookupKey()
方法。根据
DBContextHolder.getDBKey()
返回的数据源键,动态选择数据源。
ThreadLocal
是什么?在DB-Router
中是如何使用的?ThreadLocal
是什么?ThreadLocal
是 Java 提供的一个线程局部变量工具类,用于在多线程环境下为每个线程存储独立的数据。
在
DB-Router
中的使用存储路由信息:通过
ThreadLocal
存储当前线程的数据源和分表信息,确保线程上下文隔离
什么是哈希散列?在
DB-Router
中为什么选择了哈希散列算法?哈希散列
将任意长度的输入通过哈希函数映射为固定长度输出的算法。
为什么选择了哈希散列算法
可以将字段均匀映射到分库分表的索引上,避免数据倾斜
哈希算法计算速度快
实现简单
中等技术问题
什么是 MyBatis Plugin?在 DB-Router 中如何应用 MyBatis Plugin 实现动态变更表信息?
什么是 MyBatis Plugin?
MyBatis Plugin 是 MyBatis 提供的一个扩展机制,允许开发者在 MyBatis 的核心流程中插入自定义逻辑。
通过实现 Interceptor 接口,可以拦截 MyBatis 的某些方法(如 SQL 执行、结果映射等),并在拦截的方法中执行自定义逻辑。
在 DB-Router 中如何应用 MyBatis Plugin?
在 DB-Router 中,通过 MyBatis Plugin 拦截 SQL 执行,并根据分表策略动态修改表名。
实现过程:
实现
Interceptor
接口,定义插件逻辑。使用
@Intercepts
和@Signature
注解指定拦截的目标方法。在
intercept
方法中,解析 SQL 语句,并根据分表策略动态修改表名。
分库分表的散列算法有哪些,各自的优缺点是什么?
常见散列算法
在 DB-Router 中如何支持个性化的分库分表控制?请结合具体实例说明。
自定义注解:通过自定义注解(如
@DBRouter
)标记需要分库分表的方法或类。路由策略接口:定义路由策略接口(如
IDBRouterStrategy
),支持多种分库分表算法。上下文管理:通过
ThreadLocal
存储当前线程的路由信息,确保线程隔离。
在 DB-Router 中如何实现扩展监控、扫描、策略等规则?
监控
通过 AOP 或 MyBatis Plugin 拦截数据库操作,记录执行时间、SQL 语句等信息。
@Around("execution(* com.example.mapper.*.*(..))") public Object monitor(ProceedingJoinPoint jp) throws Throwable { long startTime = System.currentTimeMillis(); Object result = jp.proceed(); long endTime = System.currentTimeMillis(); logger.info("Method {} executed in {} ms", jp.getSignature(), endTime - startTime); return result; }
扫描
通过反射扫描类或方法上的注解,动态加载路由配置。
public void scanRouterConfig() { Reflections reflections = new Reflections("com.example.mapper"); Set<Class<?>> classes = reflections.getTypesAnnotatedWith(DBRouter.class); for (Class<?> clazz : classes) { DBRouter dbRouter = clazz.getAnnotation(DBRouter.class); // 加载路由配置 } }
策略
定义策略接口,支持多种分库分表算法
public interface IDBRouterStrategy { void doRouter(String dbKeyAttr); void clear(); } public class DBRouterStrategyHashCode implements IDBRouterStrategy { @Override public void doRouter(String dbKeyAttr) { // 实现哈希散列算法 } @Override public void clear() { // 清理上下文信息 } }
难度技术问题
在 DB-Router 的架构模型中,如何实现扩展性和灵活性的平衡?
主要是通过插件化设计、策略模式和配置化来实现。
插件化设计允许通过Mybatis plugin 和 AOP 切面 扩展功能,不需要去修改核心代码
策略模式通过定义路由策略接口,支持多种路由算法,方便扩展新策略
配置化通过文件或动态注解调整路由规则
在 DB-Router 中如何保证数据路由的高效性和准确性?
高效算法确保路由快速计算
缓存机制避免重复计算
在 DB-Router 中,如何避免分库分表后产生的性能问题?
均匀分布数据,避免数据倾斜
在 DB-Router 中如何应对高并发的场景?请结合具体实例说明。
限流、缓存、异步
在 DB-Router 的设计过程中,遇到了哪些技术难点?是如何解决的
动态数据源切换:运行时动态切换还要保证线程隔离
路由策略扩展:如何支持多种算法
高并发
评论