輕量級數(shù)據(jù)庫中間件Sharding-JDBC源碼分析SQL 解析之更新SQL

Sharding-JDBC架構(gòu)圖如下:

左邊部分是部署架構(gòu)圖,右邊部分則是核心邏輯架構(gòu)圖。

使用Sharding-JDBC,性能是大家最關(guān)心的問題。

在數(shù)據(jù)量一致的情況下,使用Sharding-JDBC和原生JDBC的性能測試報告如下:

查詢操作:Sharding-JDBC的TPS為JDBC的TPS的99.8%。

插入操作:Sharding-JDBC的TPS為JDBC的TPS的90.2%。

更新操作:Sharding-JDBC的TPS為JDBC的TPS的93.1%。

可以看到,Sharding-JDBC在查詢中的性能損失非常低,插入和更新略高。

將單表的數(shù)據(jù)拆分為二,放入兩個表中,使用Sharding-JDBC和原生JDBC的性能測試報告如下:

查詢操作:TPS雙庫比單庫可以增加大約94%的性能。

插入操作:TPS雙庫比單庫可以增加大約60%的性能。

更新操作:TPS雙庫比單庫可以增加大約89%的性能。

結(jié)果表明,Sharding-JDBC可有效利用水平擴展大幅度提升性能。

下面我將按照模塊深度剖析Sharding-JDBC的詳細功能和主要實現(xiàn),請大家和我一起探索與評估它的水有多深。

分片規(guī)則配置

Sharding-JDBC的分片策略配置是自定義的,因此可以通過編程的方式最大限度的靈活調(diào)整。它并不僅支持=運算符分片,可支持BETWEEN和IN的運算符分片,支持將一條邏輯SQL最終散落至多個數(shù)據(jù)節(jié)點。同時支持多分片鍵,例如:根據(jù)用戶ID分庫,訂單ID分表這種分庫分表結(jié)合的分片策略;或根據(jù)年分庫,月份+用戶區(qū)域ID分表這樣的多片鍵分片。

通過編程的方式定制分片規(guī)則雖然靈活,但配置起來略顯繁瑣。因此Sharding-JDBC又提供了Inline表達式編寫分片策略的方式,用于配置集中化,以避免配置散落在配置文件和代碼中的情況。此外,它還提供了定制化的Spring命名空間和YAML進一步簡化配置。

JDBC規(guī)范重寫

Sharding-JDBC對JDBC規(guī)范的重寫思路是針對DataSource、Connection、Statement、PreparedStatement和ResultSet這5個核心接口封裝,將多個實現(xiàn)類集合納入Sharding-JDBC實現(xiàn)類管理。分布式主鍵也屬于JDBC協(xié)議的一部分。

Sharding-JDBC盡量最大化實現(xiàn)JDBC協(xié)議,但分布式畢竟與原生JDBC不同,所以目前仍有未實現(xiàn)的接口,包括游標,存儲過程、SavePoint以及向前遍歷和修改ResultSet等不太常用的功能。此外,為了保證兼容性,并未實現(xiàn)JDBC 4.1及其后發(fā)布的接口(如:DBCP 1.x版本不支持JDBC 4.1)。

SQL解析

SQL解析作為分庫分表類產(chǎn)品的核心,性能和兼容性是最重要的衡量指標。目前常見的SQL解析器主要有fdb,jsqlparser和Druid。Sharding-JDBC1.4.x之前的版本使用Druid作為SQL解析器,經(jīng)實際測試,它的性能遠超其它解析器。

從1.5.x版本開始,Sharding-JDBC采用完全自研的SQL解析引擎。由于目的不同,它并不需要將SQL轉(zhuǎn)為AST語法樹,也無需通過Visitor的方式二次遍歷。它采用對SQL“半理解”的方式,僅提煉分片需要關(guān)注的上下文,因此SQL解析的性能和容錯性得到了進一步的提高。

