分享

Kudu的Schema表结构设计 | 微店数据科学团队博客

 KyunraWang 2018-02-05

Kudu有着和MySQL等传统RDBMS类似的存储结构。表结构的设计对性能和稳定性的起着决定性的作用。本文把Kudu官网的表结构设计做了少许整理,结合微店自身业务做了些许的实践和测试。

宏观来看,Kudu的表结构设计有三个重要概念:列设计、主键设计和切片设计。其中列设计、主键设计和传统的数据库类似,切片设计是分布式数据库特有的概念,切片设计把数据分布在不同的机器上,对于不同的业务场景,不同的读写分布,其切片设计可能千差万别。

Schema设计目标

一个比较好的Schema设计应当有如下的目标:

  • 数据的随机读写均匀分布在不同的切片上,充分利用分布式多机资源,使得总吞吐量达到最大。
  • 切片后的数据(Tablets)应当均匀增长,每个切片的大小相当,并且每个切片的负载相当。
  • SQL对应的Scan操作应当读取最少的数据。这是影响Kudu之上BI分析最关键的一步,甚至有时候要放弃写性能来达到最大的Scan性能。通常情况下影响Scan性能的是主键和切片设计,两者在不同的场景下作用不一。

列设计

和传统的数据库类似,Kudu表都由不同的列组成。Kudu支持的列类型如下(除了被设置为主键外,如下类型均可包含null):

  • boolean
  • 8-bit signed integer(Kudu只支持有符号整数)
  • 16-bit signed integer
  • 32-bit signed integer
  • 64-bit signed integer
  • unixtime_micros (unix时间戳)
  • single-precision (32-bit) IEEE-754 floating-point number
  • double-precision (64-bit) IEEE-754 floating-point number
  • UTF-8 encoded string (压缩前最大64K)
  • binary (压缩前最大64K)

相比于Hbase的所有皆是字节的设计方案,Kudu对于结构化的数据类型有着更好的存储和检索效率,Kudu要求用户在表的创建时设置好每列所对应的数据类型,Kudu除了支持每列不同类型的设计之外,还可以针对每一列设置不同的压缩方式。

列编码设计

针对不同的类型,Kudu默认会选择不同的列编码方式,以达到最大的存储和检索效率。

列类型支持的编码方式默认
int8, int16, int32plain, bitshuffle, run lengthbitshuffle
int64, unixtime_microsplain, bitshuffle, run lengthbitshuffle
float, doubleplain, bitshufflebitshuffle
boolplain, run lengthrun length
string, binaryplain, prefix, dictionarydictionary

Plain Encoding

平铺的编码方式数据用原本的结构来存储,如int32类型固定的占用4个字节。

Bitshuffle Encoding

Bitshuffle压缩算法原始论文发表在这里。Bitshuffle的开源实现在这里。该编码算法大概原理为按照数据出现频次来对比特重新分布,最终采用LZ4压缩落地。对于重复数据出现比较多的列,或者主键排序之后相邻单元差异较小,Bitshuffle是一个不错的选择。

Run Length Encoding

该编码方式把相邻的相同元素按长度进行编码存储。比如原始数据为aaabbbbc,编码后变为a3b4c1。如某列出现的值类别很少,比如性别、国家等。用该编码方式较合适。

Dictionary Encoding

字典的编码方式把输入的字符串转换为[0-n)的整数,n为所有字符串去重后的个数。显而易见,n越小,字典的编码方式效率越高。另外当n比较大时,Kudu会自动的进行降级处理,编码方式自动降级为平铺的编码(Plain Encoding)

Prefix Encoding

前缀编码,可以近似认为内部使用Trie(字典树)进行存储,当数据前缀相同部分较多时比较适合采用该编码方式。另外主键中的第一列是按前缀进行字典序排序,此时也可采用前缀编码。

列压缩

Kudu允许的落地压缩算法为LZ4、Snappy和zlib(gz)。默认情况下,Kudu不压缩数据。通常情况下压缩算法会提高空间利用率,但是会降低Scan性能。

LZ4和Snappy比较类似,空间和时间有着很好地均衡,zlib有着较高的压缩比,但是Scan性能最差。

需要注意的是Bitshuffle Encoding已经在最后采用了LZ4,所以对于采用这种编码方式的列,无需再指定额外的压缩算法。

主键设计

每一个Kudu的表都有且仅有一个主键,主键可以包含多个列,同时要求每一列的值都不能为空(non-nullable),另外bool和浮点数也不能作为主键。

