Simple-Sharding :一款极简的分库分表中间件

Simple-Sharding是一款基于JDBC API开发、简单易用的分库分表中间件,目标是通过较少的代码来揭示分库分表中间件最核心的本质。

背景

目前大多数互联网公司在遇到数据层瓶颈的时候,几乎都会做垂直或水平拆分。垂直拆分即按业务将库表分离,但是当拆分后的单表数据量达到一个新的量级的时候,会接着对这个大表做水平拆分,即将单个大表拆分成多个分表,有时会将其中的一些分表落地到不同的分库,以此来应对快速增长的业务。

从知名的分库分表中间件TDDL和Cobar开始,各个公司也都相继研发甚至开源了自己的分库分表中间件,这些中间件主要分为两类:一类是基于JDBC API实现的中间件,一类是类似于MySQL Proxy的代理中间件。整体的思路都是通过拦截应用层的SQL请求,根据相应规则做路由分发,然后落地到物理节点,最后执行获取结果。

根据笔者研究市面上已有代码的经验,发现成熟的项目往往代码量庞大,历史变更较多,导致学习研究分库分表中间件并不是一件十分容易的事情,很多时候抓不住本质,于是笔者就根据目前已经学习到的知识和经验,自己动手写了一个极简主义的分库分表中间件,我把她命名为Simple-Sharding !

开源地址为https://github.com/yuanwhy/simple-sharding ,欢迎Star , 嘿嘿~

Simple-Sharding

下面具体介绍Simple-Sharding的一些细节以及笔者在其中的思考,欢迎批评指正。

实现思路

每一个学习过Java操作数据库的同学,最开始都是从JDBC的知识入手,后来才慢慢在项目中引入像Mybatis这样的ORM框架,建立起更加复杂的DAL(Data Access Layer)。

比如典型的代码如下:

1
2
3
4
5
6
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的主要作用就是创建逻辑意义的数据库连接给上层使用,内部实现如下:

1
2
3
4
5
6
7
@Override
public Connection getConnection() throws SQLException {

Connection connection = new LogicConnection(this);

return connection;
}

应用将Simple-Sharding的DataSource注入到自己的IoC容器中,代替传统的c3p0或dbcp数据源:

1
2
3
4
5
6
7
8
9
10
<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语句的关键:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@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进行路由和重写,即根据分库字段获得分库的真实库名,根据分表字段获得分表的真实表名。

1
2
3
4
5
6
7
8
9
10
11
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表,现在根据需求做分库分表,一种比较合理的方案是按买家和卖家来分库,每个库再做分表,于是逻辑架构如下图所示:

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组件:

1
2
3
4
5
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为

1
select * from passport.user where role = 0 and id = 11;

重写后的SQL为

1
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。

关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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默认隔离级别):

1
2
3
4
5
6
7
8
9
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 .