Sharding-JDBC原始碼解析與vivo的定製開發

vivo互联网技术發表於2024-03-08

作者:vivo IT 平臺團隊 - Xiong Huanxin

Sharding-JDBC是在JDBC層提供服務的資料庫中介軟體,在分庫分表場景具有廣泛應用。本文對Sharding-JDBC的解析、路由、改寫、執行、歸併五大核心引擎進行了原始碼解析,並結合業務實踐經驗,總結了使用Sharding-JDBC的一些痛點問題並分享了對應的定製開發與改造方案。

本文原始碼基於Sharding-JDBC 4.1.1版本。

一、業務背景

隨著業務併發請求和資料規模的不斷擴大,單節點庫表壓力往往會成為系統的效能瓶頸。公司IT內部營銷庫存、交易訂單、財經臺賬、考勤記錄等多領域的業務場景的日增資料量巨大,存在著資料庫節點壓力過大、連線過多、查詢速度變慢等情況,根據資料來源、時間、工號等資訊來將沒有聯絡的資料儘量均分到不同的庫表中,從而在不影響業務需求的前提下,減輕資料庫節點壓力,提升查詢效率和系統穩定性

圖片

二、技術選型

我們對比了幾款比較常見的支援分庫分表和讀寫分離的中介軟體。

圖片

Sharding-JDBC作為輕量化的增強版的JDBC框架,相較其他中介軟體效能更好,接入難度更低,其資料分片、讀寫分離功能也覆蓋了我們的業務訴求,因此我們在業務中廣泛使用了Sharding-JDBC。但在使用Sharding-JDBC的過程中,我們也發現了諸多問題,為了業務更便捷的使用Sharding-JDBC,我們對原始碼做了針對性的定製開發和元件封裝來滿足業務需求。

圖片

三、原始碼解析

3.1 引言

Sharding-JDBC作為基於JDBC的資料庫中介軟體,實現了JDBC的標準api,Sharding-JDBC與原生JDBC的執行對比流程如下圖所示:

圖片

相關執行流程的程式碼樣例如下:

  • JDBC執行樣例

//獲取資料庫連線
try (Connection conn = DriverManager.getConnection("mysqlUrl", "userName", "password")) {
    String sql = "SELECT * FROM  t_user WHERE name = ?";
    //預編譯SQL
    try (PreparedStatement preparedStatement = conn.prepareStatement(sql)) {
        //引數設定與執行
        preparedStatement.setString(1, "vivo");
        preparedStatement.execute(sql);
        //獲取結果集
        try (ResultSet resultSet = preparedStatement.getResultSet()) {
            while (resultSet.next()) {
                //處理結果
            }
        }
    }
}
  • Sharding-JDBC 原始碼