跟MySQL类似,主键具有唯一性,同一个主键只能对应一行数据,对于主键重复的数据insert会触发duplicate key error。

跟MySQL等传统数据库不一样的是,Kudu目前并不支持自增主键。不过主键是Kudu表结构最重要的设计,对于Kudu而言,自增主键通常也不是很好地选择,有的同学为了方便甚至随机生成一个ID作为主键写入Kudu,该方式对Kudu Scan性能的提升没有任何帮助,放弃主键的这种设计对Kudu而言是极大的浪费。

对于切片之后的每片数据,可以近似认为是按照主键有序存储的,主键字典序相近的数据会放在一起,充分利用这个特性,可以极大的提高Scan性能。
比如我们要检索一个店铺的所有商品,那么把店铺ID作为主键的第一列,可以极大的提高Scan的性能,这是因为店铺ID作为主键第一列,一个店铺下的所有商品变会有序放在一起。
我们知道磁盘的随机读性能要远低于顺序读,如果一个店铺的所有商品集中放在一起,Scan操作只需要顺序读一次,如果店铺下的商品是随机存储在n个位置,Scan操作则需要随机读n次。

切片设计(partitioning)

作为一个分布式的数据存储引擎,切片是最基本也是最重要的设计之一。对于每个数据库表(table)而言,Kudu会把一个table按照切片规则分成多个partition,一个partition存储在tablet服务之中。每个tablet都有一个一主多从的tablet服务,每条数据属于且仅属于一个tablet,数据和tablet的从属关系规则,由切片规则决定。

优化一个数据库表的切片规则需要考虑随机读、随机写、扫描三种操作,需根据业务场景的不同侧重来最终决定。

随机写压力场景

对于写压力比较大的业务场景,最重要的一点是把写压力均匀分担到不同的tablet之中,这种场景下切片设计通常采用hash partitioning,hash切片拥有良好的随机性。
相比Hbase而言,Kudu的架构可以轻松应对随机写的场景。

随机读压力场景

对于随机读压力比较大的业务场景并不是很建议使用Kudu,通常情况下Hbase是一个更好的选择,不过Kudu也拥有不错的随机读性能。Kudu官方的性能测试,在读压力分布符合齐夫定律时,Hbase有读性能优势,随机分布下,Kudu和Hbase的的随机读性能相当。不过通常情况下业务场景的读分布符合齐夫定律,也就是我们常说的28原则,80%的读集中在20%的数据上。
如果用Kudu的业务场景确实随机读压力较大,则通常采用hash partitioning。

小范围Scan场景

对于拥有大量小范围Scan的业务场景,比如扫描一个店铺的所有商品,比如找到一个用户看过的所有商品,诸如此类的业务场景最好将同一个Scan所需要的数据放置在同一个tablet里面。比如按店铺id做hash,可以把同一个店铺的所有信息放置在同一个tablet里。按userid做hash,可以把一个用户的所有信息放置在同一个tablet里面。

大范围Scan

如果业务场景的Scan所需要扫描的数据量比较大,又想这类Scan跑的快,则需要把这类Scan所需要的数据分布到多个tablets里面,充分利用多机分布式计算能力。假设我们有一个表存储了最近12个月的数据,一个设计方案是按照月来切片,一共12个tablet,但如果大部分BI查询对应的Scan只需要最近1个月的数据,则这种设计便不合理,因为Scan的压力全部集中到了一个tablet之中。
这种情况下一个更好的设计方案是按月切片再按hash切片,具体方案后续再详细分析。

Range切片

Range的切片方式把数据按照范围进行分类,每个partition会分配一个固定的范围,每个数据只会属于一个切片,不同partition的范围不能有重叠。切片在表的创建阶段配置,后续不可修改,但是可以删除和新增,如果数据找不到所属的切片,会插入失败。

range的切片方式通常与时间有关系,值得注意的是,老的切片可以删掉,同时可以增加新的切片,意味着与时间强相关的数据可以按照这种方式来切片,老的数据可以通过删除切片的方式删除。同时Kudu对此类操作的支持非常高效,完全不用担心删除或者新增切片会影响数据读写。上文有讲到,单片数据过大会影响Kudu的性能,配置为时间相关的Range切片方式,可以很好地控制每片数据的总大小。比如有日志型数据,每秒平均有100条数据写入,配置每天一个切片,则单片数据量规模约为864w。如果配置为hash的切片方式,则单片数据会随着时间推移越来越多大。

