近两年,KUDU 在大数据平台的应用越来越广泛。在阿里、小米、网易等公司的大数据架构中,KUDU 都有着不可替代的地位。本文通过分析 KUDU 的设计, 试图解释为什么 KUDU 会被广泛应用于大数据领域,因为还没有研究过 KUDU 的代码,下面的介绍是根据 KUDU 的论文和网上的一些资料学习自己理解所得,如有不实之处,劳请指正。
在 KUDU 之前,大数据主要以两种方式存储:
- 静态数据:以 HDFS(如:Hive) 引擎作为存储引擎,适用于高吞吐量的离线大数据分析场景。这类存储的局限性是数据无法进行随机的读写。不支持单条记录级别的update操作,随机读写性能差。
- 动态数据:以 Hbase、Cassandra 作为存储引擎,适用于大数据随机读写场景。这类存储的局限性是批量读取吞吐量远不如 HDFS,不适用于批量数据分析的场景。却并不适用于基于SQL的数据分析方向,大批量数据获取时的性能较差。
那为什么Hbase不适合做分析呢?
因为分析需要批量获取数据,而Hbase本身的设计并不适合批量获取数据
1)Hbase是列式数据库,其实从底层存储的角度来说它并不是列式的,获取指定列数据时是会读到其他列数据的。相对而言Parquet格式针对分析场景就做了很多优化。
2)Hbase是LSM-Tree架构的数据库,这导致了Hbase读取数据路径比较长,从内存到磁盘,可能还需要读多个HFile文件做版本合并。
LSM 的中心思想就是将随机写转换为顺序写来大幅提高写入操作的性能,但是牺牲了部分读的性能。随机读写是指对任意一个位置的读和写,磁盘随机读写慢是因为需要寻道,倒带才可以访问到指定存储点,而内存不需要,可以任意指定存储点。
从上面分析可知,这两种数据在存储方式上完全不同,进而导致使用场景完全不同,但在真实的场景中,边界可能没有那么清晰,面对既需要随机读写,又需要批量分析的大数据场景,该如何选择呢?这个场景中,单种存储引擎无法满足业务需求,我们需要通过多种大数据工具组合来满足这一需求,一个常见的方案是:
满足数据更新+批量分析的大数据架构
为了让数据平台同时具备随机读写和批量分析能力,传统的做法是采用混合架构(hybrid architecture),也就是我们常说的T+1的方式,数据实时更新在Hbase,第二天凌晨同步到HDFS做离线分析。
如上图所示,数据实时写入 Hbase,实时的数据更新也在 Hbase 完成,为了应对 OLAP 需求,我们定时(通常是 T+1 或者 T+H)将 Hbase 数据写成静态的文件(如:Parquet)导入到 OLAP 引擎(如:HDFS),第二天凌晨同步到HDFS做离线分析。这一架构能满足既需要随机读写,又可以支持 OLAP 分析的场景,但他有如下缺点:
这样的缺点很明显,时效性差,数据链路长,过程复杂,开发成本高。
- 架构复杂。从架构上看,数据在 Hbase、消息队列、HDFS 间流转,涉及环节太多,运维成本很高。并且每个环节需要保证高可用,都需要维护多个副本,存储空间也有一定的浪费。最后数据在多个系统上,对数据安全策略、监控等都提出了挑战。
- 时效性低。数据从 Hbase 导出成静态文件是周期性的,一般这个周期是一天(或一小时),在时效性上不是很高。
- 难以应对后续的更新。真实场景中,总会有数据是「延迟」到达的。如果这些数据之前已经从 Hbase 导出到 HDFS,新到的变更数据就难以处理了,一个方案是把原有数据应用上新的变更后重写一遍,但这代价又很高。
- 引入实时计算框架如Sparkstreaming、Strom、Flink等计算固化的流量指标,但是这种方式只能满足基础的指标如PV、UV、引导成交等等,且不支持维度下钻,如果想要新增一种维度就必须走需求评审、开发排期等环节,从需求提出到业务方能看到数据的时间比较久,而且无法满足用户在大促期间的临时取数需求。
- 将Kafka流量流接入Hive,并开发小时级别的离线Hive任务,由于是明细数据,这种方式支持维度的下钻,对于分析师、产品运营也比较友好。但是小时级别的任务会占用集群大量的资源,如果上一小时的任务没有在一小时内跑完就会造成任务追尾的情况出现,这样下去集群只会越来越堵,目前团队内部已经禁止开发新的小时级任务。
其实第二种方式已经能够满足多维度看数据的需求了,只是基于Hive跑小时级任务实在太消耗资源,而且有延迟的风险,那么有没有一种存储技术具有高吞吐量连续读取数据的能力,而且也支持低延迟的随机读写呢?一般来说这2个要求是互斥的,但是Kudu却在这2者之间做了很好的平衡。
为了解决上述架构的这些问题,KUDU 应运而生。KUDU 的定位是 「Fast Analytics on Fast Data」,是一个既支持随机读写、又支持 OLAP 分析的大数据存储引擎
KUDU 定位
从上图可以看出,KUDU 是一个「折中」的产品,在 HDFS 和 Hbase 这两个偏科生中平衡了随机读写和批量分析的性能。从 KUDU 的诞生可以说明一个观点:底层的技术发展很多时候都是上层的业务推动的,脱离业务的技术很可能是「空中楼阁」。
1、数据模型,有表结构
KUDU 的数据模型与传统的关系型数据库类似,有表结构,需定义Schema信息,一个 KUDU 集群由多个表组成,每个表由多个字段组成,一个表必须指定一个由若干个(>=1)字段组成的主键,如下图:
KUDU 数据模型
KUDU 表中的每个字段是强类型的,而不是 Hbase 那样所有字段都认为是 bytes。这样做的好处是可以对不同类型数据进行不同的编码,节省空间。需在写入数据前定义好每一列的类型,(方便做类似于parquet的列式存储)。同时,因为 KUDU 的使用场景是 OLAP 分析,有一个数据类型对下游的分析工具也更加友好。
2、支持随机读写,核心 API
KUDU 的对外 API 主要分为写跟读两部分。其中写包括:Insert、Update、Delete,所有写操作都必须指定主键;读 KUDU 对外只提供了 Scan 操作,Scan 时用户可以指定一个或多个过滤器,用于过滤数据。
3、一致性模型,事务管理(ACID),事务4个特征
跟大多数关系型数据库一样,KUDU 也是通过 MVCC(Multi-Version Concurrency Control)来实现内部的事务隔离。KUDU 默认的一致性模型是 Snapshot Consistency,即客户端可以一致的访问到某个时间点的一个快照。
如果有更高的外部一致性(external consistency)需求,KUDU 目前还没有实现,不过 KUDU 提供了一些设计方案。这里先介绍下外部一致性,它是指:多个事务并发执行达到串行效果,并且保证修改时间戳严格按照事务发生先后顺序,即如果有先后两个事务 A、B, A 发生在 B 之前,那么对于客户端来说,要么看到 A,要么看到 A、B,不会只看到 B 而看不到 A。KUDU 提供了两个实现外部一致性的方案:
- 方案一:在各个 Client 之间传播带有时间戳的 token,大致思路是: Client 提交完一个写请求后,生成一个带时间戳的 token,然后把这个 token 传播给其他客户端,其他客户端请求的时候可以带上这个 token。
- 方案二:类似 Google Spanner 的方案,通过 commit-wait 机制实现外部一致性。
这里我们衍生介绍下 Google Spanner 是如何实现分布式事务的外部一致性的。首先我们先明确下分布式事务外部一致性这个问题的由来。首先,在数据库中,我们出于性能考虑,一般我们对读不加排他锁,只对写进行加排他锁,这就会带来一个问题,数据在读取的时候可能正在被修改,导致同一事务中多次读取到的数据可能不一致,为了解决这个问题,我们引入了 MVCC。在单机系统中,通过 MVCC 就能解决外部一致性问题,因为每个事务都有一个在本机生成的一个时间戳,根据事务的时间戳先后,我们就能判断出事务发生的先后顺序。但是在分布式系统中,要实现外部一致性就没有那么简单了,核心问题是事务在不同的机器上执行,而不同机器的本地时钟是有误差的,因此就算是真实发生的事务顺序是 A->B,但是在事务持久化的时候记录的时间戳可能是 B < A,这时如果一个事务 C 来读取数据,可能只读到 B 而没有读到 A。从上面的分析我们可以发现,分布式系统中保证事务的外部一致性的核心是一个精确的事务版本(时间戳),而最大的难点也在这里,计算机上的时钟不是一个绝对精确的时间,它跟标准时间是有一定的随机的误差的,导致分布式系统中不同机器之间的时间有偏差。Google Spanner 的解决思路是把不同机器的误差时间控制在一个很小的确定的范围内,再配合 commit-wait 机制来实现外部一致性。
控制时间误差的方案称为 TrueTime,它通过硬件(GPS 和原子钟)和软件结合,保证获取到的时间在较小误差(±4ms)内绝对正确,具体的实现这里就不展开了,有兴趣的同学可以自行找资料研究。TrueTime 对外只提供 3 个 API,如下:TrueTime API
这里最主要的 API 是 TT.now(),它范围当前绝对精确时间的上下界,表示当前绝对精确时间在 TT.now().earliest 和 TT.now().latest 之间。
有了一个有界误差的 TrueTime 后,就可以通过 commit-wait 机制来实现外部一致性了,具体的方案如下:
commit-wait 过程
如上图所示,在一个事务开始获取锁执行后,生成事务的时间版本 s=TT.now().latest,然后开始执行事务的具体操作,但是一个事务的结束并不只由事务本身的时间消耗决定,它还要保证后续的事务时间版本不会早于自己,因此,事务需要等待直到 TT.now().earliest > s 后,才算真正结束。根据整个 commit-wait 过程我们可以知道,整个事务提交过程需要等待 2 倍的平均误差时间(ε),TrueTime 的平均误差时间是 4 ms,因此一次 commit-wait 需要至少 8 ms。
之前我们提到,KUDU 也借鉴 Spanner 使用 commit-wait 机制实现外部一致性,但是 commit-wait 强依赖于 TrueTime,而 TrueTime 需要各种昂贵的硬件设备支持,目前 KUDU 通过纯软件算法的方式来实现时钟算法,为 HybridTime,但这个方案时间误差较大,考虑到 commit-wait 需要等待 2ε 时间,因此误差一大实际场景使用限制就很多了。
整体架构
KUDU 中存在两个角色
- Mater Server :负责集群管理、元数据管理等功能 (Master节点)
- Tablet Server:负责数据存储,并提供数据读写服务(可对比理解Hbase中的RegionServer角色),一个 tablet server 存储了table表的tablet 和为 tablet 向 client 提供服务。
- Tablet:一个 tablet 是一张 table连续的segment,tablet是kudu表的水平分区(Hbase的region)
为了实现分区容错性,跟其他大数据产品一样,对于每个角色,在 KUDU 中都可以设置特定数量(一般是 3 或 5)的副本。各副本间通过 Raft 协议来保证数据一致性。Raft 协议与 ZAB 类似,都是 Paxos 协议的工程简化版本
KUDU Client 在与服务端交互时,先从 Master Server 获取元数据信息,然后去 Tablet Server 读写数据
数据分区策略
与大多数大数据存储引擎类似,KUDU 对表进行横向分区,KUDU 表会被横向切分存储在多个 tablets 中。不过相比与其他存储引擎,KUDU 提供了更加丰富灵活的数据分区策略。
一般数据分区策略主要有两种,
一、一种是 Range Partitioning,按照字段值范围进行分区,Hbase 就采用了这种方式,如下图:
Range Partitioning 的优势是在数据进行批量读的时候,可以把大部分的读变成同一个 tablet 中的顺序读,能够提升数据读取的吞吐量。并且按照范围进行分区,我们可以很方便的进行分区扩展。其劣势是同一个范围内的数据写入都会落在单个 tablet 上,写的压力大,速度慢。
二、另一种分区策略是 Hash Partitioning,按照字段的 Hash 值进行分区,Cassandra 采用了这个方式,见下图:
Hash Partitioning
与 Range Partitioning 相反,由于是 Hash 分区,数据的写入会被均匀的分散到各个 tablet 中,写入速度快。但是对于顺序读的场景这一策略就不太适用了,因为数据分散,一次顺序读需要将各个 tablet 中的数据分别读取并组合,吞吐量低。并且 Hash 分区无法应对分区扩展的情况。
各种分区策略的优劣对比见下图:
各种分区策略的优劣
既然各分区策略各有优劣,能否将不同分区策略进行组合,取长补短呢?这也是 KUDU 的思路,KUDU 支持用户对一个表指定一个范围分区规则和多个 Hash 分区规则,如下图:
组合分区策略
存储设计目标
- 快速的列扫描
- 低延迟的随机更新
- 稳定的性能表现
存储方式
KUDU 是一个列式存储的存储引擎,其数据存储方式如下:
KUDU 存储
列式存储的数据库很适合于 OLAP 场景,其特点如下:
- 优势
- 查询少量列时 IO 少,速度快
- 数据压缩比高
- 便于查询引擎性能优化:延迟物化、直接操作压缩数据、向量化执行
- 劣势
- 查询列太多时性能下降(KUDU 建议列数不超过 300 )
- 不适合 OLTP 场景
存储实现,数据存储结构
一张表table会分成若干个tablet,每个tablet包括metaData元信息及若干个RowSet,RowSet包含一个MemRowSet及若干个DiskRowSet,DiskRowSet中包含一个BloomFile、Ad_hoc Index、baseData、DeltaMem及若干个RedoFile和UndoFile(UndoFile一般情况下只有一个)。
• MemRowSet:用于新数据insert及已在MemRowSet中的数据的更新,一个MemRowSet写满后会将数据刷到磁盘形成若干个DiskRowSet。每次到达32M生成一个DiskRowSet。
• DiskRowSet:用于老数据的变更(mutation),后台定期对DiskRowSet做compaction,以删除没用的数据及合并历史数据,减少查询过程中的IO开销。
• BloomFile:根据一个DiskRowSet中的key生成一个bloom filter,用于快速模糊定位某个key是否在DiskRowSet中存在。
• Ad_hocIndex:是主键的索引,用于定位key在DiskRowSet中的具体哪个偏移位置。
• baseData是MemRowSet flush下来的数据,按列存储,按主键有序。
• UndoFile是基于baseData之前时间的历史数据,通过在baseData上apply UndoFile中的记录,可以获得历史数据。
• RedoFile是基于baseData之后时间的变更(mutation)记录,通过在baseData上apply RedoFile中的记录,可获得较新的数据。
• DeltaMem用于DiskRowSet中数据的变更mutation,先写到内存中,写满后flush到磁盘形成RedoFile。
kudu自己存储数据不依赖与HDFS存储;不依赖于zookeeper,将它的功能集成进了自身的TMaster;
与其他大数据存储引擎类似,KUDU 的存储也是通过 LSM 树(Log-Structured Merge Tree)来实现的。LSM 的中心思想就是将随机写转换为顺序写来大幅提高写入操作的性能,但是牺牲了部分读的性能。
KUDU 的最小存储单元是 RowSets,KUDU 中存在两种 RowSets:MemRowSets、DiskRowSets,数据先写内存中的 MemRowSet,MemRowSet 满了后刷到磁盘成为一个 DiskRowSet,DiskRowSet 一经写入,就无法修改了。见下图:
当然上面只是最粗粒度的一个写入过程,为了解释 KUDU 的为什么既能支持随机读写,又能支持大数据量的 OLAP 分析,我们需要更进一步进行解剖分析。我们需求探究的主要两个问题是:
- 如何应对数据变更?
- 如何优化读写性能以满足 OLAP 场景?
应对数据变更
首先上面我们讲了,DiskRowSet 是不可修改了,那么 KUDU 要如何应对数据的更新呢?在 KUDU 中,把 DiskRowSet 分为了两部分:base data、delta stores。base data 负责存储基础数据,delta stores负责存储 base data 中的变更数据。整个数据更新方案如下:
应对数据更新
如上图所示,数据从 MemRowSet 刷到磁盘后就形成了一份 DiskRowSet(只包含 base data),每份 DiskRowSet 在内存中都会有一个对应的 DeltaMemStore,负责记录此 DiskRowSet 后续的数据变更(更新、删除)。DeltaMemStore 内部维护一个 B-树索引,映射到每个 row_offset 对应的数据变更。DeltaMemStore 数据增长到一定程度后转化成二进制文件存储到磁盘,形成一个 DeltaFile,随着 base data 对应数据的不断变更,DeltaFile 逐渐增长。
优化读写性能
首先我们从 KUDU 的 DiskRowSet 数据结构上分析:
DiskRowSet 数据结构
从上图可知,在具体的数据(列数据、变更记录)上,KUDU 都做了 B- 树索引,以提高随机读写的性能。在 base data 中,KUDU 还针对主键做了好几类索引(实际上由于 delta store 只记录变更数据,base data 中对主键的索引即本 DiskRowSet 中全局的主键索引):
- 主键范围索引:记录本 DiskRowSet 中主键的范围,用于粗粒度过滤一些主键范围
- 布隆过滤器:通过主键的布隆过滤器来实现不存在数据的过滤
- 主键索引:要精确定位一个主键是否存在,以及具体在 DiskRowSet 中的位置(即:row_offset),通过以 B-树为数据结构的主键索引来快速查找。
随着时间的推移,KUDU 中的小文件会越来越多,主要包括各个 DiskRowSet 中的 base data,还有每个 base data 对应的若干份 DeltaFile。小文件的增多会影响 KUDU 的性能,特别是 DeltaFile 中还有很多重复的数据。为了提高性能,KUDU 会进行定期 compaction,compaction 主要包括两部分:
- DeltaFile compaction:过多的 DeltaFile 影响读性能,定期将 DeltaFile 合并回 base data 可以提升性能。在通常情况下,会发生频繁变更的字段是集中在少数几个字段中的,而 KUDU 是列式存储的,因此 KUDU 还在 DeltaFile compaction 时做了优化,文件合并时只合并部分变更列到 base data 中对应的列。
- DiskRowSet compaction:除了 DeltaFile,定期将 DiskRowSet 合并也能提升性能,一个原因是合并时我们可以将被删除的数据彻底的删除,而且可以减少同样 key 范围内数据的文件数,提升索引的效率。
当用户的查询存在列的过滤条件时,KUDU 还可以在查询时进行 延迟物化(Lazy Materialization )来提升性能。举例说明,现在我们有这样一张表:
用户的 SQL 是这样的:
SELECT * FROM tb WHERe sex=‘男’ ADN age > 20
KUDU 中数据查询过程是这样的:
- 扫描 sex 列,过滤出要查询的行 [1,3]
- 扫描 age 列,过滤出要查询的行 [3,4]
- 过滤条件相交,得到 3
- 真正读取 id=3 行对应的所有列信息,组装
上述查询中,KUDU 真正需要去物理读取的数据只有 id=3 这一行,这样就减少了 IO 数量。
读写过程
数据写过程
如上图,当 Client 请求写数据时,先根据主键从 Mater Server 中获取要访问的目标 Tablets,然后到依次对应的 Tablet 获取数据。因为 KUDU 表存在主键约束,所以需要进行主键是否已经存在的判断,这里就涉及到之前说的索引结构对读写的优化了。一个 Tablet 中存在很多个 RowSets,为了提升性能,我们要尽可能地减少要扫描的 RowSets 数量。首先,我们先通过每个 RowSet 中记录的主键的(最大最小)范围,过滤掉一批不存在目标主键的 RowSets,然后在根据 RowSet 中的布隆过滤器,过滤掉确定不存在目标主键的 RowSets,最后再通过 RowSets 中的 B-树索引,精确定位目标主键是否存在。如果主键已经存在,则报错(主键重复),否则就进行写数据(写 MemRowSet)。
数据更新过程
数据更新的核心是定位到待更新数据的位置,这块与写入的时候类似,就不展开了,等定位到具体位置后,然后将变更写到对应的 delta store 中。
数据读过程
如上图,数据读取过程大致如下:先根据要扫描数据的主键范围,定位到目标的 Tablets,然后读取 Tablets 中的 RowSets。在读取每个 RowSet 时,先根据主键过滤要 scan 范围,然后加载范围内的 base data,再找到对应的 delta stores,应用所有变更,最后 union 上 MenRowSet 中的内容,返回数据给 Client。
这里介绍一个小米使用 KUDU 的案例。具体的业务场景是这样的:
收集手机App和后台服务发送的 RPC 跟踪事件数据,然后构建一个服务监控和问题诊断的工具。
- 高写入吞吐:每天大于200亿条记录
- 为了能够尽快定位和解决问题,要求系统能够查询最新的数据并能快速返回结果
- 为了方便问题诊断,要求系统能够查询/搜索明细数据(而不只是统计信息)
在使用 KUDU 前,小米的架构是这样的:
一部分源系统数据是通过Scribe(日志聚合系统)把数据写到HDFS,另一部分源系统数据直接写入Hbase。然后通过Hive/MR/Spark作业把两部分数据合并,给离线数仓和 OLAP 分析。
在使用 KUDU 后,架构简化成了:
从上图我们可以看到,所有的数据存储都集中到的 KUDU 一个上,减少了整体的架构复杂度,同时,也大大提升了实时性。
链接:https://www.jianshu.com/p/93c602b637a4
spark-shell --jar kudu-spark.jar XXX
spark对kudu表的创建
定义kudu的表需要分成5个步骤:
1:提供表名
2:提供schema
3:提供主键
4:定义重要选项;例如:定义分区的schema
5:调用create Table api
spark删除kudu表
1.安装部署完之后的kudu页面
2. kudu语法示例
建表语句:
通过Impala使用Kudu可以新建内部表和外部表两种。
- 内部表(Internal Table):事实上是属于Impala管理的表,当删除时会确确实实地删除表结构和数据。在Impala中建表时,默认建的是内部表。
- 外部表(External Table):不由Impala管理,当删除这个表时,并不能从源位置将其删除,只是接触了Kudu到Impala之间对于这个表的关联关系
建表语句中,默认第一个就是Primary Key,是个not null列,在后面的中列出,这边至少写一个。
- storage_handler:选择通过Impala访问kudu的机制,必须填成
- kudu.table_name:Impala为Kudu建(或者关联的)的表名
- kudu.master_addresses:Impala需要访问的Kudu master列表
- kudu.key_columns:Primary key列表
插入数据
Impala默认一次同时最多插入1024条记录,作为一个batch
更新数据
删除数据
修改表属性
3 表设计
kudu的表具有类似于传统RDBMS中的表的数据结构。schema设计对于实现Kudu的最佳性能和操作稳定性至关重要。业务场景的多变,对于table来说并不存在一种最好的schema设计。大部分情况下,创建kudu的表需要考虑三个问题:
- 列的设计(column design)
- 主键设计(primary key design)
- 分区设计(partitioning design)
比较好的Schema设计应该满足一下要求:
- 数据的分布和存储的方式满足:读取和写入操作都可以均匀的分散到tablet servers上(受分区影响)
- tablet将以均匀,可预测的速度增长,并且tablet server的负载将随着时间的推移保持稳定(受分区影响最大)
- 扫描将读取完成查询所需的最少数据量。(这主要受主键设计的影响,但分区也通过分区修剪发挥作用
从kudu的官方文档中可以看到spark 如何集成kudu的。从文档中的demo可以看到,kudu表的创建只能调用NOSQL API 来实现,无法通过spark sql直接创建一张kudu表。spark sql查询kudu表也是先注册一张临时表后,才能用sql 语句查询的。
那么有没有方法在spark sql上直接提交一个Create DDL语句来创建一张关联kudu的表呢? 答案是:可以,通过 spark sql的USING OPTIONS语法实现,该语法是spark sql用来创建外部数据源的表的,我们可以通过该语法创建kudu数据源的表。
假设我们已经通过api 创建了一张kudu表kudu_test,impala建kudu表
接下来我们要通过spark sql 去创建一张关联表,spark_kudu_test。
这样我们就能够通过spark sql去操作kudu的数据了。
从MySql导出数据到本地txt
保存到hdfs中目录下
在hive shell中创建hive表
在impala-shell下创建kudu表
将hive表中的内容插入kudu表