分库分表之ShardingSphere-JDBC

730 阅读11分钟

1、背景

在互联网及现代数字化企业应用中,随着业务量的增长,单一数据库服务器的存储容量、I/O吞吐量以及CPU处理能力都会达到瓶颈,导致查询响应时间延长,影响用户体验甚至服务宕机。为了满足日益增长的数据量,分库分表是一种有效的数据库优化手段,尤其适用于数据量大、并发高的业务场景。通过合理的设计和实施,不仅可以显著提升系统的性能,还能为未来的扩展提供更好的支持。

2、分库分表的概念

分库分表指的是将原本集中存储的数据按照一定的规则分散存储到多个数据库和/或多个表中。这样做可以将数据的读写操作分散到不同的物理节点上,从而提高数据处理的并发能力和系统的整体性能。

2.1、分库分表的优势

  1. 提高性能:通过将数据分散到不同的数据库或表中,可以显著减少每个数据库的负载,从而 提升查询速度和事务处理能力。
  2. 扩展性:允许系统根据需要动态添加新的存储节点,实现水平扩展。
  3. 高可用性:多个数据库实例意味着更高的容错能力和更易于实现的数据备份与恢复机制。
  4. 简化维护:因为数据被划分到了不同的库和表中,所以可以更方便地进行维护工作,如索引优化、统计分析等。

2.2、分库分表的类型

分库分表主要包括两种类型:垂直分片(Vertical Sharding)和水平分片(Horizontal Sharding)。

2.2.1 垂直分片:

  1. 定义:将不同业务模块的数据拆分到不同的数据库中。
  2. 优点:简化数据模型,减少单个数据库的压力。
  3. 缺点:可能导致跨库的Join操作,增加复杂性。

image.png

2.2.2 水平分片:

  1. 定义:将同一业务模块的数据按照某种规则拆分到多个表或多个数据库中。
  2. 优点:提升数据的并发处理能力。
  3. 缺点:跨表查询变得复杂,需要在应用层或中间件层面解决。

image.png

2.3 分库分表的实现形式

分库分表主要包括两种形式:Client(客户端代码编写)和Proxy(部署代理端)。

2.3.1. Client端分片

  1. 逻辑层处理:分片逻辑由应用程序代码直接控制,通常需要开发人员编写额外的代码来实现路由算法,决定数据应该存储在哪一个数据库实例中。
  2. 灵活性:开发者可以根据业务需求灵活地调整分片规则,例如按照用户ID、订单时间等标准进行分片。
  3. 透明度低:对应用程序来说,分片不是透明的,因此需要修改应用代码以适应分片架构。
  4. 性能开销:由于分片逻辑是在应用层实现的,可能会引入额外的计算开销,尤其是在复杂查询的情况下。
  5. 维护成本:随着业务的增长,维护分片逻辑的成本也会增加,因为每次更改分片策略都需要更新应用程序代码,并且可能涉及到版本控制和部署的问题。

2.3.2 Proxy端分片

  1. 代理层处理:分片逻辑由独立的代理服务或中间件负责,应用程序发送的标准SQL语句经过代理后被转发到正确的数据库实例上执行。
  2. 透明性高:对于应用程序而言,分片是完全透明的,开发者不需要关心具体的数据分布情况,只需像操作单一数据库那样工作即可。
  3. 易于管理:分片配置集中管理,便于监控和维护,降低了运维复杂度。
  4. 性能影响较小:相比于客户端分片,代理端分片减少了应用层的负担,但增加了网络通信的成本。
  5. 社区支持与成熟度:市场上存在多种成熟的分片代理解决方案,如MyCat、ShardingSphere等,它们提供了丰富的特性和良好的社区支持。

3、ShardingSphere-JDBC

ShardingSphere-JDBC定位为轻量级Java框架,在Java的JDBC层提供的额外服务。它使用客户端直连数据库,以jar 包形式提供服务,无需额外部署和依赖,可理解为增强版的JDBC驱动,完全兼容JDBC和各种ORM框架。

  • 适用于任何基于 JDBC 的 ORM 框架,如:JPA, Hibernate, Mybatis, Spring JDBC Template 或直接使用 JDBC;
  • 支持任何第三方的数据库连接池,如:DBCP, C3P0, BoneCP, HikariCP 等;
  • 支持任意实现 JDBC 规范的数据库,目前支持 MySQL,PostgreSQL,Oracle,SQLServer 以及任何可使用 JDBC 访问的数据库。

