作者:天穆
一、基础:数据分布
(一)扩展性:scale up & scale out
分布式系统里常见的扩展性问题有两种:scale up 和 scale out。拿数据存储举例,如果一块盘存不下,换一个更大的盘,从1t到4t到8t到更大的硬盘,但是这种方式很容易触及到系统的容量上限。
因为不可能把一块盘做的非常大,所以现在业界最常用的扩展性的方式是通过scale out方式,一块硬盘不够,用更多的盘,当一台机器盘的数量达到上限,用更多的机器组成集群,如果集群不够了,用更多的集群组成联邦。再往前一步可以用一个机房,用更多机房,甚至全球分布,扩展整个系统的容量。
(二)基本问题:数据分布策略
做scale out的时候必然会面临一个问题,当存储数据的节点或盘变多了以后,必须要解决数据怎么在硬盘和机器上分布问题,也就是数据分布策略。
理解数据分布策略,可以从读、写两个方面开始,比如写的时候一个请求或者要写一行数据,要写到哪个机器上、写到哪个盘上;从读的角度来讲,一个请求读取数据,不可能访问整个集群的所有盘或者所有机器,这样读取这行数据太慢了,所以必须有很好的算法或者是分布策略,能够让读、写的请求能够一次到达目标。最多可以有多一条的方式,但是最终期待的是一条就能获取。
讨论具体的分布策略之前,要明白设计分布策略的目标,第一是:负载均衡;第二是:线性扩展。
负载均衡:是希望在写的时候,能够均匀的写到每一台机器上、每一块盘上,整体是均匀的,不会某些盘或者某些机器成为写热点,也不会因为某些机器写的太多,水位很高,其他机器都空着。从读的角度来讲也一样,希望读能够很均匀的分布在整个集群上,不会导致热点和倾斜。
达到负载均衡以后,还要有线性扩展,比如集群扩缩容、盘坏了要下盘、还要加新的盘上来,这个时候希望无论机器怎么扩容,盘怎么增减,整个系统仍然处于负载均衡的状态。只要保证这一点,当系统盘增加了或者机器增加的时候,整个系统仍然能够处于线性扩容的关系,机器多了能够存的数据就多了,能承载的吞吐也变多了,是整个数据分布的目标。
总结:
负载均衡:
- 写:均匀的写到集群的每一台机器上,每一块盘上;
- 读:均匀的从集群的每台机器上、每块盘上读数据。
线性扩展:
- 机器扩缩容,磁盘上下线,系统始终/最终处于负载均衡状态;
- 系统容量、吞吐与系统资源成正比(线性关系)。
(三)两种分布策略
目前业界有两种比较典型的分布策略,一种是顺序分布,一种是Hash分布。
顺序分布:根据用户定义组件,让数据从最小的主键开始,依次往后排,如图例所示:user_id和ts是联合的主键,先按user_id 1、2、3、4、5排序,排完之后,再按ts进行排序。顺序分布是把整个表做拆分,例如:把user_id 等于1的分到一个Region里面,user_idt等于2、3的分到一个Region里面。
Hash公布:需要有一个Hash算法,选一个分区键,经过算法得到所在机器的名字。常见的一种算法就是取模“分区 = user_id % 机器数”,可以拿user_id模上这个机器数,得到所在的机器。如图例所示,假设有3台机器,“%3 = 0”在第一台机器上,“%3 = 1”在第二台机器上,以此类推,是一种基于规则的分布。
1)顺序分布:目前比较典型的产品有hbase,tidb。
顺序分布的缺点:
- 第一,是比较依赖主键的值,如果user_id分布不均匀,因为通常user_id是顺序分配的,比如有1、2、3、4、5、6、7、8、9、10,user_id更大的时候,热度会比较高,user_id小的时候,热度会比较低。会产生一种问题,越往表的尾部越热,头部的可能就会冷一点,会产生数据倾斜以及访问倾斜,需要通过额外设计或人工介入调整。
- 第二,相同前缀的数据也可能会分开,比如上图所示的“user_id = 3” 的数据,可能会被分到两个Region上,当访问等于3的所有数据时,必然会涉及到两次Region。
- 第三,因为有强大的干预能力,需要很复杂的路由表机制。
顺序分布优点:
- 第一,一个Region包含哪些数据,通过路由表决定,比如HBase的meta表,tidb里面是PD。Region可以灵活分布,比如让user_id=1的数据,在Region里面拆分,也可以把user_id=1、2、3合并。
- 第二, Region在哪台机器可以人工指定,比如可以让Region 1单独分一台机器,Region 1、3共享另外一台机器,在生产上,尤其是在有数据热点场景下,有人工介入干预能力。
2)Hash分布:是基于规则的分布,选取分区键,user_id根据分布算法或者Hash算法,得到所在的机器。比较典型的代表产品有cassandra、dynamodb。跟传统关系数据库里面的分库分表非常类似,因为没有外部依赖,所以比较简单。
缺点
第一,是在做扩缩容的时候,需要对很多的数据进行搬迁,所以需要一致性hash方案。
第二,是分区无法灵活调整,因为是基于规则的,当数据基于分区键算好分区之后,所在的机器就确定了,不能灵活调整。
第三,有数据倾斜问题,比如有超大分区,比如user_id=1是个超大的用户,记录非常多,会产生热点的问题,user_id=1的所有的数据强制分布在某一台机器上,数据特别多的话,这台机器很快会达到上限。
(四)Hash分布:分区键的选择
如图所示,基于直觉的方式是选user_id作为分区键,为什么不能用把TS也放进去?
假设把TS放进去,user_id和TS一起算Hash,势必会产生一种情况,就是user_id = 3的数据,可能分布在整个集群的不同位置,做查询的时候where user_id=3,等于3的所有数据会面临查很多分区。而且 user_id=3下面的TS,没法知道有多少,是一个不可预测的值,这时涉及到跨分区的查询,这种查询会退化成全面表扫描,是不能接受的。
选择分区键要结合查询的场景,选择合适的分区键,尽量避免或者一定要避免跨分区的查询。比如where user_id>3,这种是没办法直接高效的定位查询,一定要扫全表;但是where user_id in (3, 6, 9, ...)这种,是可以拆分成多个请求逐个查询,因为是可枚举的。
二、Cassandra的数据模型
(一)Partition Key,Clustering key
Cassandra数据模型里ts叫聚类键,user_id叫分区键,分区键和聚类键加一起,构成表的主键,主键要求唯一性。比如下图所示的表里面,user_id和TS放到一起一定要全局唯一,如果400有两个,就是冲突的数据。
对于分区键和聚类键,可以有很多个,可以很多个Key作为分区键,也可以有很多Key作为聚类键。除了主键之外,Cassandra里面还有非主键,或者叫属性列或者叫数据列,比如location存具体数据,不参与数据排序。
(二)联合主键与前缀匹配
key比较多的场景称为联合主键或,联合主键如何排序以及查询?如图例所示的场景,分区键是city,有两个聚类键,一个是last_name,一个是first_name。因为分区间键不参与排序,当我们做Hash分布的时候,分区键在整个表里面随机分布,但是在某一个特定的分区键下面,clustering key是顺序分布的。图例中是按last_name前缀排序, p排在前面,w排在后面,在last_name相同的时候,再排下一个列, potter相同的时候, Harry排在前面,James排在后面,是这种排序规则。
因为是这样排的,所以在查的时候,要从左到右依次去查,有以下几种情况:
1.where city = 'hangzhou' and last_name = 'Potter',前缀扫描;
2. where city = 'hangzhou' and last_name = 'Potter' and first_name = 'James',单行读;
这两种可以很高效的完成,因为查询的扫描范围和结果集一样大,有一些场景不能很好的支持,如:
3.where city = 'hangzhou' and first_name = 'Harry',跳过了last_name列,直接查first_name,这种查询first_name不能够用于圈定扫描范围,会变成一个filter,直接对每一行数据过滤,查询的扫描范围是city = 'hangzhou'的所有数据,为每一行数据基于first_name = 'Harry'做过滤,假设'hangzhou'是一个很大的Partition Key,数据量很多,这个查询会非常低效。
4. where city >= 'hangzhou',当city >= 'hangzhou'的时候,就是一个跨分区键的查询,也不能被支持。
5.where city = 'hangzhou' and last_name >='P' and first_name = 'James',first_name进入filter。
6.where city = 'hangzhou' and last_name >='P' and first_name = 'Ron';
这两个查询从表上看,James排在前面,Ron排在后面,但是事实上last_name是范围查询,first_name字段变成filter来扫,而不是用来缩小查询范围,所以说5和6两个语句的扫描范围一样。
(三)逻辑分区:一组具有相同前缀的行
一个Partition Key的值代表一个分区,但本质上来讲,并不是物理上的分区,比如一块盘、一个机器,有物理的实体跟其对应,但是分区不会有一个文件或者实体跟其对应,分区是一种逻辑概念。
在这里面把分区定义成是一组具有相同前缀的行,前缀是Partition Key,如下图所示,Partition Key等于杭州,杭州这两行数据就是一个分区,等于上海的就是另外一个分区,这种就是叫逻辑分区。在物理上没有一个有力度的实体跟它对应,所以它的数量可以无穷大,这里的city是一个字符串,可以有无穷多的数据组合,city分区键可以无穷无尽的分区。
- 分区键:值域可能非常大(比如long),分区键的每一个值,都代表了一个"分区";
- "分区"的数量可能会非常大;
- "分区"的本质:一组具有相同前缀的行,"前缀"即分区键的值;
- 所有的分区都是"逻辑分区";
- 线性扩展。
线性扩展:是指分区根据一致性Hash算法划分到某一个机器上,一台机器可以服务很多分区,机器数量增加之后,能够承载的分区数量也会相应的增加,能够获得线性扩展能力。除非产生了一些巨大的分区,这些分区把一些机器占满了,这种情况下线性扩展能力是受限的。
三、范式与反范式设计
(一)范式化与反范式化
范式化:是传统关系型数据库要求的概念,数据库刚出现的时候,盘都比较贵,存储空间都比较贵,数据库的表设计必须要满足降低数据冗余度的原则,需要范式化的设计,减少数据冗余度。
另外需要增加数据的一致性校验,比如有很多表,一些表来存买家,一些表存卖家,一些表来存订单,通过主键和外键之间的关系进行关联,通过外建描述数据的完整性,也是范式化设计的一部分。这种通常是用于关系数据库的设计,而且能够很好的解决复杂业务的设计,通过一整套的方法论,业务模型进行抽象。
在NoSQL系统里面,强调反范式化的设计,通过增加冗余度换取更好的性能。带来的一个问题就是数据冗余,存储空间开销上升,但是现在存储越来越便宜了,成本并没有上升很多。
范式化(Normalization)
- 目的:
➢ 降低数据冗余度;
➢ 增加数据的完整性(如外键)。
• 通常用于关系型数据库的设计
反范式化(Denormalization)
- 增加冗余度,用空间换时间;
- 数据在多个地方都有,存在一致性问题。
(二)示例
下图所示,是一个部门和部门下的雇员之间的表设计,比如有个department表,存 depId和名字,还有一个user的表,来存每一个人和userId,要描述一个部门还有哪些人的时候,需要把这两张表关联起来。记录表的depId和userId之间的关系,当查一个部门有哪些人的时候,要先扫这个部门的人员表,得到这个部门的userId信息,比如查depId=2,得到的userId是1和2,这时转user表拿到1和2两个ID的用户名,同时拿depId=2的depName,才能获取depName是Math,一次查询,需要有三张表,这是范式化设计。
反范式化设计,就用一张表来代替。如下图所示,depName和userName直接存在一起,查询一次搞定。缺点是名字重复存在,depName内容也重复存在,数据冗余度增加。另外,当修改名字的时候,要改很多地方。
(三)反范式化优缺点
优点:
- 多个表的数据统一到一张表里;
- JOIN不是必须的(大部分NoSQL也不支持join),查询更高效;
- 采用宽表设计,从业务设计来讲业务更简洁,查询更简洁,整个业务模式会更清晰,SQL会更简单,维护性会更好;
- 当业务出现问题的时候,调查问题的效率得到相应的提升。
缺点:
- 冗余存储,空间开销增加。但是因为现在存储变便宜了,所以说成本没有增加。
- 数据冗余之后,带来的一致性的问题,比如只有一张表,Math存了两次,但是假设当有很多张表的时候,都有Math字段,会面临在多张表之间处理一致性问题。
(四)原则
反范式化设计的基本原则是:
- 根据读写模式来设计表,设计主键;
- 使用分区键来规划数据分布:一次查询需要的数据,尽可能在一个分区里;
- 使用聚类键来保证数据在分区内的唯一性,并控制结果集中的数据的排序(ASC/DESC);
- 设计好主键以后,使用非主键列来记录额外信息。这个时候非主键包含了很多业务字段,比如订单存储,希望其包含订单金额、订单ID、买家名字、卖家信息、商品信息等,是一张大宽表,可以通过一次或者是少量的查询,得到需要的所有数据,避免join,提升整个系统的查询性能。
- 反范式化设计:将原本需要通过join得到的数据,都包含进来。
四、典型场景分析
(一)典型场景一:物流详情
场景描述:
- 电商物流订单,每个订单会经历多轮中转最后达到用户手中。每一次中转会产生一个事件,比如已揽收、装车、到达xx中转站、派送中、已签收。
- 需要记录全网所有物流订单的状态变化,为用户提供订变更记录的查询能力。
- 订单数据量极大,可能有上百亿;体量不能影响读写性能。
场景抽象:
- 写:记录一个订单的一次状态变更。
- 读:读取一个订单最近N条记录;读取一个订单的全部记录。
如下图所示:表中有两列主键,orderId指订单的ID,是分区键;gmtCreated指事件产生的时间,是聚类键;非主键列detail指的是一次事件的信息,比如已揽收或到达的状态,是数据列。
1)物流详情:高表设计
"高表"设计:
- 行不断增加,一行描述一个订单的一个事件。
- 一个订单的所有数据,由连续的一组行来描述(一个逻辑分区)。查一个订单的所有数据时,事实是查一组具有相同前缀的行,就是查一个分区的数据。
优缺点:
- 单个分区键下的key数量可以很多;
- 过多的数据将导致宽分区的产生,应避免;
- 无论数据量多大,单次next()的RT可控:流式ResultSet。
高表设计可以避免很大的行产生,因为所有的变化都产生在行里面,不是产生在列里面。可以很好的解决orderId的问题,如果某一个订单数据量特别多的时候,会产生宽分区,需要避免。常规做法是增加维度,拆开分到不同的分区里面。
高表设计无论数据量多大,单次读下一行数据的时间不变,有流式ResultSet能力,一次加载一部分数据。
2)物流详情:宽表设计(不推荐)
宽表设计:
- 用一行来描述一个订单的所有事件,每一列是一个事件,用事件的发生事件作列名;
- 也可将所有事件encode到一个列里。
宽表设计,用一行来描述一个订单的所有事件,每一次事件通过一列来描述。
如上图所示,把时间作为列名,每一个列记录了一个订单的某一次事件。也可以把后面的列合到一起,变成一个列。
优缺点:
- 单行读;读一个订单的所有的数据时,只做单行读,业务会更简单。
- 无法预知列名,列数量,每一行的列都可能不一样,强依赖schema-free能力;
- 只能读所有数据,不容易实现topN读取;
- 超大行风险:个别行的列特别多;会影响性能。
所以在物流详情场景下,不推荐宽表设计,建议用高表设计。
(二)典型场景二:时序类---监控系统
如下图所示,是CPU监控,对整个集群的多台机器做 CPU指标的监控,CPU指标有user、system、idle等不同类型,还有很多主机如host,一台机器的某一个CPU user指标有很多点位,比如这里面192.168.1.1机器在CPU type里面产生了两个点,一个是30,一个是40,这个是时间线。
这个表里面列出来的是监控系统里面需要的数据,在这个数据场景下,怎么选择分区键、聚类键以及监控数据的存储,可以有以下几种选择:
分区键怎么选:
- metric;
- metric + host;
- metric + host + type;
- metric + type + host。
监控数据怎么存?
- 一行一个点;
- 一行存所有点;
- 一行存有限个点:如1分钟/1小时内产生的点。
1)分区键:只使用metric
只使用metric作为分区键,意味着分区只有 CPU,如果加入网络、磁盘,每一个metric是一个分区,意味着所有被监控的对象,所有机器的所有数据,都在一个分区下面,很容易触发单分区限制。
因为有变量和不变量的问题, CPU指标本身是不变量,即使未来新增指标,通常也是低频事件。但被监控的机器是变量,会不断的增加,可能数量巨大(比如物流订单的数量)
- 单分区限制:所有机器的指标都聚集在一个分区里,被监控的机器可能无限增长,但单机的承受能力不会线性增长。
- 业务侧:识别变量和不变量
- cpu指标本身是不变量,即使未来新增指标,通常也是低频事件;
- 被监控的机器是变量,会不断的增加,可能数量巨大(比如物流订单的数量)。
2)分区键:metric + host
metric + host策略可以很好的控制了单分区的数据量,不会出现宽分区。因为除了 host以外,没有其他维度大幅度的变化量,如下图所示,type和TS都不会太大变化,TS本质上是作为host下面的一个子集存在,比如在做一个查询的时候,要查某一台机器的某一段时间范围的 CPU指标,肯定希望这台机器的数据都排在一起,用一个查询搞定,所以TS不能够放在分区键里面。
这个设计的缺点是,并发读写同一个机器的cpu指标,请求都路由给同一台机器,不利于并发。
优缺点总结:
- 很好的控制了单分区的数据量,不会出现宽分区;
- 单机的所有类别的cpu指标都在一个分区里;
- 并发读写同一个机器的cpu指标,请求都路由给同一台机器,不利于并发。
3)分区键:metric + host + type
metric + host + type策略,把指标类别也加到分区键里面,可以很好的适配并发查询模式,提高整个集群的吞吐。因为 metric + host + type整体作为分区键,只有三个全相等的时候,才会分在一个分区里面。
另外一种方式是host和type交换位置,其对采用一致性hash的cassandra来说,没有区别。但是对于顺序分布来讲,可能会有一点区别,因为改变了key的值域范围,可能导致值变少了,这个时候会产生聚合效应,可能导致一些潜在的问题。总结:
- 同一台机器的不同cpu类别的指标在不同的分区里,很好的支持并发访问;
- host和type的顺序
- 对采用一致性hash的cassandra来说,没有区别。
4)优化:type合并至metric中
metric + host + type策略还有另一个优化,type合并至metric中,如下图所示,是时序数据库建模时的特点,type在时序数据库里面叫 tag,标签的意思,可以有很多标签,比如IP是一种标签,所在机房可能也是一个标签,甚至可以有业务的标签, CPU的 type也是一种标签,这种场景下,可以把标签合到 metric中。
因为type是可枚举的,只有几种,不会增加也不会减少。合并过去之后,减少了列,存储的列变少以后,可以提高性能,减少开销,仅数据量较大时,体量小的时候看不出来。
还有一种场景,储存的是进程ID,这种时候没办法合并,比如监控每一个进程的网络流量,这个时候进程ID没法合到 Metric里面,因为进程ID不可预估,而且不可枚举。
5)数据点位的存储:宽表 or 高表
数据点位的存储指的是每个时间点metric的值,依然可以有高表和宽表的设计。如下图所示,高表设计,把TS放到Clustering key里面,一行存储一个数据点。高表设计因为一行只有一列,容易扩展多值监控,对于像经纬度,一个点位有两个值,在高表设计里面很容易扩展。
宽表设计,一行存储这个某个机器的某个指标的所有点,这种宽表设计还是会存在单行上线的问题,列多了以后会有性能问题。
融合设计,一行记录有限个点,比如存1分钟采集的所有的点或者1小时的点,结合高表和宽表的设计,粒度选择合适的时候,得到最优的性能,而且能够配合整个系统内部机制,如cache,bloomfilter等。
当然这些例子,在时序场景下面比较简单,能够解决简单业务的时序设计问题,对于业界的实际数据库来讲,但生产使用时有很多考量因素。
总结:
- 高表设计:一行存储一个数据点,如上表所示;
- 容易扩展多值监控:如经纬度;
- 宽表设计:一行存储这个某个机器的某个指标的所有点;
- 单行上限;
- 融合设计:一行记录有限个点:如1分钟,1小时内采集到的所有点;
- 粒度选择:由指标的采集频率决定,以控制单行的列数;
- 适当的控制行数,可以配合一些内部优化机制:如cache,bloomfilter等;
注意:时序建模的原理很简单,但生产使用时有很多考量因素,各TSDB都有不同的侧重点。应根据业务实际需要选择合适的模型,没有银弹
(三)常见误区
1)常见误区一:分页查询
常见的分页查询误区,从Mexico[MOU23]过来的用户很容易遇到一个问题拆请求,如下图所示,做一个大表的扫描,user id=3的数据可能非常多,为了避免一次返回太多的数据,需要对请求进行拆分。
比如按TS进行分页,先扫500的,再扫下500,再扫下500,一次一次扫。在 MySQL里面这样做是合理的,因为RPC一次返回所有记录。但是在Cassandra里面没必要,因为Cassandra用的是一种流式ResultSet方式,在系统设计层面,已经考虑到了不断往下next的情况,已经做了请求拆分。比如第一次next的时候,会新加载500行的数据,等到这500行数据消化完了,再下一次next,会加载下500行数据,如此往复,直到所有结果集返回。
总结:
流式ResultSet:
- 为了避免单次RPC返回过多数据导致RT过高,CQL driver会自动对请求进行拆分;
- 第一次next()调用会从服务端load N行数据,之后的N-1次next()只从内存消费数据;
- 下一次next()会再加载N行数据到客户端,如此往复,直到所有结果集返回。
参见:https://docs.datastax.com/en/developer/java-driver/3.2/manual/paging/。
结论:不要为了拆分大请求而进行分页。
2)常见误区二:修改主键
- 场景1:修改主键的schema,在MySQL里面可以,但在Cassandra里面不允许,只能重新建表。
- 场景2:修改主键的值,本身就是错误的说法。考虑java的map的key, key能修改吗?修改key的逻辑就是删除老key,写入新key。从数据库角度来讲,没有修改主建操作,只有删除、添加这两种操作,非主键可以修改。