﻿近日，字节跳动旗下的企业级技术服务平台火山引擎正式对外发布「ByteHouse」，作为 ClickHouse 企业版，解决开源技术上手难 & 试错成本高的痛点，同时提供商业产品和技术支持服务。

作为国内规模最大的 ClickHouse 用户，目前字节跳动内部的 ClickHouse 节点总数超过 1 万 5 千个，管理总数据量超过 600PB，最大的集群规模在 2400 余个节点。综合来说，字节跳动广泛的业务增长分析很多都建立在 ClickHouse 为基础的查询引擎上。在打造 ClickHouse 企业版「ByteHouse」的路程中，我们经过了多年的探索与沉淀，今天和大家分享字节跳动过去使用 ClickHouse 的两个典型应用与优化案例。

<span id="推荐系统实时指标"></span>
# **推荐系统实时指标**

在字节跳动内部“AB实验”应用非常广泛，特别是在验证推荐算法和功能优化的效果方面。最初，公司内部专门的 AB 实验平台已经提供了 T+1 的离线实验指标，而推荐系统需要更快地观察算法模型、或者某个功能的上线效果，因此需要一份能够实时反馈的数据作为补充：


* 能同时查询聚合指标和明细数据；

* 能支持多达几百列的维度和指标，且场景灵活变化，会不断增加；

* 可以高效地按ID过滤数据；

* 需要支持一些机器学习和统计相关的指标计算（比如 AUC）。


<span id="︱技术选型"></span>
###  **︱技术选型**

字节内部有很多分析引擎，ClickHouse、 Druid、 Elastic Search、 Kylin等，通过分析用户需求后选择了ClickHouse：


* 能更快地观察算法模型，没有预计算所导致的高数据时延；

* ClickHouse 既适合聚合查询，配合跳数索引后，对于明细点查性能也不错；

* 字节自研的ClickHouse 支持 Map 类型，支持动态变更的维度和指标，更加符合需求；

* BitSet 的过滤 Bloom Filter 是比较好的解决方案，ClickHouse 原生就有 BF 的支持；

* 字节自研的 ClickHouse 引擎已经通过UDF 实现了相关的能力，而且有比较好的扩展性。


每个产品都有自己合适的场景，但是对于当前场景的需求评估下，ClickHouse 更加合适。

<span id="︱方案评估"></span>
###  **︱方案评估**

**方案对比**

确认技术选型后，在如何实现部分，也有两种方式：