3.1 ShardingSphere-JDBC的架构

image.png

image.png

SQL 解析 :分为词法解析和语法解析。 先通过词法解析器将 SQL 拆分为一个个不可再分的单词。再使用语法解析器对 SQL 进行理解,并最终提炼出解析上下文。 解析上下文包括表、选择项、排序项、分组项、聚合函数、分页信息、查询条件以及可能需要修改的占位符的标记。

image.png SQL 路由 :根据解析上下文匹配用户配置的分片策略,并生成路由路径。目前支持分片路由和广播路由。

image.png SQL 改写 :将 SQL 改写为在真实数据库中可以正确执行的语句。SQL 改写分为正确性改写和优化改写。

image.png SQL 执行 :通过多线程执行器异步执行。

image.png 结果归并 :将多个执行结果集归并以便于通过统一的 JDBC 接口输出。结果归并包括流式归并、内存归并和使用装饰者模式的追加归并这几种方式。

image.png 查询优化 :由 Federation 执行引擎(开发中)提供支持,对关联查询、子查询等复杂查询进行优化,同时支持跨多个数据库实例的分布式查询,内部使用关系代数优化查询计划,通过最优计划查询出结果。

3.2 SpringBoot整合ShardingSphere-JDBC

implementation 'org.apache.shardingsphere:shardingsphere-jdbc-core-spring-boot-starter:5.2.1'
spring:
  shardingsphere:
    enabled: true
    props:
      sql-show: false # 显示SQL语句,方便调试,默认false
    datasource:
      names: master,slave # 数据源名称列表,需与上文一致
      master:
        type: com.zaxxer.hikari.HikariDataSource
        username: test
        password: test
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbcUrl: jdbc:mysql://xxxxxxxxx:33066/test?serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
        hikari:
          max-pool-size: 40
          minimum-idle: 20
          idleTimeout: 3500000
          maxLifetime: 3500000
          connectionTimeout: 10000
          autoCommit: true
      slave:
        type: com.zaxxer.hikari.HikariDataSource
        username: test
        password: test
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbcUrl: jdbc:mysql://xxxxxxxxx:33067/test?serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
        hikari:
          max-pool-size: 40
          minimum-idle: 20
          idleTimeout: 3500000
          maxLifetime: 3500000
          connectionTimeout: 10000
          autoCommit: true
    rules:
      # 读写分离配置
      readwrite-splitting:
        dataSources:
          ds_ms:
            staticStrategy:
              writeDataSourceName: master
              readDataSourceNames:
                - slave
            loadBalancerName: random
        # 负载策略,随机
        loadBalancers:
          random:
            type: RANDOM
      # 分片配置
      sharding:
        # 分片算法
        sharding-algorithms:
          # 自定义分片策略:以时间按年分片
          table-cus-standard-algorithm:
            type: INTERVAL
            props:
              datetime-pattern: yyyy-MM-dd HH:mm:ss
              datetime-lower: '2015-01-01 00:00:00'
              datetime-upper: '2029-12-31 23:59:59'
              sharding-suffix-pattern: yyyy
              datetime-interval-amount: 1
              datetime-interval-unit: YEARS
          # 自定义分片策略:使用订单号取模策略
          t_order_item_inline
            type: INLINE 
            props: 
              # 按照order_id进行取模分片
              algorithm-expression: t_biz_order_item_${order_id % 2}
        # 分片表配置
        tables:
          t_biz_task:
            actual-data-nodes: ds_ms.t_biz_task_$->{2015..2030}
            # 主键生成策略
            key-generate-strategy:
              column: id
              # 自定义id算法
              keyGeneratorName: cussnowflake
            # 数据表分片字段及分片算法
            table-strategy:
              standard:
                sharding-column: creation_time
                # 使用自定义分片算法
                sharding-algorithm-name: table-cus-standard-algorithm
          t_biz_history_task:
            actual-data-nodes: ds_ms.t_biz_history_task_$->{2015..2030}
            table-strategy:
              standard:
                sharding-column: completion_time
                sharding-algorithm-name: table-cus-standard-algorithm
		key-generators:
          t_biz_order_item: 
            actual-data-nodes: ds_ms.t_order_item_${0..1} 
            table-strategy: 
              standard: 
                shardingColumn: order_id 
                sharding-algorithm-name: t_order_item_inline
       # 雪花算法策略
       snowflake:
          type: SNOWFLAKE
          props:
            worker-id: 6
            max-vibration-offset: 15
       cussnowflake:
          type: CUS_SNOWFLAKE

