博客 / 工程

我们如何使用 ClickHouse 构建 19 PiB 日志平台并节省数百万美元

author avatar
Rory Crispin
2024 年 4 月 2 日 - 41 分钟阅读

简介

我们坚信尽可能使用我们自己的技术进行实践,尤其是在解决我们认为 ClickHouse 最擅长解决的挑战时。去年,我们详细解释了我们如何使用 ClickHouse 构建内部数据仓库以及我们面临的挑战。在这篇文章中,我们探讨了一个不同的内部用例:可观测性,以及我们如何使用 ClickHouse 来满足我们的内部需求,并存储 ClickHouse Cloud 生成的海量日志数据。正如您将在下面读到的,这每年为我们节省数百万美元,并使我们能够扩展我们的 ClickHouse Cloud 服务,而无需担心可观测性成本,或在我们保留的日志数据上做出妥协。

为了让其他人从我们的旅程中受益,我们提供了我们自己基于 ClickHouse 的日志解决方案的详细信息,该解决方案仅在我们的 AWS 区域就包含了超过 19 PiB 未压缩数据,或 37 万亿行数据。作为一般设计理念,我们渴望最大限度地减少活动部件的数量,并确保设计尽可能简单且可复现。

正如我们在后面的定价分析部分中展示的那样,对于我们的工作负载,假设 Datadog 和 AWS 列出的托管 ClickHouse 和将数据存储在 S3 中的价格,ClickHouse 至少比 Datadog 便宜 200 倍。

拥有巨大挑战的小团队

当我们首次发布我们的初始 ClickHouse Cloud 产品一年多以前,我们不得不做出妥协。虽然我们当时认识到 ClickHouse 可以用于构建可观测性解决方案,但我们的首要任务是构建 Cloud 服务本身。为了尽快交付世界一流的服务,我们最初决定使用 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 的经验,我们知道对于相同的工作负载,它会便宜得多,并且看到其他公司已经基于 ClickHouse 构建了他们的日志解决方案(例如,highlight.ioSignoz),我们优先将日志数据的存储迁移到 ClickHouse,并组建了一个内部可观测性团队。凭借 1.5 名全职工程师的小团队,我们在不到三个月的时间内着手构建了我们基于 ClickHouse 的日志平台“LogHouse”!

我们的内部可观测性团队负责的不仅仅是 LogHouse。为了更广泛地让 ClickHouse 的工程师能够了解我们正在运行的 ClickHouse Cloud 中所有集群的运行状况,我们提供了许多服务,包括警报服务,用于检测应主动解决的集群行为模式。

问题的规模

截至撰写本文时,ClickHouse Cloud 在 9 个 AWS 和 4 个 GCP 区域可用,Azure 支持即将推出。仅我们最大的区域每秒就产生超过 110 万条日志行。在所有 AWS 区域中,这创造了一些令人瞠目结舌的数字:logging_01.png

这里可能立即显而易见的是我们实现的压缩级别 - 19 PiB 压缩到大约 1.13 PiB,或 17 倍压缩。ClickHouse 实现的这种压缩级别对于项目的成功至关重要,并使我们能够在成本效益高的同时仍然提供良好的查询性能。

我们整个 LogHouse 环境目前超过 19 PiB。此数字仅包括我们的 AWS 环境。

ClickHouse Cloud 基础设施

我们在之前的文章中讨论了 ClickHouse Cloud 背后的架构。此架构中与我们的日志解决方案相关的最主要功能是,ClickHouse 实例作为 Kubernetes 中的 pod 部署,并由自定义 operator 编排。

Pod 将日志记录到 stdoutstderr,并由 Kubernetes 作为文件捕获,按照标准配置。然后,OpenTelemetry 代理可以读取这些日志文件并将它们转发到 ClickHouse。

虽然我们的大部分数据来自 ClickHouse 服务器(某些实例在重负载下每秒可以记录 4,000 行)和 Keeper 日志,但我们也从云数据平面收集数据。这包括来自我们的 operator 和在节点上运行的支持服务的日志。对于 ClickHouse 集群编排,我们依赖 ClickHouse Keeper(由我们的核心团队开发的 C++ ZooKeeper 替代方案),它生成涉及集群操作的详细日志。虽然 Keeper 日志在任何时候都占流量的 50% 左右(而 ClickHouse 服务器日志占 49%,数据平面占 1%),但此数据集的保留期较短,为 1 周(而服务器日志为 6 个月),这意味着它在整体数据中占比要小得多。

