分库分表

分库分表

垂直拆分

对于一个刚上线的互联网项目来说,由于前期活跃用户数量并不多,并发量也相对较小,所以此时企业一般都会选择将所有数据存放在一个数据库 中进行访问操作。举例来说,对于一个电商系统,其用户模块和产品模块的表刚开始都是位于一个库中。

user、useraccount表属于用户模块,productcategory、product表属于产品模块。随着公司业务的发展,技术团队人员也得到了扩张,划分为不同的技术小组,不同的小组负责不同的业务模块。例如A小组负责用户模块,B小组负责产品模块。此时数据库也迎来了第一次拆分:垂直拆分。

这里的垂直拆分,指的是将一个包含了很多表的数据库,根据表的功能的不同,拆分为多个小的数据库,每个库包含部分表。通常来说,垂直拆分,都是根据业务来对一个库中的表进行拆分的。关于垂直拆分,还有另一种说法,将一个包含了很多字段的大表拆分为多个小表,每个表包含部分字段,这种情况在实际开发中基本很少遇到。

垂直拆分的另一个典型应用场景是服务化(SOA)改造。在服务化的背景下,除了业务上需要进行拆分,底层的存储也需要进行隔离。垂直拆分会使得单个用户请求的响应时间变长,原因在于,在单体应用的场景下,所有的业务都可以在一个节点内部完成,而垂直拆分之后,通常会需要进行RPC调用。然后虽然单个请求的响应时间增加了,但是整个服务的吞吐量确会大大的增加。

Sharding |水平分区

经过水平分区设置后的业务表,必然能够将原本一张表维护的海量数据分配给N个子表进行存储和维护。水平分表从具体实现上又可以分为3种:只分表、只分库、分库分表。如果说读写分离实现了数据库读能力的水平扩展,那么分库分表就是实现了写能力的水平扩展。

  • 只分表:将db库中的user表拆分为2个分表,user_0user_1,这两个表还位于同一个库中。适用场景:如果库中的多个表中只有某张表或者少数表数据量过大,那么只需要针对这些表进行拆分,其他表保持不变。

  • 只分库:将db库拆分为db_0db_1两个库,同时在db_0db_1库中各自新建一个user表,db_0.user表和db_1.user表中各自只存原来的db.user表中的部分数据。

  • 分库分表:将db库拆分为db_0db_1两个库,db_0中包含user_0user_1两个分表,db_1中包含user_2user_3两个分表。下图演示了在分库分表的情况下,数据是如何拆分的:假设db库的user表中原来有4000W条数据,现在将db库拆分为2个分库db_0db_1user表拆分为user_0、user_1、user_2、user_3四个分表,每个分表存储1000W条数据。

水平分区的优势

  • 存储能力的水平扩展:在读写分离的情况下,每个集群中的masterslave基本上数据是完全一致的,从存储能力来说,在存在海量数据的情况下,可能由于磁盘空间的限制,无法存储所有的数据。而在分库分表的情况下,我们可以搭建多个mysql主从复制集群,每个集群只存储部分分片的数据,实现存储能力的水平扩展。

  • 写能力的水平扩展:在读写分离的情况下,由于每个集群只有一个master,所有的写操作压力都集中在这一个节点上,在写入并发非常高的情况下,这里会成为整个系统的瓶颈。而在分库分表的情况下,每个分片所属的集群都有一个master节点,都可以执行写入操作,实现写能力的水平扩展。此外减小建立索引开销,降低写操作的锁操作耗时等,都会带来很多显然的好处。

SQL解析与执行

分库分表的挑战主要体现在4个方面:基本的数据库增删改功能,分布式id,分布式事务,动态扩容。

对于开发人员而言,虽然分库分表的,但是其还是希望能和单库单表那样的去操作数据库。例如我们要批量插入四条用户记录,并且希望根据用户的id字段,确定这条记录插入哪个库的哪张表。例如1号记录插入user1表,2号记录插入user2表,3号记录插入user3表,4号记录插入user0表,以此类推。sql如下所示:

insert into user(id,name) values (1,tianshouzhi),(2,huhuamin), (3,wanghanao),(4,luyang)

这种sql语法只能操作mysql的单个库和单个表。所以必须将sql改成4条如下所示,然后分别到每个库上去执行。

insert into user0(id,name) values  (4,luyang)
...

该操作包含以下流程:

  • sql解析:首先对sql进行解析,得到需要插入的四条记录的id字段的值分别为1,2,3,4

  • sql路由:sql路由包括库路由和表路由。库路由用于确定这条记录应该插入哪个库,表路由用于确定这条记录应该插入哪个表。

  • sql改写:因为一条记录只能插入到一个库中,而上述批量插入的语法将会在 每个库中都插入四条记录,明显是不合适的,因此需要对sql进行改写,每个库只插入一条记录。

  • sql执行:一条sql经过改写后变成了多条sql,为了提升效率应该并发的到不同的库上去执行,而不是按照顺序逐一执行

  • 结果集合并:每个sql执行之后,都会有一个执行结果,我们需要对分库分表的结果集进行合并,从而得到一个完整的结果。

SQL解析

用户执行只是一条sql,并传入相关参数。数据库中间件内部需要通过sql解析器,对sql进行解析。可以将sql解析,类比为xml解析,xml解析的最终结果是得到一个document对象,而sql解析最终得到一个抽象语法树(AST)。通过这个语法树,我们可以很简单的获取到sql的一些执行,例如当前执行的sql类型,查询了那些字段,数据库表名,where条件,sql的参数等一系列信息。

通常来说,对于sql解析,内部需要经过词法(lex)解析和语法(Syntax)解析两个阶段,最终得到一个语法树。

例如mycatzebra采用的都是druid解析器,shard-jdbc一开始也用的是druid解析器,后面自研了解析器。目前较为流行的sql解析器包括:FoundationDB SQL Parser,Jsqlparser,Druid SQL Parser等。其中,其中Fdbparserjsqlparser都是基于javacc实现的。mycat团队曾经做过一个性能测试,druid解析器的解析性能通常能达到基于javacc生成的sql解析器10~20倍。

SQL路由

路由规则是分库分表的基础,其规定了数据应该按照怎样的规则路由到不同的分库分表中。对于一个数据库中间件来说,通常是支持用户自定义任何路由规则的。路由规则本质上是一个脚本表达式,数据库中间件通过内置的脚本引擎对表达式进行计算,确定最终要操作哪些分库、分表。

常见的路由规则包括哈希取模,按照日期等。常见的路由规则包括哈希取模,按照日期等。常见的用户表分表时,使用id来作为计算分表、分表,因此把id字段就称之为路由字段,或者分区字段。

不管执行的是INSERT、UPDATE、DELETE、SELECT语句,SQL中都应该包含这个路由字段。否则,对于插入语句来说,就不知道插入到哪个分库或者分表;对于UPDATE、DELETE、SELECT语句而言,则更为严重,因为不知道操作哪个分库分表,意味着必须要对所有分表都进行操作。SELECT聚合所有分表的内容,极容易内存溢出,UPDATE、DELETE更新、删除所有的记录,非常容易误更新、删除数据。因此,一些数据库中间件,对于SQL可能有一些限制,例如UPDATEDELETE必须要带上分区字段,或者指定过滤条件。

SQL改写

通常对于INSERT、UPDATE、DELETE等,改写相对简单。比较复杂的是SELECT语句的改写,对于一些复杂的SELECT语句,改写过程中会进行一些优化,例如将子查询改成JOIN,过滤条件下推等。因为SQL改写很复杂,所以很多数据库中间件并不支持复杂的SQL(通常有一个支持的SQL),只能支持一些简单的OLTP场景。

