• 多租户shardingSphere+mybatis实现实现


    多租户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;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82

    为了更方便和采用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;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    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); // 情况二,忽略多租户的表
        }
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    原始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)
    
    • 1
    • 2

    经过分库分表后的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

    总结

    租户的分库分表场景适合的业务包括租户的数据量较多,存储遇到瓶颈的情况,分库分表后会遇到如下几个问题,

    1.复杂条件查询,例如按照时间查询多个租户的数据,做数据分析或者报表统计,这样分库分表性能还是存在问题

    2.租户数据分摊均匀,租户数据是否能按照路由算法均匀分摊在不同的库或者表,取决于算法对于路由键是否均匀

    3.每个租户的数据分布不是那么均匀,如何导致某个库的压力比较大,或者后续租户需要扩容,重新分布,如何处理。

    对于2、3,在设计路由算法需要考虑路由的均匀性,以及重新扩容或者缩容上的可拓展性,尽量以合适的资源承载均匀的数据。对于1的问题,取决于实际业务的情况,实时性要求高的,可以采用冷热数据库,热裤承载近期的热点数据,实时要求低的也可以采用非关系型数据去存储,做好主备、容灾,异步拉取分库分表的数据。存储的架构的设计是随着业务扩大一步一步演化,需要在实践中一步一步探索适合自身业务的解决方案。

  • 相关阅读:
    理解React Hooks看这一篇就够了
    行业发展解读:互联网人,如何“变道”自动驾驶?
    C++入门(1):命名空间,IO流 输入输出,缺省参数
    nomachine连接无显示器的Ubuntu/Debian时黑屏
    CAN和CAN FD
    十一、Filter&Listener
    计算机网络(上)
    嵌入式Linux驱动开发(LCD屏幕专题)(四)
    代码随想录算法训练营第23期day14|二叉树层序遍历、226.翻转二叉树、101. 对称二叉树
    防火墙综合实验
  • 原文地址:https://blog.csdn.net/qq_17236715/article/details/127717084