切片的设计对Scan性能的影响至关重要,比如对于时间序列类型的数据而言,往往查询的是近期的数据,如果按时间进行切片,则Scan操作可以跳过大部分数据,如果单纯按照默认的hash方式切片,Scan操作则需要扫描全表。

Hash切片

Hash切片把每行数据hash之后分配到对应的tablet。hash切片在设计上相对简单,通常情况下只需要配置计算hash值的列,比如前文所列举的例子,如果你需要查询一个店铺的所有商品,则把shopid作为hash列是比较恰当的选择。相同shopid的hash值是相同的,相同hash值的数据肯定会被分配到同一个partition之中。

需要注意的是hash的切片方式是不可修改的,所以随着数据量的增长,hash的切片方式会造成单片的数据量过大,甚至超过单个tablet服务所能承受的数据量。

hash的切片方式对于随机读写友好,对于写操作而言,hash的切片方式会均匀的把写入压力分担到多个切片之中。对于随机读而言,按照主键进行hash之后Kudu可以提前预知读操作所对应的切片,避免每个切片都查一次。

多级切片

Kudu支持多层的切片方式,hash和range的切片方式可以结合起来。比如按照月把数据分成多片,每个月的数据再按照hash进行二级切片。

合理的使用多级切片,可以充分利用不同切片方式的优势。

切片调优

合理的切片可以让Kudu的Scan操作跳过部分切片,比如上文举例说明的时间序列类型存储。合理的切片还需要避免写入热点,防止大量的写入分配到同一个tablet服务之中。

切片设计案例

考虑如下表结构的设计

CREATE TABLE metrics ( host STRING NOT NULL, metric STRING NOT NULL, time INT64 NOT NULL, value DOUBLE NOT NULL, PRIMARY KEY (host, metric, time),);

该表有四个字段,host、metric、time和value。主键包含三列(host、metric和time)

Range切割方法

一个自然的方式是按照时间切片,我们可以把数据分为(2015年之前,2015年,2015年之后)。或者更直接的按照年份进行切分(2014年,2015年、2016年……)

上图便是这两种切分方式的图形化表示。第一种方式的优势在于切片配置的可拓展性强,第二种方式随着时间推移,切片方式需要调整。但是如果按天进行切割,第二种方式会有较多的切片,Kudu目前的架构并不支持太多的切片。但是第一种切片方式每个partition所对应的数据量不一致,容易造成单个tablet过大。

Hash和Range切割方法

上述表结构可以使用time字段进行range的切分方式,也可以使用(host+metric)的hash切分方式。两种切分方式各有自己的优势和列式。

切分策略表增长
按时间范围切割容易造成写集中在最后一片基于时间的Scan性能高切片可以增加
按host和metric做hash写会均匀分布到不同切片基于host和metric的查询性能高切片会无限制变大

基于hash的切片方式可以极大的提高写性能,基于range的切片方式可以提高部分Scan性能,同时还可以防止单片数据的过大增长。Kudu所提供的多级切片方式可以较好的结合两种不同切片方式的优点。下图为结合hash和range的两级切片方式示意图:

实际案例测试

为了实际测试本文中的Kudu表结构设计理论,作者创建了三张数据表。该数据是微店后台piwik收集的实际pv数据。通过Impala后台,可以看到每个SQL操作所扫描的切片数量。

表1:

该表采用了基本的hash切片,并且采用了line_id作为hash id(不指定hash id,主键即为hash id默认值)

该表共有50个切片。

create table speed1 ( line_id string, request_time timestamp, idvisitor string, primary key(line_id))partition by hash partitions 50stored as kudu;

表2

该表采用了range的切片方式,并且每天一个片。需要注意的是,因为把request_time加入了切片规则,所以主键之中必须包含request_time

该表共有13个切片,分别对应13天的数据。

