基于 MyBatis 实现多租户数据隔离的实践

欢迎大家共同探讨解决方案,欢迎私信。

目前面临的问题

多租户技术现在是一种很常见的软件技术。网上会有很多“优雅”的设计方案,但是在实际项目开发中,尤其是老项目,需要综合考虑“历史设计问题”、“对用户的影响程度”、“异常能否回滚”等各种因素。

目前项目现状:

  • 系统已经通过 region 字段区分多租户,大部分表都有这个字段,只有部分中间表(基于 id 关联)无此字段;所有的 SQL 中都带有 region 字段;
  • 【一个大坑】系统所有的表关联关系均使用自增 id 进行关联;
  • 目前同一套代码部署了两套环境,两个数据库;

项目要实现的功能:

  • 将两套环境合并为一套环境;

解决思路:

  • 系统多库
    • 根据环境路由库,这也是我一开始提的思路。但是种种原因,这个被否决;
  • 同库多 Schema
    • MySQL 不支持;
  • 同库同 Schema 同表
    • 唯一的方案;

同库同 Schema 同表需要解决的问题

使用该方案面临以下几个问题:

  • 数据合并问题
    • 所有表均使用自增 id 关联,迁移难度大;
  • 大表问题
    • 部分表数据量过大,合并后势必要进行拆分
      • 水平拆分

解决思路:

  • 数据合并问题

    • 迁移前梳理表关联关系;
    • 所有表 id 增加一个较大值,保证两个表不出现相同 id;同时关联 id 也增加该值;
  • 大表水平拆分

    • 根据哪个字段去分?
      • 无法根据自增 id 分,选择根据 region 拆分,如租户 1 走 e_admin_1 表,租户 2 走 e_admin_2 表;

根据租户标识拆分表

使用该方案面临以下几个问题:

  • 使用哪种技术?

    • Sharding-JDBC
    • MyBatis 插件
  • 如何获取当前环境

    • 用户登录如何获取当前是哪个租户环境?
    • 外部系统调用如何获取调用的是哪个租户环境?
    • 定时任务如何获取环境?

解决思路:

  • 使用哪种技术?

    • 结合对项目的改动影响以及由于历史包袱引起的功能定制问题,选择使用 MyBatis 插件
  • 如何获取当前环境

    • 用户登录
      • 系统会将当前登录用户的信息放在 HttpServletRequest 中,通过 RequestContextHolder 可以全局获取;
    • 外部系统调用
      • 外部调用接口中增加 region 租户标识参数,将该标识放入绑定当前请求的全局参数中
    • 定时任务
      • 循环所有租户,每次循环将 region 租户标识参数放入当前循环中;租户过多可以拆分多个定时任务;
  • region 到底放在哪

    • 可以放在ThreadLocal 中,但是由于可能会出现异步执行的 SQL,建议放在 ThreadLocal 的增加版 TransmittableThreadLocal 中(后续会有文章介绍);

实现代码

其实关键是思路,代码就很简单了。

租户标识:

/**
* 租户id存入 ThreadLocal
*/
public class RegionIsolationHolder implements AutoCloseable{

    public static final TransmittableThreadLocal<Long> ISOLATION_THREAD_LOCAL = new TransmittableThreadLocal<>();

    public static Long get() {
        return ISOLATION_THREAD_LOCAL.get();
    }

    public static void set(Long region) {
        ISOLATION_THREAD_LOCAL.set(region);
    }

    public static void remove() {
        ISOLATION_THREAD_LOCAL.remove();
    }

    @Override
    public void close() {
        remove();
    }
}

拦截器(主要就是根据当前租户环境去替换表):

@Slf4j
@Intercepts({
        @Signature(method = "prepare", type = StatementHandler.class, args = {Connection.class, Integer.class})
})
public class IsolationInterceptor implements Interceptor {

    /**
     * 逻辑表
     */
    @Getter
    @Setter
    private String[] logicTables;

    private static final String TABLE_NAME_SPLITTER = "_";