SQL解析模塊由Lexer和Parser兩個模塊組成。Lexer用于將SQL拆解為Token,并將其歸類為關(guān)鍵詞,表達式,字面量和操作符。Parser則用于理解SQL和提煉分片上下文,并標記可能需要改寫的位置。分片上下文包含SELECTItems、表信息、分片條件、自增主鍵信息、排序信息、分組信息和Limit信息。一次解析過程是不可逆的,一個個Token的依次解析,因此解析性能很高。由于各種數(shù)據(jù)庫的SQL差異很大,因此在解析模塊對每種數(shù)據(jù)庫提供方言的支持。

Sharding-JDBC支持各種連接、聚合、排序、分組以及分頁的解析,并且可以有限度的支持子查詢。

SQL路由

SQL路由是根據(jù)分片規(guī)則配置以及解析上下文中的分片條件,將SQL定位至真正的數(shù)據(jù)源。它又分為直接路由、簡單路由和笛卡爾積路由。

滿足直接路由的條件比較苛刻,如果通過Hint(通過HintAPI直接指定路由至庫表)方式分片,且僅分庫,則無需SQL解析和結(jié)果歸并。因此它的SQL兼容性最好,可以執(zhí)行包括子查詢、OR、UNION等復雜情況的任意SQL。

簡單路由是Sharding-JDBC最推薦使用的分片方式,它是指不包含JOIN或僅包含Binding表JOIN的SQL。Binding表是指使用同樣的分片鍵和分片規(guī)則的一組表,也就是說任何情況下,Binding表的分片結(jié)果應與主表一致。例如:order表和order_item表,都根據(jù)order_id分片,結(jié)果應是order_1與order_item_1成對出現(xiàn)。這樣的關(guān)聯(lián)查詢和單表查詢復雜度和性能相當。如果分片條件不是等于,而是BETWEEN或IN,則路由結(jié)果不一定落入單庫(表),因此一條邏輯SQL最終可能拆分為多條SQL語句。

笛卡爾積查詢最為復雜,因為無法根據(jù)Binding關(guān)系定位分片規(guī)則的一致性,所以非Binding表的關(guān)聯(lián)查詢需要拆解為笛卡爾積組合執(zhí)行。查詢性能較低,而且數(shù)據(jù)庫連接數(shù)較高,需謹慎使用。

SQL改寫

SQL改寫模塊的用途是將邏輯SQL改寫為可以分布式執(zhí)行的SQL。在Sharding-JDBC 1.5.x版本,SQL改寫進行了調(diào)整和大量優(yōu)化。1.4.x及之前版本,SQL改寫是在SQL路由之前完成的,在1.5.x中調(diào)整為SQL路由之后,因為SQL改寫可以根據(jù)路由至單庫表還是多庫表而進行進一步優(yōu)化。SQL改寫分為正確性改寫和優(yōu)化改寫兩部分。

正確性改寫包括將分表的邏輯表名稱替換為真實表名稱,修正分頁信息和增加補列。舉兩個例子:

AVG計算。分布式場景,以avg1 + avg2 + avg3 / 3計算平均值并不正確,需要改寫為 (sum1 + sum2 + sum3) / (count1 + count2 + count3)。這就需要將包含AVG的SQL改寫為SUM和COUNT,并在結(jié)果歸并時重新計算平均值。

分頁。假設每10條數(shù)據(jù)為一頁,取第2頁數(shù)據(jù)。在分片環(huán)境下獲取LIMIT 10, 10,歸并之后再根據(jù)排序條件取出前10條數(shù)據(jù)是不正確的結(jié)果。正確的做法是將分條件改寫為LIMIT 0, 20,取出所有前兩頁數(shù)據(jù),再結(jié)合排序條件計算出正確的數(shù)據(jù)。因此越是獲取靠后數(shù)據(jù),分頁的效率就會越低。有很多方法可避免使用LIMIT進行分頁。比如構(gòu)建記錄行記錄數(shù)和行偏移量的二級索引,或使用上次分頁數(shù)據(jù)結(jié)尾ID作為下次查詢條件的分頁方式。

