Simple-Sharding是一款基于JDBC API開發、簡單易用的分庫分表中間件,目標是通過較少的代碼來揭示分庫分表中間件最核心的本質。
背景
目前大多數互聯網公司在遇到數據層瓶頸的時候,幾乎都會做垂直或水平拆分。垂直拆分即按業務將庫表分離,但是當拆分后的單表數據量達到一個新的量級的時候,會接著對這個大表做水平拆分,即將單個大表拆分成多個分表,有時會將其中的一些分表落地到不同的分庫,以此來應對快速增長的業務。
從知名的分庫分表中間件TDDL和Cobar開始,各個公司也都相繼研發甚至開源了自己的分庫分表中間件,這些中間件主要分為兩類:一類是基于JDBC API實現的中間件,一類是類似于MySQL Proxy的代理中間件。整體的思路都是通過攔截應用層的SQL請求,根據相應規則做路由分發,然后落地到物理節點,最后執行獲取結果。
根據筆者研究市面上已有代碼的經驗,發現成熟的項目往往代碼量龐大,歷史變更較多,導致學習研究分庫分表中間件并不是一件十分容易的事情,很多時候抓不住本質,于是筆者就根據目前已經學習到的知識和經驗,自己動手寫了一個極簡主義的分庫分表中間件,我把她命名為Simple-Sharding !
開源地址為https://github.com/yuanwhy/simple-sharding ,歡迎Star,嘿嘿~
下面具體介紹Simple-Sharding的一些細節以及筆者在其中的思考,歡迎批評指正。
實現思路
每一個學習過Java操作數據庫的同學,最開始都是從JDBC的知識入手,后來才慢慢在項目中引入像Mybatis這樣的ORM框架,建立起更加復雜的DAL(Data Access Layer)。
比如典型的代碼如下:
Class.forName("com.mysql.jdbc.Driver");
Connection conn = DriverManager.getConnection(DB_URL,USER,PASS);
Statement stmt = conn.createStatement();
String sql;
sql = "SELECT id, first, last, age FROM Employees";
ResultSet rs = stmt.execute(sql);
JDBC全稱Java Database Connectivity, 是Java官方定義的一套訪問數據庫的接口規范,這些接口主要包括:
- DataSource
- Connection
- Statement
- PreparedStatement
- ResultSet
每個數據庫廠商都會自己實現這一套接口并提供給應用程序使用,比如MySQL提供的Connector/J
,程序包為mysql-connector-java-{version}.jar
。再復雜的DAO(Data Access Object),本質上內部還是通過JDBC來操作數據庫,所有的ORM框架內部只有通過調用JDBC才能獲取數據庫連接。所以,Simple-Sharding的設計就是重寫這套JDBC API,提供給應用新的DataSource實現類。
在Simple-Sharding中, 實現了前四個關鍵的接口,即
- LogicDataSource
- LogicConnection
- LogicStatement
- LogicPreparedStatement
比如,LogicDataSource的主要作用就是創建邏輯意義的數據庫連接給上層使用,內部實現如下:
@Override
public Connection getConnection() throws SQLException {
Connection connection = new LogicConnection(this);
return connection;
}
應用將Simple-Sharding的DataSource注入到自己的IoC容器中,代替傳統的c3p0或dbcp數據源:
<bean id="dataSource" class="com.yuanwhy.simple.sharding.jdbc.LogicDataSource">
<property name="logicDatabase" value="passport"/>
<property name="shardingRule" ref="shardingRule"/>
<property name="physicalDataSourceMap">
<map>
<entry key="passport_0" value-ref="physicalDataSource0"/>
<entry key="passport_1" value-ref="physicalDataSource1"/>
</map>
</property>
</bean>
而LogicConnection的主要作用就是獲得Statement,而Statement是執行SQL語句的關鍵:
@Override
public Statement createStatement() throws SQLException {
Statement statement = new LogicStatement(this);
return statement;
}
@Override
public PreparedStatement prepareStatement(String sql) throws SQLException {
LogicPreparedStatement prepareStatement = new LogicPreparedStatement(this, sql);
return prepareStatement;
}
規則接口
除了提供JDBC API之外,還要提供給應用指明分庫分表規則的接口,因為中間件需要根據用戶定義的規則對原始的SQL進行路由和重寫,即根據分庫字段獲得分庫的真實庫名,根據分表字段獲得分表的真實表名。
public interface ShardingRule {
String getFieldNameForDb();
String getFieldNameForTable();
String getDbSuffix(Object fieldValueForDb);
String getTableSuffix(Object fieldValueForTable);
}
接口ShardingRule定義了四個方法,getFieldNameForDb是希望能得知哪一個是分庫字段,getFieldNameForTable是希望能得知哪一個是分表字段,然后通過getDbSuffix和getTableSuffix分別從分庫字段值和分表字段值中計算出物理庫名和物理表名的后綴。
Simple-Sharding提供了一個默認的分庫分表規則的實現HashShardingRule,該規則采用取模hash法來獲取后綴,當然應用也可以自己實現這個接口,來自定義分庫分表規則。
數據模型
在Simple-Sharding的Unit Test中建立了這樣一個數據模型:在passport庫中有user表,user表分買家(Role為0)和賣家(Role為1),user表有id、name、role等必要的字段。傳統的場景是passport庫中只有一個user表,現在根據需求做分庫分表,一種比較合理的方案是按買家和賣家來分庫,每個庫再做分表,于是邏輯架構如下圖所示:
分表規則默認使用HashShardingRule,為了考慮分布的均勻性,一般選擇id為分表字段進行取模運算作為表名后綴,比如這里分了兩個表,用戶id為11的分表為user_1表(11%2 = 1)。用戶id應該設置為全局唯一,這時候數據庫自增顯然不再適用,全局唯一id生成算法又是另外一個話題了,這里不作為討論的重點。
SQL解析與執行
在調用statement.execute(sql)
執行SQL時,statement獲得的是原始SQL,中間件需要把原始SQL語句解析成AST(抽象語法樹),然后取得分庫分表字段值。Simple-Sharding使用了現成的SQL Parser工具 ,即阿里開源的Druid內部的SQL Parser組件:
List<SQLStatement> sqlStatements = SQLUtils.parseStatements(sql, JdbcConstants.MYSQL);
SQLStatement currentSqlStatement = sqlStatements.get(0);
MySqlSchemaStatVisitor visitor = new MySqlSchemaStatVisitor();
currentSqlStatement.accept(visitor);
之后就可以通過currentSqlStatement和visitor直接獲得分庫分表字段值,當然前提是ShardingRule中配置了分庫分表字段的名稱。
解析之后,一方面要從配置的所有物理庫中獲取目標物理分庫physicalDataSource0或physicalDataSource1,另一方面要將原始SQL進行替換,將passport替換成passport_0或passport_1、user替換成user_0或user_1。
比如原始SQL為
select * from passport.user where role = 0 and id = 11;
重寫后的SQL為
select * from passport_0.user_1 where role = 0 and id = 11;
之后便是通過獲取到的物理數據源physicalDataSource0來像傳統方式一樣執行SQL語句,并將獲得的結果集返回給上層應用。在Simple-Sharding的LogicStatement類的doExecute方法中具體展示了在物理數據源上執行真實SQL的過程。
事務支持
Simple-Sharding目前僅支持單庫事務,分布式事務太過復雜,目前暫不考慮。單庫事務的實現思路其實很簡單,只要保證一串事務內的SQL解析后都落地到同一個分庫即可,即整個事務階段LogicConnection只會使用一個物理Connection,事務結束后又開啟新的事務的時候,LogicConnection又會開啟一個新的可能完全不同的物理Connetion。
關鍵代碼如下:
if(this.logicConnection.getPhysicalConnection() != null) {
if (physicalDbName.equals(this.logicConnection.getPhysicalDbName())) {
physicalConnection = this.logicConnection.getPhysicalConnection();
} else {
throw new RuntimeException("不支持跨庫事務 : " + originalSql);
}
} else {
physicalConnection = physicalDataSource.getConnection();
this.logicConnection.setPhysicalConnection(physicalConnection);
this.logicConnection.setPhysicalDbName(physicalDbName);
physicalConnection.setAutoCommit(this.logicConnection.getAutoCommit());
}
那么如何測試事務呢?在Simple-Sharding的Unit Test中給出了測試事務的用例:獲取Connection之后,autoCommit設置為false便在邏輯上開啟了一個事務(autoCommit=true的時候每一條SQL都默認是一個事務),之后執行增刪改查并且沒提交的時候,在其他會話中會表現出隔離性(MySQL默認隔離級別):
connection1.setAutoCommit(false);
User user = new User(123, "yuanwhy", 18, User.Role.BUYER.getId());
insertUser(connection1, user);
User foundUserFromConnection1 = selectUser(connection1, user);
Assert.assertTrue(user.equals(foundUserFromConnection1));
User foundUserFromConnection2 = selectUser(connection2, user);
Assert.assertTrue(foundUserFromConnection2 == null); // 事務隔離,connection2一定讀不到connection1的數據
這樣就基本在Simple-Sharding中實現了單庫事務,單庫事務對實際應用也是必須的,這一點在很多分庫分表中間件中都有實現。
總結
Simple-Sharding在JDBC API的基礎上實現了一套新的數據源,內部提供了基本的分庫分表支持,同時簡單地實現了單庫事務,基本上把分庫分表中間件最核心的流程走通了一遍。作為研究性質的項目,Simple-Sharding目前還很年輕,不推薦在生產環境中使用,希望對于想學習分庫分表中間件的同學有所幫助。
最后,歡迎Star和交流 : https://github.com/yuanwhy/simple-sharding .