为什么需要分库分表?

背景

首先,抽奖活动通常会在短时间内吸引大量用户参与,导致系统瞬时请求量激增。同时,活动有明确的时间范围,需要在活动期间完成。然后还有一点是抽奖活动数据不会长期累积,活动结束后数据量相对来说是稳定的。

引入

业务体量较大,数据增长较快,所以需要把用户数据拆分到不同的库表中去,减轻数据库压力

主要操作

垂直拆分

不同表分到不同的数据库中,数据压力分摊不同库,按照业务将表分类,专库专用

水平拆分

同一张表分到不同的数据库,一般是由于单机瓶颈

为什么自研分库分表

  • 现有的路由组件功能虽大,维护成本高;随着版本升级,代码维护复杂性增强。自研组件更轻量,且对当前业务更针对性,不依赖于第三方组件升级。

  • 定制需求更方便,避免引入不必要的依赖。

项目结构

- 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);
          }
      }
  • 初始化 DynamicDataSourceDynamicMybatisPlugin

    • 初始化 DynamicDataSourceDynamicMybatisPlugin,以便在分库分表流程中使用。

    • 对应代码

      @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();
      }

路由策略

  • IDBRouterStrategyDBRouterStrategyHashCode

  • 代码作用

    • 定义并实现具体的路由策略。

      • 定义一个路由策略接口 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 执行。

      • 拦截 StatementHandlerprepare 方法,获取 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();
        }

具体操作流程

  1. 调用带有 @DBRouter 注解的插入,查询,删除方法。

  2. DBRouterJoinPoint 拦截方法调用,获取路由字段的值(如 userId)。

  3. 调用 IDBRouterStrategy 的实现类,计算目标数据库和表的索引。

  4. DynamicDataSource 根据计算结果,动态切换到目标数据源。

  5. DynamicMybatisPlugin 拦截 SQL 语句,修改表名(如将 user 替换为 user_03)。

  6. 在目标数据库和表上执行插入,查询,删除操作。

  7. 清理 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 语句,并根据分表策略动态修改表名。

  • 分库分表的散列算法有哪些,各自的优缺点是什么?

    常见散列算法

    算法

    优点

    缺点

    哈希散列

    1. 实现简单。
    2. 数据分布均匀。
    3. 计算速度快。

    1. 扩容困难,需要重新哈希。
    2. 无法保证顺序性。

    一致性哈希

    1. 扩容时影响范围小。
    2. 数据分布相对均匀。

    1. 实现复杂。
    2. 需要维护虚拟节点。

    范围分片

    1. 支持范围查询。
    2. 扩容方便。

    1. 数据分布可能不均匀。
    2. 热点问题较严重。

    取模分片

    1. 实现简单。
    2. 数据分布均匀。

    1. 扩容困难,需要重新取模。
    2. 无法保证顺序性。

  • 在 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 的设计过程中,遇到了哪些技术难点?是如何解决的

    • 动态数据源切换:运行时动态切换还要保证线程隔离

    • 路由策略扩展:如何支持多种算法

    • 高并发