作者: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的執行對比流程如下圖所示:
相關執行流程的代碼樣例如下:
try (Connection conn = DriverManager.getConnection("mysqlUrl", "userName", "password")) {
String sql = "SELECT * FROM t_user WHERE name = ?";
try (PreparedStatement preparedStatement = conn.prepareStatement(sql)) {
preparedStatement.setString(1, "vivo");
preparedStatement.execute(sql);
try (ResultSet resultSet = preparedStatement.getResultSet()) {
while (resultSet.next()) {
}
}
}
}
org.apache.shardingsphere.shardingjdbc.jdbc.core.statement
public boolean execute() throws SQLException {
try {
clearPrevious();
prepare();
initPreparedStatementExecutor();
return preparedStatementExecutor.execute();
} finally {
clearBatch();
}
}
org.apache.shardingsphere.underlying.pluggble.prepare.BasePrepareEngine
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));
if (properties.<Boolean>getValue(ConfigurationPropertyKey.SQL_SHOW)) {
SQLLogger.logSQL(sql, properties.<Boolean>getValue(ConfigurationPropertyKey.SQL_SIMPLE), result.getSqlStatementContext(), result.getExecutionUnits());
}
return result;
}
org.apache.shardingsphere.shardingjdbc.jdbc.core.statement.ShardingPreparedStatement
public ResultSet getResultSet() throws SQLException {
if (null != currentResultSet) {
return currentResultSet;
}
if (executionContext.getSqlStatementContext() instanceof SelectStatementContext || executionContext.getSqlStatementContext().getSqlStatement() instanceof DALStatement) {
List<ResultSet> resultSets = getResultSets();
MergedResult mergedResult = mergeQuery(getQueryResults(resultSets));
currentResultSet = new ShardingResultSet(resultSets, mergedResult, this, executionContext);
}
return currentResultSet;
}
從對比的執行流程圖可見:
【JDBC】:執行的主要流程是通過Datasource獲取Connection,再注入SQL語句生成PreparedStatement對象,PreparedStatement設置佔位符參數執行後得到結果集ResultSet。
【Sharding-JDBC】:主要流程基本一致,但Sharding基於PreparedStatement進行了實現與擴展,具體實現類ShardingPreparedStatement中會抽象出解析、路由、重寫、歸併等引擎,從而實現分庫分表、讀寫分離等能力,每個引擎的作用說明如下表所示:
//*相關引擎的源碼解析在下文會作更深入的闡述
3.2 解析引擎
3.2.1 引擎解析
解析引擎是Sharding-JDBC進行分庫分表邏輯的基礎,其作用是將SQL拆解爲不可再分的原子符號(稱爲token),再根據數據庫類型將這些token分類成關鍵字、表達式、操作符、字面量等不同類型,進而生成抽象語法樹,而語法樹是後續進行路由、改寫操作的前提(這也正是語法樹的存在使得Sharding-JDBC存在各式各樣的語法限制的原因之一)。
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
private RouteContext createRouteContext(final String sql, final List<Object> parameters, final boolean useCache) {
SQLStatement sqlStatement = parserEngine.parse(sql, useCache);
try {
SQLStatementContext sqlStatementContext = SQLStatementContextFactory.newInstance(metaData.getSchema(), sql, parameters, sqlStatement);
return new RouteContext(sqlStatementContext, parameters, new RouteResult());
} catch (final IndexOutOfBoundsException ex) {
return new RouteContext(new CommonSQLStatementContext(sqlStatement), parameters, new RouteResult());
}
}
org.apache.shardingsphere.sql.parser.SQLParserEngine
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();
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語法有一定的限制。
public final class SelectStatement extends DMLStatement {
private ProjectionsSegment projections;
private final Collection<TableReferenceSegment> tableReferences = new LinkedList<>();
private WhereSegment where;
private GroupBySegment groupBy;
private OrderBySegment orderBy;
private LimitSegment limit;
private SelectStatement parentStatement;
private LockSegment lock;
}
SQLStatement還會被進一步轉換成SQLStatementContext,如SelectStatement 會被轉換成SelectStatementContext ,其結構與SelectStatement 類似不再多說,值得注意的是雖然這裏定義了containsSubquery來判斷是否包含子查詢,但4.1.1源碼永遠是返回的false,與having類似,這意味着Sharding-JDBC不會對子查詢語句做特殊處理。
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() {
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需要往哪些庫哪些表執行的關係)。
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校驗->生成分片條件->合併分片值後得到路由結果。
org.apache.shardingsphere.sharding.route.engine.ShardingRouteDecorator
public RouteContext decorate(final RouteContext routeContext, final ShardingSphereMetaData metaData, final ShardingRule shardingRule, final ConfigurationProperties properties) {
SQLStatementContext sqlStatementContext = routeContext.getSqlStatementContext();
List<Object> parameters = routeContext.getParameters();
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是不允許更新分片值的,畢竟修改分片值還需要將數據遷移至新分片值對應的庫表中,才能保證數據分片規則一致。兩者的校驗細節也有所不同:
ShardingCondition中只有一個變量routeValues,RouteValue是一個接口,有ListRouteValue和RangeRouteValue兩種實現,前者記錄了分片鍵的in或=條件的分片值,後者則記錄了範圍查詢的分片值,兩者被封裝爲ShardingValue對象後,將會透傳至分片算法中計算得到分片結果集。
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;
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;
private final Range<T> valueRange;
}
生成分片條件後還會合並分片條件,但是前文提過在SelectStatementContext中的containsSubquery永遠是false,所以這段邏輯永遠返回false,即不會合並分片條件。
org.apache.shardingsphere.sharding.route.engine.ShardingRouteDecorator
private boolean isNeedMergeShardingValues(final SQLStatementContext sqlStatementContext, final ShardingRule shardingRule) {
return sqlStatementContext instanceof SelectStatementContext && ((SelectStatementContext) sqlStatementContext).isContainsSubquery()
&& !shardingRule.getShardingLogicTableNames(sqlStatementContext.getTablesContext().getTableNames()).isEmpty();
}
然後就是通過分片路由引擎調用分片算法計算路由結果了,ShardingRouteEngine實現較多,介紹起來篇幅較多,這裏就不展開說明了,可以參考官方文檔來了解路由引擎的選擇規則。
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();
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中也曾經被提及。
主從路由的核心邏輯就是通過
MasterSlaveDataSourceRouter的route方法進行判定SQL走主庫還是從庫。主從情況下,配置的數據源實際是一組主從,而不是單個的實例,所以需要通過masterSlaveRule獲取到具體的主庫或者從庫名字。
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()) {
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);
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;
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()。
public final class MasterVisitedManager {
private static final ThreadLocal<Boolean> MASTER_VISITED = ThreadLocal.withInitial(() -> false);
public static boolean isMasterVisited() {
return MASTER_VISITED.get();
}
public static void setMasterVisited() {
MASTER_VISITED.set(true);
}
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
private Collection<ExecutionUnit> executeRewrite(final String sql, final List<Object> parameters, final RouteContext routeContext) {
registerRewriteDecorator();
SQLRewriteContext sqlRewriteContext = rewriter.createSQLRewriteContext(sql, parameters, routeContext.getSqlStatementContext(), routeContext);
return routeContext.getRouteResult().getRouteUnits().isEmpty() ? rewrite(sqlRewriteContext) : rewrite(routeContext, sqlRewriteContext);
}
執行單元包含了數據源名稱,改寫後的SQL,以及對應的參數,SQL一樣的兩個SQLUnit會被視爲相等。
@RequiredArgsConstructor
@Getter
@EqualsAndHashCode
@ToString
public final class ExecutionUnit {
private final String dataSourceName;
private final SQLUnit sqlUnit;
}
@AllArgsConstructor
@RequiredArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(of = { "sql" })
@ToString
public final class SQLUnit {
private String sql;
private final List<Object> parameters;
}
createSQLRewriteContext完成了兩件事,一個是對SQL參數進行了重寫,一個是生成了SQLToken。
org.apache.shardingsphere.underlying.rewrite.SQLRewriteEntry
public SQLRewriteContext createSQLRewriteContext(final String sql, final List<Object> parameters, final SQLStatementContext sqlStatementContext, final RouteContext routeContext) {
SQLRewriteContext result = new SQLRewriteContext(schemaMetaData, sqlStatementContext, sql, parameters);
decorate(decorators, result, routeContext);
result.generateSQLTokens();
return result;
}
org.apache.shardingsphere.sharding.rewrite.context.ShardingSQLRewriteContextDecorator
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());
}
}
sqlRewriteContext.addSQLTokenGenerators(new ShardingTokenGenerateBuilder(shardingRule, routeContext).getSQLTokenGenerators());
}
org.apache.shardingsphere.underlying.rewrite.context.SQLRewriteContext
public void generateSQLTokens() {
sqlTokens.addAll(sqlTokenGenerators.generateSQLTokens(sqlStatementContext, parameters, schemaMetaData));
}
ParameterRewriter中與分片相關的實現有兩種。
SQLToken記錄了SQL中每個token(解析引擎中提過的不可再分的原子符號)的起始位置,從而方便改寫引擎知道哪些位置需要改寫。
@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
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()) {
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'三部分進行改寫拼裝。
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();
result.append(context.getSql().substring(0, context.getSqlTokens().get(0).getStartIndex()));
for (SQLToken each : context.getSqlTokens()) {
result.append(getSQLTokenText(each));
result.append(getConjunctionText(each));
}
return result.toString();
}
ParameterBuilder有StandardParameterBuilder和GroupedParameterBuilder兩個實現。
原因和樣例可以參考官方文檔批量拆分部分。
org.apache.shardingsphere.underlying.rewrite.engine.SQLRouteRewriteEngine
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
private Collection<InputGroup<StatementExecuteUnit>> getSynchronizedExecuteUnitGroups(
final Collection<ExecutionUnit> executionUnits, final SQLExecutePrepareCallback callback) throws SQLException {
Map<String, List<SQLUnit>> sqlUnitGroups = getSQLUnitGroups(executionUnits);
Collection<InputGroup<StatementExecuteUnit>> result = new LinkedList<>();
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
private List<InputGroup<StatementExecuteUnit>> getSQLExecuteGroups(final String dataSourceName,
final List<SQLUnit> sqlUnits, final SQLExecutePrepareCallback callback) throws SQLException {
List<InputGroup<StatementExecuteUnit>> result = new LinkedList<>();
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) {
result.add(getSQLExecuteGroup(connectionMode, connections.get(count++), dataSourceName, each, callback));
}
return result;
}
SQLUnit分組和連接模式選擇沒有任何關係,連接模式的選擇只取決於maxConnectionsSizePerQuery和SQLUnit數量的大小關係,
maxConnectionsSizePerQuery代表了一個數據源一次查詢允許的最大連接數。
不過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
org.apache.shardingsphere.shardingjdbc.executor.PreparedStatementExecutor
private QueryResult getQueryResult(final Statement statement, final ConnectionMode connectionMode) throws SQLException {
PreparedStatement preparedStatement = (PreparedStatement) statement;
ResultSet resultSet = preparedStatement.executeQuery();
getResultSets().add(resultSet);
return ConnectionMode.MEMORY_STRICTLY == connectionMode ? new StreamQueryResult(resultSet) : new MemoryQueryResult(resultSet);
}
org.apache.shardingsphere.shardingjdbc.jdbc.core.statement.ShardingPreparedStatement
org.apache.shardingsphere.shardingjdbc.jdbc.core.statement.ShardingPreparedStatement
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事務中是串行的方式,其餘情況是並行,具體的執行邏輯這裏就不再展開了。
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);
return result.orElseGet(() -> new TransparentMergedResult(queryResults.get(0)));
}
@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實現,查詢類的合併是最常用也是最複雜的合併。
org.apache.shardingsphere.underlying.merge.MergeEntry
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 = ((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
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
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
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
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只會影響結果集的合併而不會影響實際的執行,而不分片表也無需進行結果集的合併,整體實現思路如圖。
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裏。
public final class RuleContextManager {
private static final ThreadLocal<RuleContextManager> SKIP_CONTEXT_HOLDER = ThreadLocal.withInitial(RuleContextManager::new);
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();
}
}
org.apache.shardingsphere.underlying.pluggble.prepare.BasePrepareEngine#buildSkipContext
private void buildSkipContext(final String sql){
Set<String> sqlTokenSet = new HashSet<>(Arrays.asList(sql.split("[\\s]")));
if (CollectionUtils.isNotEmpty(rules)) {
for (BaseRule baseRule : rules) {
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) {
for (String logicTable : logicTableNameList) {
if (sqlTokenSet.contains(logicTable)) {
return true;
}
}
return false;
}
(2)跳過解析路由:通過RuleContextManager中的skipSharding判斷是否需要跳過Sharding解析路由,但爲了兼容讀寫分離的場景,我們還需要知道這條SQL應該走主庫還是從庫,走主庫的場景在後面強制路由主庫部分有說明,SQL走主庫實際上只有兩種情況,一種是非SELECT語句,另一種就是SELECT語句帶鎖,如SELECT...FOR UPDATE,因此整體實現的步驟如下:
RuleContextManager.isSkipSharding判斷是否跳過路由。
public class SkipShardingStatement implements SQLStatement{
@Override
public int getParameterCount() {
return 0;
}
}
org.apache.shardingsphere.sql.parser.SQLParserEngine
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();
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
public ASTNode visitSelectClause(final SelectClauseContext ctx) {
SelectStatement result = new SelectStatement();
if(RuleContextManager.isSkipSharding()){
if (null != ctx.lockClause()) {
result.setLock((LockSegment) visit(ctx.lockClause()));
RuleContextManager.setMasterRoute(true);
}
return result;
}
}
org.apache.shardingsphere.underlying.route.DataNodeRouter
private RouteContext createRouteContext(final String sql, final List<Object> parameters, final boolean useCache) {
SQLStatement sqlStatement = parserEngine.parse(sql, useCache);
if (RuleContextManager.isSkipSharding()) {
return new RouteContext(sqlStatement, parameters, new RouteResult());
}
}
org.apache.shardingsphere.sharding.route.engine.ShardingRouteDecorator
public RouteContext decorate(final RouteContext routeContext, final ShardingSphereMetaData metaData, final ShardingRule shardingRule, final ConfigurationProperties properties) {
if(RuleContextManager.isSkipSharding()){
return routeContext;
}
}
(3)手動構造ExecutionUnit:ExecutionUnit中我們需要確定的內容就是datasourceName,這裏我們認爲跳過Sharding的SQL最終執行的庫一定只有一個。如果只是跳過Sharding的情況,直接從元數據中獲取數據源名稱即可,如果存在讀寫分離的情況,主從路由的結果也一定是唯一的。創建完ExecutionUnit直接放入ExecutionContext返回即可,從而跳過後續的改寫邏輯。
public ExecutionContext prepare(final String sql, final List<Object> parameters) {
List<Object> clonedParameters = cloneParameters(parameters);
buildSkipContext(sql);
RouteContext routeContext = executeRoute(sql, clonedParameters);
ExecutionContext result = new ExecutionContext(routeContext.getSqlStatementContext());
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();
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就好。
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
public RouteContext decorate(final RouteContext routeContext, final ShardingSphereMetaData metaData, final MasterSlaveRule masterSlaveRule, final ConfigurationProperties properties) {
if(properties.<Boolean>getValue(ConfigurationPropertyKey.MASTER_ROUTE_ONLY)){
MasterVisitedManager.setMasterVisited();
}
return routeContext;
}
爲了兼容之前跳過Sharding的功能,我們需要同步修改下isMasterRoute方法,如果是跳過了Sharding路由需要通過RuleContextManager來判斷是否走主庫。
org.apache.shardingsphere.masterslave.route.engine.impl.MasterSlaveDataSourceRouter
private boolean isMasterRoute(final SQLStatement sqlStatement) {
if(sqlStatement instanceof SkipShardingStatement){
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的值。
org.apache.shardingsphere.underlying.pluggble.prepare.BasePrepareEngine
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));
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中實現刷新緩存中的配置值的方法。
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;
}
@SuppressWarnings("unchecked")
public <T> T getValue(final E key) {
return (T) cache.get(key).getValue();
}
public boolean refreshValue(String key, String value){
E[] enumConstants = targetKeyClass.getEnumConstants();
for (E each : enumConstants) {
if(each.getKey().equals(key)){
try {
if(!StringUtils.isBlank(value)){
value = each.getDefaultValue();
}
TypedPropertyValue typedPropertyValue = new TypedPropertyValue(each, value);
cache.put(each, typedPropertyValue);
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 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前後的值保持一致,整體思路與實現如下。
org.apache.shardingsphere.underlying.pluggble.prepare.BasePrepareEngine
private ExecutionContext prepareBatch(List<String> splitSqlList, final List<Object> allParameters) {
List<String> sqlList = splitSqlList.stream().distinct().collect(Collectors.toList());
if (sqlList.size() > 1) {
throw new ShardingSphereException("不支持多條SQL,請檢查SQL," + sqlList.toString());
}
String sql = sqlList.get(0);
Collection<ExecutionUnit> globalExecutionUnitList = new ArrayList<>();
ExecutionContext executionContextResult = null;
int eachSqlParameterCount = allParameters.size() / splitSqlList.size();
List<List<Object>> eachSqlParameterListList = Lists.partition(allParameters, eachSqlParameterCount);
for (List<Object> eachSqlParameterList : eachSqlParameterListList) {
RouteContext routeContext = executeRoute(sql, eachSqlParameterList);
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不同,爲了不影響原有的邏輯,單獨使用了另外的變量來存放。
@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
@EqualsAndHashCode(of = { "sql" })
@ToString
public final class SQLUnit {
private String sql;
private final List<Object> parameters;
}
我們還需要改造下執行方法,在初始化執行器的時候,判斷下ExtendMap中存在我們自定義的EXECUTION_UNIT_LIST是否存在,存在則使用生成InputGroup,同一個數據源下的ExecutionUnit會被放入同一個InputGroup中。
org.apache.shardingsphere.shardingjdbc.executor.PreparedStatementExecutor#init
public void init(final ExecutionContext executionContext) throws SQLException {
setSqlStatementContext(executionContext.getSqlStatementContext());
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
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
public RouteContext decorate(final RouteContext routeContext, final ShardingSphereMetaData metaData, final ShardingRule shardingRule, final ConfigurationProperties properties) {
ShardingConditions shardingConditions = getShardingConditions(parameters, sqlStatementContext, metaData.getSchema(), shardingRule);
boolean hintAlgorithm = isHintAlgorithm(sqlStatementContext, shardingRule);
if (!properties.<Boolean>getValue(ConfigurationPropertyKey.ALLOW_EMPTY_SHARDING_CONDITIONS)) {
if(!isHintAlgorithm(sqlStatementContext, shardingRule)){
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) {
if(shardingRule.getDefaultDatabaseShardingStrategy() instanceof HintShardingStrategy
|| shardingRule.getDefaultTableShardingStrategy() instanceof HintShardingStrategy){
return true;
}
for (String each : sqlStatementContext.getTablesContext().getTableNames()) {
Optional<TableRule> tableRule = shardingRule.findTableRule(each);
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打印、強制主從切換等)
spring.shardingsphere.datasource.names=ds0,ds1
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:
spring.shardingsphere.datasource.ds0.username=root
spring.shardingsphere.datasource.ds0.password=
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:
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
vivo.it.sharding.datasource.ds0.url = jdbc:mysql:
vivo.it.sharding.datasource.ds0.username = root
vivo.it.sharding.datasource.ds0.password =
vivo.it.sharding.datasource.ds1.url = jdbc:mysql:
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的數據分片和讀寫分離功能,定製開發的功能也在生產環境經過了考驗,如有不足和優化建議,也歡迎大家批評指正。