3.2.1 分片策略及ID生成策略

ShardingSphere-JDBC内置了多种分片策略、ID生成策略、加密策略来对数据表处理,同时还通过SPI允许用户自定义算法实现数据分片、加密、ID生成等功能;

mindmap
      ShardingSphereAlgorithm
          EncryptAlgorithm(加密算法)
              MD5EncryptAlgorithm(MD5加密算法)
              AESEncryptAlgorithm(AES加密算法)
              RC4EncryptAlgorithm(RC4加密算法)
          ShardingAlgorithm(分片算法)
              ComplexKeysShardingAlgorithm(复合字段分片算法)
                  ClassBasedShardingAlgorithm(自定义类的分片算法)
                  ComplexInlineShardingAlgorithm(行表达式的复合分片算法)
              HintShardingAlgorithm
                  HintInlineShardingAlgorithm(行表达式的Hint分片算法)
              ShardingAutoTableAlgorithm
                  AutoIntervalShardingAlgorithm(可变时间范围的分片算法)
              StandardShardingAlgorithm
                  HashModShardingAlgorithm(哈希取模的分片算法)
                  ModShardingAlgorithm(取模的分片算法)
                  BoundaryBasedRangeShardingAlgorithm(分片边界的范围分片算法)
                  IntervalShardingAlgorithm(固定时间范围的分片算法)
                  InlineShardingAlgorithm(行表达式的分片算法)
                  CosIdModShardingAlgorithm(基于CosId的取模分片算法)
          KeyGenerateAlgorithm(Key生成算法)
              SnowflakeKeyGenerateAlgorithm(雪花算法)
              UUIDKeyGenerateAlgorithm(UUID生成算法)
              CosIdKeyGenerateAlgorithm(CosId生成算法)

以基于SPI实现自定义分片策略为例
标准分片算法

public interface StandardShardingAlgorithm<T extends Comparable<?>> extends ShardingAlgorithm {
  
    /**
     * 根据字段精准分片
     * @param availableTargetNames 有效的数据源及数据表名称
     * @param shardingValue 分片字段的值
     * @return 数据源及数据表的分片结果
     */
    String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<T> shardingValue);
    
    /**
     * 根据字段按照范围分片
     * @param availableTargetNames 有效的数据源及数据表名称
     * @param shardingValue 分片字段的值
     * @return 数据源及数据表的分片结果
     */
    Collection<String> doSharding(Collection<String> availableTargetNames, RangeShardingValue<T> shardingValue);
}

自定义分片算法
根据Timestamp进行数据分片,需要实现StandardShardingAlgorithm接口

public class TimestampShardingAlgorithms implements StandardShardingAlgorithm<Timestamp> {
    @Override
    public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Timestamp> shardingValue) {
        // 获取
        Timestamp shardingValueValue = shardingValue.getValue();
        for (String each : availableTargetNames) {
            if (each.endsWith("_" + DateUtil.year(shardingValueValue))) {
                return each;
            }
        }
        throw new IllegalArgumentException();
    }

    @Override
    public Collection<String> doSharding(Collection<String> availableTargetNames, RangeShardingValue<Timestamp> shardingValue) {
        Range<Timestamp> timestampRange = shardingValue.getValueRange();
        // 返回的表集合
        Collection<String> targetTables = new LinkedHashSet<>(availableTargetNames.size());
        // 获取起始时间、结束时间
        int startYear = DateUtil.year(timestampRange.lowerEndpoint());
        int endYear = DateUtil.year(timestampRange.upperEndpoint());
        for (int i = startYear; i <= endYear; i++) {
            for (String targetName : availableTargetNames) {
                if (targetName.endsWith(String.valueOf(i))) {
                    targetTables.add(targetName);
                }
            }
        }
        return targetTables;
    }
    @Override
    public void init() {}
    @Override
    public String getType() {
        return "TIME_BASE";
    }
    @Override
    public Properties getProps() {
        return null;
    }
    @Override
    public void setProps(Properties props) {}
}

