跳数索引:后起新秀ClickHouse
在之前的学习进程中,我们已然领略到了 Elasticsearch 那强大的功能特性。然而,当进行技术选型之际,价格这一因素的影响力不容小觑。Elasticsearch 在使用过程中确实便捷,但它会造成大量的硬件资源消耗,即便是财力雄厚的公司,每月面对服务器账单时,也难免会感到一丝心疼。
而 ClickHouse 作为新生代的 OLAP,尝试运用了众多饶有趣味的实现方式。尽管它依旧存在诸多不足,像是不支持数据更新、动态索引表现欠佳、查询优化颇具难度以及分布式需手动设计等问题。不过,因其架构简洁,整体成本相对低廉,逐渐赢得了众多团队的认可,不少互联网企业纷纷加入其社区,持续对 ClickHouse 进行改进。
ClickHouse 属于列式存储数据库
常用于写多读少的场景。它提供了灵活多样的分布式存储引擎,并且具备分片、集群等多种模式,可供我们在搭建时依据需求进行选择。
并行能力 CPU 吞吐和性能
我先来谈谈在真正使用 ClickHouse 时,最让我感到出乎意料的地方。我们不妨选取一个熟悉的参照物 ——MySQL。MySQL 在处理一个 SQL 请求时,只能利用一个 CPU。然而,ClickHouse 则会充分发挥多核的优势,对本地大量数据进行快速计算,所以 ClickHouse 具备更高的数据处理能力(2~30G/s,未压缩数据),但这也致使它的并发能力不高,因为一个请求就可能耗尽所有系统资源。
我们刚开始使用 ClickHouse 的时候,常常会遇到这样的情况:当查询几年的用户行为时,一个 SQL 就会让整个 ClickHouse 陷入卡顿,几分钟都没有响应。官方建议 ClickHouse 的查询 QPS 限制在 100 左右。如果我们的查询索引设置得当,几十上百亿的数据可以在 1 秒内完成数据统计并返回。作为参考,若换成 MySQL,这个时间至少需要一分钟以上;而如果 ClickHouse 的查询设计不佳,可能等半小时还未计算完毕,甚至会出现卡死的现象。
批量写入优化
ClickHouse 的客户端驱动颇具趣味。客户端设有多个写入数据缓存,当我们进行批量插入数据时,客户端会将我们要 insert 的数据先在本地缓存一段时间,直至积累到足够配置的 block_size 后,才会将数据批量提交到服务端,以此提升写入性能。倘若我们对实时性要求颇高,那么这个 block_size 可以设置得小一些,当然,代价就是性能会有所变差。
为优化高并发写服务,除了客户端所做的合并工作,ClickHouse 的引擎 MergeTree 也进行了类似的操作。正因如此,单个 ClickHouse 批量写性能能够达到 280M/s(受硬件性能及输入数据量影响)。MergeTree 采用了批量写入磁盘、定期合并的方式(batch write – merge),这种设计不禁让我们想起写性能极强的 RocksDB。实际上,ClickHouse 刚问世时,并未使用内存进行缓存,而是直接写入磁盘。在最近两年,ClickHouse 进行了更新,才实现了类似内存缓存及 WAL 日志。所以,如果你使用 ClickHouse,建议搭配使用高性能 SSD 作为写入磁盘存储。
事实上,OLAP 有两种不同的数据来源:其一来自业务系统,其二来自大数据。来自业务系统的数据,属性字段较多,但平时更新量并不大。在这种情况下,使用 ClickHouse 常常是为了进行历史数据的筛选和属性共性的计算。而来自大数据的数据通常会有很多列,每列代表不同用户行为,数据量普遍较大。由于两种情况的数据量不同,优化方式自然也各异,具体 ClickHouse 是如何对这两种方式进行优化的呢?我们结合后面的图片继续分析:
当我们批量输入的数据量小于 min_bytes_for_wide_part 设置时,会按 compact part 方式落盘。在这种方式下,落盘的数据会被放置到一个 data.bin 文件中,在 merge 时会拥有良好的写效率,此方式适用于小量业务数据筛选使用。
当我们批量输入的数据量超过了配置规定的大小时,会按 wide part 方式落盘。落盘数据时会按字段生成不同的文件。该方式适用于字段较多的数据,merge 相对会慢一些,但是对于指定参与计算列的统计计算,其并行吞吐写入和计算能力会更强,适合分析指定小范围的列计算。
可以看到,这两种方式对数据的存储和查询极具针对性。可见,字段的多少、每次的更新数据量、统计查询时参与的列个数等因素,都会影响到我们服务的效率。
当我们大部分数据都是小数据时,一条数据拆分成多个列会有些浪费磁盘 IO,而且由于是小量数据,我们也不会为其分配太多机器,这种情况推荐使用 compact parts 方式。当我们的数据列很大,需要对某几个列做数据统计分析时,wide part 的列存储则更具优势。
ClickHouse 如何提高查询效率
我们能够察觉到,数据库的存储情况和数据的使用方式以及查询操作紧密相连。不过呢,这种定期进行落盘的操作,虽说在写性能方面表现出色,可它却生成了大量的 data part 文件,这对查询效率会产生不小的影响。
那么,ClickHouse 究竟是怎样来提高查询效率的呢?接下来我们再深入剖析一下。新写入的 parts 数据是被存放在了 data parts 文件夹之中的,而且一旦数据被写入其内容便不会再发生更改。通常而言,data part 的文件夹名格式是 partition(分区)_min_block_max_block_level。为了有效提升查询效率,ClickHouse 会针对 data part 定期开展 merge 合并操作。
稀疏索引与跳数索引
ClickHouse 的查询功能离不开索引支持。Clickhouse 有两种索引方式,一种是主键索引,这个是在建表时就需要指定的;另一种是跳表索引,用来跳过一些数据。这里我更推荐我们的查询使用主键索引来查询。
主键索引
对于 ClickHouse 而言,只有当表使用主键索引的时候,数据查询才能够具备更为出色的性能。这是因为数据以及索引会依据主键来完成排序存储,如此一来,借助主键索引开展数据查询时,便能够迅速地对数据加以处理并返回相应的结果。
ClickHouse 所采用的是 “左前缀查询” 方式,即先通过索引以及分区快速地将数据范围进行缩小,之后再展开遍历计算。不过需要注意的是,这里的遍历计算是在多节点、多 CPU 的环境下并行处理的。
那么,ClickHouse 到底是怎样进行数据检索的呢?这就要求我们首先去了解一下 data parts 文件夹内部的主要数据组成情况,具体可参照下图:
接下来,让我们结合图示,依照从大到小的顺序来了解一下 data part 的目录结构吧。
在 data parts 文件夹当中,bin 文件承担着保存数据的任务,这里面存放着一个或者多个字段的数据呢。进一步对 bin 文件进行拆分的话,就会发现它里面是由多个 block 数据块所构成的。要知道,block 可是磁盘交互读取时的最小单元哦,它的大小是由 min_compress_block_size 这一设置来决定的。
再把目光聚焦到 block 内部的结构上,这里面保存着多个 granule(颗粒),而 granule 则是数据扫描的最小单位呢。每个 granule 默认情况下会保存 8192 行数据,并且其中的第一条数据就是主键索引数据哦。
至于 data part 文件夹内的主键索引,它里面保存的是经过排序后的所有主键索引数据,而这个排序顺序在创建表的时候就已经被明确指定好了。
为了能够加快查询的速度,data parts 内的主键索引(也就是我们所说的稀疏索引)是会被加载到内存当中的。不仅如此,为了更好地配合快速查找到数据在磁盘中的具体位置,ClickHouse 在 data part 文件夹中,还会保存多个按照字段名来命名的 mark 文件呢。这个 mark 文件所保存的内容包括 bin 文件中压缩后的 block 的 offset,以及 granularity 在解压后 block 中的 offset 哦。整体的查询效果可以参照下图:
具体查询过程是这样的,我们先用二分法查找内存里的主键索引,定位到特定的 mark 文件,再根据 mark 查找到对应的 block,将其加载到内存,之后在 block 里找到指定的 granule 开始遍历加工,直到查到需要的数据。同时由于 ClickHouse 允许同一个主键多次 Insert 的,查询出的数据可能会出现同一个主键数据出现多次的情况,需要我们人工对查询后的结果做去重。
跳数索引
或许你已经察觉到了,在 ClickHouse 里,除了主键之外,是不存在其他索引的。这就意味着,那些无法运用主键索引来进行的查询统计,就只能通过扫描全表的方式去完成计算了。然而,数据库通常每天都要保存几十亿甚至几百亿的数据量呀,要是采用全表扫描的方式,其性能表现可就太差劲了。
所以呢,在面对性能方面的抉择时,ClickHouse 采用了一种反向的思维,特意设计出了跳数索引,以此来减少在遍历 granule 过程中所造成的资源浪费。以下是几种常见的跳数索引方式:
-
min_max:它主要是用于辅助数字字段的范围查询,会保存当前矩阵内的最大数和最小数。通过这种方式,在进行相关查询时,就能依据所保存的最大最小数信息,快速判断该范围是否符合查询条件,进而对不符合条件的部分进行排除,节省查询资源。
- set :可以把它理解成是将字段内所有出现过的枚举值都一一罗列出来,并且还能设置要取多少条。这样在查询涉及到该字段的枚举值相关内容时,就可以根据已列出的枚举值情况,迅速筛选出符合条件的部分,排除掉那些明显不符合的,从而加快查询速度。
-
Bloom Filter:它是利用 Bloom Filter 的原理来确认数据有没有可能存在于当前块当中。当进行查询时,通过 Bloom Filter 的判断,能够快速知晓数据是否有可能在当前块,如果不太可能,那就可以直接跳过对该块的进一步查询操作,有效节省了查询时间。
-
func :这种跳数索引支持很多在 where 条件内出现的函数。具体的详细内容呀,你可以到官网去查看哦。
这些跳数索引会依据上面所提到的类型以及与之对应的字段,被保存在 data parts 文件夹内。需要注意的是,跳数索引并不是通过减少数据搜索的范围来实现查询加速的,而是通过排除掉那些不符合筛选条件的 granule,以此达到加快我们查询速度的目的。
好,我们回头来整体看看 ClickHouse 的查询工作流程:
- 根据查询条件,查询过滤出需要读取的 data part 文件夹范围;
- 根据 data part 内数据的主键索引、过滤出要查询的 granule;
- 使用 skip index 跳过不符合的 granule;
- 范围内数据进行计算、汇总、统计、筛选、排序;
- 返回结果。
在此,我要补充说明一下相关情况哦。在前面所提及的那五个步骤当中呢,唯有第四步里的几个操作是并行开展的,其余的流程可都是串行进行的哟。
当实际运用 ClickHouse 之后呀,大家往往会察觉到一个问题,那就是对它进行索引查询优化实在是太难啦,动不动就会出现扫描全表的情况,这究竟是为何呢?
主要原因在于,我们所拥有的大部分数据,其特征表现得并不是特别明显,而且所建立起来的索引,其区分度也是不够的呀。这就致使我们写入的数据,在每个颗粒内部的区分度并不大,如此一来,通过稀疏索引进行查询时,根本没办法借助索引排除掉大多数的颗粒,所以最终 ClickHouse 就只能选择扫描全表来开展计算啦。
另外还有一方面的因素哦。由于目录数量过多,存在多份数据同时分散在多个 data parts 文件夹之内的情况,ClickHouse 需要把所有 date part 的索引都加载起来,然后挨个进行查询,这无疑也消耗了大量的资源呀。
正是上述这两个方面的原因,才导致了 ClickHouse 在查询优化方面面临着诸多困难呢。当然啦,如果我们所输入的数据极具特征,并且在插入这些特征数据的时候,能够依照特征的排序顺序来进行插入操作的话,那么其性能表现或许就会更好一些哦。
实时统计
类似我们之前讲过的内存计算,ClickHouse 能够将自己的表作为数据源,再创建一个 Materialized View 的表,View 表会将数据源的数据通过聚合函数实时统计计算,每次我们查询这个表,就能获得表规定的统计结果。下面我给你举个简单例子,看看它是如何使用的:
-- 创建数据源表
CREATE TABLE products_orders
(
prod_id UInt32 COMMENT '商品',
type UInt16 COMMENT '商品类型',
name String COMMENT '商品名称',
price Decimal32(2) COMMENT '价格'
) ENGINE = MergeTree()
ORDER BY (prod_id, type, name)
PARTITION BY prod_id;
--创建 物化视图表
CREATE MATERIALIZED VIEW product_total
ENGINE = AggregatingMergeTree()
PARTITION BY prod_id
ORDER BY (prod_id, type, name)
AS
SELECT prod_id, type, name, sumState(price) AS price
FROM products_orders
GROUP BY prod_id, type, name;
-- 插入数据
INSERT INTO products_orders VALUES
(1,1,'过山车玩具', 20000),
(2,2,'火箭',10000);
-- 查询结果
SELECT prod_id,type,name,sumMerge(price)
FROM product_total
GROUP BY prod_id, type, name;
在数据源被插入到 ClickHouse 数据源表,进而生成 data parts 数据的这个过程中,就会触发 View 表的相关操作哦。
View 表会依据我们在创建它之时所设置好的聚合函数,针对插入进来的数据展开批量的聚合处理呢。每一批数据经过处理后,都会生成一条具体的聚合统计结果,随后这些结果会被写入到磁盘当中。
而当我们要对统计数据进行查询的时候呀,ClickHouse 并不会直接使用之前生成的那些初步聚合结果,而是会对这些数据再次进行聚合汇总操作,只有经过这样的再次处理,才能获取到最终的结果,并将其对外进行展示呢。
如此一来,便实现了指标统计的功能啦。不得不说,这种实现方式是非常契合 ClickHouse 的引擎思路的,确实很有特色呀。
分布式表
最后呢,我要额外给大家分享一个关于 ClickHouse 的新特性哦。不过需要说明的是,这部分的实现目前还不是很成熟呢,所以我们主要把关注点放在这个特性所支持的功能方面呀。
首先来说说 ClickHouse 的分布式表吧。它和 Elasticsearch 可不一样哦,Elasticsearch 能够全智能地帮我们完成分片调度的工作,而 ClickHouse 的分布式表则需要研发人员手动去进行设置和创建呢。虽然官方也提供了分布式自动创建表以及分布式表的语法,但是我个人并不是很推荐大家使用哦。这是因为就目前来讲,资源的调配还是更倾向于人工规划的,ClickHouse 自身并不会自动去进行合理规划呀。要是使用类似的命令的话,很可能会出现用 100 台服务器创建出 100 个分片的情况,这可就太浪费资源啦。
那么,使用 ClickHouse 的分布式表具体要怎么做呢?我们得先在不同的服务器上手动去创建具有相同结构的分片表哦,同时呢,还需要在每个服务器上创建分布式表映射,这样一来,在每台服务器上就都能够访问这个分布式表啦。
这里要特别提一下哦,我们通常所理解的分片概念可能是同一个服务器可以存储多个分片,但 ClickHouse 可不是这样的哦,它规定一个表在一个服务器里只能存在一个分片呢。
接下来讲讲 ClickHouse 分布式表的数据插入方式吧,一般来说有两种哦。
第一种方式是直接对分布式表插入数据,这样的话,数据会先在本地进行保存,然后再异步转发到对应的分片当中,通过这种方式来实现数据的分发存储哦。
第二种方式呢,是由客户端根据不同的规则(比如随机、hash 等),将分片数据推送到对应的服务器上。这种方式相对来讲性能会更好一些哦,不过呢,这么做的话,客户端就需要知道所有分片节点的 IP 地址啦。很显然,这种方式在面对失败恢复的情况时是不太有利的哦。所以呢,为了能够更好地平衡高可用和性能这两方面,还是推荐大家选择前一种方式哦。
但是哦,由于各个分片为了保证高可用,会先在本地存储一份数据,然后再同步推送,这其实是挺浪费资源的呢。面对这种情况呀,我们比较推荐的方式是通过类似 proxy 服务来进行一层转发,用这种方式就可以解决节点变更以及直连分发等方面的问题啦。
再来说说主从分片的事儿吧。ClickHouse 的表是按照表来设置副本的(也就是主从同步哦),副本之间是支持同步更新或者异步同步的呢。主从分片是通过在 ZooKeeper 内的相同路径设置分布式表来实现同步的哦,这种设置方式就导致了 ClickHouse 的分片和复制会有很多种组合方式哦,比如说:一个集群内有多个子集群、一个集群整体有多个分片、客户端自行分片写入数据、分布式表代理转发写入数据等等多种方式组合在一起呢。简单来讲呀,就是 ClickHouse 支持人为去做资源共享的多租户数据服务哦。
最后呢,当我们要对服务器进行扩容的时候哦,需要手动去修改新加入集群的分片,还要创建分布式表以及本地表哦,只有这样的配置才能够实现数据的扩容呢,不过要注意哦,这种扩容方式下的数据是不会自动迁移的哦。
总结
ClickHouse 作为 OLAP 领域崭露头角的代表,其具备诸多独具匠心的设计,这些设计不仅在 OLAP 数据库界掀起了一场变革,还促使众多云厂商深入思考,参照它的理念去实现 HTAP 服务。
经过今日的详细讲解,想必大家已然明晰了 ClickHouse 的关键特性。现在让我们一同来回顾一下吧。
在数据写入方面,ClickHouse 借助分片以及内存按周期顺序落盘的方式,有效地提升了写并发的能力。就查询效率而言,它通过后台定期对 data parts 文件进行合并操作,使得查询效率得以提高。
说到索引,ClickHouse 先是利用稀疏索引来缩小检索数据时的颗粒范围,而针对那些无法通过主键进行的查询,它则采用跳数索引,以此减少遍历数据所需的数据量。此外,ClickHouse 还拥有多线程并行读取筛选的精妙设计。
正是上述这些特性相互配合,共同成就了 ClickHouse 强大的大吞吐数据查找功能。
近期,关于选择 Elasticsearch 还是 ClickHouse 更为合适的话题,引发了极为热烈的讨论。就目前的情况来看,二者之间尚未能完全决出高低优劣。
在此,我给出一些个人建议供大家参考。倘若团队拥有丰富的硬件资源,但研发人员数量相对较少,那么选择 Elasticsearch 或许是个不错的选择;要是硬件资源较为匮乏,而研发人员数量较多,这种情况下便可以考虑试用一下 ClickHouse;如果硬件资源和研发人员数量都比较少,那么建议购买云服务提供的云分布式数据库来开展相关工作。总之,具体的选择需要依据团队的实际情况进行合理决策呀。
我还特意为你整理了一张评估表格,贴在了文稿里。