    private static final String PROPERTY_SPLITTER = ",";

    private static final Map<SystemOriginEnum, String[]> SYSTEM_ORIGIN_MAP = new ConcurrentHashMap<>(200);

    private static final Map<String, String> TRANS_MAP = new ConcurrentHashMap<>(200);

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        if (invocation.getTarget() instanceof StatementHandler) {
            StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
            MetaObject metaStatementHandler = SystemMetaObject.forObject(statementHandler);
            MappedStatement mappedStatement = (MappedStatement) metaStatementHandler.getValue("delegate.mappedStatement");
            BoundSql boundSql = statementHandler.getBoundSql();
            //获取SQL
            String sql = boundSql.getSql();

            Field field = boundSql.getClass().getDeclaredField("sql");
            field.setAccessible(true);
            field.set(boundSql, transSql(sql));
        }
        return invocation.proceed();
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {
        processProperty(properties);
    }

    private void processProperty(Properties properties) {
        try {
            BeanInfo info = Introspector.getBeanInfo(this.getClass(), Object.class);
            PropertyDescriptor[] propertyDescriptors = info.getPropertyDescriptors();
            Stream.of(propertyDescriptors).filter(desc -> !StringUtils.equals("properties",desc.getName())).forEach(desc -> {
                String value = properties.getProperty(desc.getName());
                if (StringUtils.isNotBlank(value)) {
                    try {
                        String[] strings = parsePropertyValue(value);
                        desc.getWriteMethod().invoke(this, new Object[]{strings});
                    } catch (IllegalAccessException | InvocationTargetException e) {
                        throw new IsolationException(ErrorCodeEnum.PROPERTY_ILLEGAL, e);
                    }
                }
            });
        } catch (IntrospectionException e) {
            throw new IsolationException(ErrorCodeEnum.PROPERTY_ILLEGAL, e);
        }
    }
    private String[] parsePropertyValue(String value){
        return Stream.of(value.split(PROPERTY_SPLITTER)).map(StringUtils::trim).toArray(String[]::new);
    }

    private String[] getPhysicalTables() {
        Long region = getRegion();
        return  Stream.of(logicTables).map(s -> concat(s, region)).toArray(String[]::new);
    }

    /**
     * 获取租户
     * @return
     */
    private Long getRegion() {
        Long region = RegionIsolationHolder.get();
        Optional.ofNullable(region).orElseThrow(() -> new IsolationException(ErrorCodeEnum.REGION_INVALID));
        return region;
    }

    public String concat(String source, Long region) {
        if (Objects.isNull(region)) {
            return source;
        }
        return new StringBuilder(source).append(TABLE_NAME_SPLITTER).append(region).toString();
    }

    /**
     *替换表名
     * @param sql
     * @return
     */
    private String transSql(String sql) {
        //todo 可加缓存
        return StringUtils.replaceEach(sql, logicTables, getPhysicalTables());
    }


    /**
     * 可以加入一些获取表名的相关操作,如缓存,提前解析等
     * @param sql
     * @return
     * @throws JSQLParserException
     */
    private static Set<String> parseStatement(String sql) throws JSQLParserException {
       /* List stmtList = SQLUtils.parseStatements(sql, JdbcConstants.MYSQL);
        return stmtList.stream().map(stmt->{
            MySqlSchemaStatVisitor visitor = new MySqlSchemaStatVisitor();
            stmt.accept(visitor);
            return visitor.getCurrentTable();
        }).collect(Collectors.toSet());*/
        return new HashSet<>(getTableList(getStatement(sql)));
    }

    private static Statement getStatement(String sql) throws JSQLParserException {
        return CCJSqlParserUtil.parse(new StringReader(sql));
    }

    private static List<String> getTableList(Statement statement){
        TablesNamesFinder tablesNamesFinder = new TablesNamesFinder();
        return tablesNamesFinder.getTableList(statement);
    }

}

欢迎关注公众号
​​​​​​在这里插入图片描述

你可能感兴趣的:(mybatis)