<span>![图片](https://lf3-volc-editor.volccdn.com/obj/tos-cn-v-697126/e4af8b0c0a854064b0f84fada34f2e86.png) </span>

**最终方案 & 效果**

由于外部写入并不可控和技术栈上的原因，我们最终采用了 Kafka Engine 的方案，也就是 ClickHouse 内置消费者去消费 Kafka。整体的架构如图：

<span>![图片](https://lf3-volc-editor.volccdn.com/obj/tos-cn-v-697126/02c4896ee2414b33adf8252c5632d6d5.png) </span>


* 数据由推荐系统直接产生，写入 Kafka——为了弥补缺少 Flink 的 ETL 能力，推荐系统做了相应配合，修改 Kafka Topic 的消息格式直接适配 ClickHouse 表的 schema；

* 敏捷 BI 平台也适配了一下实时的场景，可以支持交互式的查询分析；

* 如果实时数据有问题，也可以从 Hive 把数据导入至 ClickHouse 中，除此之外，业务方还会将 1% 抽样的离线数据导入过来做一些简单验证，1% 抽样的数据一般会保存更久的时间。


除了技术选型和实现方案，我们在支持推荐系统的实时数据时遇到过不少问题，其中最大的问题随着推荐系统产生的数据量越来越大，单个节点的消费能力也要求越来越大，主要碰到如下问题：

<span id="︱问题一：写入吞吐量不足"></span>
###  **︱问题一：写入吞吐量不足**

**挑战** ：在有大量辅助跳数索引的场景下，索引的构建严重影响写入吞吐量。

**解决方案：异步构建索引。** 

<span>![图片](https://lf3-volc-editor.volccdn.com/obj/tos-cn-v-697126/15ae6ea6bc6a4b62ad28b50e3af48bd7.png) </span>

社区版本的实现里的具体逻辑如下：


* 解析输入数据生成内存中数据结构的 Block；

* 然后切分 Block，并按照表的 schema 构建 columns 数据文件；

* 最后扫描根据 skip index schema 去构建 skip index 文件。三个步骤完成之后才会算 Part 文件构建完毕。


在需要保证构建完 columns 数据之后用户即可正常查询的前提下，ByteHouse 同步完成前面两步，第三步把构建好的 part  放入到一个异步索引构建队列中，由后台线程构建索引文件。

**效果** ：在改成异步后，整体的写入吞吐量大概能提升 20%。

<span id="︱问题二：kafka-消费能力不足"></span>
###  **︱问题二：Kafka 消费能力不足**

**挑战** ：社区版本的 Kafka 表，内部默认只会有一个消费者，这样会比较浪费资源并且性能达不到性能要求。

**尝试优化过程：** 


* 尝试通过增大消费者的个数来增大消费能力，但社区的实现是由一个线程去管理多个的消费者，多个消费者消费到的数据最后仅能由一个输出线程完成数据构建，所以这里没能完全利用上多线程和磁盘的潜力；

* 尝试通过创建多张 Kafka Table 和 Materialized View 写入同一张表，但是对于运维会比较麻烦。


**解决方案：支持多线程消费。** 

<span>![图片](https://lf3-volc-editor.volccdn.com/obj/tos-cn-v-697126/6e79daa31aaf47949a41b157dc31b1d1.png) </span>

前面提到的优化手段都不尽如人意，最后决定改造 Kafka Engine 在其内部支持多个消费线程，简单来说就是每一个线程它持有一个消费者，然后每一个消费者负责各自的数据解析、数据写入，这样的话就相当于一张表内部同时执行多个的 INSERT Query。

**效果** ：通过多线程实现多消费者同时消费写入表，写入性能达到接近于线性的提升。

<span id="︱问题三：出现故障无法保证数据完整性"></span>
###  **︱问题三：出现故障无法保证数据完整性**

**挑战** ：在主备模式下，如果数据同时两个节点都写入，一旦一个节点出现故障，新启的节点恢复过程中容易出现各种问题，包括性能下降，无法保证分片，最严重可能导致查询结果不正确。

**解决方案：确保主备模式下只会写入一个主备其中一个节点。** 

为了避免两个节点消费这个数据，改进版的 Kafka Engine 参考了 ReplicatedMergeTree 基于 ZooKeeper 的选主逻辑。对于每一对副本的一对消费者，会尝试在 ZooKeeper 上完成选主逻辑，确保选举成为主节点的消费者才能消费，另一个节点则会处于一个待机状态。

<span>![图片](https://lf3-volc-editor.volccdn.com/obj/tos-cn-v-697126/823dac3ffaa44ae4a0d43f67e3f233a6.png) </span>

有了这样的单节点消费机制, 系统会检测 ReplicatedMergeTree 表数据是否完整，如果数据不完整则代表不能正常服务，此时消费者会主动出让 Leader，让副本节点上成为消费者，也就是新写入的数据并不会写入到缺少数据的节点，对于查询而言，由于查询路由机制的原因也不会把 Query 路由到缺少数据的节点上，所以一直能查询到最新的数据。

<span>![图片](https://lf3-volc-editor.volccdn.com/obj/tos-cn-v-697126/1db01a891e264ea987dbbcedf4bcdfaf.png) </span>

**效果** ：改进 Kafka engine 确保主备模式下只有一个节点能消费数据，即使出现节点故障在新节点恢复过程中同样保障了解决了数据完整性的问题。

<span id="标题广告投放实时数据"></span>
# **标题广告投放实时数据**

第二个典型案例关于广告的投放数据，一般是运营人员需要查看广告投放的实时效果。由于业务的特点，当天产生的数据往往会涉及到多天的数据。这套系统原来基于 Druid 实现的，Druid 在这个场景会有一些难点：

<span>![图片](https://lf3-volc-editor.volccdn.com/obj/tos-cn-v-697126/2d43c9a516694df585f16c77c90ec7fd.png) </span>

选择了 ClickHouse 之后能解决 Druid 不足的地方，但还是有部分问题需要解决。

<span id="︱问题一：buffer-engine-无法和-replicatedmergetree-一起使用"></span>
###  **︱问题一：Buffer Engine 无法和 ReplicatedMergeTree 一起使用**

**问题 & 挑战** ：社区提供了 Buffer Engine 为了解决单次写入生成过多 parts 的问题， 但是不太能配合 ReplicatedMergeTree 一起工作, 写入不同 Replica 的 Buffer 仅缓存了各自节点上新写入的数据, 导致查询会出现不一致的情况。

<span>![图片](https://lf3-volc-editor.volccdn.com/obj/tos-cn-v-697126/37f5f902ef1c4cee9d4bb942ae2037e6.png) </span>

**解决方案：** 

改进了 Buffer Engine 做了如下的调整和优化：


* 我们选择将 Kafka/Buffer/MergeTree 三张表结合起来，提供的接口更加易用；

* 把 Buffer 内置到 Kafka engine 内部， 作为 Kafka engine 的选项可以开启/关闭，使用更方便；

* Buffer table 内部类似 pipeline 模式处理多个 Block；

* 支持了 ReplicatedMergeTree 情况下的查询。


首先确保一对副本仅有一个节点在消费，所以一对副本的两个 Buffer 表，只有一个节点有数据。如果查询发送到了没有消费的副本，会额外构建一个特殊的查询逻辑，从另一个副本的 Buffer 表里读取数据。

<span>![图片](https://lf3-volc-editor.volccdn.com/obj/tos-cn-v-697126/c69e1adf73c7442d9640e4f51dcfa8f7.png) </span>

**效果** ：增强 Buffer Engine，解决了Buffer Engine 和 ReplicatedMergeTree 同时使用下查询一致性的问题。

<span id="︱问题二：出现宕机后可能会出现数据丢失后者重复消费的情况"></span>
###  **︱问题二：出现宕机后可能会出现数据丢失后者重复消费的情况**

**挑战** ：ClickHouse 缺少事务支持。一批次写入只写入部分 part 后出现宕机，因为没有事务保障重启后可能出现丢失或者重复消费的情况。

<span>![图片](https://lf3-volc-editor.volccdn.com/obj/tos-cn-v-697126/5cc62d5068ce432a9cf0de27ef15ca0a.png) </span>

**解决方案** ：

<span>![图片](https://lf3-volc-editor.volccdn.com/obj/tos-cn-v-697126/73e1150cf18b4736be38c62e27a0c9a2.png) </span>

参考了 Druid 的 KIS 方案自己管理 Kafka Offset，实现单批次消费/写入的原子语义：实现上选择将 Offset 和 Parts 数据绑定在一起，增强了消费的稳定性。每次消费时，会默认创建一个事务，由事务负责把 Part 数据和 Offset 一同写入磁盘中，如果出现失败，事务会一起回滚 Offset 和写入的 part 然后重新消费。

**效果** ：确保了每次插入数据的原子性，增强了数据消费的稳定性。

<span id="小结"></span>
# **小结**

实时数据分析是 ClickHouse 的优势场景，结合字节跳动实时数据场景的特点，我们对 ClickHouse 进行了优化和改造，并将这些能力沉淀到了 ByteHouse 上。ByteHouse 基于自研技术优势和超大规模的使用经验，为企业大数据团队带来新的选择和支持，以应对复杂多变的业务需求，高速增长的数据场景。未来，ByteHouse将不断以字节和外部最佳实践输出行业用户，帮助企业更好地构建交互式大数据分析平台，并更广泛的与 ClickHouse 研发者社群共享经验，共同推动 ClickHouse 社区的发展。



