前提概要
十年前,我们曾经将存储设备实时数据的数据库从 MongoDB 迁移到 HBase,当时还专门写了一篇文章记录了前后的始末:最后,我们把设备实时数据存放到了 HBase 里。
在十年内的时光里,HBase 陪伴我们走过了技术的快速增长时代,见证了数据量从几百万条到几十亿条的增长过程。期间,我们还不断总结过经验,在我们的使用基础上缝缝补补,巩固 HBase 的使用,参见曾经总结的一些文章:
然而,随着时间的推移,十年后的今天,我们终于还是要跟 HBase 说再见了,它还是到了要退休的年纪了。
HBase 很好很强大,但是……
HBase 很好很强大,非常好,在我们之前精心设计的 row key 下,HBase 的性能非常好,查询一台设备的历史数据往往能在 10ms 之内出结果,对于聚合数据,在我们的 down sampling 方案下,查询半年的数据也能在 50ms 以内出结果。陪伴我们的业务系统高效稳定地运行了十年。
然而,也到了不得不说再见的时候了。
增加 / 修改查询太复杂了
HBase 只提供了一套 Java API 的方式查询,只提供了有限的几种查询算法。每个查询都得独立编写一套 Java 代码实现查询逻辑。
每次新增或修改查询简直是一种煎熬,可能过一段时间就压根看不懂之前是怎么查数据的,光 Scan 中的 startRow 和 stopRow 就能让人头疼半天,得仔细回忆 row key 的设计,小心翼翼的反复核对 startRow 和 stopRow 的值,才能保证查询的正确性。
写过 HBase 查询的同学一定知道我在说什么。
原本的设计不够用了
随着业务的逐步发展,原本单设备历史数据查询 + down sampling 的聚合查询方案已经不够用了,我们已经开始有了更复杂的查询需求,之前总是在外部进行数据处理,无形中提高了上层应用的复杂度。
比如我们有了联合多台设备分析的需求,那么势必需要将多台设备的实时数据全部拉出来,然后进行离线计算得到结果。HBase 的查询方式已经无法满足这种需求了,因为数据量非常大,将这些数据全部取出离线计算还需要消耗大量的资源。最重要的是,以前 down sampling 的方式聚合数据无法直接参与这种复杂的计算,它的结果是不正确的(相当于先对每个变量聚合取平均值之后再参与公式计算,很明显结果是不对的)。
HBase 更新不给力,商业化公司支持度也越来越少
我们选择 HBase 的时候,正是 Hadoop 技术生态火爆的时候,Hadoop 生态圈的发展如日中天,当时市面上有商业化支持 HBase 的公司就有好几家。
最终我们选择的是 Cloudera 的 CDH 发行版,因为它提供了最完整的 Hadoop 生态支持,运维简单,最主要的是,它对于小规模集群完全免费,简直业界良心!
然而随着大数据浪潮逐步褪去,Hadoop 生态圈的热度也逐渐降低,Cloudera 也逐步放弃开源版本的支持,对于免费版本的 CDH 再也不更新了。后来随着并购潮的出现,Cloudera 也收购了 Hortonworks,合并了两家公司的产品线,于是市面上的商业支持公司也就越来越少了。
再者就是 Hadoop 生态似乎已经到了一个瓶颈期,整个生态并没有什么新的突破,HBase 的更新也越来越慢,社区活跃度也逐渐降低。
ClickHouse 的出现让人眼前一亮
在这个时候,ClickHouse 的出现让我们眼前一亮。ClickHouse 是俄罗斯最大的搜索引擎 Yandex 于 2016 年开源的数据库,它本身就在大规模数据场景下检验过,性能绝对不用担心。
在简单体验了下大规模数据量的查询在毫秒级就可以出结果后,那种惊喜的感觉溢于言表。
它使用 SQL 语法,查询方式非常简单,再也不用写复杂的 Java 代码了,只需要维护 SQL 就好了,简直是太棒了!
在国内已经有不少大公司已经成功使用 Clickhouse 了,比如 QQ 音乐,携程,新浪,字节跳动等等,用它来存储大规模数据毫无压力。
最让我感到惊讶的是,这个数据库支持 UDF (用户自定义函数),这简直是王炸。因为我们的计算需要用到很多专业的数学计算公式,这些公式往往是数据库不提供的函数。之前我们通过调用专业的数学计算库离线计算的,有了 UDF 之后,我们可以直接在 SQL 中使用这些公式进行计算了,简直是太方便了。
而且 Clickhouse 目前开发非常活跃,每周一个小版本发布,每半年一个大版本发布,社区活跃度非常高,让我们用着也放心。
综合评判之后,我们决定将设备实时数据从 HBase 迁移到 ClickHouse。由于云服务厂商(包括 Clickhouse 官方的 Clickhouse Cloud) 均不提供 UDF 功能,所以我们只能自己部署 ClickHouse 的集群来使用。
迁移过程
在设计好 Clickhouse 的表结构之后,我们写了专用程序,将 HBase 的数据可以导出,并导入到 Clickhouse,它支持按照时间范围筛选,这样可以帮助我们导出一部分数据测试,还能实现分批迁移的需求。
本地开发与设计阶段
我们首先需要设计库表结构,然后从 HBase 导出了一年的数据量,在本地离线导入旧数据进行开发和测试。
初版表设计
Clickhouse 只是用于存储设备的实时数据,其他业务数据还在关系型数据库中 (我们使用 PostgreSQL),所以我们只需要设计一个设备实时数据的表结构即可。
Clickhouse 支持大表,所以我们也没必要进行复杂的分库分表操作,所有设备的实时数据存入一张表即可。
按照关系型数据库的设计经验,我们很快设计了第一版表结构,参考 SQL 如下:
CREATE TABLE IF NOT EXISTS equipment_data (
equipment_id String NOT NULL CODEC(ZSTD(3)),
ts DateTime64(3) NOT NULL CODEC(Delta, ZSTD(3)),
metric LowCardinality(String) NOT NULL CODEC(ZSTD(3)),
metric_value Float64 NOT NULL CODEC(ZSTD(3))
) ENGINE = ReplacingMergeTree
PRIMARY KEY (equipment_id)
ORDER BY (equipment_id, metric, ts);
非常典型的一个时序数据库的设计,由于我们的绝大多数查询都是查询某个设备的某个指标的历史数据,类似于:
select * from equipment_data
where equipment_id = '123456'
and metric = 'METRIC'
and ts between '2025-01-01 00:00:00' and '2025-01-02 00:00:00';
所以按照传统的关系型数据库的设计方式,很容易设计出来如上的表结构,也就是 设备 ID 做主键,设备 ID + 指标名 + 时间戳 作为排序键。由于有去重需求,所以采用了 ReplacingMergeTree
引擎,它在 Merge parts 的过程中会按照排序键进行去重,虽然简单,但对于我们的场景足够了。
查询速度没得说,毫秒级别的查询速度,但是压缩率非常不理想。经过计算发现只有 4 倍左右的压缩率,远不及 HBase 20 倍压缩率那么高。即使调整多次 CODEC 也无济于事,经过分析发现是 metric_value
的数据关联度非常低,导致整体压缩率不高。
第二次表设计
后来在扫官方 issue 的时候无意发现这一条: #55954#discussioncomment-7362995,才让我恍然大悟,改用了一种看似非常奇怪的排序键设计:
CREATE TABLE IF NOT EXISTS equipment_data (
equipment_id String NOT NULL CODEC(ZSTD(3)),
ts DateTime64(3) NOT NULL CODEC(Delta, ZSTD(3)),
metric LowCardinality(String) NOT NULL CODEC(ZSTD(3)),
metric_value Float64 NOT NULL CODEC(ZSTD(3))
) ENGINE = ReplacingMergeTree
PRIMARY KEY (toStartOfMonth(ts), equipment_id)
ORDER BY (toStartOfMonth(ts), equipment_id, ts, metric);
这种看似反常规的排序键设计 (一般常见的排序键设计原则是低基数的字段在前,高基数的字段在后,以提高压缩率),实际却非常有效。因为 metric_value
的数据关联度非常低 (高基数),放到后面导致压缩率严重拖后腿,所以人为调整下,将它提前,而 metric
本身基数非常低,所以即使排序键不放前面也不会降低太多的压缩率,反而将整体的数据关联性大幅提升,所以总体压缩率直接起飞,达到了惊人的 60 倍压缩,只需要 HBase 的三分之一存储空间!
虽然相比最早版本的 schema,同样的 SQL 执行速度变慢(大约为 100ms 左右出结果),但是极大的提高了压缩率,这个损耗我们认为是非常值得的,它虽然牺牲了一些查询速度,但是大幅降低了存储成本。
并且增加了合理的跳数索引之后,即使对于海量数据查询也不会太慢:
CREATE TABLE IF NOT EXISTS equipment_data (
equipment_id String NOT NULL CODEC(ZSTD(3)),
ts DateTime64(3) NOT NULL CODEC(Delta, ZSTD(3)),
metric LowCardinality(String) NOT NULL CODEC(ZSTD(3)),
metric_value Float64 NOT NULL CODEC(ZSTD(3)),
INDEX idx_ts (ts) TYPE minmax,
INDEX idx_equipment_id (equipment_id) TYPE bloom_filter
) ENGINE = ReplacingMergeTree
PRIMARY KEY (toStartOfMonth(ts), equipment_id)
ORDER BY (toStartOfMonth(ts), equipment_id, ts, metric);
注意: Clickhouse 的索引是跳数索引,也就是和关系型数据库的索引不同,它不是用来直接定位数据在哪的,而是用来确保数据一定不在哪,从而减少扫描的数据量。所以对于类似于
WHERE field = 'value'
的查询,使用布隆过滤器是非常合适的。
另外对于聚合查询,我们原本在 HBase 中使用的 down sampling 方案在 Clickhouse 中也有了更好的替代品,那就是投影(Projection),将常用的聚合查询直接添加为投影,这样聚合查询也可以在几十到几百毫秒内出结果。
我们重新写了新版本的 RESTful API 查询接口,完全兼容旧版本基于 HBase 的查询接口,保证了上层应用的兼容性,在其他上层应用不需要任何修改的前提下,平滑迁移 HBase 到 ClickHouse。
生产环境迁移
在本地测试通过之后,我们开始在生产环境进行迁移。我们先使用迁移工具将 HBase 中的热数据(最近 6 个月的数据)先行迁移到 Clickhouse 中,然后部署新版本的 RESTful API 接口,上层应用先开始使用 Clickhouse 的数据。
同时,HBase 剩余的数据将继续在后台逐步迁移(后来一共花了将近 1 个月的时候才完成整个 HBase 的数据迁移)。
改造之前 kafka 的 consumer 代码,实现双写逻辑,将设备数据同时写入 HBase 和 Clickhouse 中,这样万一出现了短时无法解决的问题,我们还可以快速回退到 HBase 中,给修复 BUG 留出时间。
最后等整体业务稳定之后,我们再停止 HBase 服务和双写,释放 HBase 的资源。
在我们的集群配置不高的场景下,Clickhouse 依然表现出亮眼的性能。我们的节点配置比较老,是 2 核 8G 内存的机器,存储也是 HDD 硬盘,在 50 亿规模的数据量下,查询也能在 100ms 左右出结果,非常不错。
迁移过后的评测
经过一段时间的使用,我们对 ClickHouse 的表现非常满意。虽然相比 HBase 的查询速度略有下降,但是整体性能依然非常优秀,存储成本大幅降低。程序的复杂度大幅降低,所有的查询需求都是一个个 SQL 语句,非常方便维护。
同时,我们也积极回馈 Clickhouse 社区,提交了几个小的 PR 和 issue,帮助改进,目前已经有两个 issue 被合并: #2004, #2116
但是我们也发现了一些产品环境中出现的问题。
Clickhouse 重单机轻集群的设计
Clickhouse 的设计理念是重单机轻集群,单机性能非常强大,支持多核并行计算,单机可以处理 TB 级别的数据量。但是对于集群查询功能却非常薄弱,它的设计理念和 Hadoop 截然不同。
起初我们部署了三台 Clickhouse 的副本集集群,期望通过副本并行查询的方式提高查询性能,而 Clickhouse 提供了这两个参数:
于是满心欢喜的给所有的查询都加上了这两个参数,结果悲剧了。发现它有以下几个问题:
- 某些查询会出现找不到表名的情况(主要是嵌套子查询使用 alias 的场景),但是这个问题可以通过追加参数 allow_experimental_analyzer=0 来解决,参见 issue: #70356
- Projection 可能会失效。对于命中 Projection 的查询,加上上面那两个参数可能反而不如单节点查询速度快,所以我们只能在查询时提前判断是否命中 Projection,决定是否加上这两个参数。
- 某些查询甚至直接报错,主要是针对于使用了 UDF 的查询,在并行查询的时候会出现问题
不过对于能使用分片并行查询的查询,性能还是有非常明显的提升的。所以是否使用分片查询需要根据具体的查询情况来判断,需要测试性能的差异。
Clickhouse-java 在压测表现不佳
当请求压力上来的时候,我们的 Restful API 却表现的十分拉胯,具体表现就是查询结果感觉是一个个请求串行执行的,明明是多线程并发请求,结果却像是单线程串行执行的。如果某个查询比较慢,则所有的查询都会被 hang 住。
之前我们的技术选型文章也提到了,我们使用的是 Vert.x 的异步编程模型,甚至这次迁移还升级到了 Java 21 版本,使用了虚拟线程,进一步提升 Vert.x 的性能,按理说不应该这么拉胯的才对,于是我在 clickhouse-java 的源码中找到了答案: Client.java#L2106-L2113
private <T> CompletableFuture<T> runAsyncOperation(Supplier<T> resultSupplier, Map<String, Object> requestSettings) {
boolean isAsync = MapUtils.getFlag(requestSettings, configuration, ClientConfigProperties.ASYNC_OPERATIONS.getKey());
if (isAsync) {
return sharedOperationExecutor == null ? CompletableFuture.supplyAsync(resultSupplier) :
CompletableFuture.supplyAsync(resultSupplier, sharedOperationExecutor);
}
return CompletableFuture.completedFuture(resultSupplier.get());
}
它的 API 看似返回的是个异步结果 (CompletableFuture),但它的实现方式默认却是同步的 (CompletableFuture.completedFuture(resultSupplier.get())
),也就是在查询的时候会去 Join 线程池,导致所有的查询都被串行执行。只有在请求设置中显式指定 ASYNC_OPERATIONS
为 true
才能开启真正的异步查询。
定位到问题之后,问题就好解决了,在初始化 Clickhouse 的客户端时,显式指定 ASYNC_OPERATIONS
为 true
即可。
Client client = new Client.Builder()
.addEndpoint("https://clickhouse-cloud-instance:8443/")
.setUsername(user)
.setPassword(password)
.useAsyncRequests(true) // 显式开启异步操作
.build();
结语
经过这次迁移,我们终于将设备实时数据从 HBase 成功迁移到了 ClickHouse。虽然过程并不轻松,但最终的结果让我们感到非常满意。
Clickhouse 虽然使用 SQL 语法,但是设计上和传统的关系型数据库有很大不同,所以需要充分评测之后,才能得出最优的表结构设计和查询方式。
在使用上,官方推荐尽可能使用 bulk insert 的方式写入数据,不但性能更好,而且可以避免 too many parts 的问题,如果无法在客户端层面实现 bulk insert,可以使用 async insert 的方式来实现。
如果期望使用 Clickhouse 集群并行查询功能,需要充分测试,因为单机能跑的 SQL 查询不一定能在集群并行模式上跑通。
如果数据量真的特别大,建议合理化设置分区键,虽然分区不会提高读写性能,但是可以避免 part 过大时,在 Merge 的时候内存耗尽的情况。
优化 Clickhouse 的内存消耗,就要尽可能避免查询太大的数据量,可以考虑以下一些优化策略:
- 尽可能减少使用复杂的聚合查询,可以改为物化视图或者投影方式减少直接聚合查询的使用
- 设置合理的 partitioning key,避免单个分区数据量过大,从而避免太大的 part merge 导致内存消耗过大
- order by + limit 并不会减少数据扫描量,应该改用其他等效实现,比如 limit 1 的场景往往可以用 MAX / MIN 函数来代替,也可以使用窗口函数 rank 来代替
- 严格控制扫描到的数据量,合理化设置 WHERE 条件以及增加合理的跳数索引,防止因为扫描的数据量太大造成内存溢出
本文包含付费内容,需要会员权限才能查看完整内容。