Sharding-JDBC架构图如下:
左边部分是部署架构图,右边部分则是核心逻辑架构图。
使用Sharding-JDBC,性能是大家最关心的问题。
在数据量一致的情况下,使用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在查询中的性能损失非常低,插入和更新略高。
将单表的数据拆分为二,放入两个表中,使用Sharding-JDBC和原生JDBC的性能测试报告如下:
查询操作:TPS双库比单库可以增加大约94%的性能。
插入操作:TPS双库比单库可以增加大约60%的性能。
更新操作:TPS双库比单库可以增加大约89%的性能。
结果表明,Sharding-JDBC可有效利用水平扩展大幅度提升性能。
下面我将按照模块深度剖析Sharding-JDBC的详细功能和主要实现,请大家和我一起探索与评估它的水有多深。
分片规则配置
Sharding-JDBC的分片策略配置是自定义的,因此可以通过编程的方式最大限度的灵活调整。它并不仅支持=运算符分片,可支持BETWEEN和IN的运算符分片,支持将一条逻辑SQL最终散落至多个数据节点。同时支持多分片键,例如:根据用户ID分库,订单ID分表这种分库分表结合的分片策略;或根据年分库,月份+用户区域ID分表这样的多片键分片。
通过编程的方式定制分片规则虽然灵活,但配置起来略显繁琐。因此Sharding-JDBC又提供了Inline表达式编写分片策略的方式,用于配置集中化,以避免配置散落在配置文件和代码中的情况。此外,它还提供了定制化的Spring命名空间和YAML进一步简化配置。
JDBC规范重写
Sharding-JDBC对JDBC规范的重写思路是针对DataSource、Connection、Statement、PreparedStatement和ResultSet这5个核心接口封装,将多个实现类集合纳入Sharding-JDBC实现类管理。分布式主键也属于JDBC协议的一部分。
Sharding-JDBC尽量最大化实现JDBC协议,但分布式毕竟与原生JDBC不同,所以目前仍有未实现的接口,包括游标,存储过程、SavePoint以及向前遍历和修改ResultSet等不太常用的功能。此外,为了保证兼容性,并未实现JDBC 4.1及其后发布的接口(如:DBCP 1.x版本不支持JDBC 4.1)。
SQL解析
SQL解析作为分库分表类产品的核心,性能和兼容性是最重要的衡量指标。目前常见的SQL解析器主要有fdb,jsqlparser和Druid。Sharding-JDBC1.4.x之前的版本使用Druid作为SQL解析器,经实际测试,它的性能远超其它解析器。
从1.5.x版本开始,Sharding-JDBC采用完全自研的SQL解析引擎。由于目的不同,它并不需要将SQL转为AST语法树,也无需通过Visitor的方式二次遍历。它采用对SQL“半理解”的方式,仅提炼分片需要关注的上下文,因此SQL解析的性能和容错性得到了进一步的提高。
SQL解析模块由Lexer和Parser两个模块组成。Lexer用于将SQL拆解为Token,并将其归类为关键词,表达式,字面量和操作符。Parser则用于理解SQL和提炼分片上下文,并标记可能需要改写的位置。分片上下文包含SELECTItems、表信息、分片条件、自增主键信息、排序信息、分组信息和Limit信息。一次解析过程是不可逆的,一个个Token的依次解析,因此解析性能很高。由于各种数据库的SQL差异很大,因此在解析模块对每种数据库提供方言的支持。
Sharding-JDBC支持各种连接、聚合、排序、分组以及分页的解析,并且可以有限度的支持子查询。
SQL路由
SQL路由是根据分片规则配置以及解析上下文中的分片条件,将SQL定位至真正的数据源。它又分为直接路由、简单路由和笛卡尔积路由。
满足直接路由的条件比较苛刻,如果通过Hint(通过HintAPI直接指定路由至库表)方式分片,且仅分库,则无需SQL解析和结果归并。因此它的SQL兼容性最好,可以执行包括子查询、OR、UNION等复杂情况的任意SQL。
简单路由是Sharding-JDBC最推荐使用的分片方式,它是指不包含JOIN或仅包含Binding表JOIN的SQL。Binding表是指使用同样的分片键和分片规则的一组表,也就是说任何情况下,Binding表的分片结果应与主表一致。例如:order表和order_item表,都根据order_id分片,结果应是order_1与order_item_1成对出现。这样的关联查询和单表查询复杂度和性能相当。如果分片条件不是等于,而是BETWEEN或IN,则路由结果不一定落入单库(表),因此一条逻辑SQL最终可能拆分为多条SQL语句。
笛卡尔积查询最为复杂,因为无法根据Binding关系定位分片规则的一致性,所以非Binding表的关联查询需要拆解为笛卡尔积组合执行。查询性能较低,而且数据库连接数较高,需谨慎使用。
SQL改写
SQL改写模块的用途是将逻辑SQL改写为可以分布式执行的SQL。在Sharding-JDBC 1.5.x版本,SQL改写进行了调整和大量优化。1.4.x及之前版本,SQL改写是在SQL路由之前完成的,在1.5.x中调整为SQL路由之后,因为SQL改写可以根据路由至单库表还是多库表而进行进一步优化。SQL改写分为正确性改写和优化改写两部分。
正确性改写包括将分表的逻辑表名称替换为真实表名称,修正分页信息和增加补列。举两个例子:
AVG计算。分布式场景,以avg1 + avg2 + avg3 / 3计算平均值并不正确,需要改写为 (sum1 + sum2 + sum3) / (count1 + count2 + count3)。这就需要将包含AVG的SQL改写为SUM和COUNT,并在结果归并时重新计算平均值。
分页。假设每10条数据为一页,取第2页数据。在分片环境下获取LIMIT 10, 10,归并之后再根据排序条件取出前10条数据是不正确的结果。正确的做法是将分条件改写为LIMIT 0, 20,取出所有前两页数据,再结合排序条件计算出正确的数据。因此越是获取靠后数据,分页的效率就会越低。有很多方法可避免使用LIMIT进行分页。比如构建记录行记录数和行偏移量的二级索引,或使用上次分页数据结尾ID作为下次查询条件的分页方式。
优化改写是1.5.x重点提升的部分,实现的功能比较零散,这里同样举两个例子:
单路由拒绝改写。这是将SQL改写挪到SQL路由之后的原因。当获得路由结果之后,单路由的情况因为不涉及到结果归并,因此分页、补列等改写都无需存在。尤其是分页,无需将数据从第1条开始取,节省了网络带宽。
流式归并改写。一会讲到归并时会说,这里先提一句,将仅包含GROUPBY的SQL改写为GROUPBY + ORDERBY。
SQL执行
路由至真实数据源后,Sharding -JDBC将采用多线程并发执行SQL。它用3种执行引擎分别对应处理Statement,PreparedStatement和AddBatchPreparedStatement。Sharding-JDBC线程池放在一个名为ShardingContext的对象中,它的生命周期同ShardingDataSource保持一致。如果一个应用中创建了多个Sharding-JDBC的数据源,它们将持有不同的线程池。
结果归并
Sharding-JDBC支持的结果归并从功能上分为遍历、排序、分组和分页4种类型,它们是组合而非互斥的关系。从结构划分,可分为流式归并、内存归并和装饰者归并。流式归并和内存归并是互斥的,装饰者归并可以在流式归并和内存归并之上做进一步的处理。
流式归并是将数据游标与结果集的游标保持一致,顺序的从结果集中一条条的获取正确的数据。遍历和排序都是流式归并,分组比较复杂,分为流式分组和内存分组。内存归并则是需要将结果集的所有数据都遍历并存储在内存中,再通过内存归并后,将内存中的数据伪装成结果集返回。
遍历类型最为简单,只需将多结果集组成链表,遍历完成当前结果集后,将链表位置后移,继续遍历下一个结果集即可。
排序类型稍微复杂,由于ORDER BY的原因,每个结果集自身数据是有序的,因此只需要将结果集当前游标指向的值排序即可。Sharding-JDBC在排序类型归并时,将每个结果集的当前排序数据实现了比较器,并将其放入优先级队列。每次JDBC调用next时,将队列顶端的结果集出队并next,然后获取新的队列顶端的结果集供JDBC获取数据。
分组类型最为复杂,分组归并已经不属于OLTP范畴,而更面向OLAP,但由于遗留系统使用很多,因此Sharding-JDBC还是将其实现。分组归并分成流式分组归并和内存分组归并。流式分组归并节省内存,但必须要求排序和分组的数据保持一致。如果GROUPBY和ORDER BY的内容不一致,则必须使用内存分组归并。由于数据不是按照分组需要的顺序取出,因此需要将结果集中的所有数据全部加载至内存。在SQL改写时提到的仅有GROUP BY的SQL,会优化增加ORDER BY语句,即使将内存分组归并优化为流式分组归并的提升。
无论是流式分组还是内存分组,对聚合的处理都是一致的。聚合分为比较、累加和平均值3种类型。比较聚合包括MAX和MIN,只返回最大(小)结果。累加聚合包括SUM和COUNT,需要将结果累加后返回。平均值聚合则是通过SQL改写的SUM和COUNT计算,相关内容已在SQL改写涵盖,不再赘述。
最后再聊一下装饰者归并,他是对所有的结果集归并进行统一的功能增强,目前装饰者归并只有分页一种类型。
上述的所有归并类型,都可能分页或不分页,因此可以通过装饰者模式来增加分页的能力。分页归并会将改写的LIMIT中,不需要获取的数据过滤掉。Sharding-JDBC的分页很容易产生误解,很多人认为分页会占用大量内存,因为Sharding-JDBC会因为分布式正确性的考量,将LIMIT 100000, 10改写为LIMIT 0, 100010,产生Sharding-JDBC会将100010数据都加载到内存的错觉。通过上面分析可知,会全部加载到内存的只有内存分组归并这一种情况。其他情况都是通过流式获取结果集数据的方式,因此Sharding-JDBC会通过结果集的next方法将无需取出的数据全部跳过,并不会将其存入内存。
分布式主键
分布式主键在这里单独提炼出一个章节,因为它是贯穿于Sharding-JDBC整个生命周期的。
分布式主键最独立的部分是生成策略,Sharding-JDBC提供灵活的配置分布式主键生成策略方式。在分片规则配置模块可配置每个表的主键生成策略,默认使用snowflake。
通过策略生成的分布式主键可以无缝的融入JDBC协议,它实现了Statement的getGeneratedKeys方法,将其返回改写后的Result和ResultMetaData,将Sharding-JDBC生成的分布式主键伪装为数据库生成的自增主键返回。
SQL解析时,需要根据分布式主键配置策略判断是否在逻辑SQL中已包含主键列,如果未包含则需要将INSERTItems和INSERT Values的最后位置写入解析上下文。
SQL改写时,将根据解析上下文中的位置改写SQL,增加未包含的主键列名称和值。如果是Statement则在INSERT Values后追加生成后的分布式主键;如果是PreparedStatement则在INSERT Values后追加?,并在传入的参数后追加生成后的分布式主键。
更新SQL解析比查询SQL解析复杂度低的多的多。不同数据库在插入SQL语法上也统一的多。本文分享 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 目前仅支持第一种。业务场景上使用第二种的很少很少。
Sharding-JDBC 更新SQL解析主流程如下:
// AbstractUpdateParser.java
@Override
public UpdateStatement parse() {
sqlParser.getLexer().nextToken(); // 跳过 UPDATE
skipBetweenUpdateAndTable(); // 跳过关键字,例如: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 也会因此,能够覆盖更多的业务场景。传送门
登记吧,骚年!传送门
2. UpdateStatement
更新SQL 解析结果。
public final class UpdateStatement extends AbstractSQLStatement {
}
对,没有其他属性。
我们来看下 UPDATE t_user SET nickname = ?, age = ? WHERE user_id = ? 的解析结果:
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() 小节。
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在线业务的分片化的数据库治理微服务基础组件积极的发展下去。真诚邀请感兴趣的人关注和参与。
在此我向大家推荐一个架构学习交流群。交流学习群号:938837867 暗号:555 里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化、分布式架构等这些成为架构师必备