当然也有一些数据库中间件,不满足于只支持OLTP,在迈向OLAP的方向上进行了更多的努力。例如阿里的TDDL、蚂蚁的Zdal、大众点评的zebra,都引入了apache calcite,尝试对复杂的查询SQL(例如嵌套子查询,join)进行支持,通过过滤条件下推,流式读取,并结合RBO(基于规则的优化)CBO(基于代价的优化)来对一些简单的OLAP场景进行支持。

SQL执行

当经过SQL改写阶段后,会产生多个SQL,需要到不同的分片上去执行,通常我们会使用一个线程池,将每个SQL包装成一个任务,提交到线程池里面并发的去执行,以提升效率。

这些执行的SQL中,如果有一个失败,则整体失败,返回异常给业务代码。

结果集合并

结果集合并,是数据库中间件的一大难点,需要针对性分析,主要是考虑实现的复杂度,以及执行的效率问题,对于一些复杂的SQL,可能并不支持。例如:

  • 对于查询条件:大部分中间件都支持=IN作为查询条件,且可以作为分区字段。但是对于NIT IN、BETWEEN…AND、LIKE,NOT LIKE等,只能作为普通的查询条件,因为根据这些条件,无法记录到底是在哪个分库或者分表,只能全表扫描。

  • 聚合函数:大部分中间件都支持MAX、MIN、COUNT、SUM,但是对于AVG可能只是部分支持。另外,如果是函数嵌套、分组(GROUP BY)聚合,可能也有一些数据库中间件不支持。

  • 子查询:分为FROM部分的子查询和WHERE部分的子查询。大部分中对于子查询的支持都是非常有限,例如语法上兼容,但是无法识别子查询中的分区字段,或者要求子查询的表名必须与外部查询表名相同,又或者只能支持一级嵌套子查询。

  • JOIN:对于JOIN的支持通常很复杂,如果做不到过滤条件下推和流式读取,在中间件层面,基本无法对JOIN进行支持,因为不可能把两个表的所有分表,全部拿到内存中来进行JOIN,内存早就崩了。

  • 分页排序:通常中间件都是支持ORDER BYLIMIT的。但是在分库分表的情况下,分页的效率较低。例如对于 limit 100,10 ORDER BY id。表示按照id排序,从第100个位置开始取10条记录。那么,大部分数据库中间件实际上是要从每个分表都查询110(100+10)条记录,拿到内存中进行重新排序,然后取出10条。假设有10个分表,那么实际上要查询1100条记录,而最终只过滤出了10记录。因此,在分页的情况下,通常建议使用 where id > ? limit 10 的方式来进行查询,应用记住每次查询的最大的记录id。之后查询时,每个分表只需要从这个id之后,取10条记录即可,而不是取offset + rows条记录。

Binding Table

小表广播

分布式系统

在分库分表后,我们不能再使用mysql的自增主键。因为在插入记录的时候,不同的库生成的记录的自增id可能会出现冲突。因此需要有一个全局的id生成器。目前分布式id有很多中方案,其中一个比较轻量级的方案是twittersnowflake算法。

分布式事务是分库分表绕不过去的一个坎,因为涉及到了同时更新多个分片数据。例如上面的批量插入记录到四个不同的库,如何保证要么同时成功,要么同时失败。关于分布式事务,mysql支持XA事务,但是效率较低。柔性事务是目前比较主流的方案,柔性事务包括:最大努力通知型、可靠消息最终一致性方案以及TCC两阶段提交。但是无论XA事务还是柔性事务,实现起来都是非常复杂的。

动态扩容

动态扩容指的是增加分库分表的数量。例如原来的user表拆分到2个库的四张表上。现在我们希望将分库的数量变为4个,分表的数量变为8个。这种情况下一般要伴随着数据迁移。例如在4张表的情况下,id7的记录,7%4=3,因此这条记录位于user3这张表上。但是现在分表的数量变为了8个,而7%8=0,而user0这张表上根本就没有id=7的这条记录,因此如果不进行数据迁移的话,就会出现记录找不到的情况。

下一页