優(yōu)化改寫是1.5.x重點提升的部分,實現(xiàn)的功能比較零散,這里同樣舉兩個例子:

單路由拒絕改寫。這是將SQL改寫挪到SQL路由之后的原因。當獲得路由結(jié)果之后,單路由的情況因為不涉及到結(jié)果歸并,因此分頁、補列等改寫都無需存在。尤其是分頁,無需將數(shù)據(jù)從第1條開始取,節(jié)省了網(wǎng)絡帶寬。

流式歸并改寫。一會講到歸并時會說,這里先提一句,將僅包含GROUPBY的SQL改寫為GROUPBY + ORDERBY。

SQL執(zhí)行

路由至真實數(shù)據(jù)源后,Sharding -JDBC將采用多線程并發(fā)執(zhí)行SQL。它用3種執(zhí)行引擎分別對應處理Statement,PreparedStatement和AddBatchPreparedStatement。Sharding-JDBC線程池放在一個名為ShardingContext的對象中,它的生命周期同ShardingDataSource保持一致。如果一個應用中創(chuàng)建了多個Sharding-JDBC的數(shù)據(jù)源,它們將持有不同的線程池。

結(jié)果歸并

Sharding-JDBC支持的結(jié)果歸并從功能上分為遍歷、排序、分組和分頁4種類型,它們是組合而非互斥的關(guān)系。從結(jié)構(gòu)劃分,可分為流式歸并、內(nèi)存歸并和裝飾者歸并。流式歸并和內(nèi)存歸并是互斥的,裝飾者歸并可以在流式歸并和內(nèi)存歸并之上做進一步的處理。

流式歸并是將數(shù)據(jù)游標與結(jié)果集的游標保持一致,順序的從結(jié)果集中一條條的獲取正確的數(shù)據(jù)。遍歷和排序都是流式歸并,分組比較復雜,分為流式分組和內(nèi)存分組。內(nèi)存歸并則是需要將結(jié)果集的所有數(shù)據(jù)都遍歷并存儲在內(nèi)存中,再通過內(nèi)存歸并后,將內(nèi)存中的數(shù)據(jù)偽裝成結(jié)果集返回。

遍歷類型最為簡單,只需將多結(jié)果集組成鏈表,遍歷完成當前結(jié)果集后,將鏈表位置后移,繼續(xù)遍歷下一個結(jié)果集即可。

排序類型稍微復雜,由于ORDER BY的原因,每個結(jié)果集自身數(shù)據(jù)是有序的,因此只需要將結(jié)果集當前游標指向的值排序即可。Sharding-JDBC在排序類型歸并時,將每個結(jié)果集的當前排序數(shù)據(jù)實現(xiàn)了比較器,并將其放入優(yōu)先級隊列。每次JDBC調(diào)用next時,將隊列頂端的結(jié)果集出隊并next,然后獲取新的隊列頂端的結(jié)果集供JDBC獲取數(shù)據(jù)。

分組類型最為復雜,分組歸并已經(jīng)不屬于OLTP范疇,而更面向OLAP,但由于遺留系統(tǒng)使用很多,因此Sharding-JDBC還是將其實現(xiàn)。分組歸并分成流式分組歸并和內(nèi)存分組歸并。流式分組歸并節(jié)省內(nèi)存,但必須要求排序和分組的數(shù)據(jù)保持一致。如果GROUPBY和ORDER BY的內(nèi)容不一致,則必須使用內(nèi)存分組歸并。由于數(shù)據(jù)不是按照分組需要的順序取出,因此需要將結(jié)果集中的所有數(shù)據(jù)全部加載至內(nèi)存。在SQL改寫時提到的僅有GROUP BY的SQL,會優(yōu)化增加ORDER BY語句,即使將內(nèi)存分組歸并優(yōu)化為流式分組歸并的提升。

