前言
這是一篇將“介紹 Sharding-JDBC 基本使用方法”作為目標的文章,但筆者卻把大部分文字放在對 Sharding-JDBC 的工作原理的描述上,因為筆者認為原理是每個 IT 打工人學習技術的歸途。
使用框架、中間件、數(shù)據(jù)庫、工具包等公共組件來組裝出應用系統(tǒng)是我們這一代 IT 打工人工作的常態(tài)。對于這些公共組件——比如框架——的學習,有些人的方法是這樣的:避開復雜晦澀的框架原理,僅僅關注它的各種配置、API、注解,在嘗試了這個框架的常用配置項、API、注解的效果之后,就妄稱自己學會了這個框架。這種對技術的膚淺的認知既經(jīng)不起實踐的考驗,也經(jīng)不起面試官的考驗,甚至連自己使用這些配置項、API、注解在干什么都沒有明確的認知。
所以,打工人們,還是多學點原理,多看點源碼,讓優(yōu)秀的設計思想、算法和編程風格沖擊一下自己的大腦吧 :-)
因為 Sharding-JDBC 的設計細節(jié)實在太多,因此本文不可能對 Sharding-JDBC 進行面面俱到的講解。筆者在本文中僅僅保留了對 Sharding-JDBC 的核心特性、核心原理的講解,并盡量使用簡單生動的文字進行表達,使讀者閱讀本文后對 Sharding-JDBC 的基本原理和使用有清晰的認知。為了使這些文字盡量擺脫枯燥的味道,文章采用了第一人稱的講述方式,讓 Sharding-JDBC 現(xiàn)身說法,進行自我剖析,希望給大家一個更好的閱讀體驗。
但是,妄圖不動腦子就能對某項技術產(chǎn)生深度認知是絕不可能的,你思考得越多,你得到的越多。這就印證了那句話:“我變禿了,也變強了?!?/p>
1. 我的出生和我的家族
我是 Sharding-JDBC,一個關系型數(shù)據(jù)庫中間件,我的全名是 Apache ShardingSphere JDBC,我被冠以 Apache 這個貴族姓氏是 2020 年 4 月的事情,這意味著我進入了代碼世界的“體制內”。但我還是喜歡別人稱呼我的小名,Sharding-JDBC。
我的創(chuàng)造者在我誕生之后給我講了我的身世:
“
你的誕生是一個必然的結果。
在你誕生之前,傳統(tǒng)軟件的存儲層架構將所有的業(yè)務數(shù)據(jù)存儲到單一數(shù)據(jù)庫節(jié)點,在性能、可用性和運維成本這三方面已經(jīng)難于滿足互聯(lián)網(wǎng)的海量數(shù)據(jù)場景。
從性能方面來說,由于關系型數(shù)據(jù)庫大多采用 B+樹類型的索引,在數(shù)據(jù)量逐漸增大的情況下,索引深度的增加也將使得磁盤訪問的 IO 次數(shù)增加,進而導致查詢性能的下降;同時,高并發(fā)訪問請求也使得集中式數(shù)據(jù)庫成為系統(tǒng)的最大瓶頸。
從可用性的方面來講,應用服務器節(jié)點能夠隨意水平拓展(水平拓展就是增加應用服務器節(jié)點數(shù)量)以應對不斷增加的業(yè)務流量,這必然導致系統(tǒng)的最終壓力都落在數(shù)據(jù)庫之上。而單一的數(shù)據(jù)庫節(jié)點,或者簡單的主從架構,已經(jīng)越來越難以承擔眾多應用服務器節(jié)點的數(shù)據(jù)查詢請求。數(shù)據(jù)庫的可用性,已成為整個系統(tǒng)的關鍵。
從運維成本方面考慮,隨著數(shù)據(jù)庫實例中的數(shù)據(jù)規(guī)模的增大,DBA 的運維壓力也會增加,因為數(shù)據(jù)備份和恢復的時間成本都將隨著數(shù)據(jù)量的增大而愈發(fā)不可控。
這樣看來關系型數(shù)據(jù)庫似乎難以承擔海量記錄的存儲。
然而,關系型數(shù)據(jù)庫當今依然占有巨大市場,是各個公司核心業(yè)務的基石。在傳統(tǒng)的關系型數(shù)據(jù)庫無法滿足互聯(lián)網(wǎng)場景需要的情況下,將數(shù)據(jù)存儲到原生支持分布式的 NoSQL 的嘗試越來越多。但 NoSQL 對 SQL 的不兼容性以及生態(tài)圈的不完善,使得它們在與關系型數(shù)據(jù)庫的博弈中處于劣勢,關系型數(shù)據(jù)庫的地位卻依然不可撼動,未來也難于撼動。
我們目前階段更加關注在原有關系型數(shù)據(jù)庫的基礎上做增量,使之更好適應海量數(shù)據(jù)存儲和高并發(fā)查詢請求的場景,而不是要顛覆關系型數(shù)據(jù)庫。
分庫分表方案就是這種增量,它的誕生解決了海量數(shù)據(jù)存儲和高并發(fā)查詢請求的問題。
但是,單一數(shù)據(jù)庫被分庫分表之后,繁雜的庫和表使得編寫持久層代碼的工程師的思維負擔翻了很多倍,他們需要考慮一個業(yè)務 SQL 應該去哪個庫的哪個表里去查詢,查詢到的結果還要進行聚合,如果遇到多表關聯(lián)查詢、排序、分頁、事務等等問題,那簡直是一個噩夢。
于是我們創(chuàng)造了你。你可以讓工程師們以像查詢單數(shù)據(jù)庫實例和單表那樣來查詢被水平分割的庫和表,我們稱之為透明查詢。
你是水平分片世界的神。
”
這使我感到驕傲。
我被定位為一個輕量級 Java 框架,我在 Java 的 JDBC 層提供的額外服務,可以說是一個增強版的 JDBC 驅動,完全兼容 JDBC 和各種 ORM 框架。
我適用于任何基于 JDBC 的 ORM 框架,如:JPA, Hibernate, Mybatis, Spring JDBC Template 或直接使用 JDBC。
我支持任何第三方的數(shù)據(jù)庫連接池,如:DBCP, C3P0, BoneCP, Druid, HikariCP 等。
我支持任意實現(xiàn) JDBC 規(guī)范的數(shù)據(jù)庫,目前支持 MySQL,Oracle,SQLServer,PostgreSQL 以及任何遵循 SQL92 標準的數(shù)據(jù)庫。
我的創(chuàng)造者起初只創(chuàng)造了我一個獨苗,后來為了我的家族的興盛,我的兩個兄弟——Apache ShardingSphere Proxy、Apache ShardingSphere Sidecar 又被創(chuàng)造了出來。前者被定位為透明化的數(shù)據(jù)庫代理端,提供封裝了數(shù)據(jù)庫二進制協(xié)議的服務端版本,?于完成對異構語?的支持;后者被定位為 Kubernetes 的云原?數(shù)據(jù)庫代理,以 Sidecar 的形式代理所有對數(shù)據(jù)庫的訪問。通過無中心、零侵?的?案提供與數(shù)據(jù)庫交互的的嚙合層,即 Database Mesh,又可稱數(shù)據(jù)庫?格。
因此,我們這個家族叫做 Apache ShardingSphere,旨在在分布式的場景下更好利用關系型數(shù)據(jù)庫的計算和存儲能力,而并非實現(xiàn)一個全新的關系型數(shù)據(jù)庫。我們三個既相互獨立,又能配合使用,均提供標準化的數(shù)據(jù)分片、分布式事務和數(shù)據(jù)庫治理功能。
2. 我統(tǒng)治的世界和我的職責
我是 Sharding-JDBC,我生活在一個數(shù)據(jù)水平分片的世界,我統(tǒng)治著這個世界里被水平拆分后的數(shù)據(jù)庫和表。
在分片的世界里,數(shù)據(jù)分片有兩種法則:垂直拆分和水平拆分。
按照業(yè)務拆分的方式稱為垂直分片,又稱為縱向拆分,它的核心理念是專庫專用。在拆分之前,一個數(shù)據(jù)庫由多個數(shù)據(jù)表構成,每個表對應著不同的業(yè)務。而拆分之后,則是按照業(yè)務將表進行歸類,分布到不同的數(shù)據(jù)庫中,從而將壓力分散至不同的數(shù)據(jù)庫。下圖展示了根據(jù)業(yè)務需要,將用戶表和訂單表垂直分片到不同的數(shù)據(jù)庫的方案。
031
垂直分片往往需要對架構和設計進行調整。通常來講,是來不及應對互聯(lián)網(wǎng)業(yè)務需求快速變化的;而且,它也并無法真正的解決單點瓶頸。如果垂直拆分之后,表中的數(shù)據(jù)量依然超過單節(jié)點所能承載的閾值,則需要水平分片來進一步處理。
水平分片又稱為橫向拆分。相對于垂直分片,它不再將數(shù)據(jù)根據(jù)業(yè)務邏輯分類,而是通過某個字段(或某幾個字段),根據(jù)某種規(guī)則將數(shù)據(jù)分散至多個庫或表中,每個分片僅包含數(shù)據(jù)的一部分。例如:根據(jù)主鍵分片,偶數(shù)主鍵的記錄放入 0 庫(或表),奇數(shù)主鍵的記錄放入 1 庫(或表),如下圖所示。
032
水平分片從理論上突破了單機數(shù)據(jù)量處理的瓶頸,并且擴展相對自由,是分庫分表的標準解決方案。我管轄的就是水平分片世界。
通過分庫和分表進行數(shù)據(jù)的拆分來使得各個表的數(shù)據(jù)量保持在閾值以下,是應對高并發(fā)和海量數(shù)據(jù)系統(tǒng)的有效手段。此外,使用多主多從的分片方式,可以有效的避免數(shù)據(jù)單點,從而提升數(shù)據(jù)架構的可用性。
其實,水平分庫本質上還是在分表,因為被水平拆分后的庫中,都有相同的表分片。
分庫和分表這項工作并不是我來做,我雖然是神,但我還沒有神到能理解你們這些工程師的業(yè)務設計和架構設計,從而自動把你們的業(yè)務數(shù)據(jù)庫和業(yè)務表進行分片。對哪部分進行分片、怎樣分片、分多少份,這些工作全部由這些工程師進行。當這些分庫分表的工作被完成后,你們只需要在我的配置文件中或者通過我的 API 告訴我這些拆分規(guī)則(這就是后文要提到的分片策略)即可,剩下的事情,交給我去做。
我是 Sharding-JDBC,我的職責是盡量透明化水平分庫分表所帶來的影響,讓使用方盡量像使用一個數(shù)據(jù)庫一樣使用水平分片之后的數(shù)據(jù)庫集群,或者像使用一個數(shù)據(jù)表一樣使用水平分片之后的數(shù)據(jù)表。由于我的治理,每個服務器節(jié)點只能看到一個邏輯上的數(shù)據(jù)庫節(jié)點,和其中的多個邏輯表,它們看不到真正存在于物理世界中的被水平分割的多個數(shù)據(jù)庫分片和被水平分割的多個數(shù)據(jù)表分片。服務器節(jié)點看到的簡單的持久層結構,其實是我苦心營造的幻象。
033
而為了營造這種幻象,我在幕后付出了很多。
當一個 Java 應用服務器節(jié)點將一個查詢 SQL 交給我之后,我要做下面幾件事:
1)SQL 解析:解析分為詞法解析和語法解析。我先通過詞法解析器將這句 SQL 拆分為一個個不可再分的單詞,再使用語法解析器對 SQL 進行理解,并最終提煉出解析上下文。簡單來說就是我要理解這句 SQL,明白它的構造和行為,這是下面的優(yōu)化、路由、改寫、執(zhí)行和歸并的基礎。
2)SQL 路由:我根據(jù)解析上下文匹配用戶對這句 SQL 所涉及的庫和表配置的分片策略(關于用戶配置的分片策略,我后文會慢慢解釋),并根據(jù)分片策略生成路由后的 SQL。路由后的 SQL 有一條或多條,每一條都對應著各自的真實物理分片。
3)SQL 改寫:我將 SQL 改寫為在真實數(shù)據(jù)庫中可以正確執(zhí)行的語句(邏輯 SQL 到物理 SQL 的映射,例如把邏輯表名改成帶編號的分片表名)。
4)SQL 執(zhí)行:我通過多線程執(zhí)行器異步執(zhí)行路由和改寫之后得到的 SQL 語句。
5)結果歸并:我將多個執(zhí)行結果集歸并以便于通過統(tǒng)一的 JDBC 接口輸出。
034
如果你連讀這段工作流程都很困難,那你就能明白我在這個水平分片的世界里有多辛苦。關于這段工作流程,我會在后文慢慢說給你聽。
3. 召喚我的方式
我是 Sharding-JDBC,我被定位為一個輕量級數(shù)據(jù)庫中間件,當你們召喚我去統(tǒng)治水平拆分后的數(shù)據(jù)庫和數(shù)據(jù)表時,只需要做下面幾件事:
1)引入依賴包。
maven 是統(tǒng)治依賴包世界的神,在他誕生之后,一切對 jar 包的引用就變得簡單了。向 maven 獲取我的 jar 包,咒語是:
于是,我就出現(xiàn)在了這個項目中!
如果你們構建的項目已經(jīng)被 Springboot 統(tǒng)治了(Springboot 是 Spring 的繼任者,Spring 是統(tǒng)治對象世界的神,Springboot 繼承了 Spring 的統(tǒng)治法則,并簡化了 Spring 的配置),那么就可以向 maven 獲取我的 springboot starter jar 包,咒語是:
這樣,我就能和 Springboot 神共存于同一個項目。
2)進行水平分片規(guī)則配置。
你們要把水平分片規(guī)則配置告訴我,這樣我才能知道你們是怎樣水平拆分數(shù)據(jù)庫和數(shù)據(jù)表的。你們可以通過我提供的 Java API,或者配置文件告訴我分片規(guī)則。
如果是以 Java API 的方式進行配置,示例如下:
//配置真實數(shù)據(jù)源 Map
這段配置代碼中涉及的 t_order 表(存儲訂單的基本信息)的表結構為:
order_id | user_id | create_time | remarks | total_price |
---|---|---|---|---|
t_order_item 表(存儲訂單的商品和價格明細信息)的結構為:
order_id | production_code | count | price | discount |
---|---|---|---|---|
這段配置代碼描述了對 t_order 表進行的如下圖所示的數(shù)據(jù)表水平分片(對 t_order_item 表也要進行類似的水平分片,但是這部分配置省略了):
035
在這段配置中,或許你們注意到了一些奇怪的表達式:
ds$->{0..1}.t_order$->{0..1} ds_${user_id%2} t_order_${order_id%2}
這些表達式被稱為 Groovy 表達式,它們的含義很容易識別:
1)對 t_order 進行兩種維度的拆分:數(shù)據(jù)庫維度和表維度數(shù);
2)在數(shù)據(jù)庫維度,t_order.user_id % 2 == 0 的記錄全部落到 ds0,t_order.user_id % 2 == 1 的記錄全部落到 ds1;(有人稱這一過程為水平分庫,其實它的本質還是在水平地分表,只不過依據(jù)表中 user_id 的不同把拆分的后的表放入兩個數(shù)據(jù)庫實例。)
3)在表維度,t_order.order_id% 2 == 0 的記錄全部落到 t_order0,t_order.order_id% 2 == 1 的記錄全部落到 t_order1。
4)對記錄的讀和寫都按照這種方向進行,“方向”,就是分片方式,就是路由。
我允許你們這些工程師使用這種簡潔的 Groovy 表達式告訴我你們設置的分片策略和分片算法。但是這種方式所能表達的含義是有限的。因此,我提供了分片策略接口和分片算法接口讓你們利用 Java 代碼盡情表達更為復雜的分片策略和分片算法。關于這一點,我將在《我的特性和工作方法》這一章詳述。
而且在這里我要先告訴你,分片算法是分片策略的組成部分,分片策略設置=分片鍵設置+分片算法設置。上述配置里使用的策略是 Inline 類型的分片策略,使用的算法是 Inline 類型的行表達式算法,你或許不清楚我現(xiàn)在講的這些術語,不要著急,我會在《我的特性和工作方法》這一章詳述。
如果是以配置文件的方式進行配置,示例如下(這里以我的 springboot starter 包的 properties 配置文件為例):
# 配置真實數(shù)據(jù)源 spring.shardingsphere.datasource.names=ds0,ds1 # 配置第 1 個數(shù)據(jù)源 spring.shardingsphere.datasource.ds0.type=org.apache.commons.dbcp2.BasicDataSource spring.shardingsphere.datasource.ds0.driver-class-name=com.mysql.jdbc.Driver spring.shardingsphere.datasource.ds0.url=jdbc//localhost:3306/ds0 spring.shardingsphere.datasource.ds0.username=root spring.shardingsphere.datasource.ds0.password= # 配置第 2 個數(shù)據(jù)源 spring.shardingsphere.datasource.ds1.type=org.apache.commons.dbcp2.BasicDataSource spring.shardingsphere.datasource.ds1.driver-class-name=com.mysql.jdbc.Driver spring.shardingsphere.datasource.ds1.url=jdbc//localhost:3306/ds1 spring.shardingsphere.datasource.ds1.username=root spring.shardingsphere.datasource.ds1.password= # 配置 t_order 表規(guī)則 spring.shardingsphere.rules.sharding.tables.t_order.actual-data-nodes=ds$->{0..1}.t_order$->{0..1} # 配置 t_order 被拆分到多個子庫的策略 spring.shardingsphere.rules.sharding.tables.t_order.database-strategy.standard.sharding-column=user_id spring.shardingsphere.rules.sharding.tables.t_order.database-strategy.standard.sharding-algorithm-name=database_inline # 配置 t_order 被拆分到多個子表的策略 spring.shardingsphere.rules.sharding.tables.t_order.table-strategy.standard.sharding-column=order_id spring.shardingsphere.rules.sharding.tables.t_order.table-strategy.standard.sharding-algorithm-name=table_inline # 省略配置 t_order_item 表規(guī)則... # ... # 配置 t_order 被拆分到多個子庫的算法 spring.shardingsphere.rules.sharding.sharding-algorithms.database_inline.type=INLINE spring.shardingsphere.rules.sharding.sharding-algorithms.database_inline.props.algorithm-expression=ds_${user_id % 2} # 配置 t_order 被拆分到多個子表的算法 spring.shardingsphere.rules.sharding.sharding-algorithms.table_inline.type=INLINE spring.shardingsphere.rules.sharding.sharding-algorithms.table_inline.props.algorithm-expression=t_order_${order_id % 2}
這段配置文件的語義和上面的 Java 配置代碼同義。
3)創(chuàng)建數(shù)據(jù)源。
若使用上文所示的 Java API 進行配置,則可以通過 ShardingSphereDataSourceFactory 工廠創(chuàng)建數(shù)據(jù)源,該工廠產(chǎn)生一個 ShardingSphereDataSource 實例,ShardingSphereDataSource 實現(xiàn)自 JDBC 的標準接口 DataSource(所以 ShardingSphereDataSource 實例也是接口 DataSource 的實例)。之后,就可以通過 dataSource 調用原生 JDBC 接口來執(zhí)行 SQL 查詢,或者將 dataSource 配置到 JPA,MyBatis 等 ORM 框架來執(zhí)行 SQL 查詢。
//創(chuàng)建ShardingSphereDataSource DataSourcedataSource=ShardingSphereDataSourceFactory.createDataSource( dataSourceMap, Collections.singleton(shardingRuleConfig,newProperties()) );
若使用上文所示的基于 springboot starter 的 properties 配置文件進行分片配置,則可以直接通過 Spring 提供的自動注入的方式獲得數(shù)據(jù)源實例 dataSource(同樣,這也是一個 ShardingSphereDataSource 實例)。之后,就可以通過 dataSource 調用原生 JDBC 接口來執(zhí)行 SQL 查詢,或者將 dataSource 配置到 JPA,MyBatis 等 ORM 框架來執(zhí)行 SQL 查詢。
/** *注入一個ShardingSphereDataSource實例 */ @Resource privateDataSourcedataSource;
有了 dataSource(以上兩種方式產(chǎn)生的 dataSource 沒有區(qū)別,都是 ShardingSphereDataSource 的一個實例,業(yè)務代碼將 SQL 交給這個 dataSource,也就是交到了我的手中),就可以執(zhí)行 SQL 查詢了。
4)執(zhí)行 SQL。這里給出 dataSource 調用原生 JDBC 接口來執(zhí)行 SQL 查詢的示例:
Stringsql="SELECTi.*FROMt_orderoJOINt_order_itemiONo.order_id=i.order_idWHEREo.user_id=?ANDo.order_id=?"; try( Connectionconn=dataSource.getConnection(); PreparedStatementps=conn.prepareStatement(sql) ){ ps.setInt(1,10); ps.setInt(2,1000); try( ResultSetrs=preparedStatement.executeQuery() ){ while(rs.next()){ //... } } }
在這個示例中,Java 代碼調用 dataSource 的 JDBC 接口時,只感覺自己在對一個邏輯庫中的兩個邏輯表進行關聯(lián)查詢,并沒有意識到物理分片的存在。而背后是我在進行 SQL 語句的解析、路由、改寫、執(zhí)行和結果歸并!
4. 我的特性和我的工作方法
4.2. 一些核心概念
我是 Sharding-JDBC,我是統(tǒng)治水平分片世界的神,我要向你們解釋我的特性和治理方法。在此之前,我要給出一系列用于描述我的術語。
4.2.1. 邏輯表和物理表
例如,訂單表根據(jù)主鍵尾數(shù)被水平拆分為 10 張表,分別是 t_order0 到 t_order9,它們的邏輯表名為 t_order,而 t_order0 到 t_order9 就是物理表。
4.2.2. 分片鍵
例如,若根據(jù)訂單表中的訂單主鍵的尾數(shù)取模結果進行水平分片,則訂單主鍵為分片鍵。訂單表既可以根據(jù)單個分片鍵進行分片,也同樣可以根據(jù)多個分片鍵(例如 order_id 和 user_id)進行分片。
4.2.3. 路由
應用程序服務器將針對邏輯表編寫的 SQL 交給我,我在執(zhí)行前,要找到 SQL 語句里包含的查詢條件(where ......)所對應的分片(物理表),然后再針對這些分片進行查詢,這個找分片的過程叫做路由。
而怎樣找分片,是由你們在分片策略中告訴我的。
4.2.4. 分片策略和分片算法
在上文的配置示例中,有如下的一段:
...... //配置t_order被拆分到多個子庫的策略 orderTableRuleConfig.setDatabaseShardingStrategy( newStandardShardingStrategyConfiguration( "user_id", "dbShardingAlgorithm" ) ); //配置t_order被拆分到多個子表的策略 orderTableRuleConfig.setTableShardingStrategy( newStandardShardingStrategyConfiguration( "order_id", "tableShardingAlgorithm" ) ); ...... ShardingRuleConfigurationshardingRuleConfig=newShardingRuleConfiguration(); shardingRuleConfig.getTables().add(orderTableRuleConfig); //配置t_order被拆分到多個子庫的算法 PropertiesdbShardingAlgorithmrProps=newProperties(); dbShardingAlgorithmrProps.setProperty( "algorithm-expression", "ds${user_id%2}" ); shardingRuleConfig.getShardingAlgorithms().put( "dbShardingAlgorithm", newShardingSphereAlgorithmConfiguration("INLINE",dbShardingAlgorithmrProps) ); //配置t_order被拆分到多個子表的算法 PropertiestableShardingAlgorithmrProps=newProperties(); tableShardingAlgorithmrProps.setProperty( "algorithm-expression", "t_order${order_id%2}" ); shardingRuleConfig.getShardingAlgorithms().put( "tableShardingAlgorithm", newShardingSphereAlgorithmConfiguration("INLINE",tableShardingAlgorithmrProps) ); ......
它們表達的就是對 t_order 表進行的分片策略和分片算法的配置。
上文說到,我允許你們這些工程師使用簡潔的 Groovy 表達式告訴我你們設置的分片策略和分片算法。但是這種方式所能表達的含義是有限的。因此,我提供了分片策略接口和分片算法接口讓你們利用靈活的 Java 代碼盡情表達更為復雜的分片策略和分片算法。
所謂分片策略,就是分片鍵和分片算法的組合,由于分片算法的獨立性,我將其獨立抽離出來,由你們自己實現(xiàn),也就是告訴我數(shù)據(jù)是怎么根據(jù)分片鍵的值找到對應的分片,進而對這些分片執(zhí)行 SQL 查詢。
當然我也提供了一些內置的簡單算法的實現(xiàn)。上面提到的基于 Groovy 表達式的分片算法就是我內置的一種算法實現(xiàn),你們只要給我一段語義準確無誤的 Groovy 表達式,我就能知道怎么根據(jù)分片鍵的值找到對應的分片。
我的分片策略有兩個維度,如下圖所示,分別是數(shù)據(jù)源分片策略(databaseShardingStrategy)和表分片策略(tableShardingStrategy)。數(shù)據(jù)源分片策略表示數(shù)據(jù)被路由到目標物理數(shù)據(jù)庫的策略,表分片策略表示數(shù)據(jù)被路由到目標物理表的策略。表分片策略是依賴于數(shù)據(jù)源分片策略的,也就是說要先分庫再分表,當然也可以只分表。
036
我目前可以提供如下幾種分片(無論是對庫分片還是對表分片)策略:標準分片策略(使用精確分片算法或者范圍分片算法)、復合分片策略(使用符合分片算法)、Hint 分片策略(使用 Hint 分片算法)、Inline 分片策略(使用 Grovvy 表達式作為分片算法)、不分片策略(不使用分片算法)。
我的 Jar 包源碼里的策略類和算法接口如下:
037
038
一、標準分片策略
標準分片策略 StandardShardingStrategy 的源代碼(部分)如下,這是一個 final class。
packageorg.apache.shardingsphere.core.strategy.route.standard; ...... publicfinalclassStandardShardingStrategyimplementsShardingStrategy{ privatefinalStringshardingColumn; /** *要配合PreciseShardingAlgorithm或RangeShardingAlgorithm使用 *標準分片策略 */ privatefinalPreciseShardingAlgorithmpreciseShardingAlgorithm; privatefinalRangeShardingAlgorithmrangeShardingAlgorithm; publicStandardShardingStrategy( //傳入分片配置 finalStandardShardingStrategyConfigurationstandardShardingStrategyConfig ){ ...... //從配置中提取分片鍵 shardingColumn=standardShardingStrategyConfig.getShardingColumn(); //從配置中提取分片算法 preciseShardingAlgorithm=standardShardingStrategyConfig.getPreciseShardingAlgorithm(); rangeShardingAlgorithm=standardShardingStrategyConfig.getRangeShardingAlgorithm(); } @Override publicCollection
其中 PreciseShardingAlgorithm(接口)和 RangeShardingAlgorithm(接口)的源代碼分別為:
packageorg.apache.shardingsphere.api.sharding.standard; ...... publicinterfacePreciseShardingAlgorithm
標準分片策略提供對 SQL 語句中的操作符 =、>、 <、>=、<=、IN 和 BETWEEN AND 的分片操支持。
標準分片策略只支持單分片鍵,例如對 t_order 表只根據(jù) order_id 分片。標準分片策略提供 PreciseShardingAlgorithm(接口)和 RangeShardingAlgorithm(接口)兩個分片算法。PreciseShardingAlgorithm(接口)顧名思義用于處理操作符=和 IN 的精確分片。RangeShardingAlgorithm (接口)顧名思義用于處理操作符 BETWEEN AND、>、<、>=、<= 的范圍分片。
我舉個例子幫助你理解以上兩段話的含義。以 t_order 為例,假如你使用 order_id 作為 t_order 的分片鍵,并設計了以下的分片策略:
策略一:設置 6 個分片 t_order.order_id%6==0的查詢分片到t_order0 t_order.order_id%6==1的查詢分片到t_order1 t_order.order_id%6==2的查詢分片到t_order2 t_order.order_id%6==3的查詢分片到t_order3 t_order.order_id%6==4的查詢分片到t_order4 t_order.order_id%6==5的查詢分片到t_order5 策略二:設置 2 個分片 t_order.order_id%6in(0,2,4)的查詢分片到t_order1 t_order.order_id%6in(1,3,5)的查詢分片到t_order1 策略三:經(jīng)過估算訂單不超過 60000個,設置 6 個分片 t_order.order_idbetween0and10000的查詢分片到t_order0 t_order.order_idbetween10000and20000的查詢分片到t_order1 t_order.order_idbetween20000and30000的查詢分片到t_order2 t_order.order_idbetween30000and40000的查詢分片到t_order3 t_order.order_idbetween40000and50000的查詢分片到t_order4 t_order.order_idbetween50000and60000的查詢分片到t_order5 策略四:經(jīng)過估算訂單不超過 20000個,設置 2 個分片 t_order.order_id<=10000?的查詢分片到?t_order0 t_order.order_id?>10000的查詢分片到t_order1 ......
那你就可以把以下三項:
1)分片鍵 order_id
2)描述以上分片策略內容的 PreciseShardingAlgorithm(接口)的實現(xiàn)類或 RangeShardingAlgorithm(接口)的實現(xiàn)類
3)前兩項(即分片策略)的作用目標 t_order 表
寫到分片配置里(無論是通過配置 API 還是通過配置文件),那我就能知道如何去路由 SQL,即根據(jù)分片鍵的值,找到對應的分片表(或分片庫)。
有了這些配置,我就能幫你們透明處理如下 SQL 語句,不管實際的物理分片是怎樣的:
--注:使用 t_order.order_id 作為 t_order 表的分片鍵 SELECTo.*FROMt_orderoWHEREo.order_id=10; SELECTo.*FROMt_orderoWHEREo.order_idIN(10,11); SELECTo.*FROMt_orderoWHEREo.order_id>10; SELECTo.*FROMt_orderoWHEREo.order_id<=?11; SELECT?o.*?FROM?t_order?o?WHERE?o.order_id?BETWEEN?10?AND?12; ...... INSERT?INTO?t_order(order_id,?user_id)?VALUES?(20,?1001); ...... DELETE?FROM?t_order?o?WHERE?o.order_id?=?10; DELETE?FROM?t_order?o?WHERE?o.order_id?IN?(10,?11); DELETE?FROM?t_order?o?WHERE?o.order_id?>10; DELETEFROMt_orderoWHEREo.order_id<=?11; DELETE?FROM?t_order?o?WHERE?o.order_id?BETWEEN?10?AND?12; ...... UPDATE?t_order?o?SET?o.update_time?=?NOW()?WHERE?o.order_id?=?10; ......
二、復合分片策略
復合分片策略 ComplexShardingStrategy 的源代碼(部分)如下,這是一個 final class。
packageorg.apache.shardingsphere.core.strategy.route.complex; ...... publicfinalclassComplexShardingStrategyimplementsShardingStrategy{ @Getter privatefinalCollection
其中 ComplexKeysShardingAlgorithm(接口)的源代碼為:
packageorg.apache.shardingsphere.api.sharding.complex; ...... publicinterfaceComplexKeysShardingAlgorithm
復合分片策略提供對 SQL 語句中的操作符 =、>、<、>=、<=、IN 和 ETWEEN AND 的分片操作支持。
復合分片策略支持多分片鍵,例如對 t_order 表根據(jù) order_id 和 user_id 分片。復合分片策略提供 ComplexKeysShardingAlgorithm(接口)分片算法。
我舉個例子幫助你理解以上兩段話的含義。以 t_order 為例,假如你使用 order_id 和 user_id 作為 t_order 的分片鍵,并設計了以下的分片策略:
策略一:設置 4 個分片 t_order.order_id%2==0&&t_order.user_id%2==0的查詢分片到t_order0 t_order.order_id%2==0&&t_order.user_id%2==1的查詢分片到t_order1 t_order.order_id%2==1&&t_order.user_id%2==0的查詢分片到t_order2 t_order.order_id%2==1&&t_order.user_id%2==1的查詢分片到t_order3 策略二:經(jīng)過估算訂單不超過 60000個、用戶不超過 1000個,設置 4 個分片 t_order.order_idbetween0and40000&&t_order.user_idbetween0and500的查詢分片到t_order0 t_order.order_idbetween0and40000&&t_order.user_idbetween500and1000的查詢分片到t_order1 t_order.order_idbetween40000and60000&&t_order.user_idbetween0and500的查詢分片到t_order2 t_order.order_idbetween40000and60000&&t_order.user_idbetween500and1000的查詢分片到t_order3 ......
那你就可以把以下三項:
1)分片鍵 order_id 和 user_id
2)描述以上分片策略內容的 ComplexKeysShardingAlgorithm(接口)的實現(xiàn)類
3)前兩項(即分片策略)的作用目標 t_order 表
寫到分片配置里(無論是通過配置 API 還是通過配置文件),那我就能知道如何去路由 SQL,即根據(jù)分片鍵的值,找到對應的分片表(或分片庫)。
有了這些配置,我就能幫你們透明處理如下 SQL 語句,不管實際的物理分片是怎樣的:
--注:使用 t_order.order_id、t_order.user_id 作為 t_order 表的分片鍵 SELECTo.*FROMt_orderoWHEREo.order_id=10; SELECTo.*FROMt_orderoWHEREo.order_idIN(10,11); SELECTo.*FROMt_orderoWHEREo.order_id>10; SELECTo.*FROMt_orderoWHEREo.order_id<=?11; SELECT?o.*?FROM?t_order?o?WHERE?o.order_id?BETWEEN?10?AND?12; ...... INSERT?INTO?t_order(order_id,?user_id)?VALUES?(20,?1001); ...... DELETE?FROM?t_order?o?WHERE?o.order_id?=?10; DELETE?FROM?t_order?o?WHERE?o.order_id?IN?(10,?11); DELETE?FROM?t_order?o?WHERE?o.order_id?>10; DELETEFROMt_orderoWHEREo.order_id<=?11; DELETE?FROM?t_order?o?WHERE?o.order_id?BETWEEN?10?AND?12; ...... UPDATE?t_order?o?SET?o.update_time?=?NOW()?WHERE?o.order_id?=?10; ...... SELECT?o.*?FROM?t_order?o?WHERE?o.order_id?=?10?AND?user_id?=?1001; SELECT?o.*?FROM?t_order?o?WHERE?o.order_id?IN?(10,?11)?AND?user_id?IN?(......); SELECT?o.*?FROM?t_order?o?WHERE?o.order_id?>10ANDuser_id>1000; SELECTo.*FROMt_orderoWHEREo.order_id<=?11?AND?user_id?<=?1000; SELECT?o.*?FROM?t_order?o?WHERE?(o.order_id?BETWEEN?10?AND?12)?AND?(o.user_id?BETWEEN?1000?AND?2000); ...... INSERT?INTO?t_order(order_id,?user_id)?VALUES?(21,?1002); ...... DELETE?FROM?t_order?o?WHERE?o.order_id?=?10?AND?user_id?=?1001; DELETE?FROM?t_order?o?WHERE?o.order_id?IN?(10,?11)?AND?user_id?IN?(......); DELETE?FROM?t_order?o?WHERE?o.order_id?>10ANDuser_id>1000; DELETEFROMt_orderoWHEREo.order_id<=?11?AND?user_id?<=?1000; DELETE?FROM?t_order?o?WHERE?(o.order_id?BETWEEN?10?AND?12)?AND?(o.user_id?BETWEEN?1000?AND?2000); ...... UPDATE?t_order?o?SET?o.update_time?=?NOW()?WHERE?o.order_id?=?10?AND?user_id?=?1001; ......
注:在《召喚我的方式》這一章,我給出了一段配置,這段配置表明先依照 user_id % 2 對 t_order 進行水平拆分(到不同的子庫),再依照 order_id % 2 對 t_order 進行水平拆分(到不同的子表)。但這并不是說使用了復合分片策略,而是使用了兩個兩個維度的標準分片策略。兩個維度,分別是數(shù)據(jù)源分片策略(DatabaseShardingStrategy)和表分片策略(TableShardingStrategy),且在數(shù)據(jù)源分片策略上使用 user_id 作為單分片鍵、在表分片策略上使用 order_id 作為單分片鍵。
三、Hint(翻譯為暗示) 分片策略
Hint 分片策略對應 HintShardingStrategy 這個 final class,同標準分片策略和符合分片策略的代碼類似,HintShardingStrategy 中包含一個 HintShardingAlgorithm 接口的實例,并調用它的 doSharding()方法。你們要自己去實現(xiàn)這個 HintShardingAlgorithm 接口中的 doSharding()方法,這樣我就能知道如何根據(jù)分片鍵的值,找到對應的分片表(或分片庫)。此處不在展示 HintShardingStrategy 和 HintShardingAlgorithm 的源碼。
Hint 分片策略是指通過 Hint 指定分片值而非從 SQL 中提取分片值的方式進行分片的策略。簡單來講就是我收到的 SQL 語句中不包含分片值(像上面給出的幾段 SQL 就是包含分片值的 SQL),但是工程師會通過我提供的 Java API 將分片值暗示給我,這樣我就知道怎樣路由 SQL 查詢到具體的分片了。就像下面這樣:
Stringsql="SELECT*FROMt_order"; try( //HintManager是使用“暗示”的工具,它會把暗示的分片值放入 //當前線程上下文(ThreadLocal)中,這樣當前線程執(zhí)行SQL的 //時候就能獲取到分片值 HintManagerhintManager=HintManager.getInstance(); Connectionconn=dataSource.getConnection(); PreparedStatementpstmt=conn.prepareStatement(sql); ){ hintManager.setDatabaseShardingValue(3); try(ResultSetrs=pstmt.executeQuery()){ //若t_order僅僅使用order_id作為分片鍵,則這里根據(jù)暗 //示獲取了分片值,因此上面的 SQL 的實際執(zhí)行效果相當于: //SELECT*FROMt_orderwhereorder_id=3 while(rs.next()){ //... } } }
四、不分片策略
對應 NoneShardingStrategy,這是一個 final class。由于我并不要求所有的表(或庫)都進行水平分片,因此當工程師要通過我執(zhí)行對不分片表(或庫)的 SQL 查詢時,就要使用這個不分片策略。NoneShardingStrategy 的源碼為:
packageorg.apache.shardingsphere.core.strategy.route.none; ...... @Getter publicfinalclassNoneShardingStrategyimplementsShardingStrategy{ privatefinalCollection
五、Inline 分片策略
Inline 分片策略,也叫做行表達式分片策略。Inline 分片策略對應 InlineShardingStrategy。Inline 分片策略是為用 Grovvy 表達式描述的分片算法準備的分片策略。文章開始展示的兩段配置中就使用了 Inline 分片策略。InlineShardingStrategy 把 Grovvy 表達式當做分片算法的實現(xiàn),因此 HintShardingStrategy 中不包含算法域變量,這一點有別于 StandardShardingStrategy 等 final class。這里不再展示 InlineShardingStrategy 的源碼。
我知道,這段關于分片策略和分片算法的表述很難理解。不過我還是想讓你們明白,無論對某個邏輯表(或庫)進行怎樣的分片策略配置,這些策略不過都是在告訴我怎樣處理分片,也就是告訴我如何根據(jù)分片鍵的值,找到對應的分片表(或分片庫)。只不過我的創(chuàng)造者把這個簡單的過程翻出了很多花樣,也就是你們在上面看到的各種策略,以提供使用上的靈活性。
4.2.5. 綁定表
指分片規(guī)則一致的主表和子表。例如 t_order 是主表,存儲訂單的基本信息;t_order_item 是子表,存儲訂單中的商品和價格明細。若兩張表均按照 order_id 分片,并且配置了兩個表之間的綁定關系,則此兩張表互為綁定表。綁定表之間的多表關聯(lián)查詢不會出現(xiàn)笛卡爾積關聯(lián),關聯(lián)查詢效率將大大提升。舉例說明,如果 SQL 為:
SELECTi.*FROMt_orderoJOINt_order_itemiONo.order_id=i.order_idWHEREo.order_idIN(10,11);
在不配置綁定表關系時,假設分片鍵 order_id 將數(shù)值 10 路由至第 0 片,將數(shù)值 11 路由至第 1 片,那么路由后的 SQL 應該為 4 條,它們呈現(xiàn)為笛卡爾積,這種情況是我最不愿意處理的,我要考慮所有可能的分組合,它的工作量實在太大了:
SELECTi.*FROMt_order0oJOINt_order_item0iONo.order_id=i.order_idWHEREo.order_idIN(10,11); SELECTi.*FROMt_order0oJOINt_order_item1iONo.order_id=i.order_idWHEREo.order_idIN(10,11); SELECTi.*FROMt_order1oJOINt_order_item0iONo.order_id=i.order_idWHEREo.order_idIN(10,11); SELECTi.*FROMt_order1oJOINt_order_item1iONo.order_id=i.order_idWHEREo.order_idIN(10,11);
而在配置綁定表關系后,路由的 SQL 只有 2 條:
SELECTi.*FROMt_order0oJOINt_order_item0iONo.order_id=i.order_idWHEREo.order_idIN(10,11); SELECTi.*FROMt_order1oJOINt_order_item1iONo.order_id=i.order_idWHEREo.order_idIN(10,11);
而我也提供了這種綁定關系配置的 API 和配置項,例如在 properties 配置文件中可以這么寫:
# 設置綁定表 sharding.jdbc.config.sharding.binding-tables=t_order, t_order_item
4.3. 我處理 SQL 的過程
我是 Sharding-JDBC,我是水平分片世界的神。我的職責是透明化水平分庫分表所帶來的影響,讓使用方盡量像使用一個數(shù)據(jù)庫一樣使用水平分片之后的數(shù)據(jù)庫集群,或者像使用一個數(shù)據(jù)表一樣使用水平分片之后的數(shù)據(jù)表。
我的法力,來源于我的創(chuàng)造者為我設計的內核,它把 SQL 語句的處理分成了 SQL 解析 =>SQL 路由 => SQL 改寫 => SQL 執(zhí)行 => 結果歸并五個主要流程。
039
當一個應用服務器節(jié)點將一個面向邏輯表編寫的查詢 SQL 交給我之后,我要做下面幾件事:
1)SQL 解析(由我內核中的解析引擎完成):先通過詞法解析器將邏輯 SQL 拆分為一個個不可再分的單詞,再使用語法解析器對 SQL 進行理解,并最終提煉出解析上下文。
2)SQL 路由(由我內核中的路由引擎完成):根據(jù)解析上下文匹配用戶配置的分片策略(關于用戶配置的分片策略,我后文會慢慢解釋),并生成路由路徑,路由路徑指示了 SQL 最終要到哪些分片去執(zhí)行。
3)SQL 改寫(由我內核中的改寫引擎完成):將 面向邏輯表 SQL 改寫為在真實數(shù)據(jù)庫中可以正確執(zhí)行的語句(邏輯 SQL 到物理 SQL 的映射)。
4)SQL 執(zhí)行(由我內核中的執(zhí)行引擎完成):通過多線程執(zhí)行器異步執(zhí)行路由和改寫之后得到的 SQL 語句。
5)結果歸并(由我內核中的歸并引擎完成):將多個執(zhí)行結果集歸并以便于通過統(tǒng)一的 JDBC 接口輸出。
4.3.1. SQL 解析
SQL 解析 SQL 解析分為詞法解析和語法解析。
我的解析引擎先通過詞法解析器將這句 SQL 拆分為一個個不可再分的單詞,再使用語法解析器對 SQL 進行理解,并最終提煉出解析上下文。解析上下文包括表、選擇項、排序項、分組項、聚合函數(shù)、分頁信息、查詢條件以及可能需要修改的占位符的標記。簡單來說就是我要理解這句 SQL,明白它的結構和意圖。所幸,SQL 是一個語法簡單的語言,SQL 解析這件事情并不復雜。
我先使用解析引擎的詞法解析器用于將 SQL 拆解為不可再分的原子符號,我把它們叫做 Token,并將其歸類為關鍵字、表達式、字面量、操作符,再使用解析引擎的語法解析器將 SQL 轉換為抽象語法樹。
例如,以下 SQL:
SELECTid,nameFROMt_userWHEREstatus='ACTIVE'ANDage>18
被我的詞法解析器和語法解析器解析之后得到的抽象語法樹為:
040
在上圖中,為了便于理解,抽象語法樹中的關鍵字和操作符的 Token 用綠?表示,字面量的 Token 用紅?表示,灰?表示需要進一步拆分。
最后,我通過對抽象語法樹的遍歷去提煉分片所需的上下文,并標記有可能需要改寫的位置。供分片使用的解析上下文包含查詢選擇項(Select Items)、表信息(Table)、分片條件(Sharding Condition)、自增主鍵信息(Auto increment Primary Key)、排序信息(Order By)、分組信息(Group By)以及分頁信息(Limit、Rownum、Top)。
SQL 解析是下面的路由、改寫、執(zhí)行和歸并的基礎。
4.3.2. SQL 路由
我的內核在這一階段根據(jù) SQL 的解析上下文匹配數(shù)據(jù)庫和表的分片策略(還記得嗎,我在《一些核心概念》這一節(jié)說過,分片策略=分片鍵+分片算法,分片策略會指示我如何根據(jù)分片鍵的值,找到對應的分片表或分片庫),找到對應的分片表或分片庫,并生成路由后的 SQL。
對于攜帶分片鍵的 SQL,我會根據(jù)分片鍵值的不同可以劃分為單片路由 (比如分片鍵的操作符是=)、多片路由 (比如分片鍵的操作符是 IN、BETWEEN AND、>、<、>=、<=,或者多表關聯(lián)查詢)。單片路由生成針對某個分片進行查詢的 SQL,多片路由生成針對某些分片進行查詢的 SQL。
不攜帶分片鍵的 SQL 則采用全路由(全路由是一種特殊的多片路由),即生成針對所有分片進行查詢的 SQL。但如果這條 SQL 能夠匹配 Hint 分片策略,我就知道工程師會通過我的 API 把分片鍵值暗示給我,這時候我從 API 拿到分片鍵值后也會去做單片或者多片路由。
這里的單片路由、多片路由或者全庫路由是對路由劃分的一種角度,它反映了我最終執(zhí)行 SQL 的路徑有幾條:若 SQL 解析上下文最終被計算出存在單片路由,在一個數(shù)據(jù)源內我只需要針對一個分片上去執(zhí)行 SQL;若 SQL 解析上下文最終被計算出存在多片路由,在一個數(shù)據(jù)源內我需要針對多個分片上去執(zhí)行 SQL。若 SQL 解析上下文最終被計算出存在全路由,在一個數(shù)據(jù)源內我就要針對全部分片去執(zhí)行 SQL。
下面是一些實例:
--若僅以 user_id 作為分片鍵對 t_user 進行分片,且分片算法為 user_id % 5,則以下 SQL 在一個數(shù)據(jù)源內會針對一個特定分片執(zhí)行: SELECT*FROMt_userWHEREuser_id=1009--路由到t_user4執(zhí)行 --若僅以 user_id 作為分片鍵對 t_user 進行分片,且分片算法為 user_id % 5,則以下 SQL 在一個數(shù)據(jù)源內會針對多個分片執(zhí)行: SELECT*FROMt_userWHEREuser_idin(1002,1003,1009)--路由到t_user2、t_user3、t_user4 SELECT*FROMt_userWHEREuser_id>1002ANDuser_id<=?1004?--路由到?t_user3、t_user4 SELECT?*?FROM?t_user?WHERE?user_id?between?1002?and?1004?--路由到?t_user2、t_user3 --?若僅以 user_id 作為分片鍵對 t_user 進行分片,且分片算法為 user_id % 5,則以下 SQL 在一個數(shù)據(jù)源內會針對所有的分片執(zhí)行: SELECT?count(1)?FROM?t_user?--路由到?t_user0、t_user1、t_user2、t_user3、t_user4 SELECT?*?FROM?t_user?where?age?18?--路由到?t_user0、t_user1、t_user2、t_user3、t_user4
4.3.3. SQL 改寫
?程師交給我處理的 SQL 是面向邏輯表書寫的 SQL,并不能夠直接在數(shù)據(jù)庫中執(zhí)行,所以我的內核要完成 SQL 改寫,將面向邏輯表的 SQL 改寫面向物理表的 SQL。SQL 改寫分為標識符改寫、補列、分頁修正、批量拆分。
041
一、標識符改寫
在水平分片的場景中,需要將 SQL 中的邏輯表名改寫為路由之后所對應的物理分片表名,索引名稱以及 Schema 名稱也要進行邏輯名到物理名的改寫。
1)表名稱改寫
表名稱改寫是指將找到邏輯表名在原始 SQL 中的位置,并將其改寫為真實分片表名的過程。比如,若邏輯 SQL 為:
SELECTorder_idFROMt_orderWHEREorder_id=1;
假設該 SQL 配置分片鍵 order_id,并且 order_id=1 的情況,將路由至分片表 1。那么改寫之后的 SQL 應該為:
SELECTorder_idFROMt_order1WHEREorder_id=1;
你或許會以為只要通過字符串查找和替換就可以達到 SQL 改寫的效果,但事實并非如此,例如:
SELECTt_order.order_idFROMt_orderASt_orderWHEREt_order.order_id=1ANDremarks='備注t_orderxxx';
SQL 改寫則僅需要改寫表名稱就可以了,別名“t_order”、備注字段內容“t_order”均無需改寫:
SELECTt_order.order_idFROMt_order_1ASt_orderWHEREt_order.order_id=1ANDremarks='備注t_orderxxx';
因此表名稱改寫是一個典型的需要對 SQL 進行詞法和語法解析的場景,它依賴于 SQL 解析上下文,即依賴于對 SQL 語義的理解,而不是簡單的字符串替換!對于包含索引和 Schema 的 SQL 改寫也是一樣。
2)索引名稱改寫
索引名稱是另一個有可能改寫的標識符。在某些數(shù)據(jù)庫中(如 MySQL、SQLServer),索引是以表為維度創(chuàng)建的,在不同的表中的索引是可以重名的;而在另外的一些數(shù)據(jù)庫中(如 PostgreSQL、Oracle),索引是以數(shù)據(jù)庫為維度創(chuàng)建的,即使是作用在不同表上的索引,它們也要求其名稱的唯一性。這些瑣碎的規(guī)則都要納入我的索引改寫算法的考量之中。
3)Schema 名稱改寫
我對于 Schema(Schema 這個詞語的含義是 DBMS 系統(tǒng)中的數(shù)據(jù)庫實例,上文講的 ds0、ds1 就是兩個數(shù)據(jù)庫實例) 管理的方式與管理表的方式如出一轍,即采用邏輯 Schema 去管理一組數(shù)據(jù)源。因此,對于包含 Schema 的 SQL,我需要將用戶在 SQL 中書寫的邏輯 Schema 改寫為真實的數(shù)據(jù)庫分片 Schema。但我目前還不支持在 DQL(數(shù)據(jù)查詢語言,SELECT)和 DML(數(shù)據(jù)操縱語言,INSERT、UPDATE、DELETE 等)語句中使用 Schema,我只能改寫數(shù)據(jù)庫管理語句中的 Schema,例如:
SHOWCOLUMNSFROMt_orderFROMorder_ds;
我對這句數(shù)據(jù)庫管理語句的處理的方式是,將邏輯 Schema 改寫為隨機查找到的一個正確的真實 Schema。這很簡單粗暴,但合理,因為每個 Schema 中的 t_order 表的 COLUMNS 都是一樣的。
二、補列
1)排序補列
如下所示的一個 SQL 語句,查詢邏輯表 t_order 中的 order_id 和 user_id,并且得到的結果根據(jù) user_id 降序排列,這個語句經(jīng)過路由和改寫之后在我的內核的執(zhí)行階段執(zhí)行起來顯然沒有什么問題。
SELECTorder_id,user_idFROMt_orderORDERBYuser_id;
但如果 SQL 語句是:
SELECTorder_idFROMt_order0ORDERBYuser_id;
我的內核在執(zhí)行階段就無法執(zhí)行,因為這個語句查詢的結果只有 order_id,但卻要按照每個 order_id 對應的 user_id 排列 order_id,而結果集中沒有 user_id 列。所以,我的內核在補列階段要對這個 SQL 補充一列 user_id,補列的結果為:
SELECTorder_id,user_idFROMt_order0ORDERBYuser_id;
再比如:
--補列前(結果集o.*中不包含排序鍵order_item_id) SELECTo.*FROMt_ordero,t_order_itemiWHEREo.order_id=i.order_idORDERBYuser_id,order_item_id; --補列后(結果集o.*中包含排序鍵order_item_id) SELECTo.*,order_item_idFROMt_ordero,t_order_itemiWHEREo.order_id=i.order_idORDERBYuser_id,order_item_id;
2)分組補列
和排序補列類似,分組補列的目的是在結果字段中補全分組鍵,比如:
--補列前(結果集order_id中不包含分組鍵user_id) SELECTorder_idFROMt_orderGROUPBYuser_id --補列后(結果集order_id中包含分組鍵user_id) SELECTorder_id,user_idFROMt_orderGROUPBYuser_id
3)聚合補列
分組和排序補列是簡單的補列處理情形。復雜的補列情形如處理使用 AVG 等聚合函數(shù)的 SQL 語句的補列。
將邏輯表 t_order 僅使用 order_id 為分片鍵水平分片成 3 個物理表 t_order0、t_order1、t_order2。使用 avg1 + avg2 + avg3 / 3 計算邏輯表的某列的平均值并不正確,正確的算法為 (sum1 + sum2 + sum3) / (count1 + count2 + count3)。這就需要將包含 AVG 的 SQL 改寫為 SUM 和 COUNT,并在結果歸并時重新計算平均值。例如以下 SQL:
SELECTAVG(age)FROMt_userWHEREage>=18;
會被補列處理成:
SELECTCOUNT(age)ASAVG_DERIVED_COUNT,SUM(age)ASAVG_DERIVED_SUMFROMt_userWHEREage>=18;
再經(jīng)過路由和改寫,最終執(zhí)行的 SQL 為:
SELECTCOUNT(age)ASAVG_DERIVED_COUNT,SUM(age)ASAVG_DERIVED_SUMFROMt_user0WHEREage>=18; SELECTCOUNT(age)ASAVG_DERIVED_COUNT,SUM(age)ASAVG_DERIVED_SUMFROMt_user1WHEREage>=18; SELECTCOUNT(age)ASAVG_DERIVED_COUNT,SUM(age)ASAVG_DERIVED_SUMFROMt_user2WHEREage>=18;
最后,按照 (sum1 + sum2 + sum3) / (count1 + count2 + count3)在結果歸并時計算出正確的平均值。
這很好理解,打個比方,一個學校四年級學生全部有 400 人,被水平分片到 4 個班級,分別是四(1)班、四(2)班、四(3)班、四(4)班,各班人數(shù) 100 左右。一次期末考試之后,統(tǒng)計整個四年級的平均成績,一定是:
( 四(1)班總分+ 四(2)班總分+ 四(3)班總分+ 四(4)班總分 )/( 四(1)班人數(shù)+ 四(2)班人數(shù)+ 四(3)班人數(shù)+ 四(4)班人數(shù) )
而不會是:
( 四(1)班平均分+ 四(2)班平均分+ 四(3)班平均分+ 四(4)班平均分 )/4
4)自增主鍵補列
還有一種補列發(fā)生在執(zhí)行 INSERT 的 SQL 語句時。
INSERT 語句如果使用數(shù)據(jù)庫自增主鍵,是無需寫入主鍵字段的,依靠數(shù)據(jù)庫實例本身自動產(chǎn)生自增主鍵。但單個數(shù)據(jù)庫實例產(chǎn)生的自增主鍵是無法滿足數(shù)據(jù)表多分片場景下的主鍵的唯一性要求的,因此我提供了分布式自增主鍵的生成算法(如雪花算法),并且可以通過補列,讓使用方無需改動現(xiàn)有代碼,即可將數(shù)據(jù)庫現(xiàn)有的自增主鍵透明地替換成分布式自增主鍵。舉例說明,假設表 t_order 的主鍵是 order_id,原始的 SQL 為:
INSERTINTOt_example(`field1`,`field2`)VALUES(10,1);
可以看到,上述 SQL 中并未包含自增主鍵,是需要數(shù)據(jù)庫自行填充的,如果我不干預,數(shù)據(jù)庫會使用一個局部自增主鍵來填充,這可能會造成全局范圍內的多個 t_order 分片表里包含重復主鍵。但有我在,我就不會讓數(shù)據(jù)庫使用它自己的局部自增主鍵,而是使用我提供的分布式自增主鍵。因此,SQL 將被改寫為:
INSERTINTOt_example(id,`field1`,`field2`)VALUES(snow_flake_id,10,1);
上述 SQL 中的 snow_flake_id 表示自動生成的分布式全局自增主鍵值。
顯然,所有的補列都是基于 SQL 語義進行的,有賴于 SQL 的詞法和語法分析。因此,我還是要重復那句話:SQL 解析是 SQL 路由、改寫、執(zhí)行和歸并的基礎。
三、分頁修正
從多個表分片中獲取分頁數(shù)據(jù)與單表的場景是不同的。假設每 10 條數(shù)據(jù)為一頁,要從一個邏輯表中查詢 2 頁數(shù)據(jù)。在分片環(huán)境下從每個物理分片中獲取 LIMIT 10, 10,歸并之后再根據(jù)排序條件取出前 10 條數(shù)據(jù)是不正確的。
舉例說明,假設 t_order 根據(jù) order_iid % 2 分成兩片,若對邏輯表 t_order 分頁查詢的 SQL 為:
SELECTageFROMt_userORDERBYageDESCLIMIT1,2;
若直接路由并改寫成:
SELECTageFROMt_user0ORDERBYageDESCLIMIT1,2; SELECTageFROMt_user1ORDERBYageDESCLIMIT1,2;
得到的結果會出乎你的預料,下圖展示了不進行 SQL 的改寫的分頁執(zhí)行結果。
042
通過圖中所示,想要取得兩個分片表中共同的按照分數(shù)排序的第 2 條和第 3 條數(shù)據(jù),應該是 95 和 90。由于執(zhí)行的 SQL 只能從每個表中獲取第 2 條和第 3 條數(shù)據(jù),即從 t_user0 表中獲取的是 90 和 80;從 t_user1 表中獲取的是 85 和 75。因此進行結果歸并時,只能從獲取的 90,80,85 和 75 之中進行歸并,那么結果歸并無論怎么實現(xiàn),都不可能獲得正確的結果。
正確的做法是將分頁條件改寫為 LIMIT 0, 3,取出所有前兩頁數(shù)據(jù),再結合排序條件計算出正確的數(shù)據(jù)。即:
SELECTageFROMt_userORDERBYageDESCLIMIT0,3;
路由并改寫之后的結果為:
SELECTageFROMt_user0ORDERBYageDESCLIMIT0,3; SELECTageFROMt_user1ORDERBYageDESCLIMIT0,3;
下圖展示了進行正確的 SQL 改寫之后的分頁執(zhí)行結果:
043
在這種做法下,獲取數(shù)據(jù)的偏移量位置越靠后,使用 LIMIT 分頁方式的效率就越低。但有很多方法可以避免使用 LIMIT 進行分頁。比如使用上次分頁數(shù)據(jù)結尾 ID 作為下次查詢條件的分頁方式等(我會在后文給出示例)。
四、批量拆分
1)批量插入拆分
在處理批量插入的 SQL 時,如果插入的數(shù)據(jù)是跨分片的,那么需要對 SQL 進行改寫來防止將多余的數(shù)據(jù)寫入到數(shù)據(jù)庫中。舉例說明,如下 SQL:
INSERTINTOt_order(order_id,xxx)VALUES(1,'xxx'),(2,'xxx'),(3,'xxx');
假設數(shù)據(jù)表 t_order 仍然是按照 order_id 的奇偶值分為兩片的,僅將這條 SQL 中的表名進行修改,然后發(fā)送至數(shù)據(jù)庫完成 SQL 的執(zhí)行,則兩個分片都會寫入相同的記錄。雖然只有符合分片查詢條件的數(shù)據(jù)才能夠被查詢語句取出,但存在冗余數(shù)據(jù)的實現(xiàn)方案并不合理。因此我需要將路由后的 SQL 改寫為:
INSERTINTOt_order0(order_id,xxx)VALUES(2,'xxx'); INSERTINTOt_order1(order_id,xxx)VALUES(1,'xxx'),(3,'xxx');
2)In 查詢拆分
使用 IN 的批量查詢與批量插入的情況相似,不過使用 IN 的批量查詢操作并不會導致數(shù)據(jù)查詢結果錯誤(批量插入操作與批量查詢操作的不同之處在于,查詢語句中即使用了不存在于當前分片的分片鍵值,也不會對結果產(chǎn)生影響。因此對批量查詢 SQL 進行拆分并不是必須的,而插入操作則必須將多余的分片鍵值刪除)。
因此對于如以下 SQL 的批量拆分改寫,我偷了個懶:
SELECT*FROMt_orderWHEREorder_idIN(1,2,3);
直接路由并改寫為:
SELECT*FROMt_order0WHEREorder_idIN(1,2,3); SELECT*FROMt_order1WHEREorder_idIN(1,2,3);
實際上,更好的改寫結果是:
SELECT*FROMt_order0WHEREorder_idIN(2); SELECT*FROMt_order1WHEREorder_idIN(1,3);
這樣可以進一步的提升查詢性能,但我的創(chuàng)造者給我設計的內核并沒有進行這種優(yōu)化。雖然 SQL 的執(zhí)行結果是正確的,但并未達到最優(yōu)的查詢效率。
4.3.4. SQL 執(zhí)行
在完成 SQL 解析、改寫和路由之后,我終于要執(zhí)行 SQL 了!但這也是我的內核最復雜的工作部分。
我擁有一個自動化的 SQL 執(zhí)行引擎,它負責將改寫和路由完成之后的真實 SQL 安全且高效發(fā)送到底層數(shù)據(jù)源執(zhí)行。它不是簡單地將 SQL 通過 JDBC 直接發(fā)送至數(shù)據(jù)源執(zhí)行,也并非直接將執(zhí)行請求放入線程池去并發(fā)執(zhí)行,而是采用了復雜的控制策略。我的執(zhí)行引擎的工作目標是平衡資源占用(資源包括數(shù)據(jù)庫連接、內存和線程)與執(zhí)行效率(時間)。
在講解我的執(zhí)行引擎執(zhí)行 SQL 的過程之前,我要先向各位介紹我的執(zhí)行引擎的連接模式。
一個面向邏輯表編寫的 SQL 交到我的手中,會被我路由、改寫成面向多個物理分片表的 SQL(也可以稱為真實 SQL)。執(zhí)行多個真實 SQL,最理想的情況是為每個分片 SQL 查詢創(chuàng)建一個數(shù)據(jù)庫連接,且每個連接交由一個專門的線程來處理。但是計算機系統(tǒng)所能提供的資源是有限的,不可能讓進程無限創(chuàng)建數(shù)據(jù)庫連接和線程。
從資源控制的角度看,業(yè)務方訪問數(shù)據(jù)庫的連接數(shù)量應當有所限制(你們常用的數(shù)據(jù)庫連接池就在做這件事)。它能夠有效地防止某一業(yè)務操作過多地占用資源,從而將數(shù)據(jù)庫連接的資源耗盡,以致于影響其他業(yè)務的正常訪問。特別是,在一個數(shù)據(jù)庫實例中存在較多分片表的情況下,一條不包含分片鍵的邏輯 SQL 經(jīng)過路由過程將產(chǎn)生大量落在同庫不同分片表的真實 SQL,如果每條真實 SQL 都占用一個獨立的連接,那么一次查詢無疑將會占用過多的資源。
從執(zhí)行效率的角度看,為每個分片查詢維持一個獨立的數(shù)據(jù)庫連接,可以更加有效的利用多線程來提升執(zhí)行效率,因為若為每個數(shù)據(jù)庫連接開啟獨立的處理線程,可以并行處理查詢結果集。而且,為每個分片查詢維持一個獨立的數(shù)據(jù)庫連接,還能夠避免過早的將查詢結果集加載至數(shù)據(jù)庫客戶端(我,Sharding-JDBC,數(shù)據(jù)庫中間件,運行在應用程序所在的 JVM 上,就是一個數(shù)據(jù)庫客戶端)的內存,代以流式處理方式來處理。若為每個分片查詢維持一個獨立的數(shù)據(jù)庫連接,能夠持有查詢結果集游標位置的引用,在需要獲取相應數(shù)據(jù)時移動游標即可。以結果集游標下移進行結果歸并的方式,稱之為流式歸并,它無需將結果數(shù)據(jù)全數(shù)加載至數(shù)據(jù)庫客戶端內存,可以有效的節(jié)省數(shù)據(jù)庫客戶端內存資源,進而減少數(shù)據(jù)庫客戶端垃圾回收的頻次(說的簡單些,即先將查詢結果集保留在數(shù)據(jù)庫服務器的緩沖區(qū)內,然后客戶端這邊采用流式處理方式一點點獲取數(shù)據(jù)來處理。避免一次性將結果集送到客戶端,占用客戶端太多內存)。當無法保證每個分片查詢持有一個獨立數(shù)據(jù)庫連接時,則需要在復用該數(shù)據(jù)庫連接獲取下一個分片查詢的結果集之前,將當前的分片查詢結果集全數(shù)加載至內存。因此,即使可以采用流式歸并,在此場景下也將退化為內存歸并。
綜上所述,我的執(zhí)行引擎一方面想控制數(shù)據(jù)庫連接的數(shù)量;另一方面想為每個分片查詢維持一個獨立的數(shù)據(jù)庫連接,以采用更優(yōu)的流式歸并模式達到對數(shù)據(jù)庫客戶端內存資源的節(jié)省。如何處理好兩者之間的關系,是我的執(zhí)行引擎需要解決的問題。
舉個例子,如果一條邏輯 SQL 在經(jīng)過我的路由和改寫處理之后,需要操作某數(shù)據(jù)庫實例下的 200 張分表。那么,是選擇創(chuàng)建 200 個連接并行執(zhí)行,還是選擇創(chuàng)建一個連接串行執(zhí)行呢?效率與資源控制又應該如何抉擇呢?針對上述場景,我的執(zhí)行引擎提供了一種解決思路。它提出了連接模式(Connection Mode)的概念,將其劃分為內存限制模式(MEMORY_STRICTLY)和連接限制模式(CONNECTION_STRICTLY)這兩種類型。內存限制模式要求更多的連接,但占用更少的客戶端內存;而連接限制模式要求更少的連接,但占用更多的客戶端內存。
一、內存限制模式
在這種模式下,我的執(zhí)行引擎對一次操作所耗費的數(shù)據(jù)庫連接數(shù)量不做限制。如果實際執(zhí)行的 SQL 需要對某數(shù)據(jù)庫實例中的 200 張分片表做操作,則對每張分片表創(chuàng)建一個新的數(shù)據(jù)庫連接,并通過多線程的方式并發(fā)處理,以達成執(zhí)行效率最大化。并且在 SQL 滿足條件情況下,優(yōu)先選擇流式歸并,以防止數(shù)據(jù)庫客戶端出現(xiàn)內存溢出或避免頻繁垃圾回收情況。
二、連接限制模式
在這種模式下,我的執(zhí)行引擎嚴格控制對一次操作所耗費的數(shù)據(jù)庫連接數(shù)量。如果實際執(zhí)行的 SQL 需要對某數(shù)據(jù)庫實例中的 200 張分片表做操作,那么只會創(chuàng)建唯一的數(shù)據(jù)庫連接,并對其 200 張分片表串行處理。如果一次操作中的分片散落在不同的數(shù)據(jù)庫,仍然采用多線程處理對不同庫的操作,但每個庫的每次操作仍然只創(chuàng)建一個唯一的數(shù)據(jù)庫連接。這樣即可以防止對一次請求對數(shù)據(jù)庫連接占用過多所帶來的問題。該模式始終選擇內存歸并。
內存限制模式適用于 OLAP(以讀操作為主)操作,可以通過放寬對數(shù)據(jù)庫連接的限制提升系統(tǒng)吞吐量;連接限制模式適用于 OLTP (以寫操作為主)操作。OLTP 通常帶有分片鍵,會路由到單一的分片,因此嚴格控制數(shù)據(jù)庫連接,以保證在線系統(tǒng)數(shù)據(jù)庫資源能夠被更多的應用所使用。
我最初想將使用何種模式的決定權交由你們這些工程師來配置,讓你們依據(jù)自己業(yè)務的實際場景需求選擇使用內存限制模式或連接限制模式。這種想法將兩難的選擇的決定權交由用戶,使得用戶必須要了解這兩種模式的利弊,并依據(jù)業(yè)務場景需求進行選擇。而且這種靜態(tài)的連接模式配置,缺乏靈活性。
在實際的使用場景中,面對不同的邏輯 SQL,每次的路由結果是不同的。這就意味著某些操作可能需要使用內存歸并,而某些操作則可能選擇流式歸并更優(yōu),具體采用哪種方式不應該由用戶在我啟動之前配置好,而是應該根據(jù)具體的邏輯 SQL,來動態(tài)地決定連接模式。
為了降低用戶的使用成本,以及讓連接模式能夠動態(tài)變化,我的執(zhí)行引擎在其內部消化了連接模式概念(可我還是認為應該告訴你們這些被屏蔽的東西,畢竟技術的原理和優(yōu)秀的設計思想是促進你們進步的重要因素),根據(jù)當前場景自動選擇最優(yōu)的執(zhí)行方案。
我的執(zhí)行引擎將連接模式的選擇粒度細化至每一次邏輯 SQL 請求。針對每次邏輯 SQL 請求,我的執(zhí)行引擎都將根據(jù)其路由結果,進行實時的演算和權衡,并自主地采用恰當?shù)倪B接模式執(zhí)行,以達到資源控制和效率的最優(yōu)平衡。
針對這種自動化的執(zhí)行引擎,用戶只需配置 maxConnectionSizePerQuery 即可,該參數(shù)表示進行一次邏輯查詢時每個數(shù)據(jù)庫所允許使用的最大連接數(shù),這是我的執(zhí)行引擎進行演算和權衡的重要參數(shù)。
好了,我的執(zhí)行引擎提供的連接模式講完了,我可以給你們講我的執(zhí)行引擎執(zhí)行 SQL 的過程了。我的執(zhí)行引擎把執(zhí)行 SQL 分為準備和執(zhí)行兩個階段。
一、準備階段
準備階段分為結果集分組和執(zhí)行單元創(chuàng)建兩個步驟。
結果集分組是實現(xiàn)內化連接模式(向使用我的工程師屏蔽內存限制模式或連接限制模式的選擇)概念的關鍵,結果集分組的工作,一言以蔽之,就是決定每個連接要處理的查詢請求/要執(zhí)行的 SQL。結果集分組具體步驟如下:
1)先將 SQL 的路由結果按照數(shù)據(jù)源的名稱進行分組;
2)然后通過下圖的公式,可以獲得每個數(shù)據(jù)庫實例在 maxConnectionSizePerQuery 的允許范圍內,每個連接需要執(zhí)行的 SQL 路由結果組,并計算出本次請求的最優(yōu)連接模式。
044
在 maxConnectionSizePerQuery 允許的范圍內,當一個連接需要執(zhí)行的請求數(shù)量大于 1 時,意味著當前的數(shù)據(jù)庫連接無法持有相應的分片結果集,則必須采用內存歸并;反之,當一個連接需要執(zhí)行的請求數(shù)量等于 1 時,意味著當前的數(shù)據(jù)庫連接可以持有相應的分片結果集,則可以采用流式歸并。每一次的連接模式的選擇,是針對每一個物理數(shù)據(jù)庫的。也就是說,在同一次查詢中,如果該查詢被路由至一個以上的數(shù)據(jù)庫,每個數(shù)據(jù)庫的連接模式不一定一樣,它們可能是混合存在的形態(tài)。
通過上一步驟獲得的路由分組結果創(chuàng)建執(zhí)行的單元,執(zhí)行單元包括連接+該連接上要執(zhí)行的 SQL。當數(shù)據(jù)源使用數(shù)據(jù)庫連接池等控制數(shù)據(jù)庫連接數(shù)量的技術時,在獲取數(shù)據(jù)庫連接時,如果不妥善處理并發(fā),則有一定幾率發(fā)生死鎖。在多個請求相互等待對方釋放數(shù)據(jù)庫連接資源時,將會產(chǎn)生饑餓等待,造成交叉的死鎖問題。舉例說明,假設一次查詢需要在某一數(shù)據(jù)源上獲取兩個數(shù)據(jù)庫連接,并路由至同一個數(shù)據(jù)庫的兩個分表查詢。則有可能出現(xiàn)查詢 A 已獲取到該數(shù)據(jù)源的 1 個數(shù)據(jù)庫連接,并等待獲取另一個數(shù)據(jù)庫連接;而查詢 B 也已經(jīng)在該數(shù)據(jù)源上獲取到的一個數(shù)據(jù)庫連接,并同樣等待另一個數(shù)據(jù)庫連接的獲取。如果數(shù)據(jù)庫連接池的允許最大連接數(shù)是 2,那么這 2 個查詢請求將永久的等待下去。下圖描繪了死鎖的情況。
045
我為了避免死鎖的出現(xiàn),在獲取數(shù)據(jù)庫連接時進行了同步處理。具體來說就是在創(chuàng)建執(zhí)行單元時,以原子性的方式一次性獲取本次 SQL 請求所需的全部數(shù)據(jù)庫連接,杜絕了每次查詢請求獲取到部分資源的可能。由于這樣做會導致每次獲取數(shù)據(jù)庫連接時都進行連接鎖定,這會降低我執(zhí)行 SQL 的并發(fā)度。因此,我在這?進行了 2 點優(yōu)化:
1)避免鎖定一次性只需要獲取 1 個數(shù)據(jù)庫連接的操作。因為每次僅需要獲取 1 個連接,則不會發(fā)生兩個請求相互等待的場景,無需鎖定。對于大部分 OLTP 的操作,都是使用分片鍵路由至唯一的數(shù)據(jù)節(jié)點,這會使得系統(tǒng)變?yōu)橥耆珶o鎖的狀態(tài),進一步提升了并發(fā)效率。
2)僅針對內存限制模式時才進行資源鎖定。在使用連接限制模式時,所有的查詢結果集將在裝載至內存之后釋放掉數(shù)據(jù)庫連接資源,因此不會產(chǎn)生死鎖等待的問題。
二、執(zhí)行階段
該階段用于真正地執(zhí)行 SQL,它分為分組執(zhí)行和查詢結果集生成兩個步驟。
1)分組執(zhí)行:分組執(zhí)行將準備執(zhí)行階段生成的執(zhí)行單元分組下發(fā)至我的底層執(zhí)行引擎,并針對執(zhí)行過程中的每個關鍵步驟發(fā)送事件。如:執(zhí)行開始事件、執(zhí)行成功事件以及執(zhí)行失敗事件。我的執(zhí)行引擎僅關注事件的發(fā)送,它并不關心事件的訂閱者。我的其他模塊,如:分布式事務、調用鏈路追蹤等,會訂閱感興趣的事件,并進行相應的處理。我通過在執(zhí)行準備階段的獲取的連接模式,生成內存查詢結果集或流式查詢結果集,并將其傳遞至結果歸并引擎,以進行下一步的?作。
我的執(zhí)行引擎的整體工作流如下圖所示。
046
2)歸并結果集:請看下一節(jié)。
4.3.5. 結果歸并
我建議你好好看看這一節(jié),它里面包含了很多數(shù)據(jù)結構的知識。
我將從各個數(shù)據(jù)分片上獲取的結果集,組合成為一個總的結果集并正確的返回至請求客戶端,這個過程就是結果歸并。
我支持的結果歸并從結構上劃分,可分為流式歸并、內存歸并和裝飾者歸并:
1)流式歸并
流式歸并是指在實施歸并的時候,不需要將所有分片上的查詢結果全部都加載進客戶端內存,只需要把每個分片的查詢結果一點點地取到內存里面進行歸并處理,最終能夠逐條產(chǎn)生歸并的結果。后文要講的遍歷歸并、排序歸并以及流式分組歸并都屬于流式歸并。
2)內存歸并
內存歸并則是指需要將所有的分片結果集加載到內存中,再通過統(tǒng)一的分組、排序以及聚合等計算之后,再將其封裝成為能被請求客戶端逐條訪問的歸并結果集返回。
3)裝飾者歸并
裝飾者歸并是指對常規(guī)的結果集歸并利用裝飾者模式進行功能增強,目前裝飾者歸并有分頁裝飾歸并和聚合裝飾歸并這 2 種類型。我在前文講過,包含聚合函數(shù)的 SQL 經(jīng)過改寫之后要在歸并階段重新計算聚合,這就是裝飾者歸并要做的事情;同樣,包含分頁信息的 SQL 經(jīng)過改寫之后要在歸并階段重新進行分頁計算,這也是裝飾者歸并要做的事情。
我支持的結果歸并從功能上分為遍歷、排序、分組、分頁和聚合 5 種類型:
1)遍歷歸并
它是最為簡單的歸并方式。只需將多個分片結果集合并為一個單向鏈表即可。在遍歷完成鏈表中當前分片結果集之后,將鏈表元素后移一位,繼續(xù)遍歷下一個分片結果集即可。
例如,邏輯表 t_user 在單個數(shù)據(jù)源(不做分庫)中根據(jù) user_id % 3 的結果分成三片 t_user0、t_user1 和 t_user2,當查詢的邏輯 SQL 為:
SELECTageFROMt_userwhereage18
它被路由和改寫之后的結果為:
SELECTageFROMt_user0whereage18 SELECT?age?FROM?t_user1?where?age?18 SELECT?age?FROM?t_user2?where?age?18
顯然它最終產(chǎn)生三個分片結果集,對這三個結果集進行歸并,只需將他們串聯(lián)成鏈表返回給請求客戶端即可。請求客戶端讀取總的歸并結果集,也就是按照鏈表元素次序,一個分片結果集讀完后,再到下一個分片結果集去讀取。顯然這個過程是可以使用流式處理方式的,即不需要事先把三個分片結果集一次性全部加載到內存。
2)排序歸并
例如,邏輯表 t_user 在單個數(shù)據(jù)源(不做分庫)中根據(jù) user_id % 3 的結果分成三片 t_user0、t_user1 和 t_user2,當查詢的邏輯 SQL 為:
SELECTageFROMt_userorderbyageDESC
它被路由和改寫之后的結果為:
SELECTageFROMt_user0orderbyageDESC SELECTageFROMt_user1orderbyageDESC SELECTageFROMt_user2orderbyageDESC
由于在 SQL 中存在 ORDER BY 語句,因此每個分片結果集自身是有序的,因此只需要將分片結果集當前游標指向的數(shù)據(jù)值進行排序即可。這相當于對多個有序的數(shù)組進行排序,歸并排序是最適合此場景的排序算法。
我在對帶 ORDER BY 語句的分片查詢結果進行歸并時,會將每個結果集的當前數(shù)據(jù)值進行比較,并將其放入優(yōu)先級隊列。每次獲取下一條數(shù)據(jù)時,只需將隊列頂端結果集的游標下移,并根據(jù)新游標重新進入優(yōu)先級排序隊列找到自己的位置即可。
下圖展示了 3 張分片表返回的分片結果集,每個分片結果集已經(jīng)根據(jù)分數(shù)排序完畢,但是 3 個分片結果集之間是無序的。將 3 個分片結果集的當前游標指向的數(shù)據(jù)值進行排序,并放入優(yōu)先級隊列,t_user0 的第一個數(shù)據(jù)值最大,t_user2 的第一個數(shù)據(jù)值次之,t_user1 的第一個數(shù)據(jù)值最小,因此優(yōu)先級隊列根據(jù) t_user0、t_user2 和 t_user1 的方式排序隊列。
047
下圖則展現(xiàn)了進行 next 調用的時候,排序歸并是如何進行的。通過下圖你們可以看到,當進行第一次 next 調用時,排在隊列首位的 t_user0 將會被彈出隊列,并且將當前游標指向的數(shù)據(jù)值(也就是 100)返回至查詢客戶端,并且將游標下移一位之后,重新放入優(yōu)先級隊列。而優(yōu)先級隊列也會根據(jù) t_user0 的當前數(shù)據(jù)結果集指向游標的數(shù)據(jù)值(這?是 90)進行排序,根據(jù)當前數(shù)值,t_user0 排列在隊列的最后一位。之前隊列中排名第二的 t_user2 的分片結果集則自動排在了隊列首位。
048
在進行第二次 next 時,只需要將目前排列在隊列首位的 t_user2 彈出隊列,并且將其數(shù)據(jù)結果集游標指向的值返回至客戶端,并下移游標,繼續(xù)加入隊列排隊,以此類推。當一個結果集中已經(jīng)沒有數(shù)據(jù)了,則無需再次加入隊列。
可以看到,對于每個數(shù)據(jù)結果集中的數(shù)據(jù)有序,而多數(shù)據(jù)結果集整體無序的情況下,我無需將所有的數(shù)據(jù)都加載至內存即可排序,我使用的是流式歸并的方式,每次 next 僅獲取唯一正確的一條數(shù)據(jù),極大的節(jié)省了內存的消耗。
3)分組歸并
分組歸并的情況最為復雜,它分為流式分組歸并和內存分組歸并。流式分組歸并要求 SQL 的排序項與分組項的字段必須保持一致,否則只能通過內存歸并才能保證其數(shù)據(jù)的正確性。
舉例說明,假設邏輯表 t_socre(表結構中包含考生的姓名 name、科目 subject 和分數(shù) score,且為了簡單起見,不考慮重名的情況)根據(jù)科目分成 3 片:t_socre_java、t_socre_go、t_socre_python(后文插圖中的分片表均未展示科目字段,只展示姓名和分數(shù)字段)?,F(xiàn)在要通過 SQL 獲取每位考生的總分:
SELECTname,SUM(score)assum_scoreFROMt_scoreGROUPBYnameORDERBYnameasc;
以上 SQL 被路由和改寫之后的結果為:
SELECTname,SUM(score)assum_scoreFROMt_score_javaGROUPBYnameORDERBYnameasc; SELECTname,SUM(score)assum_scoreFROMt_score_goGROUPBYnameORDERBYnameasc; SELECTname,SUM(score)assum_scoreFROMt_score_pythonGROUPBYnameORDERBYnameasc;
在分組項與排序項完全一致的情況下,在三個分片表中取得的數(shù)據(jù)都是按照 name 字段升序排列的,每個分組所需的數(shù)據(jù)全部存在于各個分片結果集的當前游標所指向的數(shù)據(jù)值中,即每個 name 的分數(shù)全部存在于各個分片結果集的當前游標所指向的數(shù)據(jù)值中,因此可以采用流式歸并。如下圖所示:
049
進行歸并時,過程與排序歸并類似。下圖展現(xiàn)了進行 next 調用的時候,流式分組歸并是如何進行的。
050
通過上一張圖你們可以看到,當進行第一次 next 調用時,按照當前游標所指記錄的 name 升序排列,排在隊列首位的 t_score_java 分片將會被彈出隊列,并且將 name 同為“Jetty”的其他分片結果集中的數(shù)據(jù)一同彈出隊列。在獲取了所有的 name 為“Jetty”的同學的分數(shù)之后,進行累加操作,得到“Jetty”的總分。與此同時,所有的分片結果集中的游標都將下移至數(shù)據(jù)值“Jetty”的下一個不同的數(shù)據(jù)值,并且根據(jù)分片結果集的當前游標所指記錄的 name 值進行重排序。因此,包含名字“John”的相關數(shù)據(jù)結果集則排在的隊列的前列。
對于分組項與排序項不一致的情況,由于在每個分片結果集中分組字段的值并非有序的,因此無法使用流式歸并,需要將所有的分片結果集數(shù)據(jù)加載至內存中進行分組和聚合。例如,若通過以下 SQL 獲取每位考生的總分并按照分數(shù)從高至低排序:
SELECTname,SUM(score)assum_scoreFROMt_scoreGROUPBYnameORDERBYscoreDESC;
那么各個分片結果集中的數(shù)據(jù)如下圖所示,顯然是無法像上圖那樣進行流式歸并的,不信你按照上一張圖的過程動筆畫一下試試 :-)
051
當 SQL 中只包含分組語句時,我會通過 SQL 改寫,自動給 SQL 增加與分組項一致的排序項,這一點我在講述 SQL 改寫的沒有說,我放在這里說你會更加明白我的意圖:這能夠使得這句 SQL 的歸并階段從消耗內存的內存分組歸并方式轉化為流式分組歸并方式。
4)聚合歸并
聚合函數(shù)可以分為比較、累加和求平均值這 3 種類型。
比較類型的聚合函數(shù)是指 MAX 和 MIN。它們需要對每一個同組的結果集數(shù)據(jù)進行比較,并且直接返回其最大或最小值即可。
舉例說明,假設邏輯表 t_socre(表結構中包含考生的姓名 name、科目 subject 和分數(shù) score,且為了簡單起見,不考慮重名的情況)根據(jù)科目分成 3 片:t_socre_java、t_socre_go、t_socre_python(后文插圖中的分片表均未展示科目字段,只展示姓名和分數(shù)字段)?,F(xiàn)在要通過 SQL 獲取每位考生的單科最高分:
SELECTname,MAX(score)FROMt_scoreGROUPBYname;
以上 SQL 被路由和改寫之后的結果為:
--當SQL中只包含分組語句時,我會通過SQL改寫,自動增加與分組項一致的排序項,這能夠使得這句SQL的歸并階段從消耗內存的內存分組歸并方式轉化為流式分組歸并方式 SELECTname,MAX(score)asmax_scoreFROMt_score_javaGROUPBYnameORDERBYnameASC; SELECTname,MAX(score)asmax_scoreFROMt_score_goGROUPBYnameORDERBYnameASC; SELECTname,MAX(score)asmax_scoreFROMt_score_pythonGROUPBYnameORDERBYnameASC;
052
通過下一張圖你們可以看到,當進行第一次 next 調用時,按照當前游標所指記錄的 name 升序排列,排在隊列首位的 t_score_java 分片將會被彈出隊列,并且將 name 同為“Jetty”的其他分片結果集中的數(shù)據(jù)一同彈出隊列。在獲取了所有的 name 為“Jetty”的同學的分數(shù)之后,找出最大值,得到“Jetty”的單科最高分。與此同時,所有的分片結果集中的游標都將下移至數(shù)據(jù)值“Jetty”的下一個不同的數(shù)據(jù)值,并且根據(jù)分片結果集的當前游標所指記錄的 name 值進行重排序。因此,包含名字“John”的相關數(shù)據(jù)結果集則排在的隊列的前列。
顯然,這一過程屬于流式歸并。
053
以上是 MAX 函數(shù)的聚合方式,MIN 函數(shù)的聚合方式類似,不再贅述。
累加類型的聚合函數(shù)是指 SUM 和 COUNT。它們需要將每一個同組的結果集數(shù)據(jù)進行累加,在前面那個“獲取每位考生的總分并按照分數(shù)從高至低排序”的實例中你們已經(jīng)見識過了,不再贅述。這一過程可以流式歸并方式。
求平均值的聚合函數(shù)只有 AVG。這必須通過 SQL 改寫出的 SUM 和 COUNT 進行計算,相關內容已在 SQL 改寫的內容中涵蓋,不再贅述。這一過程可以流式歸并方式。
無論是流式分組歸并還是內存分組歸并,對聚合函數(shù)的處理都是一致的,因此,聚合歸并是在之前介紹的歸并過程之上追加的歸并能力,即裝飾。實際上我的創(chuàng)造者正是通過裝飾者模式賦予我聚合歸并能力的。
5)分頁歸并
上文所述的所有歸并類型都可能進行分頁。分頁也是追加在其他歸并類型之上的裝飾過程,我的創(chuàng)造者通過裝飾者模式賦予我對數(shù)據(jù)結果集的分頁能力。若邏輯 SQL 要查詢第 M 頁的數(shù)據(jù),查詢結果集會包含 N(N=路由后的 SQL 數(shù)量)個頁的數(shù)據(jù),分頁歸會將無需獲取的數(shù)據(jù)過濾掉,最終得到邏輯表的第 M 頁的數(shù)據(jù)。
在分片場景中,將 LIMIT 10000000, 10 改寫為 LIMIT 0, 10000010,才能保證其數(shù)據(jù)的正確性,這一點我在 SQL 改寫的分頁修正部分講過。我的分頁功能比較容易讓使用者誤解,用戶通常認為分頁歸并會占用大量內存。用戶非常容易產(chǎn)生我會將大量無意義的數(shù)據(jù)加載至內存中,造成內存溢出風險的錯覺。其實,通過流式歸并的原理可知,會將數(shù)據(jù)全部加載到內存中的只有內存分組歸并這一種情況。除了內存分組歸并這種情況之外,其他情況都可以通過流式歸并獲取數(shù)據(jù)結果集,因此我會通過結果集的 next 方法將無需取出的數(shù)據(jù)全部跳過,并不會將其存入內存。
但同時需要注意的是,由于排序的需要,大量的數(shù)據(jù)仍然需要傳輸?shù)轿宜?JVM 的內存空間(只不過我丟掉無用的數(shù)據(jù),如上段所述)。因此,采用 LIMIT 這種方式分頁,并非最佳實踐。由于 LIMIT 并不能通過索引查詢數(shù)據(jù),因此如果可以保證 ID 的連續(xù)性,通過 ID 進行分頁是比較好的解決方案,例如:
SELECT*FROMt_orderWHEREid>100000ANDid<=?100010?ORDER?BY?id;
或通過記錄上次查詢結果的最后一條記錄的 ID 進行下一頁的查詢,例如:
SELECT*FROMt_orderWHEREid>10000000LIMIT10;
5. 結束語
我是 Sharding-JDBC,一個數(shù)據(jù)庫水平分片中間件。當你們把邏輯 SQL 交給我處理時,作為中間件,我把 SQL 解析、路由、改寫、執(zhí)行、歸并的復雜工作統(tǒng)統(tǒng)對你們屏蔽了。而你們要做的就是執(zhí)行數(shù)據(jù)庫和數(shù)據(jù)表水平拆分(無論是手動拆分還是自動化拆分均可,不過拆分是你們的工作,不是我的)、實現(xiàn)我提供的分片算法接口,告訴我怎么根據(jù)分片鍵的值找到對應的分片、在配置文件或者配置 API 中描述分片策略。
再讓你們看一眼我提供的各種 ShardingAlgorithm 接口中的 doSharding()方法吧,這是你們使用我時接觸得最多的一個方法,這也是你們使用我時唯一需要動腦筋的地方:
/** *所有的分片算法interface都包含該方法 * *@param所有可能的分片表(或分片庫)名稱 *@param分片鍵的值 *@return根據(jù)分片鍵的值,找到對應的分片表(或分片庫)名稱并返回 */ Collection
我并非法力無邊,我還有很多局限。在單片路由和多片路由的場景下,我全面支持 DML、DDL、DCL、TCL 和部分 DAL,支持分頁、去重、排序、分組、聚合、不跨數(shù)據(jù)庫的關聯(lián)查詢等操作。但在多片路由的場景下,我不支持 HAVING、UNION 等操作,對子查詢的支持也有限。其他種種細節(jié),一篇文章,難以詳述。
我是 Sharding-JDBC,關于我的基本用法和基本原理,我說完了,你禿了嗎?
責任編輯:xj
原文標題:分庫分表之 Sharding-JDBC 中間件,看這篇真的夠了!
文章出處:【微信公眾號:數(shù)據(jù)分析與開發(fā)】歡迎添加關注!文章轉載請注明出處。
-
中間件
+關注
關注
0文章
64瀏覽量
18138 -
JDBC
+關注
關注
0文章
25瀏覽量
13388 -
sharding
+關注
關注
0文章
5瀏覽量
7914
原文標題:分庫分表之 Sharding-JDBC 中間件,看這篇真的夠了!
文章出處:【微信號:DBDevs,微信公眾號:數(shù)據(jù)分析與開發(fā)】歡迎添加關注!文章轉載請注明出處。
發(fā)布評論請先 登錄
相關推薦
評論