create table speed_test_2 ( line_id string, request_time timestamp, idvisitor string, primary key(line_id,request_time) )partition by range(request_time)(PARTITION cast(1505130896 as timestamp) <= values="">< cast(1505217296="" as="" timestamp),partition="" cast(1505217296="" as="" timestamp)=""><= values="">< cast(1505303696="" as="" timestamp),partition="" cast(1505303696="" as="" timestamp)=""><= values="">< cast(1505390096="" as="" timestamp),partition="" cast(1505390096="" as="" timestamp)=""><= values="">< cast(1505476496="" as="" timestamp),partition="" cast(1505476496="" as="" timestamp)=""><= values="">< cast(1505562896="" as="" timestamp),partition="" cast(1505562896="" as="" timestamp)=""><= values="">< cast(1505649296="" as="" timestamp),partition="" cast(1505649296="" as="" timestamp)=""><= values="">< cast(1505735696="" as="" timestamp),partition="" cast(1505735696="" as="" timestamp)=""><= values="">< cast(1505822096="" as="" timestamp),partition="" cast(1505822096="" as="" timestamp)=""><= values="">< cast(1505908496="" as="" timestamp),partition="" cast(1505908496="" as="" timestamp)=""><= values="">< cast(1505994896="" as="" timestamp),partition="" cast(1505994896="" as="" timestamp)=""><= values="">< cast(1506081296="" as="" timestamp),partition="" cast(1506081296="" as="" timestamp)=""><= values="">< cast(1506167696="" as="" timestamp),partition="" cast(1506167696="" as="" timestamp)=""><= values="">< cast(1506254096="" as="" timestamp))stored="" as="" kudu="">

表3

表3融合了表1和表2两种建表方式,切片方法既包含了hash,也包含了range。

该表共有13*3=39个切片,代表了13天的数据,每天3个hash切片。

create table speed4 ( line_id string, request_time timestamp, idvisitor string, primary key(line_id,request_time) )partition by hash (line_id) partitions 3,range(request_time)(PARTITION cast(1505130896 as timestamp) <= values="">< cast(1505217296="" as="" timestamp),partition="" cast(1505217296="" as="" timestamp)=""><= values="">< cast(1505303696="" as="" timestamp),partition="" cast(1505303696="" as="" timestamp)=""><= values="">< cast(1505390096="" as="" timestamp),partition="" cast(1505390096="" as="" timestamp)=""><= values="">< cast(1505476496="" as="" timestamp),partition="" cast(1505476496="" as="" timestamp)=""><= values="">< cast(1505562896="" as="" timestamp),partition="" cast(1505562896="" as="" timestamp)=""><= values="">< cast(1505649296="" as="" timestamp),partition="" cast(1505649296="" as="" timestamp)=""><= values="">< cast(1505735696="" as="" timestamp),partition="" cast(1505735696="" as="" timestamp)=""><= values="">< cast(1505822096="" as="" timestamp),partition="" cast(1505822096="" as="" timestamp)=""><= values="">< cast(1505908496="" as="" timestamp),partition="" cast(1505908496="" as="" timestamp)=""><= values="">< cast(1505994896="" as="" timestamp),partition="" cast(1505994896="" as="" timestamp)=""><= values="">< cast(1506081296="" as="" timestamp),partition="" cast(1506081296="" as="" timestamp)=""><= values="">< cast(1506167696="" as="" timestamp),partition="" cast(1506167696="" as="" timestamp)=""><= values="">< cast(1506254096="" as="" timestamp))stored="" as="" kudu="">

测试结论

对于不同类型的查询sql,本次测试结果如下:

单次查询

请求类型表1查询对应的
切片数量
表2查询对应的
切片数量
表3查询对应的
切片数量
解释
select count(*) from table_name501339表1、2、3的全部切片都有扫描
select * from table_name
where line_id='xxx'
11313表一line_id是唯一主键,只需要查询一个切片,表2表3则不行。
同时三张表line_id均是第一索引,所以查询操作都很快。
select * from table_name
where idvisitor='xxx'
501339idvisitor不在主键之中,所以需要查询所有切片
select count(*) from table_name
where request_time>adddate(now(), -5)
50515表1扫描了全部切片,表2和表3因为有range配置,所以只扫描了部分切片

可以看到Kudu会根据where条件跳过部分分区。对于带有时间where条件的大范围Scan查询而言,可以看出,表2和表3是比较合适的。表3虽然扫描的切片比表2多,但是扫描的总数据量是和表2一样的,同时表3能更好的利用多机资源,可以把并发度从表2的5提高到15.

通常情况下,应当尽量使用类似表3的结构来降低Scan操作所扫描的数据总量。

已知的限制

  • 列的数量最多300,越少越好
  • 总切片数量不适宜太多,单个物理机最多承受1000个切片
  • 每张表每片数据在1000w条左右较合适,根据列的数量,该建议需灵活配置
  • 单个cell最大64KB
  • 单行数据不能太大
  • 描述用UTF-8编码之后不能超过256字节
  • 主键对应的cell内容不可改变
  • 主键包含那几列建表时需要设置好,后续不可改变
  • 切片规则不可改变,但是Range切片可以增加切片或者删除切片
  • 列的类型不能改变

本文大部分内容翻译整理自Kudu和Impala官网

作者: 高云翔

写于:2017年09月

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多