無論是流式分組還是內(nèi)存分組,對聚合的處理都是一致的。聚合分為比較、累加和平均值3種類型。比較聚合包括MAX和MIN,只返回最大(小)結(jié)果。累加聚合包括SUM和COUNT,需要將結(jié)果累加后返回。平均值聚合則是通過SQL改寫的SUM和COUNT計算,相關(guān)內(nèi)容已在SQL改寫涵蓋,不再贅述。

最后再聊一下裝飾者歸并,他是對所有的結(jié)果集歸并進行統(tǒng)一的功能增強,目前裝飾者歸并只有分頁一種類型。

上述的所有歸并類型,都可能分頁或不分頁,因此可以通過裝飾者模式來增加分頁的能力。分頁歸并會將改寫的LIMIT中,不需要獲取的數(shù)據(jù)過濾掉。Sharding-JDBC的分頁很容易產(chǎn)生誤解,很多人認為分頁會占用大量內(nèi)存,因為Sharding-JDBC會因為分布式正確性的考量,將LIMIT 100000, 10改寫為LIMIT 0, 100010,產(chǎn)生Sharding-JDBC會將100010數(shù)據(jù)都加載到內(nèi)存的錯覺。通過上面分析可知,會全部加載到內(nèi)存的只有內(nèi)存分組歸并這一種情況。其他情況都是通過流式獲取結(jié)果集數(shù)據(jù)的方式,因此Sharding-JDBC會通過結(jié)果集的next方法將無需取出的數(shù)據(jù)全部跳過,并不會將其存入內(nèi)存。

分布式主鍵

分布式主鍵在這里單獨提煉出一個章節(jié),因為它是貫穿于Sharding-JDBC整個生命周期的。

分布式主鍵最獨立的部分是生成策略,Sharding-JDBC提供靈活的配置分布式主鍵生成策略方式。在分片規(guī)則配置模塊可配置每個表的主鍵生成策略,默認使用snowflake。

通過策略生成的分布式主鍵可以無縫的融入JDBC協(xié)議,它實現(xiàn)了Statement的getGeneratedKeys方法,將其返回改寫后的Result和ResultMetaData,將Sharding-JDBC生成的分布式主鍵偽裝為數(shù)據(jù)庫生成的自增主鍵返回。

SQL解析時,需要根據(jù)分布式主鍵配置策略判斷是否在邏輯SQL中已包含主鍵列,如果未包含則需要將INSERTItems和INSERT Values的最后位置寫入解析上下文。

SQL改寫時,將根據(jù)解析上下文中的位置改寫SQL,增加未包含的主鍵列名稱和值。如果是Statement則在INSERT Values后追加生成后的分布式主鍵;如果是PreparedStatement則在INSERT Values后追加?,并在傳入的參數(shù)后追加生成后的分布式主鍵。

更新SQL解析比查詢SQL解析復雜度低的多的多。不同數(shù)據(jù)庫在插入SQL語法上也統(tǒng)一的多。本文分享 MySQL 更新SQL解析器 MySQLUpdateParser

MySQL UPDATE 語法一共有 2 種 :

第一種:Single-table syntax

UPDATE [LOW_PRIORITY] [IGNORE] table_reference

SET col_name1={expr1|DEFAULT} [, col_name2={expr2|DEFAULT}] ...

[WHERE where_condition]

[ORDER BY ...]

[LIMIT row_count]

第二種:Multiple-table syntax

UPDATE [LOW_PRIORITY] [IGNORE] table_references

SET col_name1={expr1|DEFAULT} [, col_name2={expr2|DEFAULT}] ...

[WHERE where_condition]

Sharding-JDBC 目前僅支持第一種。業(yè)務場景上使用第二種的很少很少。

Sharding-JDBC 更新SQL解析主流程如下:

// AbstractUpdateParser.java

@Override