org.apache.shardingsphere.shardingjdbc.jdbc.core.statement#execute
    public boolean execute() throws SQLException {
        try {

從對比的執行流程圖可見:

  • 【JDBC】:執行的主要流程是透過Datasource獲取Connection,再注入SQL語句生成PreparedStatement物件,PreparedStatement設定佔位符引數執行後得到結果集ResultSet。

  • 【Sharding-JDBC】:主要流程基本一致,但Sharding基於PreparedStatement進行了實現與擴充套件,具體實現類ShardingPreparedStatement中會抽象出解析、路由、重寫、歸併等引擎,從而實現分庫分表、讀寫分離等能力,每個引擎的作用說明如下表所示:

圖片

//*相關引擎的原始碼解析在下文會作更深入的闡述

3.2 解析引擎

3.2.1 引擎解析

解析引擎是Sharding-JDBC進行分庫分表邏輯的基礎,其作用是將SQL拆解為不可再分的原子符號(稱為token),再根據資料庫型別將這些token分類成關鍵字、表示式、運算子、字面量等不同型別,進而生成抽象語法樹,而語法樹是後續進行路由、改寫操作的前提(這也正是語法樹的存在使得Sharding-JDBC存在各式各樣的語法限制的原因之一)。

圖片

▲圖片來源:ShardingSphere 官方文件

4.x的版本採用ANTLR(ANother Tool for Language Recognition)作為解析引擎,在ShardingSphere-sql-parser-dialect模組中定義了適用於不同資料庫語法的解析規則(.g4檔案),idea中也可以下載ANTLR v4的外掛,輸入SQL檢視解析後的語法樹結果。

圖片

解析方法的入口在DataNodeRouter的createRouteContext方法中,解析引擎根據資料庫型別和SQL建立SQLParserExecutor執行得到解析樹,再透過ParseTreeVisitor()的visit方法,對解析樹進行處理得到SQLStatement。ANTLR支援listener和visitor兩種模式的介面,visitor方式可以更靈活的控制解析樹的遍歷過程,更適用於SQL解析的場景。

  • 解析引擎核心程式碼

org.apache.shardingsphere.underlying.route.DataNodeRouter#createRouteContext#96
    private RouteContext createRouteContext(final String sql, final List<Object> parameters, final boolean useCache) {
        //解析引擎解析SQL
        SQLStatement sqlStatement = parserEngine.parse(sql, useCache);
        try {
            SQLStatementContext sqlStatementContext = SQLStatementContextFactory.newInstance(metaData.getSchema(), sql, parameters, sqlStatement);
            return new RouteContext(sqlStatementContext, parameters, new RouteResult());
            // TODO should pass parameters for master-slave
        } catch (final IndexOutOfBoundsException ex) {
            return new RouteContext(new CommonSQLStatementContext(sqlStatement), parameters, new RouteResult());
        }
    }
 
org.apache.shardingsphere.sql.parser.SQLParserEngine#parse0#72
    private SQLStatement parse0(final String sql, final boolean useCache) {
        //快取
        if (useCache) {
            Optional<SQLStatement> cachedSQLStatement = cache.getSQLStatement(sql);
            if (cachedSQLStatement.isPresent()) {
                return cachedSQLStatement.get();
            }
        }
        //根據資料庫型別和sql生成解析樹
        ParseTree parseTree = new SQLParserExecutor(databaseTypeName, sql).execute().getRootNode();
        //ParseTreeVisitor的visit方法對解析樹進行處理得到SQLStatement
      SQLStatement result = (SQLStatement) ParseTreeVisitorFactory.newInstance(databaseTypeName, VisitorRule.valueOf(parseTree.getClass())).visit(parseTree);
        if (useCache) {
            cache.put(sql, result);
        }
        return result;
    }

SQLStatement實際上是一個介面,其實現對應著不同的SQL型別,如SelectStatement 類中就包括查詢的欄位、表名、where條件、分組、排序、分頁、lock等變數,可以看到這裡並沒有對having這種欄位做定義,相當於Sharding-JDBC無法識別到SQL中的having,這使得Sharding-JDBC對having語法有一定的限制。

  • SelectStatement

public final class SelectStatement extends DMLStatement {
    // 欄位
    private ProjectionsSegment projections;
    // 表
    private final Collection<TableReferenceSegment> tableReferences = new LinkedList<>();
    // where
    private WhereSegment where;
    // groupBy
    private GroupBySegment groupBy;
    // orderBy
    private OrderBySegment orderBy;
    // limit
    private LimitSegment limit;
    // 父statement
    private SelectStatement parentStatement;
    // lock
    private LockSegment lock;
}

SQLStatement還會被進一步轉換成SQLStatementContext,如SelectStatement 會被轉換成SelectStatementContext ,其結構與SelectStatement 類似不再多說,值得注意的是雖然這裡定義了containsSubquery來判斷是否包含子查詢,但4.1.1原始碼永遠是返回的false,與having類似,這意味著Sharding-JDBC不會對子查詢語句做特殊處理。

  • SelectStatementContext

public final class SelectStatementContext extends CommonSQLStatementContext<SelectStatement> implements TableAvailable, WhereAvailable {
     
    private final TablesContext tablesContext;
     
    private final ProjectionsContext projectionsContext;
     
    private final GroupByContext groupByContext;
     
    private final OrderByContext orderByContext;
     
    private final PaginationContext paginationContext;
     
    private final boolean containsSubquery;
}
 
    private boolean containsSubquery() {
        // FIXME process subquery
//        Collection<SubqueryPredicateSegment> subqueryPredicateSegments = getSqlStatement().findSQLSegments(SubqueryPredicateSegment.class);
//        for (SubqueryPredicateSegment each : subqueryPredicateSegments) {
//            if (!each.getAndPredicates().isEmpty()) {
//                return true;
//            }
//        }
        return false;
    }

3.2.2 引擎總結

解析引擎是進行路由改寫的前提基礎,其作用就是將SQL按照定義的語法規則拆分成原子符號(token),生成語法樹,根據不同的SQL型別生成對應的SQLStatement,SQLStatement由各自的Segment組成,所有的Segment都包含startIndex和endIndex來定位token在SQL中所屬的位置,但解析語法難以涵蓋所有的SQL場景,使得部分SQL無法按照預期的結果路由執行。

3.3 路由引擎

3.3.1 引擎解析

路由引擎是Sharding-JDBC的核心步驟,作用是根據定義的分庫分表規則將解析引擎生成的SQL上下文生成對應的路由結果,RouteResult 包括DataNode和RouteUnit,DataNode是實際的資料來源節點,包括資料來源名稱和實際的物理表名,RouteUnit則記錄了邏輯表/庫與物理表/庫的對映關係,後面的改寫引擎也是根據這個對映關係來決定如何替換SQL中的邏輯表(實際上RouteResult 就是維護了一條SQL需要往哪些庫哪些表執行的關係)。

  • RouteResult

public final class RouteResult {
     
    private final Collection<Collection<DataNode>> originalDataNodes = new LinkedList<>();
     
    private final Collection<RouteUnit> routeUnits = new LinkedHashSet<>();
}
 
public final class DataNode {
     
    private static final String DELIMITER = ".";
     
    private final String dataSourceName;
     
    private final String tableName;
}
 
public final class RouteUnit {
     
    private final RouteMapper dataSourceMapper;
     
    private final Collection<RouteMapper> tableMappers;
}
 
public final class RouteMapper {
     
    private final String logicName;
     
    private final String actualName;
}

其中,路由有分為分片路由主從路由,兩者可以單獨使用,也可以組合使用。

  • 分片路由

ShardingRouteDecorator的decorate方法是路由引擎的核心邏輯,經過SQL校驗->生成分片條件->合併分片值後得到路由結果。

  • 分片路由decorate方法

org.apache.shardingsphere.sharding.route.engine.ShardingRouteDecorator#decorate#57
public RouteContext decorate(final RouteContext routeContext, final ShardingSphereMetaData metaData, final ShardingRule shardingRule, final ConfigurationProperties properties) {
        SQLStatementContext sqlStatementContext = routeContext.getSqlStatementContext();
        List<Object> parameters = routeContext.getParameters();
        //SQL校驗  校驗INSERT INTO .... ON DUPLICATE KEY UPDATE 和UPDATE語句中是否存在分片鍵
      ShardingStatementValidatorFactory.newInstance(
                sqlStatementContext.getSqlStatement()).ifPresent(validator -> validator.validate(shardingRule, sqlStatementContext.getSqlStatement(), parameters));
        //生成分片條件
        ShardingConditions shardingConditions = getShardingConditions(parameters, sqlStatementContext, metaData.getSchema(), shardingRule);
        //合併分片值
        boolean needMergeShardingValues = isNeedMergeShardingValues(sqlStatementContext, shardingRule);
        if (sqlStatementContext.getSqlStatement() instanceof DMLStatement && needMergeShardingValues) {
            checkSubqueryShardingValues(sqlStatementContext, shardingRule, shardingConditions);
            mergeShardingConditions(shardingConditions);
        }
        ShardingRouteEngine shardingRouteEngine = ShardingRouteEngineFactory.newInstance(shardingRule, metaData, sqlStatementContext, shardingConditions, properties);
        //得到路由結果
        RouteResult routeResult = shardingRouteEngine.route(shardingRule);
        if (needMergeShardingValues) {
            Preconditions.checkState(1 == routeResult.getRouteUnits().size(), "Must have one sharding with subquery.");
        }
        return new RouteContext(sqlStatementContext, parameters, routeResult);
    }

ShardingStatementValidator有ShardingInsertStatementValidator和ShardingUpdateStatementValidator兩種實現,INSERT INTO .... ON DUPLICATE KEY UPDATE和UPDATE語法都會涉及到欄位值的更新,Sharding-JDBC是不允許更新分片值的,畢竟修改分片值還需要將資料遷移至新分片值對應的庫表中,才能保證資料分片規則一致。兩者的校驗細節也有所不同:

  • INSERT INTO .... ON DUPLICATE KEY UPDATE僅僅是對UPDATE欄位的校驗, ON DUPLICATE KEY UPDATE中包含分片鍵就會報錯;

  • 而UPDATE語句則會額外校驗WHERE條件中分片鍵的原始值和SET的值是否一樣,不一樣則會丟擲異常。

圖片

ShardingCondition中只有一個變數routeValues,RouteValue是一個介面,有ListRouteValue和RangeRouteValue兩種實現,前者記錄了分片鍵的in或=條件的分片值,後者則記錄了範圍查詢的分片值,兩者被封裝為ShardingValue物件後,將會透傳至分片演算法中計算得到分片結果集。

  • ShardingCondition

public final class ShardingConditions {
     
    private final List<ShardingCondition> conditions;
}
 
public class ShardingCondition {
     
    private final List<RouteValue> routeValues = new LinkedList<>();
}
 
 
public final class ListRouteValue<T extends Comparable<?>> implements RouteValue {
     
    private final String columnName;
     
    private final String tableName;
    //in或=條件對應的值
    private final Collection<T> values;
     
    @Override
    public String toString() {
        return tableName + "." + columnName + (1 == values.size() ? " = " + new ArrayList<>(values).get(0) : " in (" + Joiner.on(",").join(values) + ")");
    }
}
 
public final class RangeRouteValue<T extends Comparable<?>> implements RouteValue {
     
    private final String columnName;
     
    private final String tableName;
    //between and 大於小於等範圍值的上下限
    private final Range<T> valueRange;
}

生成分片條件後還會合並分片條件,但是前文提過在SelectStatementContext中的containsSubquery永遠是false,所以這段邏輯永遠返回false,即不會合並分片條件。

  • 判斷是否需要合併分片條件

org.apache.shardingsphere.sharding.route.engine.ShardingRouteDecorator#isNeedMergeShardingValues#87
private boolean isNeedMergeShardingValues(final SQLStatementContext sqlStatementContext, final ShardingRule shardingRule) {
        return sqlStatementContext instanceof SelectStatementContext && ((SelectStatementContext) sqlStatementContext).isContainsSubquery()
                && !shardingRule.getShardingLogicTableNames(sqlStatementContext.getTablesContext().getTableNames()).isEmpty();
    }

然後就是透過分片路由引擎呼叫分片演算法計算路由結果了,ShardingRouteEngine實現較多,介紹起來篇幅較多,這裡就不展開說明了,可以參考官方文件來了解路由引擎的選擇規則

圖片

▲圖片來源:ShardingSphere 官方文件

Sharding-JDBC定義了多種分片策略和演算法介面,主要的分配策略與演算法說明如下表所示:

圖片

補充兩個細節:

(1)當ALLOW_RANGE_QUERY_WITH_INLINE_SHARDING配置設定true時,InlineShardingStrategy支援範圍查詢,但是並不是根據分片值計算範圍,而是直接全路由至配置的資料節點,會存在效能隱患。

  • InlineShardingStrategy.doSharding

org.apache.shardingsphere.core.strategy.route.inline.InlineShardingStrategy#doSharding
    public Collection<String> doSharding(final Collection<String> availableTargetNames, final Collection<RouteValue> shardingValues, final ConfigurationProperties properties) {
        RouteValue shardingValue = shardingValues.iterator().next();
        //ALLOW_RANGE_QUERY_WITH_INLINE_SHARDING設定為true,直接返回availableTargetNames,而不是根據RangeRouteValue計算
        if (properties.<Boolean>getValue(ConfigurationPropertyKey.ALLOW_RANGE_QUERY_WITH_INLINE_SHARDING) && shardingValue instanceof RangeRouteValue) {
            return availableTargetNames;
        }
        Preconditions.checkState(shardingValue instanceof ListRouteValue, "Inline strategy cannot support this type sharding:" + shardingValue.toString());
        Collection<String> shardingResult = doSharding((ListRouteValue) shardingValue);
        Collection<String> result = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
        for (String each : shardingResult) {
            if (availableTargetNames.contains(each)) {
                result.add(each);
            }
        }
        return result;
    }

(2)4.1.1的官方文件雖然說Hint可以跳過解析和改寫,但在我們上面解析引擎的原始碼解析中,我們並沒有看到有對Hint策略的額外跳過。事實上,即使使用了Hint分片SQL也同樣需要解析重寫,也同樣受Sharding-JDBC的語法限制,這在官方的issue中也曾經被提及。

圖片

▲圖片來源:ShardingSphere 官方文件

  • 主從路由

主從路由的核心邏輯就是透過MasterSlaveDataSourceRouter的route方法進行判定SQL走主庫還是從庫。主從情況下,配置的資料來源實際是一組主從,而不是單個的例項,所以需要透過masterSlaveRule獲取到具體的主庫或者從庫名字。

  • 主從路由decorate

org.apache.shardingsphere.masterslave.route.engine.MasterSlaveRouteDecorator#decorate    
    public RouteContext decorate(final RouteContext routeContext, final ShardingSphereMetaData metaData, final MasterSlaveRule masterSlaveRule, final ConfigurationProperties properties) {
        //為空證明沒有經過分片路由
        if (routeContext.getRouteResult().getRouteUnits().isEmpty()) {
            //根據SQL判斷選擇走主庫還是從庫
            String dataSourceName = new MasterSlaveDataSourceRouter(masterSlaveRule).route(routeContext.getSqlStatementContext().getSqlStatement());
            RouteResult routeResult = new RouteResult();
           //根據具體的主庫/從庫名建立路由單元
            routeResult.getRouteUnits().add(new RouteUnit(new RouteMapper(dataSourceName, dataSourceName), Collections.emptyList()));
            return new RouteContext(routeContext.getSqlStatementContext(), Collections.emptyList(), routeResult);
        }
        Collection<RouteUnit> toBeRemoved = new LinkedList<>();
        Collection<RouteUnit> toBeAdded = new LinkedList<>();
        //不為空證明已經被分片路由處理了
        for (RouteUnit each : routeContext.getRouteResult().getRouteUnits()) {
            if (masterSlaveRule.getName().equalsIgnoreCase(each.getDataSourceMapper().getActualName())) {
                //先標記移除 因為這裡是一組主從的名字而不是實際的庫
                toBeRemoved.add(each);
                //根據SQL判斷選擇走主庫還是從庫
                String actualDataSourceName = new MasterSlaveDataSourceRouter(masterSlaveRule).route(routeContext.getSqlStatementContext().getSqlStatement());
                //根據具體的主庫/從庫名建立路由單元
                toBeAdded.add(new RouteUnit(new RouteMapper(each.getDataSourceMapper().getLogicName(), actualDataSourceName), each.getTableMappers()));
            }
        }
        routeContext.getRouteResult().getRouteUnits().removeAll(toBeRemoved);
        routeContext.getRouteResult().getRouteUnits().addAll(toBeAdded);
        return routeContext;
    }

MasterSlaveDataSourceRouter中isMasterRoute方法會判斷SQL是否需要走主庫,當出現以下情況時走主庫:

  • select語句包含鎖,如for update語句

  • 不是select語句

  • MasterVisitedManager.isMasterVisited()設定為true

  • HintManager.isMasterRouteOnly()設定為true

不走主庫則透過負載演算法選擇從庫,Sharding-JDBC提供了輪詢和隨機兩種演算法。

  • MasterSlaveDataSourceRouter

public final class MasterSlaveDataSourceRouter {
     
    private final MasterSlaveRule masterSlaveRule;
     
    /**
     * Route.
     *
     * @param sqlStatement SQL statement
     * @return data source name
     */
    public String route(final SQLStatement sqlStatement) {
        if (isMasterRoute(sqlStatement)) {
            MasterVisitedManager.setMasterVisited();
            return masterSlaveRule.getMasterDataSourceName();
        }
        return masterSlaveRule.getLoadBalanceAlgorithm().getDataSource(
                masterSlaveRule.getName(), masterSlaveRule.getMasterDataSourceName(), new ArrayList<>(masterSlaveRule.getSlaveDataSourceNames()));
    }
     
    private boolean isMasterRoute(final SQLStatement sqlStatement) {
        return containsLockSegment(sqlStatement) || !(sqlStatement instanceof SelectStatement) || MasterVisitedManager.isMasterVisited() || HintManager.isMasterRouteOnly();
    }
     
    private boolean containsLockSegment(final SQLStatement sqlStatement) {
        return sqlStatement instanceof SelectStatement && ((SelectStatement) sqlStatement).getLock().isPresent();
    }
}

是否走主庫的資訊存在MasterVisitedManager中,MasterVisitedManager是透過ThreadLocal實現的,但這種實現會有一個問題,當我們使用事務先查詢再更新/插入時,第一條查詢SQL並不會走主庫,而是走從庫,如果業務需要事務的第一條查詢也走主庫,事務查詢前需要手動呼叫一次MasterVisitedManager.setMasterVisited()。

  • MasterVisitedManager

public final class MasterVisitedManager {
     
    private static final ThreadLocal<Boolean> MASTER_VISITED = ThreadLocal.withInitial(() -> false);
     
    /**
     * Judge master data source visited in current thread.
     *
     * @return master data source visited or not in current thread
     */
    public static boolean isMasterVisited() {
        return MASTER_VISITED.get();
    }
     
    /**
     * Set master data source visited in current thread.
     */
    public static void setMasterVisited() {
        MASTER_VISITED.set(true);
    }
     
    /**
     * Clear master data source visited.
     */
    public static void clear() {
        MASTER_VISITED.remove();
    }
}

3.3.2 引擎總結

路由引擎的作用是將SQL根據引數透過實現的策略演算法計算出實際該在哪些庫的哪些表執行,也就是路由結果。路由引擎有兩種實現,分別是分片路由和主從路由,兩者都提供了標準化的策略介面來讓業務實現自己的路由策略,分片路由需要注意自身SQL場景和策略演算法相匹配,主從路由中同一執行緒且同一資料庫連線內,有寫入操作後,之後的讀操作會從主庫讀取,寫入操作前的讀操作不會走主庫。

3.4 改寫引擎

3.4.1 引擎解析

經過解析路由後雖然確定了執行的實際庫表,但SQL中表名依舊是邏輯表,不能執行,改寫引擎可以將邏輯表替換為物理表。同時,路由至多庫表的SQL也需要拆分為多條SQL執行。

改寫的入口仍舊在BasePrepareEngine中,建立重寫上下文createSQLRewriteContext,再根據上下文進行改寫rewrite,最終返回執行單元ExecutionUnit。

  • 改寫邏輯入口

org.apache.shardingsphere.underlying.pluggble.prepare.BasePrepareEngine#executeRewrite
    private Collection<ExecutionUnit> executeRewrite(final String sql, final List<Object> parameters, final RouteContext routeContext) {
        //註冊重寫裝飾器
        registerRewriteDecorator();
        //建立 SQLRewriteContext
        SQLRewriteContext sqlRewriteContext = rewriter.createSQLRewriteContext(sql, parameters, routeContext.getSqlStatementContext(), routeContext);
        //重寫
        return routeContext.getRouteResult().getRouteUnits().isEmpty() ? rewrite(sqlRewriteContext) : rewrite(routeContext, sqlRewriteContext);
    }

執行單元包含了資料來源名稱,改寫後的SQL,以及對應的引數,SQL一樣的兩個SQLUnit會被視為相等。

  • ExecutionUnit

@RequiredArgsConstructor
@Getter
@EqualsAndHashCode
@ToString
public final class ExecutionUnit {
     
    private final String dataSourceName;
     
    private final SQLUnit sqlUnit;
}
 
@AllArgsConstructor
@RequiredArgsConstructor
@Getter
@Setter
//根據sql判斷是否相等
@EqualsAndHashCode(of = { "sql" })
@ToString
public final class SQLUnit {
 
    private String sql;
 
    private final List<Object> parameters;
 
}

createSQLRewriteContext完成了兩件事,一個是對SQL引數進行了重寫,一個是生成了SQLToken。

  • createSQLRewriteContext

org.apache.shardingsphere.underlying.rewrite.SQLRewriteEntry#createSQLRewriteContext
    public SQLRewriteContext createSQLRewriteContext(final String sql, final List<Object> parameters, final SQLStatementContext sqlStatementContext, final RouteContext routeContext) {
        SQLRewriteContext result = new SQLRewriteContext(schemaMetaData, sqlStatementContext, sql, parameters);
        //sql引數重寫
        decorate(decorators, result, routeContext);
        //生成SQLToken
        result.generateSQLTokens();
        return result;
    }
 
org.apache.shardingsphere.sharding.rewrite.context.ShardingSQLRewriteContextDecorator#decorate
    public void decorate(final ShardingRule shardingRule, final ConfigurationProperties properties, final SQLRewriteContext sqlRewriteContext) {
        for (ParameterRewriter each : new ShardingParameterRewriterBuilder(shardingRule, routeContext).getParameterRewriters(sqlRewriteContext.getSchemaMetaData())) {
            if (!sqlRewriteContext.getParameters().isEmpty() && each.isNeedRewrite(sqlRewriteContext.getSqlStatementContext())) {
                //引數重寫
                each.rewrite(sqlRewriteContext.getParameterBuilder(), sqlRewriteContext.getSqlStatementContext(), sqlRewriteContext.getParameters());
            }
        }
        //sqlTokenGenerators
        sqlRewriteContext.addSQLTokenGenerators(new ShardingTokenGenerateBuilder(shardingRule, routeContext).getSQLTokenGenerators());
    }
 
org.apache.shardingsphere.underlying.rewrite.context.SQLRewriteContext#generateSQLTokens
    public void generateSQLTokens() {
        sqlTokens.addAll(sqlTokenGenerators.generateSQLTokens(sqlStatementContext, parameters, schemaMetaData));
    }

ParameterRewriter中與分片相關的實現有兩種。

圖片

//*詳細的例子可以參考官方文件中分頁修正和補列部分

SQLToken記錄了SQL中每個token(解析引擎中提過的不可再分的原子符號)的起始位置,從而方便改寫引擎知道哪些位置需要改寫。

  • SQLToken

@RequiredArgsConstructor
@Getter
public abstract class SQLToken implements Comparable<SQLToken> {
     
    private final int startIndex;
     
    @Override
    public final int compareTo(final SQLToken sqlToken) {
        return startIndex - sqlToken.getStartIndex();
    }
}

建立完SQLRewriteContext後就對整條SQL進行重寫和組裝引數,可以看出每個RouteUnit都會重寫SQL並獲取自己對應的引數。

  • SQLRouteRewriteEngine.rewrite

org.apache.shardingsphere.underlying.rewrite.engine.SQLRouteRewriteEngine#rewrite
    public Map<RouteUnit, SQLRewriteResult> rewrite(final SQLRewriteContext sqlRewriteContext, final RouteResult routeResult) {
        Map<RouteUnit, SQLRewriteResult> result = new LinkedHashMap<>(routeResult.getRouteUnits().size(), 1);
        for (RouteUnit each : routeResult.getRouteUnits()) {
            //重寫SQL+組裝引數
            result.put(each, new SQLRewriteResult(new RouteSQLBuilder(sqlRewriteContext, each).toSQL(), getParameters(sqlRewriteContext.getParameterBuilder(), routeResult, each)));
        }
        return result;
    }

toSQL核心就是根據SQLToken將SQL拆分改寫再拼裝,比如select * from t_order where created_by = '123' 就會被拆分為select * from | t_order | where created_by = '123'三部分進行改寫拼裝。

  • toSQL

org.apache.shardingsphere.underlying.rewrite.sql.impl.AbstractSQLBuilder#toSQL
    public final String toSQL() {
        if (context.getSqlTokens().isEmpty()) {
            return context.getSql();
        }
        Collections.sort(context.getSqlTokens());
        StringBuilder result = new StringBuilder();
        //擷取第一個SQLToken之前的內容  select * from
        result.append(context.getSql().substring(0, context.getSqlTokens().get(0).getStartIndex()));
        for (SQLToken each : context.getSqlTokens()) {
            //重寫拼接每個SQLToken對應的內容  t_order ->t_order_0
            result.append(getSQLTokenText(each));
            //拼接SQLToken中間不變的內容 where created_by = '123'
            result.append(getConjunctionText(each));
        }
        return result.toString();
    }

ParameterBuilder有StandardParameterBuilder和GroupedParameterBuilder兩個實現。

  • StandardParameterBuilder:適用於非insert語句,getParameters無需分組處理直接返回即可

  • GroupedParameterBuilder:適用於insert語句,需要根據路由情況對引數進行分組。

原因和樣例可以參考官方文件批次拆分部分

  • getParameters

org.apache.shardingsphere.underlying.rewrite.engine.SQLRouteRewriteEngine#getParameters
    private List<Object> getParameters(final ParameterBuilder parameterBuilder, final RouteResult routeResult, final RouteUnit routeUnit) {
        if (parameterBuilder instanceof StandardParameterBuilder || routeResult.getOriginalDataNodes().isEmpty() || parameterBuilder.getParameters().isEmpty()) {
            //非插入語句直接返回
            return parameterBuilder.getParameters();
        }
        List<Object> result = new LinkedList<>();
        int count = 0;
        for (Collection<DataNode> each : routeResult.getOriginalDataNodes()) {
            if (isInSameDataNode(each, routeUnit)) {
                //插入語句引數分組構造
                result.addAll(((GroupedParameterBuilder) parameterBuilder).getParameters(count));
            }
            count++;
        }
        return result;
    }

3.4.2 引擎總結

改寫引擎的作用是將邏輯SQL轉換為實際可執行的SQL,這其中既有邏輯表名的替換,也有多路由的SQL拆分,還有為了後續歸併操作而進行的分頁、分組、排序等改寫,select語句不會對引數進行重組,而insert語句為了避免插入多餘資料,會透過路由單元對引數進行重組。

3.5 執行引擎

3.5.1 引擎解析

改寫完成後的SQL就可以執行了,執行引擎需要平衡好資源和效率,如果為每條真實SQL都建立一個資料庫連線顯然會造成資源的濫用,但如果單執行緒序列也必然會影響執行效率。

執行引擎會先將執行單元中需要執行的SQLUnit根據資料來源分組,同一個資料來源下的SQLUnit會放入一個list,然後會根據maxConnectionsSizePerQuery對同一個資料來源的SQLUnit繼續分組,建立連線並繫結SQLUnit 。

  • 執行組建立

org.apache.shardingsphere.sharding.execute.sql.prepare.SQLExecutePrepareTemplate#getSynchronizedExecuteUnitGroups
    private Collection<InputGroup<StatementExecuteUnit>> getSynchronizedExecuteUnitGroups(
            final Collection<ExecutionUnit> executionUnits, final SQLExecutePrepareCallback callback) throws SQLException {
        //根據資料來源將SQLUnit分組 key=dataSourceName
        Map<String, List<SQLUnit>> sqlUnitGroups = getSQLUnitGroups(executionUnits);
        Collection<InputGroup<StatementExecuteUnit>> result = new LinkedList<>();
        //建立sql執行組
        for (Entry<String, List<SQLUnit>> entry : sqlUnitGroups.entrySet()) {
            result.addAll(getSQLExecuteGroups(entry.getKey(), entry.getValue(), callback));
        }
        return result;
    }
 
org.apache.shardingsphere.sharding.execute.sql.prepare.SQLExecutePrepareTemplate#getSQLExecuteGroups
    private List<InputGroup<StatementExecuteUnit>> getSQLExecuteGroups(final String dataSourceName,
                                                                       final List<SQLUnit> sqlUnits, final SQLExecutePrepareCallback callback) throws SQLException {
        List<InputGroup<StatementExecuteUnit>> result = new LinkedList<>();
        //每個連線需要執行的最大sql數量
        int desiredPartitionSize = Math.max(0 == sqlUnits.size() % maxConnectionsSizePerQuery ? sqlUnits.size() / maxConnectionsSizePerQuery : sqlUnits.size() / maxConnectionsSizePerQuery + 1, 1);
        //分組,每組對應一條資料庫連線
        List<List<SQLUnit>> sqlUnitPartitions = Lists.partition(sqlUnits, desiredPartitionSize);
        //選擇連線模式 連線限制/記憶體限制
        ConnectionMode connectionMode = maxConnectionsSizePerQuery < sqlUnits.size() ? ConnectionMode.CONNECTION_STRICTLY : ConnectionMode.MEMORY_STRICTLY;
        //建立連線
        List<Connection> connections = callback.getConnections(connectionMode, dataSourceName, sqlUnitPartitions.size());
        int count = 0;
        for (List<SQLUnit> each : sqlUnitPartitions) {
            //繫結連線和SQLUnit 建立StatementExecuteUnit
            result.add(getSQLExecuteGroup(connectionMode, connections.get(count++), dataSourceName, each, callback));
        }
        return result;
    }

SQLUnit分組和連線模式選擇沒有任何關係,連線模式的選擇只取決於maxConnectionsSizePerQuery和SQLUnit數量的大小關係,maxConnectionsSizePerQuery代表了一個資料來源一次查詢允許的最大連線數。

  • 當maxConnectionsSizePerQuery<sqlunit數量時,意味著無法做到每個sqlunit獨享一個連線,需要直接查詢出結果集至記憶體中;< li="">

  • 當maxConnectionsSizePerQuery>=SQLUnit數量時,意味著可以支援每個SQLUnit獨享一個連線,可以透過ResultSet遊標下移的方式查詢結果集。

不過maxConnectionsSizePerQuery預設值為1,所以當一條SQL需要路由至多張表時(即有多個SQLUnit)會採用連線限制,當路由至單表時是記憶體限制模式。

圖片

為了避免產生資料庫連線死鎖問題,在記憶體限制模式時,Sharding-JDBC透過鎖住資料來源物件一次性建立出本條SQL需要的所有資料庫連線。連線限制模式下,各連線一次性查出各自的結果,不會出現多連線相互等待的情況,因此不會發生死鎖,而記憶體限制模式透過遊標讀取結果集,需要多條連線去查詢不同的表做合併,如果不一次性拿到所有需要的連線,則可能存在連線相互等待的情況造成死鎖。可以參照官方文件中執行引擎相關例子

  • 不同連線模式建立連線

private List<Connection> createConnections(final String dataSourceName, final ConnectionMode connectionMode, final DataSource dataSource, final int connectionSize) throws SQLException {
    if (1 == connectionSize) {
        Connection connection = createConnection(dataSourceName, dataSource);
        replayMethodsInvocation(connection);
        return Collections.singletonList(connection);
    }
    if (ConnectionMode.CONNECTION_STRICTLY == connectionMode) {
        return createConnections(dataSourceName, dataSource, connectionSize);
    }
    //記憶體限制模式加鎖 一次性獲取所有的連線
    synchronized (dataSource) {
        return createConnections(dataSourceName, dataSource, connectionSize);
    }
}

此外,結果集的記憶體合併和流式合併只在呼叫JDBC的executeQuery的情況下生效,如果使用execute方式進行查詢,都是統一使用流式方式的查詢。

  • 查詢結果歸併對比

org.apache.shardingsphere.shardingjdbc.executor.PreparedStatementExecutor#executeQuery#101 
  org.apache.shardingsphere.shardingjdbc.executor.PreparedStatementExecutor#getQueryResult
    private QueryResult getQueryResult(final Statement statement, final ConnectionMode connectionMode) throws SQLException {
        PreparedStatement preparedStatement = (PreparedStatement) statement;
        ResultSet resultSet = preparedStatement.executeQuery();
        getResultSets().add(resultSet);
        //executeQuery 中根據連線模式選擇流式/記憶體
        return ConnectionMode.MEMORY_STRICTLY == connectionMode ? new StreamQueryResult(resultSet) : new MemoryQueryResult(resultSet);
    }
 
//execute 單獨呼叫getResultSet中只會使用流式合併
org.apache.shardingsphere.shardingjdbc.jdbc.core.statement.ShardingPreparedStatement#getResultSet#158
  org.apache.shardingsphere.shardingjdbc.jdbc.core.statement.ShardingPreparedStatement#getQueryResults 
    private List<QueryResult> getQueryResults(final List<ResultSet> resultSets) throws SQLException {
        List<QueryResult> result = new ArrayList<>(resultSets.size());
        for (ResultSet each : resultSets) {
            if (null != each) {
                result.add(new StreamQueryResult(each));
            }
        }
        return result;
    }

多條連線的執行方式分為序列和並行,在本地事務和XA事務中是序列的方式,其餘情況是並行,具體的執行邏輯這裡就不再展開了。

  • isHoldTransaction

public boolean isHoldTransaction() {
        return (TransactionType.LOCAL == transactionType && !getAutoCommit()) || (TransactionType.XA == transactionType && isInShardingTransaction());
    }

3.5.2 引擎總結

執行引擎透過maxConnectionsSizePerQuery和同資料來源的SQLUnit的數量大小確定連線模式,maxConnectionsSizePerQuery=SQLUnit數量使用記憶體限制模式,當使用記憶體限制模式時會透過對資料來源物件加鎖來保證一次性獲取本條SQL需要的連線而避免死鎖。在使用executeQuery查詢時,處理結果集時會根據連線模式選擇流式或者記憶體合併,但使用execute方法查詢,處理結果集只會使用流式合併。

3.6 歸併引擎

3.6.1 引擎解析

查詢出的結果集需要經過歸併引擎歸併後才是最終的結果,歸併的核心入口在MergeEntry的process方法中,優先處理分片場景的合併,再進行脫敏,只有讀寫分離的情況下則直接返回TransparentMergedResult,TransparentMergedResult實際上沒做合併的額外處理,其內部實現都是完全呼叫queryResult的實現。

圖片

  • 歸併邏輯入口
org.apache.shardingsphere.shardingjdbc.jdbc.core.statement.ShardingPreparedStatement#mergeQuery#190
 org.apache.shardingsphere.underlying.pluggble.merge.MergeEngine#merge#61
    org.apache.shardingsphere.underlying.merge.MergeEntry#process
    public MergedResult process(final List<QueryResult> queryResults, final SQLStatementContext sqlStatementContext) throws SQLException {
        //分片合併
        Optional<MergedResult> mergedResult = merge(queryResults, sqlStatementContext);
        //脫敏處理
        Optional<MergedResult> result = mergedResult.isPresent() ? Optional.of(decorate(mergedResult.get(), sqlStatementContext)) : decorate(queryResults.get(0), sqlStatementContext);
        //只有讀寫分離的情況下,orElseGet會不存在,TransparentMergedResult
        return result.orElseGet(() -> new TransparentMergedResult(queryResults.get(0)));
    }
  • TransparentMergedResult

@RequiredArgsConstructor
public final class TransparentMergedResult implements MergedResult {
     
    private final QueryResult queryResult;
     
    @Override
    public boolean next() throws SQLException {
        return queryResult.next();
    }
     
    @Override
    public Object getValue(final int columnIndex, final Class<?> type) throws SQLException {
        return queryResult.getValue(columnIndex, type);
    }
     
    @Override
    public Object getCalendarValue(final int columnIndex, final Class<?> type, final Calendar calendar) throws SQLException {
        return queryResult.getCalendarValue(columnIndex, type, calendar);
    }
     
    @Override
    public InputStream getInputStream(final int columnIndex, final String type) throws SQLException {
        return queryResult.getInputStream(columnIndex, type);
    }
     
    @Override
    public boolean wasNull() throws SQLException {
        return queryResult.wasNull();
    }
}

我們只看分片相關的操作,ResultMergerEngine只有一個實現類ShardingResultMergerEngine,所以只有存在分片情況的時候,上文的第一個merge才會有結果。根據SQL型別的不同選擇ResultMerger實現,查詢類的合併是最常用也是最複雜的合併。

  • MergeEntry.merge

org.apache.shardingsphere.underlying.merge.MergeEntry#merge
    private Optional<MergedResult> merge(final List<QueryResult> queryResults, final SQLStatementContext sqlStatementContext) throws SQLException {
        for (Entry<BaseRule, ResultProcessEngine> entry : engines.entrySet()) {
            if (entry.getValue() instanceof ResultMergerEngine) {
                //選擇不同型別的 resultMerger
                ResultMerger resultMerger = ((ResultMergerEngine) entry.getValue()).newInstance(databaseType, entry.getKey(), properties, sqlStatementContext);
                //歸併
                return Optional.of(resultMerger.merge(queryResults, sqlStatementContext, schemaMetaData));
            }
        }
        return Optional.empty();
    }
 
org.apache.shardingsphere.sharding.merge.ShardingResultMergerEngine#newInstance
    public ResultMerger newInstance(final DatabaseType databaseType, final ShardingRule shardingRule, final ConfigurationProperties properties, final SQLStatementContext sqlStatementContext) {
        if (sqlStatementContext instanceof SelectStatementContext) {
            return new ShardingDQLResultMerger(databaseType);
        }
        if (sqlStatementContext.getSqlStatement() instanceof DALStatement) {
            return new ShardingDALResultMerger(shardingRule);
        }
        return new TransparentResultMerger();
    }

ShardingDQLResultMerger的merge方法就是根據SQL解析結果中包含的token選擇合適的歸併方式(分組聚合、排序、遍歷),歸併後的mergedResult統一經過decorate方法進行判斷是否需要分頁歸併,整體處理流程圖可以概括如下。

  • 歸併方式選擇

org.apache.shardingsphere.sharding.merge.dql.ShardingDQLResultMerger#merge
    public MergedResult merge(final List<QueryResult> queryResults, final SQLStatementContext sqlStatementContext, final SchemaMetaData schemaMetaData) throws SQLException {
        if (1 == queryResults.size()) {
            return new IteratorStreamMergedResult(queryResults);
        }
        Map<String, Integer> columnLabelIndexMap = getColumnLabelIndexMap(queryResults.get(0));
        SelectStatementContext selectStatementContext = (SelectStatementContext) sqlStatementContext;
        selectStatementContext.setIndexes(columnLabelIndexMap);
        //分組聚合,排序,遍歷
        MergedResult mergedResult = build(queryResults, selectStatementContext, columnLabelIndexMap, schemaMetaData);
        //分頁歸併
        return decorate(queryResults, selectStatementContext, mergedResult);
    }
 
org.apache.shardingsphere.sharding.merge.dql.ShardingDQLResultMerger#build
    private MergedResult build(final List<QueryResult> queryResults, final SelectStatementContext selectStatementContext,
                               final Map<String, Integer> columnLabelIndexMap, final SchemaMetaData schemaMetaData) throws SQLException {
        if (isNeedProcessGroupBy(selectStatementContext)) {
            //分組聚合歸併
            return getGroupByMergedResult(queryResults, selectStatementContext, columnLabelIndexMap, schemaMetaData);
        }
        if (isNeedProcessDistinctRow(selectStatementContext)) {
            setGroupByForDistinctRow(selectStatementContext);
            //分組聚合歸併
            return getGroupByMergedResult(queryResults, selectStatementContext, columnLabelIndexMap, schemaMetaData);
        }
        if (isNeedProcessOrderBy(selectStatementContext)) {
            //排序歸併
            return new OrderByStreamMergedResult(queryResults, selectStatementContext, schemaMetaData);
        }
        //遍歷歸併
        return new IteratorStreamMergedResult(queryResults);
    }
 
org.apache.shardingsphere.sharding.merge.dql.ShardingDQLResultMerger#decorate
    private MergedResult decorate(final List<QueryResult> queryResults, final SelectStatementContext selectStatementContext, final MergedResult mergedResult) throws SQLException {
        PaginationContext paginationContext = selectStatementContext.getPaginationContext();
        if (!paginationContext.isHasPagination() || 1 == queryResults.size()) {
            return mergedResult;
        }
        String trunkDatabaseName = DatabaseTypes.getTrunkDatabaseType(databaseType.getName()).getName();
        //根據資料庫型別分頁歸併
        if ("MySQL".equals(trunkDatabaseName) || "PostgreSQL".equals(trunkDatabaseName)) {
            return new LimitDecoratorMergedResult(mergedResult, paginationContext);
        }
        if ("Oracle".equals(trunkDatabaseName)) {
            return new RowNumberDecoratorMergedResult(mergedResult, paginationContext);
        }
        if ("SQLServer".equals(trunkDatabaseName)) {
            return new TopAndRowNumberDecoratorMergedResult(mergedResult, paginationContext);
        }
        return mergedResult;
    }

每種歸併方式的作用在官方文件有比較詳細的案例,這裡就不再重複介紹了。

3.6.2 引擎總結

歸併引擎是Sharding-JDBC執行SQL的最後一步,其作用是將多個數節點的結果集組合為一個正確的結果集返回,查詢類的歸併有分組歸併、聚合歸併、排序歸併、遍歷歸併、分頁歸併五種,這五種歸併方式並不是互斥的,而是相互組合的。

四、定製開發

在使用Sharding-JDBC過程中,我們發現了一些問題可以改進,比如存量系統資料量到達一定規模而需要分庫分表引入Sharding-JDBC時,就會存在兩大問題

一個是存量資料的遷移,這個問題我們可以透過分片演算法相容,前文已經提過分片鍵的值是不允許更改的,而且SQL如果不包含分片鍵,如果這個分片鍵對應的值是遞增的(如id,時間等),我們可以設定一個閾值,在分片演算法的doSharding中判斷分片值與閾值的大小決定將資料路由至舊錶或新表,避免資料遷移的麻煩。如果是根據使用者id取模分表,而新增的資料無法只透過使用者id判斷,這時可以考慮採用複合分片演算法,將使用者id與訂單id或者時間等遞增的欄位同時設定為分片鍵,根據訂單id或時間判斷是否是新資料,再根據使用者id取模得到路由結果即可。

另一個是Sharding-JDBC語法限制會使得存量SQL面對巨大的改造壓力,而實際上業務更關心的是需要分片的表,非分片的表不應該發生改動和影響。實際上,非分片表理論上無需透過解析、路由、重寫、合併,為此我們在原始碼層面對這段邏輯進行了最佳化,支援跳過部分解析,完全跳過分片路由、重寫和合並,儘可能減少Sharding-JDBC對非分片表的語法限制,來減少業務系統的改造壓力與風險。

圖片

4.1 跳過Sharding語法限制

Sharding-JDBC執行解析路由重寫的邏輯都是在BasePrepareEngine中,最終構造ExecutionContext交由執行引擎執行,ExecutionContext中包含sqlStatementContext和executionUnits,非分片表不涉及路由改寫,所以其ExecutionUnit我們非常容易手動構造,而檢視SQLStatementContext的使用情況,我們發現SQLStatementContext只會影響結果集的合併而不會影響實際的執行,而不分片表也無需進行結果集的合併,整體實現思路如圖。

圖片

  • ExecutionContext相關物件

public class ExecutionContext {
 
    private final SQLStatementContext sqlStatementContext;
 
    private final Collection<ExecutionUnit> executionUnits = new LinkedHashSet<>();
}
 
public final class ExecutionUnit {
     
    private final String dataSourceName;
     
    private final SQLUnit sqlUnit;
}
 
public final class SQLUnit {
 
    private String sql;
 
    private final List<Object> parameters;
 
}

(1)校驗SQL中是否包含分片表:我們是透過正則將SQL中的各個單詞分隔成Set,然後再遍歷BaseRule判斷是否存在分片表。大家可能會奇怪明明解析引擎可以幫我們解析出SQL中的表名,為什麼還要自己來解析。因為我們測試的過程中發現,存量業務上的SQL很多在解析階段就會報錯,只能提前判斷,當然這種判斷方式並不嚴謹,比如 SELECT order_id FROM t_order_record WHERE order_id=1 AND remarks=' t_order xxx';,配置的分片表t_order時就會存在誤判,但這種場景在我們的業務中沒有,所以暫時並沒有處理。由於這個資訊需要在多個物件方法中使用,為了避免修改大量的物件變數和方法入參,而又能方便的透傳這個資訊,判斷的結果我們選擇放在ThreadLocal裡。

  • RuleContextManager

public final class RuleContextManager {
 
    private static final ThreadLocal<RuleContextManager> SKIP_CONTEXT_HOLDER = ThreadLocal.withInitial(RuleContextManager::new);
 
    /**
     * 是否跳過sharding
     */
    private boolean skipSharding;
 
    /**
     * 是否路由至主庫
     */
    private boolean masterRoute;
 
    public static boolean isSkipSharding() {
        return SKIP_CONTEXT_HOLDER.get().skipSharding;
    }
 
    public static void setSkipSharding(boolean skipSharding) {
        SKIP_CONTEXT_HOLDER.get().skipSharding = skipSharding;
    }
 
    public static boolean isMasterRoute() {
 
        return SKIP_CONTEXT_HOLDER.get().masterRoute;
    }
 
    public static void setMasterRoute(boolean masterRoute) {
        SKIP_CONTEXT_HOLDER.get().masterRoute = masterRoute;
    }
 
    public static void clear(){
        SKIP_CONTEXT_HOLDER.remove();
    }
 
}
  • 判斷SQL是否包含分片表

org.apache.shardingsphere.underlying.pluggble.prepare.BasePrepareEngine#buildSkipContext
// 判斷是否可以跳過sharding,構造RuleContextManager的值
private void buildSkipContext(final String sql){
    Set<String> sqlTokenSet = new HashSet<>(Arrays.asList(sql.split("[\\s]")));
        if (CollectionUtils.isNotEmpty(rules)) {
            for (BaseRule baseRule : rules) {
                //定製方法,ShardingRule實現,判斷sqlTokenSet是否包含邏輯表即可
                if(baseRule.hasContainShardingTable(sqlTokenSet)){
                    RuleContextManager.setSkipSharding(false);
                    break;
                }else {
                    RuleContextManager.setSkipSharding(true);
                }
            }
        }
}
 
org.apache.shardingsphere.core.rule.ShardingRule#hasContainShardingTable
public Boolean hasContainShardingTable(Set<String> sqlTokenSet) {
      //logicTableNameList透過遍歷TableRule可以得到
       for (String logicTable : logicTableNameList) {
            if (sqlTokenSet.contains(logicTable)) {
                return true;
            }
        }
        return false;
    }

(2)跳過解析路由:透過RuleContextManager中的skipSharding判斷是否需要跳過Sharding解析路由,但為了相容讀寫分離的場景,我們還需要知道這條SQL應該走主庫還是從庫,走主庫的場景在後面強制路由主庫部分有說明,SQL走主庫實際上只有兩種情況,一種是非SELECT語句,另一種就是SELECT語句帶鎖,如SELECT...FOR UPDATE,因此整體實現的步驟如下:

  • 如果標記了跳過Sharding且不為select語句,直接返回SkipShardingStatement,單獨構造一個SkipShardingStatement的目的是為了能利用解析引擎中的快取,快取中不能放入null值。

  • 如果是select語句需要繼續解析,判斷是否有鎖後直接返回,避免後續解析造成語法不相容,這裡也曾嘗試用反射獲取lockClause來判斷是否包含鎖,但最終沒有成功。

  • ShardingRouteDecorator根據RuleContextManager.isSkipSharding判斷是否跳過路由。

  • 跳過解析路由

public class SkipShardingStatement implements SQLStatement{
    @Override
    public int getParameterCount() {
        return 0;
    }
}
 
org.apache.shardingsphere.sql.parser.SQLParserEngine#parse0
    private SQLStatement parse0(final String sql, final boolean useCache) {
        if (useCache) {
            Optional<SQLStatement> cachedSQLStatement = cache.getSQLStatement(sql);
            if (cachedSQLStatement.isPresent()) {
                return cachedSQLStatement.get();
            }
        }
        ParseTree parseTree = new SQLParserExecutor(databaseTypeName, sql).execute().getRootNode();
        /**
         * 跳過sharding 需要判斷是否需要路由至主庫 如果不是select語句直接跳過
         * 是select語句則需要透過繼續解析判斷是否有鎖
         */
        SQLStatement result ;
        if(RuleContextManager.isSkipSharding()&&!VisitorRule.SELECT.equals(VisitorRule.valueOf(parseTree.getClass()))){
            RuleContextManager.setMasterRoute(true);
            result = new SkipShardingStatement();
        }else {
            result = (SQLStatement) ParseTreeVisitorFactory.newInstance(databaseTypeName, VisitorRule.valueOf(parseTree.getClass())).visit(parseTree);
        }
        if (useCache) {
            cache.put(sql, result);
        }
        return result;
    }
 
org.apache.shardingsphere.sql.parser.mysql.visitor.impl.MySQLDMLVisitor#visitSelectClause
    public ASTNode visitSelectClause(final SelectClauseContext ctx) {
        SelectStatement result = new SelectStatement();
        // 跳過sharding 只需要判斷是否有鎖來決定是否路由至主庫即可
        if(RuleContextManager.isSkipSharding()){
            if (null != ctx.lockClause()) {
                result.setLock((LockSegment) visit(ctx.lockClause()));
                RuleContextManager.setMasterRoute(true);
            }
            return result;
        }
        //...後續解析
    }
 
org.apache.shardingsphere.underlying.route.DataNodeRouter#createRouteContext
    private RouteContext createRouteContext(final String sql, final List<Object> parameters, final boolean useCache) {
        SQLStatement sqlStatement = parserEngine.parse(sql, useCache);
        //如果需要跳過sharding 不進行後續的解析直接返回
        if (RuleContextManager.isSkipSharding()) {
            return new RouteContext(sqlStatement, parameters, new RouteResult());
        }
        //...解析
    }
 
org.apache.shardingsphere.sharding.route.engine.ShardingRouteDecorator#decorate
    public RouteContext decorate(final RouteContext routeContext, final ShardingSphereMetaData metaData, final ShardingRule shardingRule, final ConfigurationProperties properties) {
        // 跳過sharding路由
        if(RuleContextManager.isSkipSharding()){
            return routeContext;
        }
        //...路由
    }

(3)手動構造ExecutionUnit:ExecutionUnit中我們需要確定的內容就是datasourceName,這裡我們認為跳過Sharding的SQL最終執行的庫一定只有一個。如果只是跳過Sharding的情況,直接從後設資料中獲取資料來源名稱即可,如果存在讀寫分離的情況,主從路由的結果也一定是唯一的。建立完ExecutionUnit直接放入ExecutionContext返回即可,從而跳過後續的改寫邏輯。

  • 手動構造ExecutionUnit

public ExecutionContext prepare(final String sql, final List<Object> parameters) {
    List<Object> clonedParameters = cloneParameters(parameters);
    // 判斷是否可以跳過sharding,構造RuleContextManager的值
    buildSkipContext(sql);  
    RouteContext routeContext = executeRoute(sql, clonedParameters);
    ExecutionContext result = new ExecutionContext(routeContext.getSqlStatementContext());
    // 跳過sharding的sql最後的路由結果一定只有一個庫
    if(RuleContextManager.isSkipSharding()){
        log.debug("可以跳過sharding的場景 {}", sql);
        if(!Objects.isNull(routeContext.getRouteResult())){
            Collection<String> allInstanceDataSourceNames = this.metaData.getDataSources().getAllInstanceDataSourceNames();
            int routeUnitsSize = routeContext.getRouteResult().getRouteUnits().size();
            /*
             * 1. 沒有讀寫分離的情況下  跳過sharding路由會導致routeUnitsSize為0 此時需要判斷資料來源數量是否為1
             * 2. 讀寫分離情況下 只會路由至具體的主庫或從庫 routeUnitsSize數量應該為1
             */
            if(!(routeUnitsSize == 0 && allInstanceDataSourceNames.size()==1)|| routeUnitsSize>1){
                throw new ShardingSphereException("可以跳過sharding,但是路由結果不唯一,SQL= %s ,routeUnits= %s ",sql, routeContext.getRouteResult().getRouteUnits());
            }
            Collection<String> actualDataSourceNames = routeContext.getRouteResult().getActualDataSourceNames();
            // 手動建立執行單元
            String datasourceName = CollectionUtils.isEmpty(actualDataSourceNames)? allInstanceDataSourceNames.iterator().next():actualDataSourceNames.iterator().next();
            ExecutionUnit executionUnit = new ExecutionUnit(datasourceName, new SQLUnit(sql, clonedParameters));
            result.getExecutionUnits().add(executionUnit);
            //標記該結果需要跳過
            result.setSkipShardingScenarioFlag(true);
        }
    }else {
        result.getExecutionUnits().addAll(executeRewrite(sql, clonedParameters, routeContext));
    }
    if (properties.<Boolean>getValue(ConfigurationPropertyKey.SQL_SHOW)) {
        SQLLogger.logSQL(sql, properties.<Boolean>getValue(ConfigurationPropertyKey.SQL_SIMPLE), result.getSqlStatementContext(), result.getExecutionUnits());
    }
    return result;
}

(4)跳過合併:跳過查詢結果的合併和影響行數計算的合併,注意ShardingPreparedStatement和ShardingStatement都需要跳過

  • 跳過合併

org.apache.shardingsphere.shardingjdbc.jdbc.core.statement.ShardingPreparedStatement#executeQuery
    public ResultSet executeQuery() throws SQLException {
        ResultSet result;
        try {
            clearPrevious();
            prepare();
            initPreparedStatementExecutor();
            List<QueryResult> queryResults = preparedStatementExecutor.executeQuery();
            List<ResultSet> resultSets = preparedStatementExecutor.getResultSets();
        // 定製開發,不分片跳過合併
            if(executionContext.isSkipShardingScenarioFlag()){
                return CollectionUtils.isNotEmpty(resultSets) ? resultSets.get(0) : null;
            }
            MergedResult mergedResult = mergeQuery(queryResults);
            result = new ShardingResultSet(resultSets, mergedResult, this, executionContext);
        } finally {
            clearBatch();
        }
        currentResultSet = result;
        return result;
    }
org.apache.shardingsphere.shardingjdbc.jdbc.core.statement.ShardingPreparedStatement#getResultSet
    public ResultSet getResultSet() throws SQLException {
        if (null != currentResultSet) {
            return currentResultSet;
        }
        List<ResultSet> resultSets = getResultSets();
        // 定製開發,不分片跳過合併
        if(executionContext.isSkipShardingScenarioFlag()){
            return CollectionUtils.isNotEmpty(resultSets) ? resultSets.get(0) : null;
        }
 
        if (executionContext.getSqlStatementContext() instanceof SelectStatementContext || executionContext.getSqlStatementContext().getSqlStatement() instanceof DALStatement) {
            MergedResult mergedResult = mergeQuery(getQueryResults(resultSets));
            currentResultSet = new ShardingResultSet(resultSets, mergedResult, this, executionContext);
        }
        return currentResultSet;
    }
org.apache.shardingsphere.shardingjdbc.jdbc.core.statement.ShardingPreparedStatement#isAccumulate
    public boolean isAccumulate() {
        //定製開發,不分片跳過計算
        if(executionContext.isSkipShardingScenarioFlag()){
            return false;
        }
        return !connection.getRuntimeContext().getRule().isAllBroadcastTables(executionContext.getSqlStatementContext().getTablesContext().getTableNames());
    }

(5)清空RuleContextManager:檢視一下Sharding-JDBC其他ThreadLocal的清空位置,對應的清空RuleContextManager就好。

  • 清空ThreadLocal

org.apache.shardingsphere.shardingjdbc.jdbc.adapter.AbstractConnectionAdapter#close
public final void close() throws SQLException {
        closed = true;
        MasterVisitedManager.clear();
        TransactionTypeHolder.clear();
        RuleContextManager.clear();
        int connectionSize = cachedConnections.size();
        try {
            forceExecuteTemplateForClose.execute(cachedConnections.entries(), cachedConnections -> cachedConnections.getValue().close());
        } finally {
            cachedConnections.clear();
            rootInvokeHook.finish(connectionSize);
        }
    }

舉個例子,比如Sharding-JDBC本身是不支援INSERT INTO tbl_name (col1, col2, …) SELECT col1, col2, … FROM tbl_name WHERE col3 = ? 這種語法的,會報空指標異常。

圖片

經過我們上述改造驗證後,非分片表是可以跳過語法限制執行如下的SQL的。

圖片

透過該功能的實現,業務可以更關注與分片表的SQL改造,而無需擔心引入Sharding-JDBC造成所有SQL的驗證改造,大幅減少改造成本和風險。

4.2 強制路由主庫

Sharding-JDBC可以透過配置主從庫資料來源方便的實現讀寫分離的功能,但使用讀寫分離就必須面對主從延遲和從庫失聯的痛點,針對這一問題,我們實現了強制路由主庫的動態配置,當主從延遲過大或從庫失聯時,透過修改配置來實現SQL語句強制走主庫的不停機路由切換。

後面會說明了配置的動態生效的實現方式,這裡只說明強制路由主庫的實現,我們直接使用前文的RuleContextManager即可,在主從路由引擎裡判斷下是否開啟了強制主庫路由。

  • MasterSlaveRouteDecorator.decorate改造

org.apache.shardingsphere.masterslave.route.engine.MasterSlaveRouteDecorator#decorate
    public RouteContext decorate(final RouteContext routeContext, final ShardingSphereMetaData metaData, final MasterSlaveRule masterSlaveRule, final ConfigurationProperties properties) {
        /**
         * 如果配置了強制主庫 MasterVisitedManager設定為true
         * 後續isMasterRoute中會保證路由至主庫
         */
        if(properties.<Boolean>getValue(ConfigurationPropertyKey.MASTER_ROUTE_ONLY)){
            MasterVisitedManager.setMasterVisited();
        }
        //...路由邏輯
        return routeContext;
    }

為了相容之前跳過Sharding的功能,我們需要同步修改下isMasterRoute方法,如果是跳過了Sharding路由需要透過RuleContextManager來判斷是否走主庫。

  • isMasterRoute改造

org.apache.shardingsphere.masterslave.route.engine.impl.MasterSlaveDataSourceRouter#isMasterRoute
    private boolean isMasterRoute(final SQLStatement sqlStatement) {
        if(sqlStatement instanceof SkipShardingStatement){
            // 優先以MasterVisitedManager中的值為準
            return MasterVisitedManager.isMasterVisited()|| RuleContextManager.isMasterRoute();
        }
        return containsLockSegment(sqlStatement) || !(sqlStatement instanceof SelectStatement) || MasterVisitedManager.isMasterVisited() || HintManager.isMasterRouteOnly();
    }

當然,更理想的狀況是透過監控主從同步延遲和資料庫撥測,當超過閾值時或從庫失聯時直接自動修改配置中心的庫,實現自動切換主庫,減少業務故障時間和運維壓力。

4.3 配置動態生效

Sharding-JDBC中的ConfigurationPropertyKey中提供了許多配置屬性,而Sharding-JDBCB並沒有為這些配置提供線上修改的方法,而在實際的應用場景中,像SQL_SHOW這樣控制SQL列印的開關配置,我們更希望能夠線上修改配置值來控制SQL日誌的列印,而不是修改完配置再重啟服務。

以SQL列印為例,BasePrepareEngine中存在ConfigurationProperties物件,透過呼叫getValue方法來獲取SQL_SHOW的值。

  • SQL 列印

org.apache.shardingsphere.underlying.pluggble.prepare.BasePrepareEngine#prepare
    /**
     * Prepare to execute.
     *
     * @param sql SQL
     * @param parameters SQL parameters
     * @return execution context
     */
    public ExecutionContext prepare(final String sql, final List<Object> parameters) {
        List<Object> clonedParameters = cloneParameters(parameters);
        RouteContext routeContext = executeRoute(sql, clonedParameters);
        ExecutionContext result = new ExecutionContext(routeContext.getSqlStatementContext());
        result.getExecutionUnits().addAll(executeRewrite(sql, clonedParameters, routeContext));
        //sql列印
        if (properties.<Boolean>getValue(ConfigurationPropertyKey.SQL_SHOW)) {
            SQLLogger.logSQL(sql, properties.<Boolean>getValue(ConfigurationPropertyKey.SQL_SIMPLE), result.getSqlStatementContext(), result.getExecutionUnits());
        }
        return result;
    }

ConfigurationProperties繼承了抽象類TypedProperties,其getValue方法就是根據key獲取對應的配置值,因此我們直接在TypedProperties中實現重新整理快取中的配置值的方法。

  • TypedProperties重新整理配置

public abstract class TypedProperties<E extends Enum & TypedPropertyKey> {
     
    private static final String LINE_SEPARATOR = System.getProperty("line.separator");
     
    @Getter
    private final Properties props;
     
    private final Map<E, TypedPropertyValue> cache;
     
    public TypedProperties(final Class<E> keyClass, final Properties props) {
        this.props = props;
        cache = preload(keyClass);
    }
     
    private Map<E, TypedPropertyValue> preload(final Class<E> keyClass) {
        E[] enumConstants = keyClass.getEnumConstants();
        Map<E, TypedPropertyValue> result = new HashMap<>(enumConstants.length, 1);
        Collection<String> errorMessages = new LinkedList<>();
        for (E each : enumConstants) {
            TypedPropertyValue value = null;
            try {
                value = new TypedPropertyValue(each, props.getOrDefault(each.getKey(), each.getDefaultValue()).toString());
            } catch (final TypedPropertyValueException ex) {
                errorMessages.add(ex.getMessage());
            }
            result.put(each, value);
        }
        if (!errorMessages.isEmpty()) {
            throw new ShardingSphereConfigurationException(Joiner.on(LINE_SEPARATOR).join(errorMessages));
        }
        return result;
    }
     
    /**
     * Get property value.
     *
     * @param key property key
     * @param <T> class type of return value
     * @return property value
     */
    @SuppressWarnings("unchecked")
    public <T> T getValue(final E key) {
        return (T) cache.get(key).getValue();
    }
 
    /**
     * vivo定製改造方法 refresh property value.
     * @param key property key
     * @param value property value
     * @return 更新配置是否成功
     */
    public boolean refreshValue(String key, String value){
        //獲取配置類支援的配置項
        E[] enumConstants = targetKeyClass.getEnumConstants();
        for (E each : enumConstants) {
            //遍歷新的值
            if(each.getKey().equals(key)){
                try {
                    //空白value認為無效,取預設值
                    if(!StringUtils.isBlank(value)){
                        value = each.getDefaultValue();
                    }
                    //構造新屬性
                    TypedPropertyValue typedPropertyValue = new TypedPropertyValue(each, value);
                    //替換快取
                    cache.put(each, typedPropertyValue);
                    //原始屬性也替換下,有可能會透過RuntimeContext直接獲取Properties
                    props.put(key,value);
                    return true;
                } catch (final TypedPropertyValueException ex) {
                    log.error("refreshValue error. key={} , value={}", key, value, ex);
                }
            }
        }
        return false;
    }
}

實現了重新整理方法後,我們還需要將該方法一步步暴露至一個外部可以呼叫的類中,以便在服務監聽配置的方法中,能夠呼叫這個重新整理方法。ConfigurationProperties直接在BasePrepareEngine的建構函式中傳入,我們透過建構函式逐步反推最外層的這一物件呼叫來源,最終可以定位到在AbstractDataSourceAdapter中的getRuntimeContext()方法中可以獲取到這個配置,而這個就是Sharding-JDBC實現的JDBC中Datasource介面的抽象類,我們直接在這個類中呼叫剛剛實現的refreshValue方法,剩下的就是監聽配置,透過自己實現的AbstractDataSourceAdapter來呼叫這個方法就好了。

圖片

透過這一功能,我們可以方便的控制一些開關屬性的線上修改,如SQL列印、強制路由主庫等,業務無需重啟服務即可做到配置的動態生效。

4.4 批次update語法支援

業務中存在使用foreach標籤來批次update的語句,這種SQL在Sharding-JDBC中無法被正確路由,只會路由第一組引數,後面的無法被路由改寫,原因是解析引擎無法將語句拆分解析。

  • 批次update樣例

<update id="batchUpdate">
        <foreach collection="orderList" item="item">
               update t_order set
               status = 1,
               updated_by = #{item.updatedBy}
               WHERE created_by = #{item.createdBy};
        </foreach>
    </update>

圖片

圖片

我們透過將批次update按照;拆分為多個語句,然後分別路由,最後手動彙總路有結果生成執行單元。

為了能正確重寫SQL,批次update拆分後的語句需要完全一樣,這樣就不能使用動態拼接set條件,而是使用ifnull語法或者欄位值不發生變化時也將原來的值放入set中,只不過set前後的值保持一致,整體思路與實現如下。

圖片

  • prepareBatch實現

org.apache.shardingsphere.underlying.pluggble.prepare.BasePrepareEngine#prepareBatch
   private ExecutionContext prepareBatch(List<String> splitSqlList, final List<Object> allParameters) {
       //SQL去重
       List<String> sqlList = splitSqlList.stream().distinct().collect(Collectors.toList());
       if (sqlList.size() > 1) {
           throw new ShardingSphereException("不支援多條SQL,請檢查SQL," + sqlList.toString());
       }
       //以第一條SQL為標準
       String sql = sqlList.get(0);
       //所有的執行單元
       Collection<ExecutionUnit> globalExecutionUnitList = new ArrayList<>();
       //初始化最後的執行結果
       ExecutionContext executionContextResult = null;
       //根據所有引數數量和SQL語句數量 計算每組引數的數量
       int eachSqlParameterCount = allParameters.size() / splitSqlList.size();
       //平均分配每條SQL的引數
       List<List<Object>> eachSqlParameterListList = Lists.partition(allParameters, eachSqlParameterCount);
       for (List<Object> eachSqlParameterList : eachSqlParameterListList) {
           //每條SQL引數不同 需要根據引數路由不同的結果  實際的SqlStatementContext 是一致的
           RouteContext routeContext = executeRoute(sql, eachSqlParameterList);
           //由於SQL一樣  實際的SqlStatementContext 是一致的 只需初始化一次
           if (executionContextResult == null) {
               executionContextResult = new ExecutionContext(routeContext.getSqlStatementContext());
           }
           globalExecutionUnitList.addAll(executeRewrite(sql, eachSqlParameterList, routeContext));
       }
       //排序列印日誌
       executionContextResult.getExtendMap().put(EXECUTION_UNIT_LIST, globalExecutionUnitList.stream().sorted(Comparator.comparing(ExecutionUnit::getDataSourceName)).collect(Collectors.toList()));
       if (properties.<Boolean>getValue(ConfigurationPropertyKey.SQL_SHOW)) {
           SQLLogger.logSQL(sql, properties.<Boolean>getValue(ConfigurationPropertyKey.SQL_SIMPLE),
                   executionContextResult.getSqlStatementContext(), (Collection<ExecutionUnit>) executionContextResult.getExtendMap().get(EXECUTION_UNIT_LIST));
       }
       return executionContextResult;
   }

這裡我們在ExecutionContext單獨構造了一個了ExtendMap來存放ExecutionUnit,原因是ExecutionContext中的executionUnits是HashSet,而判斷ExecutionUnit中的SqlUnit只會根據SQL去重,批次update的SQL是一致的,但parameters不同,為了不影響原有的邏輯,單獨使用了另外的變數來存放。

  • ExecutionContext改造

@RequiredArgsConstructor
@Getter
public class ExecutionContext {
 
    private final SQLStatementContext sqlStatementContext;
 
    private final Collection<ExecutionUnit> executionUnits = new LinkedHashSet<>();
 
    /**
     * 自定義擴充套件變數
     */
    private final Map<ExtendEnum,Object> extendMap = new HashMap<>();
 
    /**
     * 定製擴充套件,是否可以跳過分片邏輯
     */
    @Setter
    private boolean skipShardingScenarioFlag = false;
}
 
@RequiredArgsConstructor
@Getter
@EqualsAndHashCode
@ToString
public final class ExecutionUnit {
     
    private final String dataSourceName;
     
    private final SQLUnit sqlUnit;
}
 
@AllArgsConstructor
@RequiredArgsConstructor
@Getter
@Setter
//根據SQL判斷是否相等
@EqualsAndHashCode(of = { "sql" })
@ToString
public final class SQLUnit {
 
    private String sql;
 
    private final List<Object> parameters;
 
}

我們還需要改造下執行方法,在初始化執行器的時候,判斷下ExtendMap中存在我們自定義的EXECUTION_UNIT_LIST是否存在,存在則使用生成InputGroup,同一個資料來源下的ExecutionUnit會被放入同一個InputGroup中。

  • InputGroup改造

org.apache.shardingsphere.shardingjdbc.executor.PreparedStatementExecutor#init
    public void init(final ExecutionContext executionContext) throws SQLException {
        setSqlStatementContext(executionContext.getSqlStatementContext());
        //相容批次update 分庫分表後同一張表的情況 判斷是否存在EXECUTION_UNIT_LIST 存在則使用未去重的List進行後續的操作
        if (MapUtils.isNotEmpty(executionContext.getExtendMap())){
            Collection<ExecutionUnit> executionUnitCollection = (Collection<ExecutionUnit>) executionContext.getExtendMap().get(EXECUTION_UNIT_LIST);
            if(CollectionUtils.isNotEmpty(executionUnitCollection)){
                getInputGroups().addAll(obtainExecuteGroups(executionUnitCollection));
            }
        }else {
            getInputGroups().addAll(obtainExecuteGroups(executionContext.getExecutionUnits()));
        }
        cacheStatements();
    }

改造完成後,批次update中的每條SQL都可以被正確路由執行。

圖片

4.5 ShardingCondition去重

當where語句包括多個or條件時,而or條件不包含分片鍵時,會造成createShardingConditions方法生成重複的分片條件,導致重複呼叫doSharding方法。

如SELECT * FROM t_order WHERE created_by = ? and ( (status = ?) or (status = ?) or (status = ?) )這種SQL,存在三個or條件,分片鍵是created_by ,實際產生的shardingCondition會是三個一樣的值,並會呼叫三次doSharding的方法。雖然實際執行還是隻有一次(批次update那裡說明過執行單元會去重),但為了減少方法的重複呼叫,我們還是對這裡做了一次去重。

圖片

圖片

去重的方法也比較簡單粗暴,我們對ListRouteValue和RangeRouteValue新增了@EqualsAndHashCode註解,然後在WhereClauseShardingConditionEngine的createShardingConditions方法返回最終結果前加一次去重,從而避免生成重複的shardingCondition造成doSharding方法的重複呼叫。

  • createShardingConditions去重

org.apache.shardingsphere.sharding.route.engine.condition.engine.WhereClauseShardingConditionEngine#createShardingConditions
    private Collection<ShardingCondition> createShardingConditions(final SQLStatementContext sqlStatementContext, final Collection<AndPredicate> andPredicates, final List<Object> parameters) {
        Collection<ShardingCondition> result = new LinkedList<>();
        for (AndPredicate each : andPredicates) {
            Map<Column, Collection<RouteValue>> routeValueMap = createRouteValueMap(sqlStatementContext, each, parameters);
            if (routeValueMap.isEmpty()) {
                return Collections.emptyList();
            }
            result.add(createShardingCondition(routeValueMap));
        }
        //去重
        Collection<ShardingCondition> distinctResult = result.stream().distinct().collect(Collectors.toCollection(LinkedList::new));
        return distinctResult;
    }

4.6 全路由校驗

分片表的SQL中如果沒有攜帶分片鍵(或者帶上了分片鍵結果沒有被正確解析)將會導致全路由,產生效能問題,而這種SQL並不會報錯,這就導致在實際的業務改造中,開發和測試很難保證百分百改造徹底。為此,我們在原始碼層面對這種情況做了額外的校驗,當產生全路由,也就是ShardingConditions為空時,主動丟擲異常,從而方便開發和測試能夠快速發現全路由SQL。

實現方式也比較簡單,校驗下ShardingConditions是否為空即可,只不過需要額外相容下Hint策略ShardingConditions始終為空的特殊情況。

  • 全路由校驗

org.apache.shardingsphere.sharding.route.engine.ShardingRouteDecorator#decorate
public RouteContext decorate(final RouteContext routeContext, final ShardingSphereMetaData metaData, final ShardingRule shardingRule, final ConfigurationProperties properties) {
        //省略...
        //獲取 ShardingConditions
        ShardingConditions shardingConditions = getShardingConditions(parameters, sqlStatementContext, metaData.getSchema(), shardingRule);
        boolean hintAlgorithm = isHintAlgorithm(sqlStatementContext, shardingRule);
        //判斷是否允許全路由
        if (!properties.<Boolean>getValue(ConfigurationPropertyKey.ALLOW_EMPTY_SHARDING_CONDITIONS)) {
            //如果不是Hint演算法
            if(!isHintAlgorithm(sqlStatementContext, shardingRule)){
                /** 如果是DML語句  則可能有兩種情況 這兩種情況是根據getShardingConditions方法的內部邏輯而來的
                 *  一種是非插入語句  shardingConditions.getConditions()為空即可
                 *  一種是插入語句 插入語句shardingConditions.getConditions()不會為空  但是ShardingCondition的routeValues是空的
                 */
                if (sqlStatementContext.getSqlStatement() instanceof DMLStatement) {
                    if(shardingConditions.getConditions().isEmpty()) {
                        throw new ShardingSphereException("SQL不包含分庫分表鍵,請檢查SQL");
                    }else {
                        if (sqlStatementContext instanceof InsertStatementContext) {
                            List<ShardingCondition> routeValuesNotEmpty = shardingConditions.getConditions().stream().filter(r -> CollectionUtils.isNotEmpty(r.getRouteValues())).collect(Collectors.toList());
                            if(CollectionUtils.isEmpty(routeValuesNotEmpty)){
                                throw new ShardingSphereException("SQL不包含分庫分表鍵,請檢查SQL");
                            }
                        }
                    }
                }
            }
        }
        boolean needMergeShardingValues = isNeedMergeShardingValues(sqlStatementContext, shardingRule);
        //省略...
        return new RouteContext(sqlStatementContext, parameters, routeResult);
    }
 
private boolean isHintAlgorithm(final SQLStatementContext sqlStatementContext, final ShardingRule shardingRule) {
        // 場景a 全域性預設策略是否使用強制路由策略
        if(shardingRule.getDefaultDatabaseShardingStrategy() instanceof HintShardingStrategy
                || shardingRule.getDefaultTableShardingStrategy() instanceof HintShardingStrategy){
            return true;
        }
        for (String each : sqlStatementContext.getTablesContext().getTableNames()) {
            Optional<TableRule> tableRule = shardingRule.findTableRule(each);
            //場景b 指定表是否使用強制路由策略
            if (tableRule.isPresent() && (shardingRule.getDatabaseShardingStrategy(tableRule.get()) instanceof HintShardingStrategy
                    || shardingRule.getTableShardingStrategy(tableRule.get()) instanceof HintShardingStrategy)) {
                return true;
            }
        }
        return false;
    }

當然這塊功能也可以在完善些,比如對分片路由結果中的資料來源數量進行校驗,從而避免跨庫操作,我們這邊沒有實現也就不再贅述了。

4.7 元件封裝

業務接入Sharding-JDBC的步驟是一樣的,都需要透過Java建立資料來源和配置物件或者使用SpringBoot進行配置,存在一定的熟悉成本和重複開發的問題,為此我們也對定製開發版本的Sharding-JDBC封裝了一個公共元件,從而簡化業務配置,減少重複開發,提升業務的開發效率,具體功能可見下。這塊沒有涉及原始碼的改造,只是在定製版本上包裝的一個公共元件。

  • 提供了預設的資料來源與連線池配置

  • 簡化分庫分表配置,業務配置邏輯表名和字尾,元件拼裝行表示式和actual-data-nodes

  • 封裝常用的分片演算法(時間、業務欄位值等),

  • 統一的配置監聽與動態修改(SQL列印、強制主從切換等)

開源Sharding-JDBC配置

//資料來源名稱
spring.shardingsphere.datasource.names=ds0,ds1
//ds0配置
spring.shardingsphere.datasource.ds0.type=org.apache.commons.dbcp.BasicDataSource
spring.shardingsphere.datasource.ds0.driver-class-name=com.mysql.jdbc.Driver
spring.shardingsphere.datasource.ds0.url=jdbc:mysql://localhost:3306/ds0
spring.shardingsphere.datasource.ds0.username=root
spring.shardingsphere.datasource.ds0.password=
//ds1配置
spring.shardingsphere.datasource.ds1.type=org.apache.commons.dbcp.BasicDataSource
spring.shardingsphere.datasource.ds1.driver-class-name=com.mysql.jdbc.Driver
spring.shardingsphere.datasource.ds1.url=jdbc:mysql://localhost:3306/ds1
spring.shardingsphere.datasource.ds1.username=root
spring.shardingsphere.datasource.ds1.password=
//分表規則
spring.shardingsphere.sharding.tables.t_order.actual-data-nodes=ds$->{0..1}.t_order$->{0..1}
spring.shardingsphere.sharding.tables.t_order.table-strategy.inline.sharding-column=order_id
spring.shardingsphere.sharding.tables.t_order.table-strategy.inline.algorithm-expression=t_order$->{order_id % 2}
spring.shardingsphere.sharding.tables.t_order_item.actual-data-nodes=ds$->{0..1}.t_order_item$->{0..1}
spring.shardingsphere.sharding.tables.t_order_item.table-strategy.inline.sharding-column=order_id
spring.shardingsphere.sharding.tables.t_order_item.table-strategy.inline.algorithm-expression=t_order_item$->{order_id % 2}
//預設分庫規則
spring.shardingsphere.sharding.default-database-strategy.inline.sharding-column=user_id
spring.shardingsphere.sharding.default-database-strategy.inline.algorithm-expression=ds$->{user_id % 2}

元件簡化配置

//資料來源名稱
vivo.it.sharding.datasource.names = ds0,ds1
//ds0配置
vivo.it.sharding.datasource.ds0.url = jdbc:mysql://localhost:3306/ds1
vivo.it.sharding.datasource.ds0.username = root
vivo.it.sharding.datasource.ds0.password =
//ds1配置
vivo.it.sharding.datasource.ds1.url = jdbc:mysql://localhost:3306/ds1
vivo.it.sharding.datasource.ds1.username = root
vivo.it.sharding.datasource.ds1.password =
//分表規則
vivo.it.sharding.table.rule.config = [{"logicTable":"t_order,t_order_item","tableRange":"0..1","shardingColumn":"order_id ","algorithmExpression":"order_id %2"}]
//預設分庫規則
vivo.it.sharding.default.db.rule.config = {"shardingColumn":"user_id","algorithmExpression":"user_id %2"}

五、使用建議

結合官方文件和業務實踐經驗,我們也梳理了部分使用Sharding-JDBC的建議供大家參考,實際具體如何最佳化SQL寫法(比如子查詢、分頁、分組排序等)還需要結合業務的實際場景來進行測試和調優。

(1)強制等級

  • 建議①:涉及分片表的SQL必須攜帶分片鍵

  • 原因:無分片鍵會導致全路由,存在嚴重的效能隱患

  • 建議②:禁止一條SQL中的分片值路由至不同的庫

  • 原因:跨庫操作存在嚴重的效能隱患,事務操作會升級為分散式事務,增加業務複雜度

  • 建議③:禁止對分片鍵使用運算表示式或函式操作

  • 原因:無法提前計算表示式和函式獲取分片值,導致全路由

  • 說明:詳見官方文件

圖片

  • 建議④:禁止在子查詢中使用分片表
  • 原因:無法正常解析子查詢中的分片表,導致業務錯誤

  • 說明:雖然官方文件中說有限支援子查詢 ,但在實際的使用中發現4.1.1並不支援子查詢,可見官方issue6164 | issue 6228

圖片

  • 建議⑤:包含CASE WHEN、HAVING、UNION (ALL)語法的分片SQL,不支援路由至多資料節點
  • 說明:詳見官方文件

(2)建議等級

  • ① 建議使用分散式id來保證分片表主鍵的全域性唯一性

  • 原因:方便判斷資料的唯一性和後續的遷移擴容

  • 說明:詳見文章《vivo 自研魯班分散式 ID 服務實踐》

  • ② 建議跨多表的分組SQL的分組欄位與排序欄位保證一致

  • 原因:分組和排序欄位不一致只能透過記憶體合併,大資料量時存在效能隱患

  • 說明:詳見官方文件

  • ③ 建議透過全域性遞增的分散式id來最佳化分頁查詢

  • 原因:Sharding-JDBC的分頁最佳化側重於結果集的流式合併來避免記憶體爆漲,但深度分頁自身的效能問題並不能解決

  • 說明:詳見官方文件

六、總結

本文結合個人理解梳理了各個引擎的原始碼入口和關鍵邏輯,讀者可以結合本文和官方文件更好的定位理解Sharding-JDBC的原始碼實現。定製開發的目的是為了降低業務接入成本,儘可能減少業務存量SQL的改造,部分改造思想其實與官方社群也存在差異,比如跳過語法解析,官方社群致力於透過最佳化解析引擎來適配各種語法,而不是跳過解析階段,可參考官方issue。原始碼分析和定製改造只涉及了Sharding-JDBC的資料分片和讀寫分離功能,定製開發的功能也在生產環境經過了考驗,如有不足和最佳化建議,也歡迎大家批評指正。

相關文章