基于 ClickHouse Cloud 构建

由于我们正在监控 ClickHouse Cloud,因此为 LogHouse 提供支持的集群不能位于 ClickHouse Cloud 基础设施本身内——否则,我们将在被监控的系统和监控工具之间创建依赖关系。

但是,我们仍然希望从 ClickHouse Cloud 背后的技术中受益——特别是通过 SharedMergeTree 表引擎实现存储和计算分离。原则上,这使我们能够受益于将数据存储在 S3 中,并在本地使用大型 NVMe 缓存,并使我们能够几乎无限地扩展集群宽度。考虑到我们的数据量只会随着我们吸引更多客户而增加,我们不想因磁盘空间限制而面临不得不配置更多集群和/或节点的挑战。通过将所有数据存储在 S3 中,节点仅使用本地 NVMe 进行缓存,我们可以轻松扩展并专注于数据本身。

logging_02.png

因此,我们在每个区域运行我们自己的“迷你 ClickHouse Cloud”(见下文),甚至使用 ClickHouse Cloud 的 Kubernetes operator。使用“云”的能力对于我们能够在如此短的时间内构建解决方案,并由一个小团队管理基础设施至关重要。我们真的站在巨人的肩膀上(又名 ClickHouse 核心团队)。

虽然此解决方案特定于我们的需求,但它等同于 ClickHouse Cloud 的 专用层 产品,客户的集群部署在专用基础设施上,允许他们控制部署的各个方面,例如更新和维护窗口。最重要的是,其他想要复制我们解决方案的用户不会受到相同的循环依赖性约束,并且可以简单地使用 ClickHouse Cloud 集群。

我们目前正在努力将内部集群(如 LogHouse)迁移到我们的 ClickHouse Cloud 基础设施中。它将被隔离在单独的 Kubernetes 集群中,但在其他方面将是一个“标准”的 ClickHouse Cloud 集群,与我们的客户使用的集群相同。

了解您的用户

使用 ClickHouse 进行日志存储要求用户拥抱基于 SQL 的可观测性。原则上,这意味着用户可以舒适地使用 SQL 和 ClickHouse 的大量字符串匹配分析函数搜索日志。借助 Grafana 等可视化工具,这种采用变得更加简单,Grafana 为常见的可观测性操作提供了查询构建器

在我们的案例中,我们的用户非常熟悉 SQL,因为他们是 ClickHouse 支持和核心工程师。虽然这具有查询通常经过高度优化的优势,但也带来了一系列挑战。最值得注意的是,这些用户是高级用户,他们最初只会使用基于可视化的工具进行初步分析,然后才希望通过 ClickHouse 客户端直接连接到 LogHouse 实例。这意味着我们需要一种简单的方法,让用户可以轻松地在任何仪表板和客户端之间导航。

在大多数情况下,对 Cloud 问题的调查是由 Prometheus 收集的集群指标触发的警报发起的。这些警报检测到可能存在问题的行为(例如,大量 parts),应进行调查。或者,客户可能会通过支持渠道提出问题,指出异常行为或无法解释的错误。

一旦需要更深入的分析,日志就变得与调查相关。至此,我们的支持或核心工程师已经确定了客户集群、其区域以及关联的 Kubernetes 命名空间。这意味着大多数搜索都按时间和一组 pod 名称进行过滤——后者可以很容易地从 Kubernetes 命名空间中识别出来。

我们稍后展示的 schema 针对这些特定工作流程进行了优化,并有助于在多 PB 级规模下提供出色的查询性能。

早期的一些设计决策

以下早期设计决策对我们的最终架构产生了重大影响。

不跨区域发送数据

首先,我们不跨区域发送日志数据。考虑到即使是小区域产生的数据量,从数据出口成本的角度来看,集中式日志记录也是不可行的。相反,我们在每个区域托管一个 LogHouse 集群。正如我们将在下面讨论的那样,用户仍然可以跨区域查询。

不使用 Kafka 队列作为消息缓冲区