根据SPI机制往文件中写入自定义分片算法的类全限定名

image.png
(此处是用的自定义Key生成算法的示例)
image.png

3.3.2 读写分离

上面的分片算法实现了不同规则对不同库,不同表进行访问,但是在日常项目中需要对进行数据读写分离来提升整体的性能,ShardingSphere-JDBC虽然提供了读写分离的功能,但是不够灵活,在MyBatis-Plus的Dynamic-DataSource中我们可以使用其提供的@DS注解来控制某个查询使用主库还是从库,在ShardingSphere-JDBC中可以使用Hint基于自定义注解来实现动态的主从切换。

@Aspect
@Component
public class ShardingSphereHintAspect {

    @Pointcut("@annotation(com.xxxx.order.annotation.ShardingHint)")
    public void masterDataSourcePointcut() {}

    @Before("masterDataSourcePointcut()")
    public void setMasterDataSourceHint() {
        HintManager hintManager = HintManager.getInstance();
        hintManager.setWriteRouteOnly();
    }

    @After("masterDataSourcePointcut()")
    public void closeHintManager() {
        HintManager.clear();
    }
}

3.3.3 集成Mybatis的问题

分片字段的问题
在集成了ShardingSphere-JDBC之后,因为之前的增删改查都没有相关的分片逻辑,如果需要附带相关数据分片处理,则需要对系统中大量的代码逻辑进行改动,存在漏改,错改的情况。基于这一点,可以使用Mybatis的拦截器功能对SQL进行拦截解析处理相关的分片字段。

@Component
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
public class MybatisShardingFiledInterceptor extends ShardingTableHandler implements Interceptor {

    private static final Logger logger = LoggerFactory.getLogger(MybatisShardingFiledInterceptor.class);

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        StatementHandler statementHandler = PluginUtils.realTarget(invocation.getTarget());
        MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
        // SQL 解析
        this.sqlParser(metaObject);
        BoundSql boundSql = (BoundSql) metaObject.getValue("delegate.boundSql");
        // sql包含分表的表才做处理
        if (rewriteTableFilter(boundSql.getSql())) {
            // 解析sql
            Statement statement = handler(boundSql.getSql());
            metaObject.setValue("delegate.boundSql.sql", statement.toString());
        }
        return invocation.proceed();
    }

    @Override
    public Object plugin(Object target) {
        return Interceptor.super.plugin(target);
    }

    @Override
    public void setProperties(Properties properties) {
        Interceptor.super.setProperties(properties);
    }
}

注册拦截器

// 注册分片字段拦截器
@Bean
public ConfigurationCustomizer configurationCustomizer() {
   return configuration -> {
      // 增加分表字段校验拦截器
      MybatisShardingFiledInterceptor mybatisShardingFiledInterceptor = new MybatisShardingFiledInterceptor();
      configuration.addInterceptor(mybatisShardingFiledInterceptor);
   };
}

对于需要分片的数据表,使用mybatis-plus的引入的sql解析组件JSQLParse来解析SQL,判断当前语句是否附带分片表的分片查询字段,如果不存在,则修改当前SQL,拼接上对应的分片字段

protected Statement handler(String boundSql) {
    // 解析sql
    try {
        Statement statement = CCJSqlParserUtil.parse(boundSql);
        if (statement instanceof Select) {
            this.processSelectBody(((Select) statement).getSelectBody());
        } else if (statement instanceof Update) {
            this.processUpdate((Update) statement);
        } else if (statement instanceof Delete) {
            this.processDelete((Delete) statement);
        }
        return statement;
    } catch (JSQLParserException jsqlParserException) {
        return null;
    }
}

在解决掉最基本的数据增删改查分片之后,发现在mybatis-plus提供的分页插件的count查询的时候,使用的是Connection.prepareStatement(sql)的方式,没有办法被我们自定义的分片拦截器捕获到,于是决定自定义count查询的sql解析类并提供给分页拦截器插件。


/**
 * COUNT SQL 解析
 */
protected ISqlParser countSqlParser;

