分库分表的学习

通常来说,数据库是比较容易成为系统瓶颈的,因为它本身是有状态的,相对于 Web 和应用服务器来说,比较难于横向扩展。而数据库的单机存储量、连接数和处理能力都是有限的,因此当一个数据库的单表数据达到一定的量级后,即使进行多种优化,比如添加从库、优化索引等,可能性能仍然下降严重,此时就需要考虑对其进行分库分表的操作了。

分库分表是拆分数据库和拆分数据表的统称,一般情况下会根据业务情况来决定是分库还是分表,或者是二者相结合。而根据拆分方式的不同,又分为垂直拆分和水平拆分。

垂直分表

垂直分表的通常情况就是:由于某个表中字段较多,我们可以通过新建一张扩展表,将不经常使用或者长度较大的字段拆分出去放到扩展表中。这种做法可以使日常的开发和维护更加方便,并且在某些数据库(比如 MySQL、SQL Server 等)中还可以避免跨页的问题。但是缺点也很明显:主键出现冗余,在某些查询时可能需要 JOIN 操作,并且事务处理也变得复杂了。垂直分表的操作一般在数据库的设计阶段就应该做好,如果在后续阶段中进行拆分,很有可能需要改写以前的查询语句,造成额外的麻烦。

垂直分表

垂直分库

垂直分库就是将数据库中关系紧密的一些表拆分出来放到一个独立的数据库服务中,这些关系紧密的表在业务上通常也是关系紧密的,因此垂直分库一般需要业务层面上的模块划分,比如将某个系统划分成用户模块、订单模块和商品模块等。

因为微服务的盛行,垂直分库在今天已经非常普及了。在高并发和海量数据的场景下,垂直切分可以从一定程度上突破单机数据库的 I/O、连接数等硬件资源的瓶颈。但是垂直分库的缺点也有很多,比如可能面临跨库 JOIN 查询,这时需要通过接口聚合的方式将数据在内存中完成关联,复杂度提高。如果某一类型的业务骤增,会造成对应业务的数据库压力增大,可能还会面临单机数据库的性能瓶颈问题。与此同时,跨库事务的一致性,也就是分布式事务处理起来也很麻烦。

垂直分库

水平分表

水平分表就是将表中不同的数据行按照一定的规律拆分到不同的表中,这种做法可以降低单表的数据量,优化查询性能,但是由于所有的表都存储在同一个库中,所以还是会面临单机数据库的性能瓶颈问题。

水平分库分表

水平分库分表与水平分表的思路相同,唯一的区别就是水平分库分表需要将拆分出来的表存放在不同的数据库服务中。在高并发和海量数据的场景下,水平分库分表可以有效地缓解单机数据库的性能问题,但是与此同时也引入了很多复杂的技术难点,接下来会详细分析。

分片策略

分片策略决定的是按照怎样的规则将表中不同的数据行进行拆分,常见的分片策略有 RangeHash + Mod 等。

Range

Range 按照区间划分表中不同的数据行,可能会是时间区间、ID 区间等。比如我们可以按照日期字段将不同年份月份甚至是每天的数据行划分到不同的库中,或者根据 ID 值的范围将 1 到 10000 的记录划分到第一个库,将 10001 到 20000 的记录划分到第二个库,以此类推。

时间区间

这种划分方式的优点在于:其天然便于水平扩展,如果后期需要对集群进行扩容,只需要增加节点即可,无需对其他分片的数据进行迁移。在使用分片字段进行范围查找时,连续的分片可以快速地定位和查询,有效避免跨分片的问题。当然缺点也同样存在:某些分片可能会在某个时间段内被大量的读写,从而造成数据分布不均匀,热点数据成为性能瓶颈。

如果我们选择具有周期性的区间进行数据划分,那么在需要扩容时,不可避免的需要进行数据的迁移。比如,按照日期字段将不同月份的数据划分到不同的库中,即 monthOfYear,那么共有 12 个库,在下次需要扩容时,可能需要按照日期字段将不同日期的数据划分到不同的库中,即 dayOfMonth,那么可能共有 31 个库,这时就需要进行数据迁移。

Hash + Mod