使用 Kafka 队列作为消息缓冲区是在日志记录架构中看到的一种流行的设计模式,并由 ELK 栈推广。它提供了一些好处;最主要的是,它有助于提供更强的消息传递保证并帮助处理反压。消息从收集代理发送到 Kafka 并写入磁盘。理论上,集群式 Kafka 实例应提供高吞吐量消息缓冲区,因为它线性写入数据到磁盘比解析和处理消息产生的计算开销更少——例如,在 Elastic 中,tokenization 和索引会产生大量开销。通过将数据从代理移开,您还可以降低因源端的 日志轮换而丢失消息的风险。最后,它提供了一些消息回复和跨区域复制功能,这可能对某些用例具有吸引力。

但是,ClickHouse 可以非常、非常快速地处理数据插入——在中等硬件上每秒数百万行。来自 ClickHouse 的反压很少见**。** 因此,在我们的规模下,利用 Kafka 队列根本没有意义,并且意味着比必要的架构复杂性和成本更高。在确定这种架构时,我们也接受一个重要的原则——并非所有日志都需要相同的交付保证。在我们的案例中,我们对传输中数据丢失的容忍度更高,因为如果我们需要日志,日志还有第二个副本(在实例本身上)——尽管如此,我们仍然希望尽量减少任何传输中数据丢失,因为消息丢失可能会扰乱调查。

改善传输中数据丢失

我们目前的传输中丢失率高于我们能够接受的水平。我们将此归因于以下几个因素

  1. 我们的摄取层缺乏自动扩缩容,这在 ClickHouse Cloud 中启动特定事件时(例如更新)可能容易受到流量峰值的影响。尽管有 (2),但这仍然是我们打算开发的功能。
  2. 我们已在 OTEL 收集器中发现一个问题,即代理和网关之间的连接未均匀分布,导致网关上出现“热点”,其中单个收集器接收更高百分比的负载。此收集器变得不堪重负,并且与 (1) 结合使用时,我们会遇到传输中日志丢失率增加的情况。我们正在解决此问题,并计划将修复程序贡献回去,以便其他人可以受益。

我们目前没有利用 ClickHouse Cloud 的自动扩缩容功能,因为来自 ClickHouse 本身的反压实例很少发生。但是,随着我们解决和修复前面的问题,我们的 ClickHouse 实例可能会遇到更高的流量峰值。到目前为止,收集器现有的挑战充当了缓冲区,使 ClickHouse 免受此类波动的影响。展望未来,我们可能因此需要探索实施 ClickHouse Cloud 自动扩缩容器的潜在好处,以更好地管理这些预期的需求增长。

尽管存在这些挑战,但我们不后悔我们不部署 Kafka 的决定。通过接受此原则,我们最终的架构更简单、更便宜、延迟更低,并且我们有信心通过采取上述措施将传输中数据丢失率降低到可接受的水平。

结构化日志记录

我们想说我们的日志始终是结构化的,并干净地插入到完美的 schema 中。然而,事实并非如此。我们的初始部署将日志作为纯文本字符串发送,用户依靠 ClickHouse 字符串函数来提取元数据。这主要是因为我们的用户倾向于以原始形式使用日志。虽然这仍然提供了出色的压缩,但查询性能受到影响,因为每个查询都会产生线性扫描。这是一个最初的架构错误,我们建议您不要重复。

在与我们的用户讨论了利弊之后,我们转向了结构化日志记录,ClickHouse 实例以 JSON 格式记录日志。如下所述,这些 JSON 键默认存储在 Map(String, String) 类型中。但是,我们期望频繁查询的选择字段在插入时提取为完整列。然后,这些可以在我们的排序键中使用,并配置为利用二级索引专用编解码器。这允许优化对 pod_name 等列的查询,确保最常见的工作流程针对查询性能进行优化。用户仍然可以访问 Map 键以获取不太常用的元数据。但是,根据我们的经验,在没有对优化列进行过滤的情况下,这种情况很少发生。

选择 OpenTelemetry (OTel)

