記錄一次億級分庫分表實踐

數據庫存儲現狀

從下圖可以看出,項目的數據庫部分表數據量高達幾十個 G,數據量超過了三千多萬,目前已經導致了數據庫的讀寫效率極其低下。

分庫分表方案

因爲是一個 SaaS 項目,因此此次採用的策略是按照租戶維度對大表進行拆分。這樣能夠實現租戶數據的隔離,但是也會出現大租戶和小租戶數據量差異導致的數據傾斜。最終,採用了租戶分表 + 算法平衡的方式。

分表策略

基於單表 2000 萬數據,考慮租戶的增量數據計算出需要的分表數量。

從而可以得到一個分表索引表:

CREATE TABLE `sharding_config` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`logical_table` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '邏輯表名稱',
`actual_table` varchar(60) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '實際映射表',
`sharding_column` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '分片字段',
`sharding_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '分表字段值',
`version` int(20) NOT NULL DEFAULT '1' COMMENT '版本',
`creator_uid` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '創建人uid',
`creator_name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '創建人姓名',
`modified_uid` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最後修改人uid',
`modified_name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最後修改人姓名',
`create_time` datetime(3) NOT NULL COMMENT '創建時間',
`modified_time` datetime(3) NOT NULL COMMENT '修改時間',
`is_del` tinyint(2) unsigned NOT NULL DEFAULT '0' COMMENT '是否刪除',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '數據分片映射關係配置表'

核心邏輯

梳理影響的服務 準備分表配置 sharding_config、xml 配置 實現 StandardShardingAlgorithm 自定義分片策略:租戶分片策略

/**
 * 自定義分片策略
 * 按照租戶(tenant_id)進行分片
 */
@Slf4j
public class TenantShardingAlgorithm implements StandardShardingAlgorithm<String> {

  /**
   * 精確分片算法類名稱,用於=和IN
   */
  @Override
  public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<String> preciseShardingValue) {
    String actualTable = availableTargetNames.stream().findFirst().get();
    String logicTable = actualTable.substring(0, actualTable.lastIndexOf(SEGMENTATION));
    return getTableName(logicTable, preciseShardingValue.getColumnName(), tenantId);
  }

  /**
   * 範圍分片算法類名稱,用於BETWEEN等
   */
  @Override
  public Collection<String> doSharding(Collection<String> availableTargetNames, RangeShardingValue<String> rangeShardingValue) {
    return availableTargetNames;
  }

  /**
   * 查詢實際表名稱
   *
   * @param logicTable     邏輯表名稱
   * @param shardingColumn 分片字段
   * @param shardingKey    分片key
   * @return
   */
  public String getTableName(String logicTable, String shardingColumn, String shardingKey) {
      // 查詢數據庫獲得邏輯表對應的實際表名
      ... 
      // 可增加緩存
      return shardingConfig.getActualTable();
    });
  }

}

提供攔截器,爲接口增加租戶信息,定時任務等接口需要手動設置租戶信息:提供分片需要的租戶 id

@Aspect
public class InjectUserAspect {

    /**
     * SpEL表達式計算器
     */
    private final CacheOperationExpressionEvaluator evaluator = new CacheOperationExpressionEvaluator();

    @Pointcut("@annotation(com.demo.annotation.InjectUserInfo)")
    public void pointcutAnnotation() {
    }

    @Around("pointcutAnnotation()")
    public Object tenantAspect(ProceedingJoinPoint joinPoint) {
        Method method = getSpecificmethod(joinPoint);
        boolean isRemove = false;
        if ((null == UserUtils.getUserInfo() || StrUtils.isEmpty(UserUtils.getUserInfo().getTenantId()))) {
            UserInfo userInfo = getUserInfoByParam(method, joinPoint);
            UserUtils.setUserInfo(userInfo);
            isRemove = true;
        }
        try {
            // 執行方法,並獲取返回值
            return joinPoint.proceed();
        } catch (Throwable e) {
            throw new RuntimeException(e);
        } finally {
            if (isRemove) {
                UserUtils.remove();
            }
        }
    }

    /**
     * 在參數中提取用戶信息ID
     *
     * @param method    method
     * @param joinPoint joinPoint
     * @return 租戶ID
     */
    private UserInfo getUserInfoByParam(Method method, ProceedingJoinPoint joinPoint) {
        InjectUserInfo injectUserInfo = AnnotationUtils.findAnnotation(method, InjectUserInfo.class);
        if (Objects.isNull(injectUserInfo)) {
            throw new RuntimeException("未獲取到註解信息");
        }
        Object[] args = joinPoint.getArgs();
        Object target = joinPoint.getTarget();

        return UserInfo.builder()
                .tenantId(getExpression(method, joinPoint, args, target, injectUserInfo.tenantId()))
                .build();
    }

    @Nullable
    private String getExpression(Method method, ProceedingJoinPoint joinPoint, Object[] args, Object target, String spEl) {
        if (StringUtils.isBlank(spEl)) {
            return null;
        }

        if (spEl.startsWith("#") || spEl.startsWith("$")) {
            Class<?> targetClass = AopProxyUtils.ultimateTargetClass(target);
            EvaluationContext evaluationContext = evaluator.createEvaluationContext(method, args, target,
                    targetClass, CacheOperationExpressionEvaluator.NO_RESULT);
            AnnotatedElementKey methodCacheKey = new AnnotatedElementKey(method, targetClass);
            // 兼容傳null值得情況
            Object tenantValue = evaluator.key(spEl, methodCacheKey, evaluationContext);
            if (Objects.isNull(tenantValue)) {
                return null;
            }
            return tenantValue.toString();

        }

        return spEl;
    }

    public static Method getSpecificmethod(ProceedingJoinPoint pjp) {
        MethodSignature methodSignature = (MethodSignature) pjp.getSignature();
        Method method = methodSignature.getMethod();

        Class<?> targetClass = AopProxyUtils.ultimateTargetClass(pjp.getTarget());
        if (targetClass == null && pjp.getTarget() != null) {
            targetClass = pjp.getTarget().getClass();
        }
        Method specificMethod = ClassUtils.getMostSpecificMethod(method, targetClass);

        specificMethod = BridgeMethodResolver.findBridgedMethod(specificMethod);
        return specificMethod;
    }
}

通過 MybatisPlus 插件對所有 SQL 新增 teant_id 查詢條件:保證 SQL 包含租戶 id

 @Bean
  public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    // 分頁插件
    interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
    // 多租戶插件
    interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {

      @Override
      public Expression getTenantId() {
        return new StringValue(UserUtils.getUserInfo().getTenantId());
      }

      @Override
      public String getTenantIdColumn() {
        return "tenant_id";
      }

      // 這是 default 方法,默認返回 false 表示所有表都需要拼多租戶條件
      @Override
      public boolean ignoreTable(String tableName) {
          return false;
      }
    }));

    return interceptor;
  }

提供數據遷移接口:分片後數據初始化遷移、分片後數據增量遷移、新租戶初始化、租戶數據重分配

/**
 * sharding 數據遷移
 *
 */
public interface IDataShardingService {

  /**
   * 數據遷移
   *
   * @param tenantIds
   */
  void dataTransfer(List<String> tenantIds);

  /**
   * 增量遷移數據
   * @param tenantIds 遷移租戶列表
   * @param beginDate 遷移開始時間
   */
  void incrementalDataTransfer(List<String> tenantIds, String beginDate);

  /**
   * 新租戶初始化
   */
  void newTenantInit();

  /**
   * 租戶分片數據重分配
   *
   * @param tenantIds 需要遷移的租戶
   * @param shardingIndex 分片下標
   */
  void tenantShardingDataReassign(List<String> tenantIds, String shardingIndex);
}

關鍵步驟



hi,你好,我是松語。985 軟件工程研究生畢業,一個工作三年的程序員。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/sHvCiPmwr6PT3g_eo3gnqg