Hash + Mod 就是对所有的数据库节点进行编号,比如一共有 4 台数据库服务节点,则编号 0 ~ 3,然后对原始数据行中某个字段的值进行一次 Hash 运算,结果需要是一个整型数值,然后对这个整型数值取模 Mod,得到的值就是对应数据库的编号。

这种分片策略中的分片字段一般会选择 ID 字段,因为对唯一字段的值进行 Hash 运算,结果会比较均匀分散,这样不容易出现热点数据的问题。

Hash+Mod

这种划分方式可以有效避免热点数据的产生,但是比较容易面临跨分片查询的问题,并且在后期扩容时还会面临数据迁移的问题。比如初期共有 4 台服务器,数据行需要经过 Hash % 4 得到节点值,如果后期又增加了 3 台服务器,那么数据行需要重新经过 Hash % 7 计算节点值,而这个值很有可能会与开始计算的值不同,因此需要进行数据迁移。

分库分表面临的问题

不管是分库还是分表,是垂直切分还是水平切分,都会面临一系列的问题,这些问题有的可以通过设计时的调整解决,有的可能需要引入更加复杂的工具来处理。

跨节点 JOIN

在数据切分之前,关联查询可以直接通过 SQL JOIN 来完成,但是切分之后,数据可能分布在不同的节点上,此时进行关联查询就比较复杂了,一般应该尽量避免关联查询。

在互联网业务系统中,本来在一开始就应该避免关联查询,如果存在关联查询,有很大的可能是因为设计的不够合理或者技术选型有误。报表类的系统在 BI 时代都是通过 OLAP、数据仓库等来实现的(现在更多的借助于离线分析、流式计算等),而不应该在数据库中直接执行大量的 JOIN 查询来进行统计和分析。

在无法避免跨节点 JOIN 操作时,一般普遍的做法是将查询分为两次,在第一次查询时找出关联数据的 ID,然后根据这些 ID 发起第二次查询得到关联数据。

跨节点分页排序

一般情况下,分页都需要对数据进行排序。当排序字段就是分片字段时,通过分片规则我们可以比较容易地定位到具体的分片上,而当排序字段不是分片字段的时候,就需要在不同的分片中分别将数据进行排序,然后把结果集进行汇总并再次进行排序。

简单的,比如要获取日期倒叙的前 10 条数据,那么就需要进行如下图的操作:

倒序前10条

而复杂的,可能需要获取第 10 页的数据(每页 10 条记录),此时需要对每个分片的数据进行排序和分页,分别取前 100 条数据,这么做是为了确保最终需要的数据一定会在这些结果集中,然后再将这些数据汇总,再次进行排序和分页,取第 10 页的数据。这些操作很显然需要消耗更多的资源,并且越往后翻页性能会越差。

倒序第十页

跨节点排序、分组和聚合操作

与跨节点分页排序类似,多数的 sharding 组件都不会对这些操作(order by、group by、count、avg 等)自动进行合并,需要手工在各个节点上得到结果集,然后在应用中进行合并,内存消耗严重。

分布式事务

在没有分库分表之前,数据库的事务都是在本地进行的。而在进行了分库分表以后,原先一次业务中的事务可能会涉及到多个数据库节点。举例来说,假如有一项消费业务,在用户选购完商品进行结算时,需要扣减用户余额,用户的积分和优惠券,而用户的积分和优惠券信息可能位于其他节点的数据库中,此时我们需要保证在结算时同时扣除余额、积分和优惠券,这一系列动作需要作为一个整体,也就是一个事务进行,这个事务就是分布式事务。

分布式事务常见的解决方案有:基于 XA 的 2PC(两阶段提交),以及 2PC 的改进版本 3PC、TCC(Try-Confirm-Cancel,事务补偿),性能出色的 Best Efforts 1PC 和基于消息队列的最终一致性方案等。

由于基于 XA 的标准分布式事务过于严格,在提交事务时需要多个节点之间进行协调,很大程度上延长了事务的执行时间,这会导致访问共享资源时发生冲突和死锁的概率增大,并且这种趋势会随着节点的增多而越发明显,从而成为系统在数据库层面上进行水平伸缩的枷锁,这也是很多 sharding 系统不使用标准分布式事务的原因。与之相对的,Best Efforts 1PC 具有出色的性能优势,同时实现的方式也比较简单,因此被大多数 sharding 系统所采用。

