分库分表

分库分表

垂直拆分

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

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

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

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

Sharding | 水平分区

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

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

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

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

水平分区的优势

  • 存储能力的水平扩展:在读写分离的情况下,每个集群中的 master 和 slave 基本上数据是完全一致的,从存储能力来说,在存在海量数据的情况下,可能由于磁盘空间的限制,无法存储所有的数据。而在分库分表的情况下,我们可以搭建多个 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)解析两个阶段,最终得到一个语法树。

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

SQL 路由

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

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

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

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 BY 和 LIMIT 的。但是在分库分表的情况下,分页的效率较低。例如对于 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 有很多中方案,其中一个比较轻量级的方案是 twitter 的 snowflake 算法。

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

动态扩容

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