public UpdateStatement parse() {

sqlParser.getLexer().nextToken(); // 跳過 UPDATE

skipBetweenUpdateAndTable(); // 跳過關(guān)鍵字,例如:MYSQL 里的 LOW_PRIORITY、IGNORE

sqlParser.parseSingleTable(updateStatement); // 解析表

parseSetItems(); // 解析 SET

sqlParser.skipUntil(DefaultKeyword.WHERE);

sqlParser.setParametersIndex(parametersIndex);

sqlParser.parseWhere(updateStatement);

return updateStatement; // 解析 WHERE

}

Sharding-JDBC 正在收集使用公司名單:傳送門。

你的登記,會讓更多人參與和使用 Sharding-JDBC。傳送門

Sharding-JDBC 也會因此,能夠覆蓋更多的業(yè)務場景。傳送門

登記吧,騷年!傳送門

2. UpdateStatement

更新SQL 解析結(jié)果。

public final class UpdateStatement extends AbstractSQLStatement {

}

對,沒有其他屬性。

我們來看下 UPDATE t_user SET nickname = ?, age = ? WHERE user_id = ? 的解析結(jié)果

3. #parse()

3.1 #skipBetweenUpdateAndTable()

在 UPDATE 和 表名 之間有些詞法,對 SQL 路由和改寫無影響,進行跳過。

// MySQLUpdateParser.java

@Override

protected void skipBetweenUpdateAndTable() {

getSqlParser().skipAll(MySQLKeyword.LOW_PRIORITY, MySQLKeyword.IGNORE);

}

// OracleUpdateParser.java

@Override

protected void skipBetweenUpdateAndTable() {

getSqlParser().skipIfEqual(OracleKeyword.ONLY);

}

3.2 #parseSingleTable()

解析,請看《SQL 解析(二)之SQL解析》的 #parseSingleTable() 小節(jié)。

3.3 #parseSetItems()

解析SET后語句。

// AbstractUpdateParser.java

/**

* 解析多個 SET 項

*/

private void parseSetItems() {

sqlParser.accept(DefaultKeyword.SET);

do {

parseSetItem();

} while (sqlParser.skipIfEqual(Symbol.COMMA)); // 以 "," 分隔

}

/**

* 解析單個 SET 項

*/

private void parseSetItem() {

parseSetColumn();

sqlParser.skipIfEqual(Symbol.EQ, Symbol.COLON_EQ);

parseSetValue();

}

/**

* 解析單個 SET 項

*/

private void parseSetColumn() {

if (sqlParser.equalAny(Symbol.LEFT_PAREN)) {

sqlParser.skipParentheses();

return;

}

int beginPosition = sqlParser.getLexer().getCurrentToken().getEndPosition();

String literals = sqlParser.getLexer().getCurrentToken().getLiterals();

sqlParser.getLexer().nextToken();

if (sqlParser.skipIfEqual(Symbol.DOT)) { // 字段有別名

// TableToken

if (updateStatement.getTables().getSingleTableName().equalsIgnoreCase(SQLUtil.getExactlyValue(literals))) {

updateStatement.getSqlTokens().add(new TableToken(beginPosition - literals.length(), literals));

}

sqlParser.getLexer().nextToken();

}

}

/**

* 解析單個 SET 值

*/

private void parseSetValue() {

sqlParser.parseExpression(updateStatement);

parametersIndex = sqlParser.getParametersIndex();

}

3.4 #parseWhere()

Sharding-JDBC將作為面向OLTP在線業(yè)務的分片化的數(shù)據(jù)庫治理微服務基礎(chǔ)組件積極的發(fā)展下去。真誠邀請感興趣的人關(guān)注和參與。

在此我向大家推薦一個架構(gòu)學習交流群。交流學習群號:938837867 暗號:555 里面會分享一些資深架構(gòu)師錄制的視頻錄像:有Spring,MyBatis,Netty源碼分析,高并發(fā)、高性能、分布式、微服務架構(gòu)的原理,JVM性能優(yōu)化、分布式架構(gòu)等這些成為架構(gòu)師必備

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內(nèi)容