使用 OpenTelemetry 的决定是我们最大的设计决策,因此值得单独讨论。这也代表了我们最初最担心的决定,因为当我们开始该项目时,ClickHouse OTel 导出器对于日志收集而言相对未经证实(与追踪相比)——它处于 alpha 阶段,而且我们不知道任何在我们规模上的部署。我们决定投资是基于以下几个因素

  • 社区采用 - 更广泛的 OTel 项目已经看到了显著的采用。我们还看到其他公司成功地使用了 ClickHouse 导出器,尽管它处于 alpha 状态。此外,许多基于 ClickHouse 的可观测性公司,例如 highlight.ioSignoz,都将 OTel 用作其摄取的标准。
  • 投资未来 - 该项目本身已达到一定的成熟度和采用率,这表明它将成为收集日志、追踪和指标的事实标准手段。Dynatrace、Datadog 和 Splunk 等专有可观测性供应商的投资表明,其作为标准的出现已被广泛接受。
  • 超越日志 - 我们考虑了其他日志收集代理,特别是 Fluentd 和 Vector,但想要一个堆栈,使我们能够轻松地扩展 LogHouse 以在以后收集指标和追踪。

自定义处理器

OTel 的常见痛点之一是需要在 YAML 中声明复杂的管道,链接接收器、处理器和发送器。我们在 OTel 收集器实例中执行大量处理(在网关层——见下文),需要根据源执行条件路由,例如,ClickHouse 实例、数据平面和 Keeper 日志都转到具有优化 schema 的单独表。

根据我们的经验,管理此 YAML 代码容易出错且难以测试。我们没有依赖它,而是在 Go 中开发了一个自定义处理器,它执行我们所有必需的转换逻辑。这意味着我们部署了 OTel 收集器的自定义构建版本,但我们受益于能够轻松测试对处理的任何更改。使用自定义处理器的这种内部管道也比从声明式方法构建的等效管道更快,从而进一步节省了我们的资源并减少了端到端延迟。

架构概述

在高层面上,我们的管道看起来像这样

logging_03.png

我们将 OTel 收集器部署为每个 Kubernetes 节点上的代理,用于日志收集,并部署为网关,消息在发送到 ClickHouse 之前进行处理。这是 Open Telemetry 记录的经典代理-网关架构,允许将日志处理的开销从节点本身移开。这有助于确保代理资源占用空间尽可能小,因为它仅负责将日志转发到网关实例。由于网关执行所有消息处理,因此也必须随着 Kubernetes 节点数量的增加以及日志的增加而扩展这些网关。正如我们将在下面展示的那样,此架构为我们留下了一个每个节点的代理,其资源根据底层实例类型分配。这是基于以下推理:较大的实例类型具有更多的 ClickHouse 活动,因此日志也更多。然后,这些代理将其日志转发到“银行”网关进行处理,这些网关根据总日志吞吐量进行扩展。

在上述基础上扩展,单个区域,无论是 AWS 还是 GCP(以及即将推出的 Azure),看起来都像下面这样

logging_04.png

应该立即显而易见的是它的架构有多么简单。关于这一点的一些重要说明

  • 我们的 ClickHouse Cloud 环境 (AWS) 目前使用 m5d.24xlarge (96 个 vCPU,384GiB RAM)、m5d.16xlarge (64 个 vCPU,256GiB RAM)、m5d.8xlarge (32 个 vCPU,128GiB RAM)、m5d.2xlarge (8 个 vCPU,32GiB RAM) 和 r5d.2xlarge (8 个 vCPU 64GiB) 的混合。由于 ClickHouse Cloud 允许用户创建不同总资源大小的集群(使用固定的 1:4 vCPU:RAM 比率),通过垂直和水平扩展,ClickHouse pod 的实际大小可能会有所不同。这些 pod 如何打包到节点上本身就是一个话题,但我们推荐这篇博客。然而,总的来说,这意味着较大的实例类型托管更多的 ClickHouse pod(这会生成我们大部分的日志数据)。这意味着实例大小与需要由代理转发的日志数据量之间存在很强的相关性。
  • OTel 收集器代理作为 每个 Kubernetes 节点上的守护程序集部署。分配给这些代理的资源取决于底层实例大小。总的来说,我们有三种不同资源尺寸的 T 恤尺寸:小号、中号或大号。对于 m5d.16xlarge 实例,我们使用大型代理,它被分配 1 个 vCPU 和 1GiB 内存。这足以处理从此节点上的 ClickHouse 实例收集日志,即使节点完全占用也是如此。我们在代理级别执行最少的处理,以最大限度地减少资源开销,仅从日志文件中提取时间戳以覆盖 Kubernetes 观察到的时间戳。为代理分配更高的资源会对托管成本以外的方面产生影响。重要的是,它们可能会影响我们的 operator 在节点上高效打包 ClickHouse 实例的能力,从而可能导致驱逐和资源未充分利用。以下显示了从节点大小到代理资源的映射(仅限 AWS)
