
本文字数:20366;估计阅读时间:51 分钟
审校:庄晓东(魏庄)


我们始终认为在可能的情况下要充分利用我们自己的技术,尤其是在解决我们认为 ClickHouse 擅长解决的挑战时。去年,我们详细介绍了我们如何利用 ClickHouse 构建内部的数据仓库以及我们所面临的挑战。在本文中,我们将探讨另一个内部使用案例:可观测性,以及我们如何利用 ClickHouse 满足内部需求,并存储 ClickHouse Cloud 生成的大量日志数据。正如后文所述,这一举措每年为我们节省了数百万美元,并使我们能够无需担心可观测性成本,也不必在保留的日志数据上做出妥协。
为了让其他人分享我们的经验,我们提供了我们自己使用 ClickHouse 的日志记录解决方案的详细信息,这个解决方案仅在我们的 AWS 区域就包含了超过 19 PiB 的未压缩数据,相当于 37 万亿行数据。在设计上,我们的通用理念是尽量减少移动部件的数量,并确保设计尽可能简单且易于复制。
如我们稍后在价格分析部分所展示的,相比于 Datadog,对于我们的工作负载来说,ClickHouse 的费用至少是 Datadog 的 200 倍之下,这是基于 Datadog 和 AWS 的 ClickHouse 托管服务以及在 S3 中存储数据的价格列表所得出的结论。

在一年多前我们首次推出 ClickHouse Cloud 初始版本时,我们面临了抉择。尽管我们当时意识到 ClickHouse 可以用于构建可观测性解决方案,但我们的首要任务是构建云服务本身。为了尽快提供一流的服务,我们最初选择了 Datadog 作为公认的云可观测性市场领导者,以加速上市进程。
虽然这一举措使我们迅速将 ClickHouse Cloud 推向 GA 阶段,但很快就意识到 Datadog 的账单成本是无法持续的负担。由于 ClickHouse 服务器和 Keeper 日志占我们收集的日志数据的 98%,我们的数据量实际上会随着我们部署的集群数量呈线性增长。
我们最初面对这一挑战时,采取了大多数 Datadog 用户可能会被迫采取的做法 - 考虑限制数据保留时间以减少成本。虽然将数据保留时间限制在 7 天可能是控制成本的有效手段,但这与我们用户(我们的核心和支持工程师调查问题)的需求以及提供一流服务的主要目标直接相冲突。如果在 ClickHouse 中发现问题(是的,我们有 bug),我们的核心工程师需要能够搜索所有集群中所有日志,最长可达 6 个月。
针对 30 天的保留期(远远低于我们的 6 个月需求),在任何折扣之前,Datadog 每百万事件的列出价格为 2.5 美元,每 GB 吞入的价格为 0.1 美元(假设年度合同),这意味着我们目前每月处理 5.4 PiB/10.17 万亿行的数据,预计成本为每月 2600 万美元。至于所需的 6 个月保留期,呃,我们干脆不去考虑了。

通过大规模操作 ClickHouse 的经验,我们知道对于相同的工作负载来说,它会显著降低成本。同时,我们也看到其他公司已经在 ClickHouse 上构建了他们的日志解决方案(例如,highlight.io 和 Signoz),因此我们将优先级放在将我们的日志数据存储迁移到 ClickHouse,并组建了一个内部的可观测性团队。在不到三个月的时间里,我们的 1.5 名全职工程师小团队着手构建了我们基于 ClickHouse 的日志平台 “LogHouse”!
我们的内部可观测性团队的职责不仅限于 LogHouse。我们的任务更广泛,旨在帮助 ClickHouse 的工程师了解我们运行的 ClickHouse Cloud 中所有集群的情况。因此,我们提供了一系列服务,包括用于主动检测需要解决的集群行为模式的警报服务。

截至目前,ClickHouse Cloud 已经在 9 个 AWS 区域和 4 个 GCP 区域提供服务,Azure 的支持也即将到来。我们最大的区域每秒产生超过 110 万条日志行。考虑到所有 AWS 区域,这就产生了一些惊人的数字:

这里可能一眼就能看出我们所达到的压缩水平 - 19 PiB 的数据压缩后仅约为 1.13 PiB,相当于约 17 倍的压缩比。ClickHouse 实现的这种高压缩水平对项目的成功至关重要,它使我们能够以高效的成本扩展,并且仍然保持良好的查询性能。
我们 LogHouse 环境的总数据量目前超过了 19 PiB。这个数字仅考虑了我们的 AWS 环境。

我们之前的文章已经介绍了 ClickHouse Cloud 的架构。与我们的日志解决方案相关的关键架构特征是,ClickHouse 实例作为 Kubernetes 中的 pods 部署,并由自定义的 operator 进行管理。
Pods 将日志记录到 stdout 和 stderr,并由 Kubernetes 按照标准配置捕获为文件。然后,OpenTelemetry 代理可以读取这些日志文件并将它们转发到 ClickHouse。
尽管大部分数据来自 ClickHouse 服务器(在高负载下,某些实例每秒可记录 4,000 行),以及 Keeper 日志,但我们也从云数据平面收集数据。这包括运行在节点上的我们的 operator 和支持服务的日志。对于 ClickHouse 集群编排,我们依赖于 ClickHouse Keeper(由我们的核心团队开发的 C++ ZooKeeper 替代品),它产生了涉及集群操作的详细日志。尽管 Keeper 日志在任何时刻占据了大约 50% 的流量(相比之下,ClickHouse 服务器日志为 49%,数据平面为 1%),但该数据集的保留时间较短,仅为 1 周(与服务器日志的 6 个月相比),因此它只占整体数据的很小比例。

