前言
对于大规模的分布式集群,或者对于数据密集型应用来说,为了提高吞吐量和性能以及可用性,一般会结合使用数据复制和数据分区。数据复制将对单库的请求压力分给更多的数据库实例,数据分区将每个实例中的庞大的数据文件以一定规则切分成更小的数据文件,并可以存储到不同的磁盘(或数据节点 Node)上,以提高请求的并发性能,同时,增加了扩展性。
本文将介绍分布式存储集群的高可用的另外一个解决方案——数据分区,以及以 MySQL 为示例看一些数据分区的具体实现。
复制和分区的差别是什么?请看下面一张图:
对于一些大规模数据集群的应用,经常能听到分库分表的解决方案。微服务的体系按照服务维度分库是必然(一个微服务对应一个库),但一个微服务刚刚起步,领域模型刚搭建的时候,切忌一开始凭着感觉来预估需要做分表。如果初期为了数据存储的扩展性,选择分区会更好实施,很多存储引擎都内置建立分区的策略。架构是演进的,随着业务数据增加到一定量之后,再进行分表也可以。
下面来具体看下数据库分表和分区的差别:
(1)存储结构和查询逻辑
分表:一张表会被分成多张表,分表后的每个表都是独立的结构,有自己的索引文件、数据文件、表结构文件。通过分表字段的查询语句会查到具体的一张分表,而不是查询所有的分表。
分区:一张表分区之后还是一张表,只是数据文件和索引文件被分成更小的数据文件和索引文件。查询的还是一张表,只是数据需要从多个分区进行查找、合并处理。
(2)实现方式
分表:分表的方法有很多,有一些是数据库存储引擎内置的方案,对应用开发透明。如果引擎不支持,需要找一些分表中间件来实现,比如 MySQL 可以使用开源的 Cobar,以及国内开源爱好者基于 Cobar 开发的,解决了一些 Cobar 问题的 mycat,类似的,阿里的 Tddl 也是支持分表的中间件。
分区:实现简单,多数存储服务都自带分区实现。对使用者来说跟平常写入、读取的方式没什么区别,对应用开发透明,只是建表时需要确定分区方案。
(3)适用场景
分表:适用于基于业务索引的分表,分表的列不一定是主键或者包含主键。需要手动创建多张表,读写的时候要应用根据分表规则路由到具体的子表中。
分区:适用于基于主键的分区。不需要任何手动处理创建表,应用程序也还是按照原来访问单表的规则进行请求,一般由存储引擎提供到分区的路由。分区规则创建时需要比较谨慎,尽量减少分区之间数据的依赖。
虽然两者有一些区别,但对于单表有大量数据的情况,很多时候是分区和分表一起用,两者都能提升读写的性能。如果想快速扩容的话,可以先分区再分表,分区对开发和运维来说更加透明。
了解了分区和分表的差别,聚焦回分区,对于有大规模数据集群的应用来说,进行数据分区的好处是:
当你决定要对一堆大数据做数据分区的时候,需要决定要以哪个维度进行分区,即当一个数据写入进来的时候,这条数据应该放到哪个分区里。如果没有明确的目的以及足够了解怎样设计分区策略,很容易出现分区之后性能反而下降。存储服务如 HBase、Cassandra、MySQL 都会有内置的分区策略,不同的存储引擎的分区策略使用的加密算法、支持的类型会略有不同,但思想都是大同小异。下面以 MySQL 为例看一下分区的方案,以及不同分区方案适合的场景。
MySQL 截止到 5.7 版本主要提供了以下几种分区类型,简单列举一些场景。
Hash 分区有两种方式,常规 Hash 和线性 Hash。常规 Hash 是使用 MySQL 内置的 Hash 算法,并且可以添加用户自定义的函数,计算指定列的值。但这个函数以及选择的列值也有约束:通过指定的函数计算之后的值必须是整数值类型(int,bigint)的数据,这种方式简单易理解并且可以分区很均匀。但问题是,在需要重新分区的时候,比如原来是 10 个分区,现在要分成 15 个分区了,那就需要数据迁移,Hash 分区不易于再分区和扩展。
线性 Hash 基于一个线性的 2 的幂运算法则,算法规则:分区值 = POWER(2, CEILING(LOG(2, 待计算的key)))。线性 Hash 扩充分区或者缩小分区时,更易扩展,适用于一开始不是很确定分区数的情况。
这两者都很适用于比如连续 key 的场景,比如想要均匀的按照某列进行分区的话,就可以选择 Hash,Hash 分区比线性分区更均匀。考虑到以后可能会扩展迁移的话,就用线性分区。
使用 Hash 分区,只要在建表语句之后加上分区策略即可。分区公式:HASH( your expression(column_name) ),示例使用 Year 函数的 Hash 分区:
PARTITION BY HASH(YEAR (xx_id))
PARTITIONS 10;
Range 分区可以理解成将分区的列值(xx_id),按照一定的范围分好,并且这个范围需要一开始定义好的,比如可以 1-10000,10001-20000。一般比较适合于时间分区,如按照月份规则来划分。MySQL 内置支持用 Less Than 语法来划分范围。
也是在 create table 之后,添加分区信息,写法如下:
PARTITION BY RANGE (xx_id) (
PARTITION p0 VALUES LESS THAN (10000),
PARTITION p1 VALUES LESS THAN (20000),
PARTITION p2 VALUES LESS THAN (50000),
PARTITION p3 VALUES LESS THAN MAXVALUE
);
如果是按照时间戳可以使用内置函数UNIX_TIMESTAMP
(按照 TIMESTAMP 时间戳类型字段),YEAR(按照 DATE 类型的时间字段的年份)等,如下:
PARTITION BY RANGE ( UNIX_TIMESTAMP(work_time) ) (
PARTITION p0 VALUES LESS THAN ( UNIX_TIMESTAMP('2016-01-01 00:00:00') ),
PARTITION p1 VALUES LESS THAN ( UNIX_TIMESTAMP('2017-01-01 00:00:00') ),
PARTITION p2 VALUES LESS THAN ( UNIX_TIMESTAMP('2018-01-01 00:00:00') ),
PARTITION p9 VALUES LESS THAN (MAXVALUE)
);
Key 分区跟 Hash 分区很像。不过 Key 分区不提供用户自定义函数,使用 MySQL 提供的 Hash 函数,并且其设置分区的列,必须至少包含部分或者全部的主键或者唯一索引。如果表没有主键,则需要使用 INT 类型的、非空的(Not Null)、唯一索引(Unique Key),否则会报错。同时,Key 分区也支持类似线性 Hash 算法的 线性 Key,如下示例:
CREATE TABLE test (
t_key INT NOT NULL PRIMARY KEY,
t_name CHAR(5),
t_date DATE
)
PARTITION BY LINEAR KEY (t_key)
PARTITIONS 10;
List 分区 跟 Range 分区比较类似,但是 Range 是连续的值在一个范围内的落在一个分区,而 List 提供了一种通过指定的离散的值的分区方式。写入的列值落在 List 集合中,就在这个分区内,如果写入的值不在定义好的 List 里面,则会报错:Table has no partition for value XX。如下示意:
CREATE TABLE test_list (
birth_month INT,
name VARCHAR(10),
)
PARTITION BY LIST(birth_month) (
PARTITION p0 VALUES IN (1, 2, 3),
PARTITION p1 VALUES IN (4, 5, 6),
PARTITION p2 VALUES IN (7, 8, 9),
PARTITION p3 VALUES IN (10, 11, 12)
);
以上四种分区,都需要分区的 key 是 Int 类型的数据列,Columns 分区可以接受一些非 Int 类型的值。Columns 分区提供了两种分区策略:按照范围值的 Range Columns 以及离散值的 List Columns 分区。
如下使用 List Columns 示例:
CREATE TABLE test_list_columns (
birth_month VARCHAR(25),
name VARCHAR(10),
city VARCHAR(15),
)
PARTITION BY LIST COLUMNS(city) (
PARTITION p_north VALUES IN('Haerbin', 'Beijing', 'Jiamusi'),
PARTITION p_east VALUES IN('Shanghai', 'Hangzhou', 'Nanjing'),
PARTITION p_west VALUES IN('Xian', 'Lasa', 'Chengdu'),
PARTITION p_sourth VALUES IN('Guangzhou', 'Nanning')
);
在一张表的数据进行分区之后,分区的文件会分散到不同的数据节点(Node)里(如文章前面第一张图的示意)。对于一个请求来说,需要知道请求到哪个 Node 里的哪个分区,也即请求的路由。路由也是“服务发现”的一环,服务发现不仅仅用于存储 DB,也可以用于应用层的服务路由。
不同的存储引擎,有的有内置路由方案,有的需要通过一些中间件来做路由配置,但基本的策略也都比较相似,比较常见的路由方式有:
Cassandra 使用 Gossip 协议来管理集群的服务实例的状态(节点上线、下线),整体的思路就是第一种方案,请求发送给任意节点,然后由那个节点决定怎样处理或者转发请求,这种方式避免了对一些其他服务类似 ZK 的依赖,更加灵活。
很多的分布式数据系统会依赖一个独立的专门处理服务发现的协作型服务,比如 ZooKeeper。HBase、Kafka 就是通过 Zookeeper 来做服务发现,整体的路由模型类似第二种方式。MongoDB 也比较类似只不过是只用它自带的 Config Server 来实现路由服务。
Redis 的分区方案是基于第三种路由方案,客户端需要自己进行分区的路由。目前被广泛使用的开源路由代理有 Twitter 的 twemproxy。Twemproxy 相当于一个路由代理,客户端将请求发送给 Twemproxy,再由 Twemproxy 通过对 Redis 分区路由配置的解析,将其请求转发给含有数据的分区的 Redis 节点,并将结果返回给客户端。
本文介绍了数据存储服务的分区,除了 MySQL,很多存储引擎都支持分区,分区可以对现有的大数据进行数据拆分、并且对开发人员透明。但分区也是有一些问题的,很多的存储引擎的分区数一般是有上限的,比如 MySQL 5.6.7 版本之前支持的最大分区数是 1024,该版本之后支持到 8192 个分区数,并且分区的策略制定后,想要重新分区一般都需要进行一定的数据迁移,所以最开始的分区策略的选择尤为重要。
在分布式的存储集群中,无论是否使用微服务,都需要进行存储层的优化,或者随着领域模型的数据的增大,很多时候是从上至下优化的,比如 DB 的读请求负载高,可以考虑用 Cache、搜索。如果是存在一些数据热区,可以针对部分大表进行垂直拆分,将一部分字段抽离出领域模型,建立关联表,因为不是所有请求都要返回所有列的数据的。如果还是无法分担,可以考虑用上一篇介绍的数据库复制,先水平扩容。如果表数据量很大,检索效率还是低,可以考虑用本文所述的数据分区,但要注意分区要让查询尽量路由到少数的分区,防止扫描过多分区文件。如果还是无法满足性能和扩展要求,可以考虑用一些中间件做水平拆分——分表,让请求尽量落在一个分表中。如果分表很难满足场景,对于写少读多的场景,那就可以再做冗余的其他查询维度的分表。
在实际选择哪个方案进行集群的扩展,都是因团队、业务而异的。了解了底层的分布式存储知识之后,就可以往应用服务去扩展了。