Kubernetes 节点大小收集器代理 T 恤尺寸分配给收集器代理的资源日志记录速率
m5d.16xlarge大型1 CPU,1GiB10k/秒
m5d.8xlarge中型0.5 CPU,0.5GiB5k/秒
m5d.2xlarge小型0.2CPU,0.2GiB1k/秒
  • OTel 收集器代理通过配置为使用拓扑感知路由的 Kubernetes 服务将其数据发送到网关实例。由于每个区域在每个可用区中至少有一个网关(用于高可用性),因此这确保了默认情况下数据尽可能路由到同一可用区。在日志吞吐量较高的较大区域中,我们配置了更多网关来处理负载。例如,在我们最大的区域中,我们目前有 16 个网关来处理 110 万行/秒。每个网关都有 11 GiB 内存和三个核心。
  • 由于我们的 LogHouse 实例驻留在不同的 Kubernetes 集群中,为了与 Cloud 保持隔离,网关通过集群的 NLB 将其流量转发到这些 ClickHouse 实例。这目前不感知可用区,并且导致区域间通信高于最佳水平。因此,我们正在确保此 NLB 利用最近宣布的 可用区 DNS 亲和性
  • 我们的网关和代理都通过 Helm chart 部署,这些 chart 作为由 ArgoCD 编排的 CI/CD 管道的一部分自动部署,配置在 Git 中。
  • 我们的收集器网关通过 Prometheus 指标进行监控,警报指示这些网关何时遇到资源压力。
  • 我们的 ClickHouse 实例使用与 Cloud 相同的部署架构——3 个 Keeper 实例分布在不同的可用区中,ClickHouse 也相应分布。
  • 我们最大的 LogHouse ClickHouse 集群由 5 个节点组成,每个节点 200GiB RAM 和 57 个核心,部署在 m5d.16xlarge 上。这处理每秒超过 100 万行的插入。
  • 每个区域当前部署一个 Grafana 实例,其流量通过 NLB 在其本地 LogHouse 集群上进行负载均衡。然而,正如我们在下面更详细讨论的那样,每个 Grafana 都可以访问其他区域(通过 TailScale VPN 和 IP 白名单)。

调整网关和处理反压

鉴于我们的网关执行我们的大部分数据处理,因此从这些网关获得性能是最大限度地降低成本和最大限度地提高吞吐量的关键。在测试期间,我们确定每个具有三个核心的网关可以处理大约 6 万个事件/秒。

我们的网关不执行数据磁盘持久化,仅在内存中缓冲事件。目前,我们的网关分配了 11 GiB,在每个区域中部署了足够的网关,以便在 ClickHouse 变得不可用的罕见情况下提供长达 2 小时的缓冲。因此,我们当前的内存与 CPU 比率和网关数量是在吞吐量与在 ClickHouse 变得不可用时提供足够的内存来缓冲日志消息之间的一种折衷方案。我们更倾向于水平扩展网关,因为它提供了更好的容错能力(如果某些 EC2 节点遇到问题,我们的风险会更小)。

重要的是,如果缓冲区已满,则队列前端的事件将被丢弃,即网关将始终接受来自代理本身的事件,我们在代理本身上不应用反压。但是,我们的 2 小时窗口已被证明是足够的,OTel 管道问题很少影响我们的数据保留质量。也就是说,我们目前还在探索 OTel 收集器是否能够为我们的网关缓冲到磁盘——从而有可能使我们能够为下游问题提供更大的弹性,同时还可能减少网关的内存占用。

logging_05.png

logging_06.png

我们在初始部署期间进行了一些初步测试,以确定最佳批处理大小。这是为了在将数据高效插入 ClickHouse 和确保日志及时可用于搜索之间取得折衷。更具体地说,虽然较大的批次通常更适合 ClickHouse 的插入操作,但这必须与数据的可用性以及网关上的内存压力(以及非常大的批次大小的 ClickHouse 上的内存压力)相平衡。我们最终确定 批处理器 的批处理大小为 15K 行,这既提供了我们所需的吞吐量,又满足了我们 2 分钟的数据可用性 SLA。