主键

当数据库被切分到多个节点上时,我们就不能再以来数据库自身的主键生成机制了,因为我们无法保证某个分片上的数据库生成的 ID 在全局上是唯一的,同时我们的应用在插入数据前需要先获取 ID,以便进行 SQL 的路由。

UUID

常见的全局唯一主键生成方案中,UUID 是最简单的一个。标准的 UUID 包含 32 个 16 进制数字,以 8-4-4-4-12 的形式分为五段,比如:550e8400-e29b-41d4-a716-446655440000,目前业界共有五种生成 UUID 的方式,详情请见 IETF 发布的 UUID 规范:A Universally Unique IDentifier (UUID) URN Namespace。UUID 的优点是通过本地生成,没有网络消耗,性能非常高,缺点是长度过长不容易存储,且可能存在信息安全问题(基于 MAC 地址生成 UUID 的算法可能会造成 MAC 地址泄露)。MySQL 官方对于主键的建议是越短越好,UUID 不符合要求,同时在 InnoDB 引擎中,UUID 的无序性可能会引起数据位置的频繁变动,严重影响性能。

sequence 表

通过数据库维护一个 sequence 表,表结构类似于:

1
2
3
4
5
CREATE TABLE `SEQUENCE` (
`table_name` varchar(18) NOT NULL,
`next_id` bigint(20) NOT NULL,
PRIMARY KEY (`table_name`)
) ENGINE=InnoDB

当需要为某个表生成 ID 时,就从 sequence 表中取出对应表的 next_id,并将 next_id 的值增加 1 后更新到数据库中以备下次使用。这个方案实现简单,但是缺点也很明显,因为所有的插入操作都需要访问这张表,因此该表很容易成为性能瓶颈,同时它也存在单点问题。

snowflake

snowflake 是 twitter 的分布式自增 ID 算法,又称雪花算法。这种算法的好处是整个 ID 是趋势递增的,性能很高也很灵活,但是由于强依赖机器的时钟,如果机器上的时钟回拨,会导致重复发号的问题。

还有一些是在雪花算法的基础上做了优化,比如百度的 uid-generator 和美团的 Leaf

数据迁移、扩容等问题

由于扩容后路由规则发生变化,扩容前的数据行很有可能需要进行数据迁移。在进行数据迁移时,一般是不允许停机的,因此理想的扩容方式就是避免数据迁移。有一个相对简单的扩容方式,利用对 2 的倍数取余具有向前兼容的特点(比如对 4 取余为 1,则对 2 取余也会是 1)来划分数据行,可以避免进行行级别的数据迁移,但是依然需要进行表级别的迁移。

中间件

目前分库分表的中间件主要分为两大类,一类是客户端架构,一类是代理架构。

客户端架构的中间件需要在应用中进行整合,中间件一般会以 jar 包的形式提供。通过修改应用的数据访问层(JDBC 或者以 JDBC 为基础的 ORM 框架)的方式对应用内产生的 SQL 进行解析、重写、路由、执行,并对结果集进行归并。这种架构的优点是无需对业务进行改造,简单且成本较低,但是缺点是中间件与应用绑定,对应用有一定的侵入性,不支持复杂的需求,并且对应用数据访问层的实现有限制。比较有代表性的产品为 ShardingSphere

客户端架构

代理架构的中间件一般是一个独立的服务,相当于实现了一个支持对应数据库协议的代理服务器。以 MySQL 为例,用户可以使用 MySQL 客户端工具或者命令行访问该中间件,中间件会使用 MySQL 协议与多个 MySQL 数据库服务器进行通信。这种架构的优点是能够处理非常复杂的需求,对应用数据访问层的实现没有限制,对于应用服务透明不会增加任何额外的负载,但是缺点是由于需要独立部署,增加了运维成本,同时应用需要经过一层代理才可以访问数据库,在网络上多了一跳,影响性能并可能会有额外的风险。比较有代表性的产品有 MyCatCobar

不管是哪种架构,几个核心的步骤都是一样:SQL 解析,重写,路由,执行和结果归并。

参考

分库分表的基本思想

Leaf —— 美团点评分布式 ID 生成系统

一种可以避免数据迁移的分库分表 scale-out 扩容方式