由于我们正在监控 ClickHouse Cloud,所以驱动 LogHouse 的集群不能与 ClickHouse Cloud 基础架构共存 - 否则,我们就会在被监视的系统和监控工具之间创建依赖关系。
然而,我们仍然希望从 ClickHouse Cloud 背后的技术中受益 - 具体来说,我们希望利用 SharedMergeTree 表引擎的技术,实现存储和计算的分离。这样做主要是为了使我们能够受益于将数据存储在具有大型 NVMe 缓存的 S3 中,同时允许我们几乎无限地扩展我们的集群宽度。考虑到我们的数据量会随着吸引更多客户而不断增加,我们不希望因为磁盘空间限制而不得不部署更多的集群和/或节点。通过将所有数据存储在 S3 中,并让节点仅使用本地 NVMe 作为缓存,我们可以轻松扩展并专注于数据本身。

因此,在每个区域,我们都运行着我们自己的 “微型 ClickHouse Cloud”(见下文),甚至使用了 ClickHouse Cloud 的 Kubernetes operator。能够使用 “云” 对于我们在如此短的时间内构建解决方案并以小团队管理基础架构至关重要。我们确实受益于巨人(也就是 ClickHouse 核心团队)的成果。
尽管这个解决方案是针对我们需求量身定制的,但它实际上相当于 ClickHouse Cloud 的专用层级(Dedicated Tier)提供,客户的集群部署在专用基础设施上,使他们能够控制部署的各个方面,如更新和维护窗口。最重要的是,其他想要复制我们解决方案的用户不会受到相同的循环依赖约束,他们只需使用 ClickHouse Cloud 集群即可。
我们目前正在将内部集群(如 LogHouse)迁移到我们的 ClickHouse Cloud 基础设施中。在这里,它将被隔离在一个单独的 Kubernetes 集群中,但除此之外,它将是一个与我们的客户使用的 “标准” ClickHouse Cloud 集群完全相同的集群。

使用 ClickHouse 进行日志存储意味着用户必须接受基于 SQL 的观测性。换句话说,用户可以舒适地使用 SQL 和 ClickHouse 的丰富字符串匹配和分析函数来搜索日志。这种采用通过可视化工具(如 Grafana)变得更加简单,Grafana 提供了常见观测操作的查询构建器。
在我们的情况下,我们的用户对 SQL 非常熟悉,因为他们是 ClickHouse 的支持和核心工程师。尽管这有利于查询通常经过了严格的优化,但也带来了一系列挑战。尤其是,这些用户是高级用户,他们只会在最初的分析阶段使用基于可视化的工具,然后希望直接通过 ClickHouse 客户端连接到 LogHouse 实例。这意味着我们需要一种简便的方法让用户能够轻松地在任何仪表板和客户端之间进行切换。
在大多数情况下,对云问题的调查是由 Prometheus 收集的集群指标触发的警报启动的。这些警报会检测到可能存在问题的行为(例如大量零件),需要进行调查。或者,客户可能会通过支持渠道提出问题,指出异常行为或无法解释的错误。
一旦需要进行更深入的分析,日志就变得关键。到了这个阶段,我们的支持或核心工程师已经确定了客户集群、其区域和相关的 Kubernetes 命名空间。这意味着大多数搜索都是根据时间和一组 pod 名称进行过滤的 - 后者可以从 Kubernetes 命名空间中轻松识别出来。
我们稍后展示的模式经过了优化,以适应这些特定的工作流程,并有助于在多 PB 规模下实现出色的查询性能。