最后,我们的吞吐量确实存在较低的时期。因此,我们还在网关中每隔 5 秒刷新一次批处理器(请参阅 timeout),以确保数据在上述 SLA 范围内可用。由于这可能会导致较小的插入操作,因此我们遵循 ClickHouse 的最佳实践,并依赖于 异步插入

摄取处理

物化视图

OTel 收集器将字段整理到两个主要映射中:ResourceAttributesLogAttributes。前者包含由代理实例添加的字段,在我们的例子中,主要包含 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 列的查询需要解压缩和读取整个 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 表,其中存储最终转换后的行。我们在下面说明此过程

logging_07.png

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

Schema

我们的数据平面和 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 = SharedMergeTree
PARTITION BY EventDate
ORDER BY (PodName, Timestamp)
TTL EventTime + toIntervalDay(180)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;

关于我们的模式的一些观察

  • 我们使用排序键 (PodName, Timestamp)。这针对我们的查询访问模式进行了优化,用户通常按这些列进行过滤。用户将需要根据他们预期的工作流程修改此设置。
  • 我们对所有 String 列使用 LowCardinality(String) 类型,但基数非常高的列除外。此字典对我们的字符串值进行编码,并已证明可以提高压缩率,从而提高读取性能。我们当前的经验法则是对任何基数低于 10,000 个唯一值的字符串列应用此编码。
  • 我们所有列的默认压缩编解码器是级别 1 的 ZSTD。这特别适用于我们的数据存储在 S3 上的情况。虽然与 LZ4 等替代方案相比,ZSTD 在压缩方面可能较慢,但这可以通过更好的压缩率和始终如一的快速解压缩(大约 20% 的差异)来弥补。当使用 S3 进行存储时,这些是更可取的属性。
  • OTel 模式 继承而来,我们对任何 map 的键和值都使用 bloom_filters。这为基于 Bloom 过滤器数据结构的 map 的键和值提供了二级索引。Bloom 过滤器是一种数据结构,它允许以空间高效的方式测试集合成员资格,但会略微增加误报的可能性。从理论上讲,这使我们能够快速评估 磁盘上的 granule 是否包含特定的 map 键或值。此过滤器在逻辑上可能是有意义的,因为某些 map 键和值应与 pod 名称和时间戳的排序键相关联,即特定的 pod 将具有特定的属性。但是,其他属性将存在于每个 pod 中 - 当查询这些值时,我们不希望这些查询被加速,因为 granule 中至少一行满足过滤条件的可能性非常高(在此配置中,块是 granule,因为 GRANULARITY=1)。有关为什么排序键和列/表达式之间需要关联的更多详细信息,请参阅 此处。此通用规则已应用于其他列,例如 Namespace。通常,这些 Bloom 过滤器已被广泛应用,需要进行优化 - 这是一项待完成的任务。误报率 0.01 也尚未调整。

数据过期

所有模式都按 EventDate 对数据进行分区,以提供许多好处 - 请参阅上述模式中的 PARTITION BY EventDate。首先,由于我们的大多数查询都是针对最近 6 小时的数据,这提供了一种快速过滤到相关部分的方法。虽然这可能意味着在更广泛的日期范围内要查询的部分数量更多,但我们尚未发现这对这些查询产生了明显的影响。

最重要的是,它使我们能够高效地使数据过期。我们可以简单地删除任何超过保留时间的 partition,使用 ClickHouse 的 核心数据管理功能 - 请参阅 TTL EventTime + toIntervalDay(180) 声明。对于 ClickHouse 服务器日志,这是 180 天,如上述模式所示,因为我们的核心团队使用它来调查任何已发现问题的历史普遍性。其他日志类型(例如来自 Keeper 的日志)的保留需求要短得多,因为它们在初始问题解决之外的用途较少。这种分区意味着我们还可以使用 ttl_only_drop_parts 来高效地删除数据。

增强 Grafana