@Override
public Object intercept(Invocation invocation) throws Throwable {
    StatementHandler statementHandler = PluginUtils.realTarget(invocation.getTarget());
    MetaObject metaObject = SystemMetaObject.forObject(statementHandler);

    // SQL 解析
    this.sqlParser(metaObject);

    // 先判断是不是SELECT操作  (2019-04-10 00:37:31 跳过存储过程)
    MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
    if (SqlCommandType.SELECT != mappedStatement.getSqlCommandType()
        || StatementType.CALLABLE == mappedStatement.getStatementType()) {
        return invocation.proceed();
    }

    // 针对定义了rowBounds,做为mapper接口方法的参数
    BoundSql boundSql = (BoundSql) metaObject.getValue("delegate.boundSql");
    Object paramObj = boundSql.getParameterObject();

    // 判断参数里是否有page对象
    IPage<?> page = ParameterUtils.findPage(paramObj).orElse(null);

    /*
     * 不需要分页的场合,如果 size 小于 0 返回结果集
     */
    if (null == page || page.getSize() < 0) {
        return invocation.proceed();
    }

    if (this.limit > 0 && this.limit <= page.getSize()) {
        //处理单页条数限制
        handlerLimit(page);
    }

    String originalSql = boundSql.getSql();
    Connection connection = (Connection) invocation.getArgs()[0];

    if (page.isSearchCount() && !page.isHitCount()) {
        // 自定义CountSQL解析类,替换这里的countSqlParser
        SqlInfo sqlInfo = SqlParserUtils.getOptimizeCountSql(page.optimizeCountSql(), countSqlParser, originalSql);
        this.queryTotal(sqlInfo.getSql(), mappedStatement, boundSql, page, connection);
        if (page.getTotal() <= 0) {
            return null;
        }
    }
    DbType dbType = this.dbType == null ? JdbcUtils.getDbType(connection.getMetaData().getURL()) : this.dbType;
    IDialect dialect = Optional.ofNullable(this.dialect).orElseGet(() -> DialectFactory.getDialect(dbType));
    String buildSql = concatOrderBy(originalSql, page);
    DialectModel model = dialect.buildPaginationSql(buildSql, page.offset(), page.getSize());
    Configuration configuration = mappedStatement.getConfiguration();
    List<ParameterMapping> mappings = new ArrayList<>(boundSql.getParameterMappings());
    Map<String, Object> additionalParameters = (Map<String, Object>) metaObject.getValue("delegate.boundSql.additionalParameters");
    model.consumers(mappings, configuration, additionalParameters);
    metaObject.setValue("delegate.boundSql.sql", model.getDialectSql());
    metaObject.setValue("delegate.boundSql.parameterMappings", mappings);
    return invocation.proceed();
}
public class MybatisShardingCountSqlParser  extends ShardingTableHandler implements ISqlParser {

    private static final List<SelectItem> COUNT_SELECT_ITEM = countSelectItem();
    private final Log logger = LogFactory.getLog(MybatisShardingCountSqlParser.class);
    private boolean optimizeJoin = false;

    @Override
    public SqlInfo parser(MetaObject metaObject, String sql) {
        if (logger.isDebugEnabled()) {
            logger.debug("JsqlParserCountOptimize sql=" + sql);
        }
        SqlInfo sqlInfo = SqlInfo.newInstance();
        try {
            Select selectStatement;
            // 自定义count sql解析器,修改sql
            if (rewriteTableFilter(sql)) {
                selectStatement = (Select) handler(sql);
            } else {
                selectStatement = (Select) CCJSqlParserUtil.parse(sql);
            }
            // 此处省略·····················
        }
    }
}

分片带来的主键id问题
在引入ShardingSphere-JDBC之后,发现使用mybatis-plus的batchSave之后不会返回主键id了,导致一些依赖主键关联的业务异常。在ShardingSphere-JDBC的github issues上面作者回复,他们不解决第三方ORM框架做的特殊处理。

batchSave successfully but cannot return the PK primary in shardingsphere-jdbc-core v5.3.1 with mybatis-plus-boot-starter · Issue #23775 · apache/shardingsphere · GitHub

image.png 针对这个问题,我们可以上面使用配置算法生成相关key或者根据自定义key生成算法来满足我们本身的id诉求。