以下早期设计决策对我们最终的架构产生了重要影响。
不跨区域传输数据
首先,我们不会跨区域传输日志数据。考虑到即使是较小的区域也会产生大量数据,从数据出口成本的角度来看,集中式日志记录并不可行。相反,我们在每个区域都部署了一个 LogHouse 集群。正如我们将在后文讨论的那样,用户仍然可以跨区域进行查询。
不使用 Kafka 队列作为消息缓冲区
在日志架构中,将 Kafka 队列作为消息缓冲区是一种常见的设计模式,并且由 ELK 堆栈广泛采用。它提供了一些好处;主要是,它有助于提供更强的消息传递保证,并有助于处理背压。消息从收集代理发送到 Kafka 并写入磁盘。理论上,集群化的 Kafka 实例应该提供高吞吐量的消息缓冲区,因为将数据线性写入磁盘的计算开销较小,而不是解析和处理消息 - 例如,在 Elastic 中,标记化和索引化会产生显着的开销。将数据从代理移开还会降低因源头的日志轮换而丢失消息的风险。最后,它提供了一些消息回复和跨区域复制的能力,对某些用例可能会有吸引力。
然而,ClickHouse 可以以极快的速度处理数据插入 - 在中等硬件上每秒能处理数百万行。ClickHouse 几乎不会出现背压。因此,在我们的规模上,使用 Kafka 队列根本没有意义,这只会增加架构的复杂性和成本,而不是必要的。在确定这种架构时,我们还坚持一个重要原则 - 并非所有日志都需要相同的传递保证。在我们的情况下,我们对在途数据的丢失更加宽容,因为如果需要,我们在实例本身上有第二个副本日志 - 尽管我们仍然努力将在途数据的丢失降至最低,因为丢失的消息可能会干扰调查。
改善在途数据丢失
我们目前的在途丢失率比我们感到舒适的要高。我们将这归因于几个因素:
在我们的摄取层中缺乏自动伸缩功能,这可能会在 ClickHouse Cloud 中触发特定事件时(例如,更新)导致交通激增。尽管有(2),但这是我们打算开发的一个功能。
我们已经确定了 OTEL 收集器中的一个问题,即代理和网关之间的连接没有均匀分布,导致“热点”在网关处发生,其中一个单个收集器接收到更高比例的负载。这个收集器变得不堪重负,结合(1),我们经历了在途日志丢失率增加的情况。我们正在解决这个问题,并打算将修复方案贡献回去,以便其他人受益。
目前我们还没有利用 ClickHouse Cloud 的自动扩展能力,因为来自 ClickHouse 本身的背压很少发生。然而,随着我们解决并解决了前述问题,我们的 ClickHouse 实例可能会经历交通激增。到目前为止,收集器现有的挑战已经充当了一个缓冲器,保护 ClickHouse 免受这种波动的影响。未来,我们可能需要探索实施 ClickHouse Cloud 的自动扩展器的潜在好处,以更好地管理这些预期的需求增加。
尽管面临这些挑战,我们对决定不部署 Kafka 没有后悔。通过遵循这一原则,我们得到了更简单、更便宜、延迟更低的架构,并且我们有信心通过采取上述措施将在途数据丢失率降至可接受的水平。
结构化日志
我们本来想说我们的日志一直都是结构化的,并且被干净地插入到一个完美的模式中。然而,情况并非如此。我们最初的部署是将日志作为纯文本字符串发送,用户需要依赖 ClickHouse 字符串函数来提取元数据。这是因为我们的用户更喜欢以原始形式消耗日志。尽管这仍然提供了很好的压缩效果,但查询性能却受到了影响,因为每个查询都需要进行线性扫描。这是一个最初的架构错误,我们建议您不要重蹈覆辙。
在与用户讨论了利弊之后,我们转向了结构化日志,使用 ClickHouse 实例以 JSON 形式记录日志。如下所述,这些 JSON 键默认存储在 Map(String, String) 类型中。然而,我们在插入时从预期频繁查询的选择字段中提取完整列。然后可以将这些字段用于我们的排序键,并配置以利用二级索引和专用编解码器。这允许对诸如 pod_name 等列进行优化查询,确保最常见的工作流程都得到了查询性能的优化。用户仍然可以访问 Map 键以获取不经常访问的元数据。然而,根据我们的经验,这很少在没有对优化列进行过滤的情况下进行。

决定采用 OpenTelemetry 是我们最重要的设计决策,因此需要单独说明。这也是最初让我们最为担心的决定,因为当我们启动项目时,与跟踪相比,ClickHouse 的 OTel 导出器在日志收集方面还比较不成熟 - 它处于 alpha 阶段,并且我们不知道是否有任何规模上的部署。我们决定投资的原因有几个:
社区采用 - OTel 项目的广泛采用令人印象深刻。我们还看到其他公司成功地使用 ClickHouse 导出器,尽管它仍处于 alpha 状态。此外,一些以 ClickHouse 为基础的可观测性公司,如 highlight.io 和 Signoz,将 OTel 视为其摄取的标准。
投资未来 - 该项目已经达到一定的成熟度和普及程度,表明它将成为收集日志、追踪和指标的事实标准。专有的可观测性供应商,如 Dynatrace、Datadog 和 Splunk,对其的投资表明了其作为标准的广泛接受。
超越日志 - 我们考虑了其他日志收集代理,特别是 Fluentd 和 Vector,但希望选择一个堆栈,使我们能够轻松扩展 LogHouse 以收集后续的指标和追踪数据。
自定义处理器
在 OTel 使用过程中,声明复杂的 YAML 流水线(链接接收器、处理器和发送器)是一个普遍的挑战。我们在 OTel 收集器实例中执行了大量的处理工作(在网关层 - 见下文),需要根据数据源执行条件路由,例如 ClickHouse 实例、数据平面和 Keeper 日志都会被路由到不同的表中,每个表都有其优化的模式。
根据我们的经验,管理这些 YAML 代码容易出错,而且测试起来很困难。因此,我们选择了一种不同的方法,即开发了一个用 Go 编写的自定义处理器,执行我们所需的所有转换逻辑。这意味着我们部署了一个 OTel 收集器的自定义版本,但我们能够轻松测试任何处理变更。与从声明式方法构建的等效管道相比,使用自定义处理器的内部管道速度更快,这进一步节省了资源并减少了端到端延迟。

在较高层面上,我们的管道如下所示:

我们在每个 Kubernetes 节点上都部署了 OTel 收集器作为代理,用于日志收集,并作为网关部署在消息发送到 ClickHouse 之前进行处理。这是一个经典的代理-网关架构,由 Open Telemetry 记录,允许将日志处理的负载从节点本身移开。这有助于确保代理的资源占用尽可能小,因为它只负责将日志转发到网关实例。由于网关执行所有消息处理,因此必须根据 Kubernetes 节点和日志数量的增加进行扩展。正如我们稍后将展示的,这种架构使得每个节点都有一个代理,其资源分配基于底层实例类型。这是因为更大的实例类型具有更多的 ClickHouse 活动,因此产生更多的日志。这些代理随后将它们的日志转发到一组网关进行处理,这些网关的规模根据总日志吞吐量进行调整。
在上述基础上,单个区域,无论是 AWS 还是 GCP(Azure 即将支持),可能如下所示:

架构的简单性一目了然。关于这一点,有几个重要的说明:
我们的 ClickHouse Cloud 环境(AWS)目前使用了各种实例类型,例如 m5d.24xlarge、m5d.16xlarge、m5d.8xlarge、m5d.2xlarge 和 r5d.2xlarge。由于 ClickHouse Cloud 允许用户创建不同总资源大小的集群,实际上,ClickHouse 实例的大小可以变化。总的来说,较大的实例类型承载更多的 ClickHouse 实例,这些实例产生了大部分我们的日志数据。这意味着实例大小与需要由代理转发的日志数据量之间存在着密切的关联。
OTel 收集器代理作为守护进程部署在每个 Kubernetes 节点上。分配给这些代理的资源取决于底层实例的大小。对于 m5d.16xlarge 实例,我们使用大型代理,分配了 1vCPU 和 1GiB 的内存。这足以处理此节点上的 ClickHouse 实例收集的日志,即使节点被完全占用也是如此。我们在代理级别上执行最小化的处理以减少资源开销,只从日志文件中提取时间戳以覆盖 Kubernetes 观察到的时间戳。将更高的资源分配给代理将会产生超出托管成本之外的影响。重要的是,它们可能会影响我们操作员有效地在节点上打包 ClickHouse 实例,可能会导致逐出和资源被低效利用。以下显示了从节点大小到代理资源的映射(仅适用于 AWS):
| Kubernetes Node Size | Collector agent T-shirt size | Resources to collector agent | Logging rate |
|---|---|---|---|
| m5d.16xlarge | Large | 1 CPU, 1GiB | 10k/second |
| m5d.8xlarge | Medium | 0.5 CPU, 0.5GiB | 5k/second |
| m5d.2xlarge | Small | 0.2CPU, 0.2GiB | 1k/second |
OTel 收集器代理通过 Kubernetes 服务,该服务配置为使用拓扑感知路由,将数据发送到网关实例。每个地区至少有一个网关位于每个可用性区域(以实现高可用性),这意味着数据默认情况下会尽可能地路由到相同的可用性区域。在日志吞吐量较高的较大地区,我们会提供更多的网关来处理负载。例如,在我们最大的地区,我们目前有 16 个网关来处理每秒 110 万行的数据。每个网关都配备 11 GiB 的 RAM 和三个核心。
由于我们的 LogHouse 实例位于不同的 Kubernetes 集群中,为了与 Cloud 保持隔离,网关通过 NLB 将其流量转发到这些 ClickHouse 实例。目前,这个 NLB 并不感知区域,导致了比最优情况更多的区域间通信。因此,我们正在努力确保该 NLB 利用最近宣布的可用性区域 DNS 亲和性。
我们的网关和代理都是通过 Helm charts 部署的。这些 Helm charts 被自动部署为 CI/CD 流水线的一部分,由 ArgoCD 进行编排,并将配置存储在 Git 中。
我们通过 Prometheus 指标监控收集器网关,当它们遇到资源压力时会触发警报。
我们的 ClickHouse 实例的部署架构与 Cloud 完全相同 - 3 个 Keeper 实例分布在不同的可用性区域,并相应地分布 ClickHouse。
我们最大的 LogHouse ClickHouse 集群由 5 个节点组成,每个节点具有 200GiB RAM 和 57 个核心,部署在 m5d.16xlarge 实例上。它能够处理每秒超过 100 万行的数据插入。
每个地区目前只部署一个 Grafana 实例,其流量通过 NLB 在其本地 LogHouse 集群上进行负载平衡。然而,正如我们稍后会详细讨论的,每个 Grafana 都能够访问其他地区(通过 TailScale VPN 和 IP 白名单)。
为了优化网关性能并处理后压力,我们必须重点关注这些网关。它们承担了大部分的数据处理工作,这对于降低成本和提高吞吐量至关重要。在测试中,我们确定每个拥有三个核心的网关大约可以处理 60K 个事件/秒。
我们的网关不会将数据持久化到磁盘,而是仅在内存中缓存事件。目前,我们将 11 GB 的内存分配给每个网关,每个地区部署了足够数量的网关,以确保在 ClickHouse 不可用时,能够提供长达 2 小时的缓冲。我们当前的内存与 CPU 配置及网关数量是为了在吞吐量和 ClickHouse 不可用时提供足够的内存缓冲日志消息之间取得平衡。我们更倾向于横向扩展网关,因为这样可以更好地应对故障情况(如果某个 EC2 节点出现问题,我们的风险就较低)。
值得注意的是,如果缓冲区变满,队列前端的事件将被丢弃,即网关始终接受来自代理本身的事件,我们不会施加后压力。然而,我们的 2 小时时间窗口已被证明是足够的,OTel 管道问题很少影响我们的数据保留质量。尽管如此,我们目前也在探索 OTel 收集器将数据缓存到网关的磁盘上的能力 - 这可能允许我们在下游问题上提供更高的弹性,同时可能减少网关的内存占用。
调优网关和处理后台压力
由于我们的网关执行了大部分数据处理工作,因此从中获取性能对于降低成本和最大化吞吐量至关重要。在测试中,我们确定每个拥有三个核心的网关大约可以处理 60K 个事件/秒。
我们的网关不会将数据持久化到磁盘,而是仅在内存中缓存事件。目前,我们将 11 GB 的内存分配给每个网关,在每个地区部署了足够数量的网关,以提供长达 2 小时的缓冲,以防 ClickHouse 出现不可用的罕见情况。我们当前的内存与 CPU 比率和网关数量因此是吞吐量和提供足够内存缓冲日志消息以应对 ClickHouse 不可用之间的折衷。我们更喜欢横向扩展网关,因为这样可以提供更好的容错性(如果某个 EC2 节点出现问题,我们的风险就较低)。
重要的是,如果缓冲区变满,队列前端的事件将被丢弃,即网关将始终接受来自代理的事件,我们不会施加后压力。然而,我们的 2 小时窗口已被证明是足够的,OTel 管道问题很少影响我们的数据保留质量。尽管如此,我们目前也在探索 OTel 收集器将数据缓冲到网关的磁盘上的能力 - 这可能允许我们在下游问题上提供更高的弹性,同时还可能减少网关的内存占用。