Grafana 是我们推荐的用于 ClickHouse 可观察性数据的可视化工具。最近发布的 4.0 版本使从 Explore 视图快速查询日志变得更加简单,并且在内部受到我们工程师的赞赏。虽然我们确实开箱即用地使用了该插件,但我们也通过额外的 scenes 插件 扩展了 Grafana 以满足我们的需求。我们称之为 LogHouse UI 的这个应用程序,具有强烈的倾向性,并针对我们的特定工作负载进行了优化,并且与 Grafana 插件紧密集成。一些需求促使我们构建了这个应用程序

  • 有一些特定的可视化效果是我们始终需要为诊断目的显示的。虽然这些可以通过仪表板来支持,但我们希望它们与我们的日志探索体验紧密集成 - 可以将其视为 Explore 和仪表板的混合体。仪表板需要使用变量,我们有时会觉得这有点笨拙,并且我们希望确保我们的查询针对最常见的访问模式进行了尽可能地优化。
  • 我们的用户在处理问题时通常使用命名空间或集群 ID 进行查询。LogHouse UI 能够自动将查询限制为按 pod 名称(以及任何用户时间限制)进行查询,从而确保我们的排序键得到有效利用。
  • 鉴于我们为不同的数据类型提供了专用的模式,LogHouse UI 插件会根据查询自动在后备模式之间切换,从而为用户提供无缝体验。
  • 我们实际的表模式可能会发生变化。虽然在大多数情况下,我们都能够在不影响 UI 的情况下进行这些修改,但界面是模式感知的,这很有帮助。因此,我们的插件确保在查询特定时间范围时使用正确的模式。这使我们可以在不必担心会破坏用户体验的情况下进行模式更改。
  • 我们的用户非常精通 SQL,并且通常喜欢在 UI 中进行初步调查后,切换到 ClickHouse 客户端查询日志数据。我们的应用程序为任何可视化效果提供了带有相关 SQL 的 clickhouse-client 快捷方式。这允许我们的用户在各个工具之间单击切换,典型的工作流程是从 Grafana 开始,然后在通过客户端连接进行更深入的分析之前,可以在客户端中制定高度集中的 SQL 查询。
  • 我们的应用程序还启用了跨区域查询,我们将在下面进行探讨。

总的来说,我们与日志数据相关的大部分工作负载都是探索性的,仪表板通常基于指标数据。因此,定制的日志探索体验是有意义的,并且已被证明值得投资。

logging_08.png

跨区域查询

始终渴望为我们的用户提供尽可能最佳的体验,我们希望提供一个可以从中查询任何 ClickHouse 集群的单一端点,而不是用户需要导航到特定区域的 Grafana 实例来查询本地数据。

正如我们之前提到的,我们的数据量实在太大,无法进行任何跨区域数据复制 - 仅数据出口费用就将超过当前运行 ClickHouse 基础设施的成本。因此,我们需要确保可以从 Grafana 托管区域(及其各自的故障转移区域)查询任何 ClickHouse 集群。

我们最初对这个问题的幼稚实现只是使用了 ClickHouse 中的 Distributed 表,因此可以从任何节点查询所有区域。这要求我们在每个 LogHouse 区域中配置一个逻辑集群 包含每个区域的分片,其中包含各自的节点。这实际上创建了一个连接到所有区域中所有 LogHouse 节点的单个单体集群。

虽然这可行,但在查询 Distributed 表时,必须向每个节点发出请求。由于我们不跨区域复制数据,因此只有一个区域中的节点可以访问每个查询的相关数据。这种缺乏地理感知能力意味着其他集群会消耗资源来评估永远无法匹配的查询。对于包含排序键(pod 名称)过滤器的查询,这会产生非常小的开销。然而,更广泛、更具探索性的查询,尤其是涉及线性扫描的查询,会浪费大量资源。

我们能够通过巧妙地使用 cluster 函数来解决这个问题,但这使得我们的查询相当笨拙且不必要地复杂。相反,我们利用 Grafana 和我们的自定义插件来执行所需的路由,并使我们的应用程序具有区域感知能力。

logging_09.png

这要求将每个集群配置为数据源。当用户查询特定的命名空间或 pod 时,我们构建的自定义插件可确保仅查询与 pod 区域关联的数据源。

性能和成本

成本分析

以下分析不考虑我们用于 LogHouse 的 GCE 基础设施,而仅关注 AWS。我们的 GCE 环境确实具有与 AWS 相似的线性价格特征,基础设施成本存在一些差异。

