記錄一次億級分庫分表實踐
數據庫存儲現狀
從下圖可以看出,項目的數據庫部分表數據量高達幾十個 G,數據量超過了三千多萬,目前已經導致了數據庫的讀寫效率極其低下。
分庫分表方案
因爲是一個 SaaS 項目,因此此次採用的策略是按照租戶維度對大表進行拆分。這樣能夠實現租戶數據的隔離,但是也會出現大租戶和小租戶數據量差異導致的數據傾斜。最終,採用了租戶分表 + 算法平衡的方式。
分表策略
基於單表 2000 萬數據,考慮租戶的增量數據計算出需要的分表數量。
-
分片算法:將所有租戶計費數據量從高到低排序,然後遍歷所有租戶,將其放到在所有表中找最小的表。這樣一個表可能只有一個租戶,一個表可能有多個租戶數據。
-
重分配條件:只有單數據量表達到 2000W 且表中的租戶不止一個時,我們觸發租戶重分配
-
重分配過程:重分配過程中需要,儘量保證大租戶數據在原表,核心目的是減小數據遷移量。
從而可以得到一個分表索引表:
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