多租户shardingSphere+mybatis实现实现
多租户分库分表场景分析
多租户是指软件架构支持一个实例服务多个用户(Customer),每一个用户被称之为租户(tenant),软件给予租户可以对系统进行部分定制的能力,例如数据权限、角色权限等。特别适合于sass化软件及相关租户业务定制的场景。
在多租户的设计上,需要考虑租户数据的物理隔离和逻辑隔离。逻辑隔离一般会用一个tenantId来做sql表数据,代码业务逻辑上来区分不同租户,且一般会放贯穿在整个服务调用链路中。物理隔离指的是数据存储的隔离,一般可以采用分库分表的模式进行隔离数据,这里有这几点好处:
1.租户数据存储在不同的数据库实例中,隔离部署,单机房挂了之后,只影响部分租户数据,做到容灾的效果。
2.数据库到达千万级以上数据之后会带给单库的实例来IO、CPU、内存的上的压力,分库分表实现能减免这种压力,并且具有可拓展性。
shardingSphere+mybatis实现
这里采用shardingSphere+mybatis的jar包方式来实现路由键租户tenantId分库分表,关于shardingSphere的使用https://blog.csdn.net/qq_17236715/article/details/126925494?spm=1001.2014.3001.5502 和 https://blog.csdn.net/qq_17236715/article/details/127680981?spm=1001.2014.3001.5502,文章有详细提交及验证相关sql和使用相关算法。
实现实例采用2X2的分库分表模式,两个库的表结构都是一样的,表结构中都带有tenant_id字段。并且采用tenant_user表作为广播表,存在于所有库中,里面存取的是租户的信息。
路由算法采用hint算法,并统一设置请求拦截器设置租户的路由键,一般租户id会通过请求头、cookie等方式带入到请求信息中,算法中会利用租户id做库的路由和表路由。考虑到是简单的2X2分库分表,这里的路由简单就是按照tenant_id %2的方式计算。请求aop代码如下,租户id是在请求头中。
@Aspect
@Order(-10)
@Component
@Slf4j
public class ShardingJdbcHintRouteAspect {
@Value("${spring.shardingsphere.sharding.binding-tables}")
private String shardingTables;
/**
* 定义一个方法,用于声明切入点表达式,方法中一般不需要添加其他代码 使用@Pointcut声明切入点表达式 后面的通知直接使用方法名来引用当前的切点表达式;如果是其他类使用,加上包名即可
*/
@Pointcut("execution(public * com.ilearning.*.controller..*Controller.*(..))")
public void declearJoinPointExpression() {
}
/**
* 前置通知
*
* @param joinPoint
*/
public void beforMethod(JoinPoint joinPoint) {
// Hint分片策略必须要使用 HintManager工具类
HintManager hintManager = HintManager.getInstance();
ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
String headValue = "";
if (sra != null) {
headValue = sra.getRequest().getHeader("tenantId");
}
log.info("sharding table value {}", shardingTables);
if (StringUtils.isNotBlank(shardingTables)) {
String[] tables = shardingTables.split(",");
for (String table : tables) {
hintManager.addDatabaseShardingValue(table, headValue);
hintManager.addTableShardingValue(table, headValue);
}
}
}
/**
* 后置通知(无论方法是否发生异常都会执行,所以访问不到方法的返回值)
*
* @param joinPoint
*/
@After("declearJoinPointExpression()")
public void afterMethod(JoinPoint joinPoint) {
log.info("After---------------");
HintManager.clear();
}
/**
* 返回通知(在方法正常结束执行的代码) 返回通知可以访问到方法的返回值!
*
* @param joinPoint
*/
public void afterReturnMethod(JoinPoint joinPoint, Object result) {
}
/**
* 环绕通知(需要携带类型为ProceedingJoinPoint类型的参数) 环绕通知包含前置、后置、返回、异常通知;ProceedingJoinPoin 类型的参数可以决定是否执行目标方法 且环绕通知必须有返回值,返回值即目标方法的返回值
*
* @param joinPoint
*/
@Around(value = "declearJoinPointExpression()")
public Object aroundMethod(ProceedingJoinPoint joinPoint) {
Object result = null;
// 前置通知
beforMethod(joinPoint);
// 执行目标方法
try {
result = joinPoint.proceed();
} catch (Throwable e) {
throw new RuntimeException(e);
}
// 后置通知 - 新建
afterReturnMethod(joinPoint, result);
return result;
}
}
为了更方便和采用mybatis的租户拦截器实现对sql的拦截,加入tenant_id 字段,将实际业务和租户的sql字段进行解耦,增加可维护性。拦截器组装tenant_id 字段如下:
@Configuration
public class TenantAutoConfiguration {
@Bean
public TenantLineInnerInterceptor tenantLineInnerInterceptor(TenantProperties properties,
MybatisPlusInterceptor interceptor) {
TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor(new TenantDatabaseInterceptor(properties));
// 添加到 interceptor 中
// 需要加在首个,主要是为了在分页插件前面。这个是 MyBatis Plus 的规定
MyBatisUtils.addInterceptor(interceptor, inner, 0);
return inner;
}
}
TenantDatabaseInterceptor 获取请求头的tenantID
@AllArgsConstructor
public class TenantDatabaseInterceptor implements TenantLineHandler {
private final TenantProperties properties;
@Override
public Expression getTenantId() {
ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
String headValue = "";
if (sra != null) {
headValue = sra.getRequest().getHeader("tenantId");
}
return new StringValue(headValue);
}
@Override
public boolean ignoreTable(String tableName) {
return CollUtil.contains(properties.getIgnoreTables(), tableName); // 情况二,忽略多租户的表
}
}
原始sql,其中 r.tenant_id = ‘56’ 是mysql plus插件组装的
SELECT l.id, l.user_id, l.status, r.id AS item_id, r.order_id, r.status AS item_status, r.user_id AS item_user_id FROM pay_parent AS l LEFT JOIN pay_parent_item r ON l.id = r.order_id AND r.tenant_id = '56' WHERE 1 = 1 AND l.user_id = ? AND r.user_id = ? AND l.tenant_id = '56' LIMIT ?
c.i.p.d.m.p.P.selectPageDetail : ==> Parameters: 56(Integer), 56(Integer), 10(Long)
经过分库分表后的sql
ShardingSphere-SQL : Actual SQL: demo0 ::: SELECT l.id, l.user_id, l.status, r.id AS item_id, r.order_id, r.status AS item_status, r.user_id AS item_user_id FROM pay_parent_0 AS l LEFT JOIN pay_parent_item_0 r ON l.id = r.order_id AND r.tenant_id = '56' WHERE 1 = 1 AND l.user_id = ? AND r.user_id = ? AND l.tenant_id = '56' LIMIT ? ::: [56, 56, 10]
<== Total: 10
总结
租户的分库分表场景适合的业务包括租户的数据量较多,存储遇到瓶颈的情况,分库分表后会遇到如下几个问题,
1.复杂条件查询,例如按照时间查询多个租户的数据,做数据分析或者报表统计,这样分库分表性能还是存在问题
2.租户数据分摊均匀,租户数据是否能按照路由算法均匀分摊在不同的库或者表,取决于算法对于路由键是否均匀
3.每个租户的数据分布不是那么均匀,如何导致某个库的压力比较大,或者后续租户需要扩容,重新分布,如何处理。
对于2、3,在设计路由算法需要考虑路由的均匀性,以及重新扩容或者缩容上的可拓展性,尽量以合适的资源承载均匀的数据。对于1的问题,取决于实际业务的情况,实时性要求高的,可以采用冷热数据库,热裤承载近期的热点数据,实时要求低的也可以采用非关系型数据去存储,做好主备、容灾,异步拉取分库分表的数据。存储的架构的设计是随着业务扩大一步一步演化,需要在实践中一步一步探索适合自身业务的解决方案。