张亮
当当架构部总监
负责分布式中间件和私有云平台建设
目前主导开源项目:Elastic-Job及Sharding-JDBC
主题简介:
1、关系型数据库中间件核心功能介绍
2、Sharding-JDBC架构及内核解析
3、Sharding-JDBC未来展望
一、关系型数据库中间件核心功能介绍
关系型数据库凭借灵活查询的SQL和稳定的存储及事务引擎,一直以来是业务存储领域的首选。而在规模越来越大的互联网年代,单一的关系型数据库却已难满足需求。开发人员不愿放弃SQL查询的灵活度及对之前代码的兼容性,而又无法承受数据量过大时所带来的性能瓶颈。因此NoSQL和NewSQL分别产生,而NoSQL的不兼容性和NewSQL的不成熟,也使得关系型数据库仍将长期存在下去。所以,继续使用关系型数据库,并能解决互联网规模所带来的冲击,数据库中间层这个既不保守,也不激进的平衡方案被大多数互联网公司所接受。关系型数据库中间件采用单体向分布式透明转化的方案来解决数据量和访问量巨大这两个互联网场景的核心问题。
关系型数据库在大于自身数据量阀值的情况下,性能会急剧下降。在面对互联网海量数据情况时,所有数据都存于单表,显然会轻易超过数据库表可承受的范围。这个单表可承受的数据量阀值,需根据数据库和并发量的差异,通过实际测试获得。
通过分库和分表拆分数据来使得各个表的数据量保持在阀值以下。拆分方式为垂直拆分和水平拆分。垂直拆分是根据业务将单库(表)拆分为多库(表)。如:将常用的字段和不常用的字段拆分至不同的库(表)中。但垂直拆分需要对架构和设计进行调整,往往来不及应对互联网业务需求的快速变化,而且也并不能真正的解决单点瓶颈。水平拆分则是根据分片算法将一个库(表)拆分为多个库(表)。如:根据ID的最后一位以10取余,尾数是0的放入0库(表),尾数是1的放入1库(表)。水平拆分从理论上突破了单机数据量处理的瓶颈,并且扩展相对自由,是分库分表的标准解决方案。
分库和读写分离疏导流量是应对高访问量的常见手段。分表虽然可以解决海量数据导致的性能问题,但无法解决过多请求访问同一数据库,导致其响应变慢的问题。所以水平拆分通常要采取分库的方式,一并解决数据量和访问量巨大的问题。读写分离是另一个疏导流量的办法,但读写数据间的延迟是架构设计时需要考虑的问题。
虽然分库可以解决上述问题,但分布式架构在获得了收益的同时,也带来了新的问题。
跨库事务是分布式数据库要面对的棘手事情。合理采用分表,可以在降低单表数据量的情况下,尽量使用本地事务,善于使用同库不同表可有效避免分布式事务带来的麻烦。在不能避免跨库事务的场景,有些业务仍然需要保持事务的一致性。而基于XA的分布式事务由于性能低下,无法被互联网公司所采纳,它们大多采用最终一致性的柔性事务代替分布式事务。
因此,最佳实践是合理地配合使用分库+分表。在实现上,分表的难度远大于分库,它需要对SQL解析,并且对表名改写,而分库则不需要。
另一个分布式衍生的问题是主键生成。它必须可以保证分布式唯一。分布式主键的生成方式分为中心化和去中心化两大类。中心化可以继续采用数据库生成自增主键的方式,为每个不同的分库设置不同的初始值,并将步长设置为分片的个数即可,这种方式对分片个数有依赖,一旦再次水平扩展,原有的分布式主键不易迁移。还有一种中心化生成分布式主键的方式,即采用Redis在内存中生成自增序列,但此种方式新增加了一个外部组件的依赖,一旦Redis不可用,则整个数据库将无法在插入,可用性会大大下降,另外Redis的单点问题也需要解决,部署复杂度较高。去中心化方式无需额外部署,可扩展性也很好,因此更推荐使用。UUID是去中心化生成分布式主键较为常见的一种方式,但它的主键很长,而且无序,通过主键排序时对数据库的性能影响较大,不建议使用。目前较为完美的方案是使用snowflake算法生成分布式唯一和基本有序的主键。
综上所述,分布式数据库中间件的功能模块非常清晰,有其存在的必要和市场价值。但与旺盛的需求形成鲜明对比,成熟的关系型数据库中间件凤毛麟角。主要原因是各公司均开发各自的中间件,没有形成统一的标准及规范,也没有动力从公司现有的系统中解耦并独立开源。当当同样自研,并决定将其开源,命名为Sharding-JDBC。从2016年开源至今,已发布了15个版本,其中包含5个里程碑版本升级。在经历了整体架构的数次精炼以及稳定性打磨后,如今它已积累了足够的底蕴,相信可以成为开发者选择技术组件时的一个参考。
二、Sharding-JDBC架构及内核解析
Sharding-JDBC完整的实现了分库分表,读写分离和分布式主键功能,并初步实现了柔性事务。
它直接实现JDBC接口,旧代码迁移成本几乎为零,可适用于任何基于Java的ORM框架,如:JPA、Hibernate、Mybatis、SpringJDBC Template等;理论上可以支持所有实现JDBC协议的数据库,但由于各种数据库的SQL方言差别较大,每种SQL都需要独立的解析器,Sharding-JDBC目前仅支持MySQL、PostgreSQL、Oracle和SQL Server这4种最主流的数据库。由于柔性事务与JDBC没有直接关系,因此正在考虑将它拆分为一个独立的项目。
Sharding-JDBC与基于MySQL等数据库协议实现的Proxy中间层在部署架构上差别很大,但在代码的核心逻辑上差别并不大。Sharding-JDBC作为lib库,是与业务代码部署在一起的,而基于Proxy的中间层则是架在数据库的前方,与应用代码在部署上隔绝。无论使用哪种架构,它们的核心逻辑均极为相似,都会分为分片规则配置、SQL解析、SQL路由、SQL改写、SQL执行以及结果归并模块,区别仅在于协议实现层的不同(JDBC或数据库协议)。
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后追加?,并在传入的参数后追加生成后的分布式主键。
受限于篇幅,读写分离、柔性事务就不在此说明了。
三、Sharding-JDBC未来展望
首先,请和我一同回顾下Sharding-JDBC每个里程碑版本的历程。
1.0.x:分库分表
1.1.x:配置简易化
1.2.x:柔性事务
1.3.x:读写分离
1.4.x:分布式主键
1.5.x:自研解析引擎 + 多数据库支持
通过这5个版本的迭代可以看到,Sharding-JDBC的精力主要集中在透明化分布式数据库这部分,因此经常有人问Sharding-JDBC和基于Proxy的数据库中间层有什么区别?和NewSQL数据库又有什么区别?
尽管部署架构不同,但功能上确实差异不明显。不过结构的不同终会将它们推向不同的方向。Sharding-JDBC与业务代码部署在一起的架构,非常适合作为微服务的数据访问层基础开发组件。Proxy和NewSQL是面向运维的数据库,而Sharding-JDBC的定位与当当一并开源的DubboX、Elastic-Job一样,是面向开发的微服务基础类库,它始终以云原生的基础开发套件为目标。
Sharding-JDBC 1.6.x到来,将会愈加明显的划清界限。Sharding-JDBC 1.6.x的目标是配置动态化和数据库治理,通过将配置存入注册中心,达到治理分库分表+读写分离的数据库的目的。在应用端进行数据库发现、流量疏导、故障转移、熔断等功能,向治理服务一样治理数据库。
Sharding-JDBC将作为面向OLTP在线业务的分片化的数据库治理微服务基础组件积极的发展下去。真诚邀请感兴趣的人关注和参与。
来源:中生代技术
原文链接