在初始部署期间,我们进行了一些基本的测试,以确定最佳的批处理大小。这是在希望高效地将数据插入到 ClickHouse 的同时,确保日志及时可用进行搜索之间的折衷。具体来说,尽管较大的批次通常对于向 ClickHouse 进行插入更为优化,但这必须与数据的可用性以及网关(和在非常大的批量大小时的 ClickHouse)的内存压力相平衡。我们最终选择了每个批处理器 15K 行的批处理大小,这提供了我们所需的吞吐量,并满足了我们的数据可用性 SLA(服务水平协议)为 2 分钟。
最后,我们确实有低吞吐量的时段。因此,我们还在每 5 秒后(参见超时)在网关中刷新批处理器,以确保数据在上述 SLA 内可用。由于这可能导致较小的插入,我们遵循 ClickHouse 的最佳实践,并依赖于异步插入。

物化视图
OTel 收集器将字段汇总为两个主要的映射:ResourceAttributes 和 LogAttributes。前者包含了代理实例添加的字段,而在我们的情况下,主要是 Kubernetes 元数据,例如 pod 名称和命名空间。相反,LogAttributes 字段包含日志消息的实际内容。由于我们记录的是结构化 JSON,因此它可以包含多个字段,例如线程名称和产生日志的源代码行。
{"Timestamp": "1710415479782166000","TraceId": "","SpanId": "","TraceFlags": "0","SeverityText": "DEBUG","SeverityNumber": "5","ServiceName": "c-cobalt-ui-85-server","Body": "Peak memory usage (for query): 28.34 MiB.","ResourceSchemaUrl": "https://opentelemetry.io/schemas/1.6.1","ScopeSchemaUrl": "","ScopeName": "","ScopeVersion": "","ScopeAttributes": "{}","ResourceAttributes": "{\"cell\":\"cell-0\",\"cloud.platform\":\"aws_eks\",\"cloud.provider\":\"aws\",\"cluster_type\":\"data-plane\",\"env\":\"staging\",\"k8s.container.name\":\"c-cobalt-ui-85-server\",\"k8s.container.restart_count\":\"0\",\"k8s.namespace.name\":\"ns-cobalt-ui-85\",\"k8s.pod.name\":\"c-cobalt-ui-85-server-ajb978y-0\",\"k8s.pod.uid\":\"e8f060c5-0cd2-4653-8a2e-d7e19e4133f9\",\"region\":\"eu-west-1\",\"service.name\":\"c-cobalt-ui-85-server\"}","LogAttributes": {\"date_time\":\"1710415479.782166\",\"level\":\"Debug\",\"logger_name\":\"MemoryTracker\",\"query_id\":\"43ab3b35-82e5-4e77-97be-844b8656bad6\",\"source_file\":\"src/Common/MemoryTracker.cpp; void MemoryTracker::logPeakMemoryUsage()\",\"source_line\":\"159\",\"thread_id\":\"1326\",\"thread_name\":\"TCPServerConnection ([#264])\"}"}
重要的是,这些映射中的键是可以更改的,且半结构化的。例如,新的 Kubernetes 标签可能随时被引入。虽然 ClickHouse 中的 Map 类型非常适用于收集任意的键值对,但它并不提供最佳的查询性能。截至 24.2 版本,对 Map 列的查询需要解压并读取整个映射值 - 即使只访问一个键。我们建议用户使用物化视图将最常查询的字段提取到专用列中,这在 ClickHouse 中很容易实现,并且极大地提高了查询性能。例如,上面的消息可能会变成:
{"Timestamp": "1710415479782166000","EventDate": "1710374400000","EventTime": "1710415479000","TraceId": "","SpanId": "","TraceFlags": "0","SeverityText": "DEBUG","SeverityNumber": "5","ServiceName": "c-cobalt-ui-85-server","Body": "Peak memory usage (for query): 28.34 MiB.","Namespace": "ns-cobalt-ui-85","Cell": "cell-0","CloudProvider": "aws","Region": "eu-west-1","ContainerName": "c-cobalt-ui-85-server","PodName": "c-cobalt-ui-85-server-ajb978y-0","query_id": "43ab3b35-82e5-4e77-97be-844b8656bad6","logger_name": "MemoryTracker","source_file": "src/Common/MemoryTracker.cpp; void MemoryTracker::logPeakMemoryUsage()","source_line": "159","level": "Debug","thread_name": "TCPServerConnection ([#264])","thread_id": "1326","ResourceSchemaUrl": "https://opentelemetry.io/schemas/1.6.1","ScopeSchemaUrl": "","ScopeName": "","ScopeVersion": "","ScopeAttributes": "{}","ResourceAttributes": "{\"cell\":\"cell-0\",\"cloud.platform\":\"aws_eks\",\"cloud.provider\":\"aws\",\"cluster_type\":\"data-plane\",\"env\":\"staging\",\"k8s.container.name\":\"c-cobalt-ui-85-server\",\"k8s.container.restart_count\":\"0\",\"k8s.namespace.name\":\"ns-cobalt-ui-85\",\"k8s.pod.name\":\"c-cobalt-ui-85-server-ajb978y-0\",\"k8s.pod.uid\":\"e8f060c5-0cd2-4653-8a2e-d7e19e4133f9\",\"region\":\"eu-west-1\",\"service.name\":\"c-cobalt-ui-85-server\"}","LogAttributes": "{\"date_time\":\"1710415479.782166\",\"level\":\"Debug\",\"logger_name\":\"MemoryTracker\",\"query_id\":\"43ab3b35-82e5-4e77-97be-844b8656bad6\",\"source_file\":\"src/Common/MemoryTracker.cpp; void MemoryTracker::logPeakMemoryUsage()\",\"source_line\":\"159\",\"thread_id\":\"1326\",\"thread_name\":\"TCPServerConnection ([#264])\"}"}
为了执行这种转换,我们在 ClickHouse 中利用物化视图。我们的网关不是将数据插入 MergeTree 表,而是插入到 Null 表中。这个表类似于 /dev/null,因为它不会持久保存接收到的数据。然而,附加到它的物化视图在数据块插入时执行 SELECT 查询。这些查询的结果被发送到目标的 SharedMergeTree 表,该表存储最终转换的行。我们在下面说明了这个过程:

利用这种机制,我们有一种灵活且原生的 ClickHouse 方法来转换我们的行。要更改从映射中提取的模式和列,我们在更新视图以提取所需列之前修改目标表。

我们的数据平面和 Keeper 日志具有专用模式。鉴于我们的大部分数据来自 ClickHouse 服务器日志,我们将在下面重点介绍此模式。这代表了接收来自物化视图数据的目标表:
CREATE TABLE otel.server_text_log_0(`Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),`EventDate` Date,`EventTime` DateTime,`TraceId` String CODEC(ZSTD(1)),`SpanId` String CODEC(ZSTD(1)),`TraceFlags` UInt32 CODEC(ZSTD(1)),`SeverityText` LowCardinality(String) CODEC(ZSTD(1)),`SeverityNumber` Int32 CODEC(ZSTD(1)),`ServiceName` LowCardinality(String) CODEC(ZSTD(1)),`Body` String CODEC(ZSTD(1)),`Namespace` LowCardinality(String),`Cell` LowCardinality(String),`CloudProvider` LowCardinality(String),`Region` LowCardinality(String),`ContainerName` LowCardinality(String),`PodName` LowCardinality(String),`query_id` String CODEC(ZSTD(1)),`logger_name` LowCardinality(String),`source_file` LowCardinality(String),`source_line` LowCardinality(String),`level` LowCardinality(String),`thread_name` LowCardinality(String),`thread_id` LowCardinality(String),`ResourceSchemaUrl` String CODEC(ZSTD(1)),`ScopeSchemaUrl` String CODEC(ZSTD(1)),`ScopeName` String CODEC(ZSTD(1)),`ScopeVersion` String CODEC(ZSTD(1)),`ScopeAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),`ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),`LogAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,INDEX idx_thread_id thread_id TYPE bloom_filter(0.001) GRANULARITY 1,INDEX idx_thread_name thread_name TYPE bloom_filter(0.001) GRANULARITY 1,INDEX idx_Namespace Namespace TYPE bloom_filter(0.001) GRANULARITY 1,INDEX idx_source_file source_file TYPE bloom_filter(0.001) GRANULARITY 1,INDEX idx_scope_attr_key mapKeys(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,INDEX idx_scope_attr_value mapValues(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,INDEX idx_res_attr_value mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,INDEX idx_log_attr_key mapKeys(LogAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,INDEX idx_log_attr_value mapValues(LogAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,INDEX idx_body Body TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 1)ENGINE = SharedMergeTreePARTITION BY EventDateORDER BY (PodName, Timestamp)TTL EventTime + toIntervalDay(180)SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;
关于我们的模式,有一些观察结果:
我们使用排序键(PodName、Timestamp)。这是针对我们的查询访问模式进行优化的,用户通常会根据这些列进行过滤。用户可能会根据其预期的工作流程进行修改。
对于所有具有非常高基数的字符串列,我们使用 LowCardinality(String) 类型。这个字典对我们的字符串值进行编码,已经证明可以提高压缩效率,从而提高读取性能。我们目前的经验法则是对于基数低于 10,000 个唯一值的任何字符串列都应用此编码。
我们所有列的默认压缩编解码器是 ZSTD,级别为 1。这是特定于我们的数据存储在 S3 上的事实。虽然与 LZ4 等替代方案相比,ZSTD 在压缩时可能更慢,但这被更好的压缩比和一致快速的解压缩所抵消(约为 20% 的差异)。在使用 S3 进行存储时,这些是首选的属性。
继承自 OTel 模式,我们对任何映射和值的键使用布隆过滤器。这为映射的键和值提供了一个基于布隆过滤器数据结构的二级索引。布隆过滤器是一种数据结构,可以以空间高效的方式测试集合成员关系,但会以略微的误报概率为代价。理论上,这使我们能够快速评估磁盘上的粒子是否包含特定的映射键或值。这个过滤器在逻辑上是有意义的,因为一些映射键和值应该与 pod 名称和时间戳的排序键相关联,即特定的 pod 将具有特定的属性。然而,其他的将存在于每个 pod 中 - 我们不希望在查询这些值时加速,因为在这种配置下,至少有一个粒子中的行满足过滤条件的机会非常高(在这个配置中,一个块就是一个粒子,因为 GRANULARITY=1)。关于为什么需要在排序键和列/表达式之间进行关联的更多细节,请参见这里。这个通用规则已经考虑到了其他列,比如 Namespace。通常情况下,这些布隆过滤器已经被大量应用,需要进行优化 - 这是一个待处理的任务。误报率为 0.01 也没有进行调整。
数据过期管理
所有的模式都通过 EventDate 进行数据分区,以提供一些好处 - 请参见上述模式中的 PARTITION BY EventDate。首先,由于我们大多数的查询都是针对最近 6 小时的数据,这提供了一个快速筛选到相关部分的方法。虽然这可能意味着在更宽的日期范围内要查询的部分数量更多,但我们并没有发现这对这些查询产生了明显的影响。
最重要的是,它允许我们有效地过期数据。我们可以简单地丢弃超过保留时间的任何分区,使用 ClickHouse 的核心数据管理能力 - 请参见 TTL EventTime + toIntervalDay(180) 声明。对于 ClickHouse 服务器日志,这是 180 天,如上述模式所示,因为我们的核心团队使用它来调查任何发现问题的历史普遍性。其他类型的日志,比如来自 Keeper 的日志,其保留需求要短得多,因为它们除了初始问题解决之外没有太多的用途。这种分区意味着我们还可以使用 ttl_only_drop_parts 来高效地丢弃数据。

Grafana 是我们推荐的用于 ClickHouse 可观测性数据的可视化工具。最近发布的 4.0 版本使得从 Explore 视图快速查询日志变得更加简单,并且在我们的工程师内部受到了很高的赞赏。虽然我们通常使用默认提供的插件,但我们还通过一个额外的场景插件扩展了 Grafana 以满足我们的需求。这个应用程序,我们称之为 LogHouse UI,是针对我们特定工作负载进行了深度定制和优化的,并且与 Grafana 插件紧密集成。以下是推动我们构建这个应用的一些要求:
我们总是需要展示特定的可视化图表以进行诊断。虽然这些可以通过仪表板来支持,但我们希望它们能与我们的日志探索体验紧密集成 - 想象一下 Explore 和仪表板的混合体。仪表板需要使用变量,但我们有时觉得这有点不太方便,我们希望确保我们的查询在最常见的访问模式下尽可能地优化。
我们的用户在处理问题时通常会使用命名空间或集群 ID 进行查询。LogHouse UI 能够自动将查询限制为按 pod 名称(以及任何用户时间限制)进行查询,从而确保我们的排序键被高效地使用。
由于我们针对不同的数据类型有专门的模式,LogHouse UI 插件会根据查询自动在支持的模式之间切换,为用户提供无缝的体验。
我们的实际表模式可能会发生变化。尽管在大多数情况下,我们能够在不影响用户界面的情况下进行这些修改,但界面是理解模式的。因此,我们的插件确保在查询特定时间范围时使用正确的模式。这使得我们能够进行模式更改,而不必担心会干扰我们的用户体验。
我们的用户在 SQL 方面非常熟练,通常在 UI 进行初步调查后会切换到 ClickHouse 客户端来查询日志数据。我们的应用程序提供了一个带有相应 SQL 的 clickhouse-client 快捷方式,用于任何可视化。这使得我们的用户能够在这两个工具之间轻松切换,典型的工作流程是从 Grafana 开始,然后通过客户端进行更深入的分析,从而制定高度集中的 SQL 查询。
我们的应用程序还实现了跨区域查询,如下所述。
通常,我们与日志数据相关的大部分工作负载都是探索性的,仪表板通常基于度量数据。因此,定制的日志探索体验是合理的,并且已经证明了投资的价值。


我们始终努力为用户提供最佳体验,因此希望提供一个单一的端点,让用户可以从该端点查询任何 ClickHouse 集群,而无需转到特定区域的 Grafana 实例来查询本地数据。
正如之前提到的,由于我们的数据量实在太大,无法通过跨区域复制数据来实现 - 数据出口费用将超过当前运行 ClickHouse 基础设施的成本。因此,我们需要确保任何 ClickHouse 集群都可以从托管 Grafana 的区域(及其相应的备用区域)进行查询。
我们最初采用的简单方法是在 ClickHouse 中使用分布式表,以便从任何节点查询所有区域的数据。这要求我们在每个 LogHouse 区域配置一个逻辑集群,其中包含每个区域的分片和相应的节点。这实际上创建了一个单一的整体集群,将所有区域的 LogHouse 节点连接在一起。
虽然这种方法可以工作,但当查询分布式表时,必须向每个节点发出请求。由于我们没有跨区域复制数据,因此只有一个区域的节点能够访问每个查询的相关数据。这种缺乏地理感知意味着其他集群会消耗资源来评估永远无法匹配的查询。对于包含对排序键(pod 名称)进行过滤的查询,这会产生非常小的开销。然而,对于更广泛的、更具调查性质的查询,特别是涉及线性扫描的查询,则会浪费大量资源。
我们通过巧妙地使用 cluster 函数来解决了这个问题,但这使得我们的查询变得相当繁琐和不必要复杂。相反,我们利用 Grafana 和我们的自定义插件来执行所需的路由,并使我们的应用程序具备区域感知能力。

这意味着每个集群都必须被配置为数据源。当用户查询特定的命名空间或 pod 时,我们开发的自定义插件会确保只查询与该 pod 所在区域相关联的数据源。

成本分析
以下分析仅针对 AWS,不考虑我们在 GCE(LogHouse) 上的基础设施。我们的 GCE 环境的基础设施成本与 AWS 类似,但基础设施成本可能有所不同。
根据官方价格,我们当前在 AWS 上的 LogHouse 基础设施每月花费 125,000 美元,并处理每月 5.4PB 的吞吐量(未压缩)。
这 125,000 美元包括承载网关的硬件成本。需要注意的是,这些网关也处理我们的度量管道,因此并非专门用于日志处理。因此,这个数字是与 LogHouse 数据导入相关的硬件成本的高估。
这个基础设施存储了六个月的数据,总计约 19 PiB 未压缩数据,以及略多于 1 PiB 的压缩数据存储在 S3 上。尽管我们很难提供一个精确的成本模型,但我们基于每月的 Tebibyte 吞吐量来预测我们的成本。这需要一些假设:
我们的 EC2 基础设施将根据吞吐量呈线性扩展。我们相信我们的 OpenTelemetry 网关将根据资源和每秒事件线性扩展。更大的吞吐量将需要更多的 ClickHouse 资源进行数据摄取。同样,我们假设这是线性的。相反,较小的吞吐量将需要更少的基础设施。
ClickHouse 为我们的数据保持约 17 倍的压缩比。
我们假设我们的用户数量在数据量增长时保持不变,并忽略了 S3 的 GET 请求费用 - 这些费用是查询和插入的函数,并且不被认为是显著的。
为了简化起见,我们假设保留时间为 30 天。
在我们最近的一个月中,我们摄取了 5532TB 的数据。利用这个数据和上述成本,我们可以计算我们每月每 Tebibyte 的 EC2 成本:
每月每 Tebibyte 的 EC2 成本为($125,000/5542)= $22.55
利用 S3 中每 Gibibyte 的成本为 $0.021,以及一个压缩比为 17 倍,这给我们提供了一个计算公式:
T = 每月吞吐量(未压缩)
成本($)= EC2 成本(摄取/查询)+ 保留(S3)= (22.55 * T)+((T*1024)/17 * 0.021)= 23.76T
这给我们每月每 Tebibyte(未压缩)的成本为 $23.76。


这里的黄线实际上代表的是 ClickHouse!由于价格比达到了 200 倍,当 ClickHouse 叠加在 Datadog 的尺度上时,它看起来像一条水平线。仔细观察,它并不完全平坦 :)
实际上,我们将数据存储了 6 个月,在 AWS LogHouse 中托管了 19 PiB 的未压缩日志。然而,重要的是,这只影响了我们的存储成本,而不改变我们使用的基础设施量。这归因于使用 S3 进行存储和计算的分离。这意味着如果需要进行线性扫描,例如在历史分析期间,我们的查询性能会较慢。我们接受这一点,以换取每 TiB 更低的总体价格,如下所示:
(1.13 PiB(压缩)* 1048576 * 0.021)= $24,883 + $125000 ≈ $150,000
因此,每月 $150,000,用于 19.11 PiB 的未压缩日志,或每 TiB 约 $7.66。
成本比较
考虑到我们从未认真考虑过其他解决方案,比如 Datadog 或 Elastic,因为它们在价格上甚至远远无法与我们竞争,很难说我们节省了具体金额。
如果我们考虑 Datadog 并将数据保留时间限制为 30 天,我们可以估算一个价格。假设我们不进行查询(可能有点乐观),我们将每 GiB 数据摄入产生 $0.1 的费用。根据我们当前的事件大小(参见前面的顶级指标),1 TiB 约等于 1.885B 事件。Datadog 还额外收取每月每百万日志事件 $2.50 的费用。
因此,我们每个月每个未压缩的 TiB 的成本是:
费用($)= 摄入 + 保留 =(T*1000*0.1)+(T*1885*2.5)= 100T+4712T=4812T
每个未压缩的 TiB 摄入成本约为 $4,230。这比 ClickHouse 贵了 200 倍多。
性能
我们最大的 ClickHouse LogHouse 集群由 5 个节点组成,每个节点配置了 200 GiB 的内存和 57 个核心,存储了超过 10 万亿行数据,将数据压缩率达到了超过 17 倍。在每个云区域都有一个集群的情况下,我们目前在 LogHouse 中托管了超过 37 万亿行和 19 PiB 数据,分布在 13 个集群/区域和 48 个节点上。

我们的查询延迟略高于一分钟。我们注意到这种性能在不断改进,并且受到我们核心团队执行的分析查询(如第 50 百分位所示)的影响,这些查询会扫描所有 37 万亿行,以确定特定的日志模式(例如,查看在过去的 6 个月中其他用户是否曾遇到过客户遇到的问题)。这可以通过查询时间的直方图来最好地说明。


从 ClickHouse 核心数据库开发的角度来看,我们的可观测性团队对几项工作充满期待,其中最重要的是半结构化数据:
最近,将 JSON 类型推向生产就绪状态的努力将对我们的日志使用案例产生重大影响。目前,这个功能正在重新设计,Variant 类型的开发为更健壮的实现奠定了基础。准备就绪时,我们预计它将取代我们的映射,使用更强类型(即非统一类型)的元数据结构,这些结构也可能是分层的。
从集成的角度来看,我们持续支持可观测性轨迹,努力提升整个流程中的集成水平。这意味着我们将升级 ClickHouse OpenTelemetry 导出器以进行收集和聚合,并提供增强和具有观点的 Grafana 插件用于 ClickHouse。

在本文中,我们分享了我们构建基于 ClickHouse 的日志记录解决方案的详细过程,目前仅在我们的 AWS 区域中,该解决方案就存储了超过 19 PiB 的数据(压缩后为 1.13 PiB)。
我们审查了我们的架构以及在构建此平台时做出的关键技术决策,希望这对于那些也有兴趣的朋友,使用最先进工具构建可观测性平台,而不是使用像 Datadog 这样的现成服务,虽然这样的人们是有用的。
最后,我们展示了对于像我们这样的可观测性工作负载,ClickHouse 的成本至少比 Datadog 低 200 倍 - 预计 Datadog 在 30 天的保留期间的成本高达每月约 2600 万美元!
征稿启示
面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出&图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:Tracy.Wang@clickhouse.com