我们当前的 AWS LogHouse 基础设施成本为每月 12.5 万美元(按标价),每月处理 5.4PiB 的吞吐量(未压缩)。

这 125,000 美元包括托管我们网关的硬件。我们注意到,这些网关还处理我们的指标管道,因此并非专门用于日志处理。因此,这个数字被高估了,因为它与 LogHouse 摄取相关的硬件有关。

该基础设施存储六个月的数据,总计 19 PiB 未压缩数据,压缩后在 S3 中存储的仅略高于 1 PiB 数据。虽然我们很难提供精确的成本模型,但我们根据每月 TiB 吞吐量来预测我们的成本。这需要一些假设

  • 我们的 EC2 基础设施将根据吞吐量线性扩展。我们确信我们的 OTel 网关将相对于资源和每秒事件数线性扩展。更大的吞吐量将需要更多的 ClickHouse 资源用于摄取。同样,我们假设这是线性的。相反,较小的吞吐量将需要较少的基础设施。
  • ClickHouse 为我们的数据保持约 17 倍的压缩率。
  • 我们假设如果我们的数据量增长,我们的用户数量保持不变,并忽略 S3 GET 请求费用 - 这些费用是查询和插入的函数,并且被认为不重要。
  • 为简单起见,我们假设保留 30 天。

在我们最近的一个月中,我们摄取了 5532TB 的数据。使用此数据和上述成本,我们可以计算出我们每月每 TiB 的 EC2 成本

每月每 TiB 的 EC2 成本为($125,000/5542)= $22.55

使用 S3 中每 GiB 0.021 美元的成本 和 17 倍的压缩率,这给了我们一个函数

T = 每月 TiB 吞吐量(未压缩)

成本 ($) = EC2 成本(摄取/查询)+ 保留(S3)= (22.55 * T) + ((T*1024 )/17 * 0.021) = 23.76T

这使我们每月每 TiB(未压缩)的成本为 23.76 美元。

logging_10.png

logging_11.png

这里的黄线实际上是 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 相当于大约 18.85 亿个事件。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 RAM 和 57 个核心,可容纳超过 10 万亿行,并将数据压缩超过 17 倍。通过在每个云区域中部署一个集群,我们目前在 LogHouse 中托管超过 37 万亿行和 19 PiB 的数据,分布在 13 个集群/区域和 48 个节点中。

Markdown Image

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

Markdown Image

展望未来

从 ClickHouse 核心数据库开发的角度来看,我们的可观察性团队对许多工作感到兴奋。其中最重要的是 半结构化数据

最近将 JSON 类型移至生产就绪状态的努力将非常适用于我们的日志记录用例。此功能目前正在重新架构,Variant 类型的开发为更强大的实现奠定了基础。准备就绪后,我们希望它能取代我们的 map,使用更强类型(即非统一类型)的元数据结构,这些结构也可能是分层的。

从集成角度来看,我们继续支持可观察性轨道,确保在整个管道中提升我们的集成能力。这转化为升级 ClickHouse OpenTelemetry 导出器以进行收集和聚合,以及增强且具有倾向性的 Grafana 插件用于 ClickHouse。

结论

在这篇文章中,我们分享了构建由 ClickHouse 驱动的日志记录解决方案的历程详情,该解决方案如今仅在我们的 AWS 区域中就存储了超过 19 PiB 的数据(压缩后为 1.13 PiB)。

我们回顾了此平台背后的架构和关键技术决策,希望这对其他有兴趣使用最佳工具构建可观察性平台,而不是使用像 Datadog 这样的开箱即用服务的人员有所帮助。

最后,我们证明,对于像我们这样的可观察性工作负载,ClickHouse 至少比 Datadog 便宜 200 倍 - Datadog 30 天保留期的预计成本高达每月约 2600 万美元!

立即开始使用 ClickHouse Cloud,并获得 300 美元信用额度。在 30 天试用期结束后,继续使用按需付费计划,或联系我们以了解有关我们基于量的折扣的更多信息。访问我们的定价页面了解详情。

分享此文章

订阅我们的新闻通讯

随时了解功能发布、产品路线图、支持和云产品!
正在加载表单...
关注我们
X imageSlack imageGitHub image
Telegram imageMeetup imageRss image
©2025ClickHouse, Inc. 总部位于美国加利福尼亚州湾区和荷兰阿姆斯特丹。