引言
在 ClickHouse,我们认为可观测性仅仅是另一个实时分析问题。作为一款高性能的实时分析数据库,ClickHouse 被用于多种场景,包括时间序列数据的实时分析。其应用场景的多样性推动了大量分析函数的发展,这些函数有助于查询大多数数据类型。这些查询特性和高压缩率使得越来越多的用户开始利用 ClickHouse 来存储可观测性数据。这类数据通常以三种形式出现:logs(日志)、metrics(指标)和 traces(追踪)数据。在这篇博客中,作为可观测性系列的第二篇文章,我们将探讨如何在 ClickHouse 中收集、存储和查询 trace(追踪)数据。
- https://clickhouse.com/blog/working-with-time-series-data-and-functions-ClickHouse
- https://clickhouse.com/docs/en/sql-reference/functions/
本文主要关注使用 OpenTelemetry 收集追踪数据并将其存储到 ClickHouse 中。结合 Grafana 和最近在 ClickHouse 插件方面的发展,追踪
数据可以轻松地可视化,并且可以与日志
和指标
相结合,从而深入理解系统的行为和性能,在检测和诊断问题时提供帮助。
我们尽量确保所有示例都可以被复现。虽然本文着重于数据收集和可视化的基础知识,但我们还提供了一些关于模式优化的小贴士。为了示例的目的,我们从官方 OpenTelemetry 示例项目进行了分叉,增加了对 ClickHouse 的支持,并包含了一个开箱即用的 Grafana 仪表板来可视化追踪数据。
什么是 Traces(追踪数据)?
Telemetry(遥测)是指系统关于自身行为产生的数据。这类数据可以表现为日志、指标和追踪数据等形式。追踪记录了请求(由应用程序或终端用户发起)在微服务和 serverless 架构等多服务架构中的传播路径。一条追踪数据由多个跨度(span)组成,每个跨度代表一个工作单元或操作。跨度提供了操作详情,主要是执行所需的时间,此外还包括其他元数据和相关日志消息。这些跨度按树状结构组织,第一个跨度关联根节点,覆盖整个追踪从开始到结束的过程。在这个根节点及其后的每个跨度之下,捕获子操作。随着我们在树中导航,我们可以看到构成上层的子操作和步骤。这为我们提供了越来越多的上下文,了解原始请求执行的工作。这一过程如下图所示:
当追踪数据与指标和日志结合时,对于深入了解系统的运行情况以及检测和解决问题至关重要。
什么是 OpenTelemetry?
OpenTelemetry 项目是一个供应商中立的开源框架,它包含 SDK、API 和组件,允许摄入、转换并将可观测性数据发送到后端。更具体地说,它主要包括以下几个核心组件:
- 一系列规格和约定 ,规定了如何收集和存储指标、日志和追踪数据。这包括针对特定语言代理的建议,以及基于protobuf的 OpenTelemetry 行协议 (OTLP) 的完整规范。这通过提供客户端-服务器接口和消息格式的完整描述,使数据可以在服务之间传输。
- 特定语言的库和 SDK,用于仪器化、生成、收集和导出可观测性数据。这对于收集追踪数据尤其重要。
- OTEL Collector,采用 Golang 编写,提供了一个供应商无关的实现,用于接收、处理和导出可观测性数据。OTEL Collector 通过支持 Prometheus 和 OTLP 等多种输入格式,以及广泛的导出目标(包括 ClickHouse),提供了一个集中式的处理网关。
总之,OpenTelemetry 标准化了日志、指标和追踪数据的收集。重要的是,它不负责将这些数据存储在可观测性后端中------这就是 ClickHouse 发挥作用的地方!
为什么选择 ClickHouse?
Trace 数据通常表示为一张表,每个跨度为一行,因此可以被视为另一种实时分析问题。除了为此类数据类型提供高压缩比外,ClickHouse 还提供了一个丰富的 SQL 接口,附加有分析函数,使得查询追踪变得简单。与 Grafana 结合使用时,用户可以获得一种成本效益高的方式来存储和可视化追踪数据。尽管其他存储解决方案可能提供类似的压缩水平,但 ClickHouse 在低延迟查询方面独树一帜,它是全球最快的分析型数据库。实际上,这些特性使得 ClickHouse 成为了许多商业可观测性解决方案首选的后端,例如:Signoz.io、Highlight.io、qryn、BetterStack,或者像 Uber、Cloudflare 或 Gitlab 这样的自建大规模可观测性平台。
- https://signoz.io/
- https://www.highlight.io/
- https://qryn.metrico.in/
- https://betterstack.com/press/series-a/
- https://www.uber.com/en-ES/blog/logging/
- https://blog.cloudflare.com/http-analytics-for-6m-requests-per-second-using-clickhouse/?glxid=fdae4a52-2711-4e88-9f04-45e566edbb1c&pagePath=%2Fblog%2Fstoring-traces-and-spans-open-telemetry-in-clickhouse&origPath=%2Fcloud&experiments=mktg-website-nav-cta-btn%3A1%2Cmktg-website-rockset-eyebrow%3A0
- https://about.gitlab.com/handbook/engineering/development/ops/monitor/observability/#how-are-we-currently-using-clickhouse
Instrumentation(仪器化)库
为最流行的编程语言提供了仪器化库。这些库既提供了自动仪器化代码的功能,利用应用程序/服务框架来捕获最常见的指标和追踪数据,也提供了手动仪器化技术。虽然自动仪器化通常已足够,后者允许用户针对其代码的特定部分进行仪器化,可能更详细地捕捉应用程序特定的指标和追踪信息。
为了本篇博客的目的,我们只关注追踪信息的捕获。OpenTelemetry 示例应用由一个微服务架构组成,其中包含许多依赖服务,每个服务使用不同的语言,为实施者提供参考。下面的简单示例展示了如何仪器化一个 Python Flask API 来收集追踪数据:
py
# These are the necessary import declarations
from opentelemetry import trace
from random import randint
from flask import Flask, request
# Acquire a tracer
tracer = trace.get_tracer(__name__)
app = Flask(__name__)
@app.route("/rolldice")
def roll_dice():
return str(do_roll())
def do_roll():
# This creates a new span that's the child of the current one
with tracer.start_as_current_span("do_roll") as rollspan:
res = randint(1, 6)
rollspan.set_attribute("roll.value", res)
return res
对于每个库的详细指南超出了本篇博客的范围,我们鼓励用户阅读与其使用的语言相关的文档。
OTEL Collector
OTEL 收集器接收来自可观测性来源的数据,如来自仪器化库的追踪数据,处理这些数据,并将其导出到目标后端。OTEL 收集器还可以通过支持多种输入格式(如 Prometheus 和 OTLP)以及广泛的导出目标(包括 ClickHouse)提供一个集中式处理网关。
Collector(收集器)使用了管道的概念。这些管道可以是日志、指标或追踪类型,并且由 receiver(接收器)、processor(处理器)和 exporter(导出器)组成。
在这个架构中的接收器充当 OTEL 数据的输入。这可以通过拉取或推送模型实现。虽然这可以在多种协议上发生,但从仪器化库来的追踪数据将通过 OTLP 使用 gRPC 或 HTTP 推送。随后处理器运行在这些数据上,提供过滤、批处理和丰富功能。最后,导出器通过推送或拉取的方式将数据发送到后端目的地。在我们的案例中,我们将把数据推送到 ClickHouse。
- https://opentelemetry.io/docs/collector/configuration/#receivers
- https://opentelemetry.io/docs/reference/specification/protocol/
- https://opentelemetry.io/docs/collector/configuration/#processors
需要注意的是,虽然收集器更常被用作 gateway(网关)/aggregator(聚合器),处理诸如批处理和重试等任务,但它也可以作为代理本身部署------这对于日志收集非常有用,正如我们在之前的帖子中所述。OTLP 代表了网关和代理实例之间通信的 OpenTelemetry 数据标准,可以通过 gRPC 或 HTTP 实现。对于追踪数据的收集目的,收集器仅仅作为网关部署,如下所示:
对于高负载环境,可以构建更高级的架构。我们推荐这个优秀的视频讨论可能的选择。
ClickHouse 支持
通过社区贡献,ClickHouse 在 OTEL 导出器中得到了支持,支持日志、追踪和指标。与 ClickHouse 的通信通过官方 Go 客户端以优化的原生格式和协议进行。在使用 OpenTelemetry 收集器之前,用户应考虑以下几点:
-
代理使用的 ClickHouse 数据模型和模式是硬编码的。截至本文撰写时,没有能力更改所使用的类型或编解码器。通过在部署连接器之前创建表来缓解这个问题,从而强制执行您的 schema。
-
导出器不是随核心 OTEL 发布一起分发的,而是作为
contrib
镜像的一个扩展。实际上这意味着在任何 HELM chart 中使用正确的 Docker 镜像。对于更轻量级的部署,用户可以构建自定义收集器镜像,仅包含所需的组件。 -
截至版本 0.74,如果未设置为
default
(如 demo 分支中所使用的),用户应在部署前预先在 ClickHouse 中创建数据库。sqlCREATE DATABASE otel
-
导出器处于 Alpha 阶段,用户应遵循 OpenTelemetry 提供的建议。
示例应用
OpenTelemetry 提供了一个演示应用,给出了 OpenTelemetry 实施的实际示例。这是一个分布式微服务架构,支持一个销售望远镜的网上商店。这个电子商务用例有助于创建一系列简单易懂的服务机会,例如推荐、支付和货币转换。网店受到负载生成器的影响,导致每个已仪器化的服务生成日志、追踪和指标。除了为实践者提供一个学习如何在其首选语言中进行仪器化的现实示例外,此演示还允许供应商展示其 OpenTelemetry 与其可观测性后端的集成。本着这一精神,我们分叉了这个应用并做了必要的更改,以便将追踪数据存储在 ClickHouse 中。
- https://opentelemetry.io/ecosystem/demo/
- https://clickhouse.com/github.com/clickHouse/opentelemetry-demo
请注意上述架构中使用的语言范围和处理诸如支付和推荐等操作的组件数量。建议用户查看代码以了解其首选语言的服务。由于存在作为网关的收集器,因此无需对任何仪器化代码进行修改。这种架构分离是 OpenTelemetry 的一个明显优势 ------ 只需更改收集器中的目标导出器即可更改后端。
本地部署
演示使用每个服务的 Docker 容器。可以使用 docker compose
和 官方文档 中概述的步骤来部署演示,将 ClickHouse 分支替换为原始仓库。
sh
git clone https://github.com/ClickHouse/opentelemetry-demo.git
cd opentelemetry-demo/
docker compose up --no-build
我们已经修改了 docker-compose 文件,以包含一个 ClickHouse 实例,在其中存储数据,并对其他服务可用,名称为 clickhouse
。
使用 Kubernetes 部署
可以轻松地使用 官方说明 在 Kubernetes 中部署演示。我们建议复制 values 文件 并修改 收集器配置。一个示例 values 文件,将所有跨度发送到 ClickHouse Cloud 实例,可以在这里找到。可以下载并使用修改后的 helm 命令进行部署。
- https://opentelemetry.io/docs/demo/kubernetes-deployment/
- https://github.com/open-telemetry/opentelemetry-helm-charts/blob/main/charts/opentelemetry-demo/values.yaml
- https://github.com/open-telemetry/opentelemetry-helm-charts/blob/185ac3ab0b3b8c83de5f6b0fa14bc6eea2607d1e/charts/opentelemetry-demo/values.yaml#L603-L660
- https://gist.github.com/gingerwizard/f63c1c809d895937fa5929ab6c7c654d#file-values-yaml-L603-L662
sh
helm install -f values.yaml my-otel-demo open-telemetry/opentelemetry-demo
集成 ClickHouse
在此文中,我们专注于导出追踪数据。尽管日志和指标也可以存储在 ClickHouse 中,但我们为了简化起见使用默认配置。日志默认未启用,而指标则发送到 Prometheus。
要将追踪数据发送到 ClickHouse,必须通过文件 otel-config-extras.yaml
添加自定义 OTEL 收集器配置。这将与 主配置 合并,并覆盖任何现有声明。附加配置如下所示:
- https://github.com/ClickHouse/opentelemetry-demo/blob/main/src/otelcollector/otelcol-config-extras.yml
- https://github.com/ClickHouse/opentelemetry-demo/blob/main/src/otelcollector/otelcol-config.yml
yaml
exporters:
clickhouse:
endpoint: tcp://clickhouse:9000?dial_timeout=10s&compress=lz4
database: default
ttl_days: 3
traces_table_name: otel_traces
timeout: 5s
retry_on_failure:
enabled: true
initial_interval: 5s
max_interval: 30s
max_elapsed_time: 300s
processors:
batch:
timeout: 5s
send_batch_size: 100000
service:
pipelines:
traces:
receivers: [otlp]
processors: [spanmetrics, batch]
exporters: [logging, clickhouse]
主要更改在于配置 ClickHouse 作为导出器。这里有几个关键设置:
endpoint
设置指定了 ClickHouse 主机和端口。注意通信通过 TCP(端口 9000)进行。对于安全连接,应使用 9440 端口,并带有secure=true
参数,例如'clickhouse://<username>:<password>@<host>:9440?secure=true'
。或者,使用dsn
参数。注意我们连接到主机clickhouse
。这是添加到本地部署 Docker 映像中的 ClickHouse 容器。可以自由修改此路径,例如指向 ClickHouse Cloud 集群。ttl_days
--- 这通过 TTL 功能控制 ClickHouse 中的数据保留。参见"Schema"部分。
我们的追踪管道利用 OTLP 接收器从仪器化库接收追踪数据。然后,此管道将这些数据传递给两个处理器:
- batch 处理器负责确保 INSERT 操作最多每 5 秒发生一次或当批量大小达到 100k 时发生。这确保了 INSERT 操作能够高效地批处理。
- spanmetrics 处理器。此处理器从追踪数据汇总请求、错误和指标,并将其转发到指标管道。我们将在后续关于指标的文章中使用它。
Schema(模式)
一旦部署完成,我们可以通过对表 otel_traces
执行简单的 SELECT 来确认追踪数据正被发送到 ClickHouse。这代表了所有跨度发送的主要数据。请注意,我们使用 clickhouse-client
访问容器(它应该在主机上的默认 9000 端口上暴露)。
sh
SELECT *
FROM otel_traces
LIMIT 1
FORMAT Vertical
Row 1:
──────
Timestamp: 2023-03-20 18:04:35.081853291
TraceId: 06cabdd45e7c3c0172a8f8540e462045
SpanId: b65ebde75f6ae56f
ParentSpanId: 20cc5cb86c7d4485
TraceState:
SpanName: oteldemo.AdService/GetAds
SpanKind: SPAN_KIND_SERVER
ServiceName: adservice
ResourceAttributes: {'telemetry.auto.version':'1.23.0','os.description':'Linux 5.10.104-linuxkit','process.runtime.description':'Eclipse Adoptium OpenJDK 64-Bit Server VM 17.0.6+10','service.name':'adservice','service.namespace':'opentelemetry-demo','telemetry.sdk.version':'1.23.1','process.runtime.version':'17.0.6+10','telemetry.sdk.name':'opentelemetry','host.arch':'aarch64','host.name':'c97f4b793890','process.executable.path':'/opt/java/openjdk/bin/java','process.pid':'1','process.runtime.name':'OpenJDK Runtime Environment','container.id':'c97f4b7938901101550efbda3c250414cee6ba9bfb4769dc7fe156cb2311735e','os.type':'linux','process.command_line':'/opt/java/openjdk/bin/java -javaagent:/usr/src/app/opentelemetry-javaagent.jar','telemetry.sdk.language':'java'}
SpanAttributes: {'thread.name':'grpc-default-executor-1','app.ads.contextKeys':'[]','net.host.name':'adservice','app.ads.ad_request_type':'NOT_TARGETED','rpc.method':'GetAds','net.host.port':'9555','net.sock.peer.port':'37796','rpc.service':'oteldemo.AdService','net.transport':'ip_tcp','app.ads.contextKeys.count':'0','app.ads.count':'2','app.ads.ad_response_type':'RANDOM','net.sock.peer.addr':'172.20.0.23','rpc.system':'grpc','rpc.grpc.status_code':'0','thread.id':'23'}
Duration: 218767042
StatusCode: STATUS_CODE_UNSET
StatusMessage:
Events.Timestamp: ['2023-03-20 18:04:35.145394083','2023-03-20 18:04:35.300551833']
Events.Name: ['message','message']
Events.Attributes: [{'message.id':'1','message.type':'RECEIVED'},{'message.id':'2','message.type':'SENT'}]
Links.TraceId: []
Links.SpanId: []
Links.TraceState: []
Links.Attributes: []
每一行代表一个跨度,其中一些也是根跨度。有一些关键字段,基本理解这些字段将使我们能够构建有用的查询。有关追踪元数据的完整描述可在此处找到 这里:
- TraceId - Trace Id 代表 Span 所属的追踪。
- SpanId - Span 的唯一 ID。
- ParentSpanId - Span 的父 Span 的 Span ID。这使得可以构建追踪调用历史。对于根跨度,这将是空的。
- SpanName - 操作的名称。
- SpanKind - 创建 Span 时,它的种类要么是客户端、服务器、内部、生产者或消费者。这个种类向追踪后端暗示了如何组装追踪。它实际上描述了 Span 与其子项和父项之间的关系。
- ServiceName - Span 起源的服务名称,例如 Adservice。
- ResourceAttributes - 包含元数据的键值对,您可以使用这些元数据来注解 Span,以携带有关正在跟踪的操作的信息。例如,这可能包含 Kubernetes 信息,如 Pod 名称或有关主机的值。请注意,我们的模式强制要求键和值都是字符串类型,并且使用 Map 类型。
- SpanAttributes - 额外的 Span 层级属性,例如
thread.id
。 - Duration - Span 的持续时间,以纳秒为单位。
- StatusCode - 可能是 UNSET、OK 或 ERROR。当应用程序代码中出现已知错误(如异常)时,会设置后者。
- Events* - 虽然可能不适合用于仪表板概览,但这些可能会引起应用程序开发者的兴趣。这可以视为 Span 上的一个结构化注解,通常用于标记 Span 持续时间内有意义的单一时刻,例如页面变为交互式时。
Events.Timestamp
、Events.Name
和Events.Attributes
可用于重建整个事件 --- 请注意,这依赖于数组位置。 - Links* - 这些表示与其他 Span 的因果关系。例如,这些可能是由于特定操作而执行的异步操作。由于请求操作而排队的处理任务可能是一个合适的 Span 链接。这里,开发人员可能会将第一个追踪的最后一个 Span 与第二个追踪的第一个 Span 链接起来,以因果关联它们。在 ClickHouse 模式中,我们再次依赖于数组类型,并通过关联列
Links.TraceId
、Links.SpanId
和Links.Attributes
的位置来实现这一点。
请注意,收集器对模式有其偏好,包括强制实施特定的编解码器。虽然这些对于一般情况来说是合理的选择,但它们阻止了用户通过收集器配置调整配置以满足自身需求。希望修改编解码器或 ORDER BY 键的用户(例如,适应特定用户的访问模式),应在提前预创建表。
sh
CREATE TABLE otel_traces
(
`Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
`TraceId` String CODEC(ZSTD(1)),
`SpanId` String CODEC(ZSTD(1)),
`ParentSpanId` String CODEC(ZSTD(1)),
`TraceState` String CODEC(ZSTD(1)),
`SpanName` LowCardinality(String) CODEC(ZSTD(1)),
`SpanKind` LowCardinality(String) CODEC(ZSTD(1)),
`ServiceName` LowCardinality(String) CODEC(ZSTD(1)),
`ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
`SpanAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
`Duration` Int64 CODEC(ZSTD(1)),
`StatusCode` LowCardinality(String) CODEC(ZSTD(1)),
`StatusMessage` String CODEC(ZSTD(1)),
`Events.Timestamp` Array(DateTime64(9)) CODEC(ZSTD(1)),
`Events.Name` Array(LowCardinality(String)) CODEC(ZSTD(1)),
`Events.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
`Links.TraceId` Array(String) CODEC(ZSTD(1)),
`Links.SpanId` Array(String) CODEC(ZSTD(1)),
`Links.TraceState` Array(String) CODEC(ZSTD(1)),
`Links.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) 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_span_attr_key mapKeys(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
INDEX idx_span_attr_value mapValues(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
INDEX idx_duration Duration TYPE minmax GRANULARITY 1
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toUnixTimestamp(Timestamp), TraceId)
TTL toDateTime(Timestamp) + toIntervalDay(3)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1
除了 TTL(见下文)之外,关于此模式还有一些重要的观察结果:
-
ORDER BY - 我们 schema 中的 ORDER BY 子句决定了数据如何排序并存储在磁盘上。这也会影响 稀疏索引的构建,并且最重要的是直接影响我们的压缩级别和查询性能。当前的子句
(ServiceName, SpanName, toUnixTimestamp(Timestamp), TraceId)
按照从右到左的顺序对数据进行排序,并针对首先按 ServiceName 过滤的查询进行了优化。对后续排序的列的过滤限制将变得越来越无效。如果您的访问模式因诊断工作流程的不同而不同,您可能需要修改此顺序和使用的列。这样做时,请考虑最佳实践以确保 键得到最优利用。 -
PARTITION BY - 此子句会导致数据在磁盘上物理分离。虽然这对于高效删除数据很有用(见下文的 TTL),但它可能会 正面和负面影响查询性能。基于分区表达式
toDate(Timestamp)
,它按天创建分区,针对最近数据(例如最后 24 小时)的查询将会受益。对于跨越多个分区/天数的查询(只有在您扩展保留期限超过默认的 3 天时才可能发生),相反可能会受到负面影响。如果您将数据保留期限扩展到几个月或几年,或者您的访问模式需要针对更宽的时间范围,则应考虑使用不同的表达式,例如按周分区,如果您的 TTL 为一年的话。 -
Map - 在上述 schema 中,Map 类型广泛用于属性。这是因为键在这里是动态的且与应用程序相关。Map 类型的灵活性是有用的,但也存在一定的代价。访问 Map 的键需要读取并加载整个列。因此,访问 Map 的键的成本比将键作为根级别的显式列更高------尤其是如果 Map 很大且包含许多键的情况下。性能差异取决于 Map 的大小,但可能会相当显著。为了应对这一问题,用户应该 将经常查询的 Map 键/值对物化 到根级别的列中。这些 物化列 将在插入时从相应的 Map 值填充,并可用于快速访问。下面是一个示例,我们将 Map 列
ResourceAttributes
中的键host.name
物化到根级别的列Host
中:
sql
CREATE TABLE otel_traces
(
`Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
`HostName` String MATERIALIZED ResourceAttributes['host.name'],
`ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
....
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toUnixTimestamp(Timestamp), TraceId)
TTL toDateTime(Timestamp) + toIntervalDay(3)
另外,这可以在数据插入后,一旦识别出您的访问模式时再进行:
sql
ALTER TABLE otel_traces ADD COLUMN HostName String MATERIALIZED ResourceAttributes['host.name'];
ALTER TABLE otel_traces MATERIALIZE COLUMN HostName;
此过程需要使用变异(mutation),这可能会 I/O 密集且应谨慎安排。Map 类型还要求值必须是相同类型,在本例中为字符串类型。这种类型信息的丢失可能会在查询时需要进行类型转换。此外,用户应该了解访问 Map 键所需的语法 - 见"查询追踪"部分。
-
https://clickhouse.com/docs/en/sql-reference/statements/create/table#materialized
-
https://clickhouse.com/docs/en/cloud/bestpractices/avoid-mutations
-
Bloom Filters - 为了弥补 ORDER BY 键的限制,Schema 创建了多个 数据跳过 Bloom 索引。这些索引旨在加速那些根据 TraceId 或属性的 Map 或键进行过滤的查询。一般来说,二级索引在主键与目标列/表达式之间存在强烈相关性或数据中某个值非常稀少时是有效的。这确保了在应用匹配此表达式的过滤器时,磁盘上的粒度 如果有很大可能不包含目标值,则可以被跳过。对于我们的特定模式,我们的 TraceId 应该是非常稀少的,并且与主键中的 ServiceName 相关。同样,我们的属性键和值也将与主键中的 ServiceName 和 SpanName 列相关。一般来说,我们认为这些都是 Bloom 索引的好候选对象。TraceId 索引非常有效,但其他的索引尚未经过真实工作负载的测试,因此在有证据表明它们确实有效之前,可能是一种过早优化。我们将在未来的文章中评估此模型的可扩展性,敬请期待!
TTL
通过收集器参数 ttl_days
,用户能够通过 ClickHouse 的 TTL 功能控制数据的过期时间。这个值反映在表达式 TTL toDateTime(Timestamp) + toIntervalDay(3)
中,默认值为 3。超过这个时间的数据将通过一个异步后台进程被删除。有关 TTL 的更多详细信息,请参阅 这里。
上述模式使用了 PARTITION BY 来辅助 TTL 功能。具体来说,当结合参数 ttl_only_drop_parts=1
使用时,这允许一天的数据被有效地删除。如上所述,这可能会 正面和负面影响查询。
- https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/mergetree/#partition-by
- https://medium.com/datadenys/using-partitions-in-clickhouse-3ea0decb89c4
Trace Id Materialized View(物化视图)
除了主表之外,ClickHouse 导出器还会创建一个 materialized view。Materialized view 是一种特殊的触发器,它会在数据插入到目标表时存储 SELECT 查询的结果。这个目标表可以汇总数据(使用聚合操作),并以针对特定查询优化的格式存储。在导出器的情况下,会创建以下视图:
sql
CREATE MATERIALIZED VIEW otel_traces_trace_id_ts_mv TO otel_traces_trace_id_ts
(
`TraceId` String,
`Start` DateTime64(9),
`End` DateTime64(9)
) AS
SELECT
TraceId,
min(Timestamp) AS Start,
max(Timestamp) AS End
FROM otel_traces
WHERE TraceId != ''
GROUP BY TraceId
这个具体的 materialized view 执行 GROUP BY TraceId
并识别每个 ID 的最大和最小时间戳。这在插入到表 otel_traces
的每一组数据(可能是数百万行)时执行。汇总后的数据随后被插入到目标表 otel_traces_trace_id_ts
中。下面展示了一些来自该表的行及其模式:
sql
SELECT *
FROM otel_traces_trace_id_ts
LIMIT 5
┌─TraceId──────────────────────────┬─────────────────────────Start─┬───────────────────────────End─┐
│ 000040cf204ee714c38565dd057f4d97 │ 2023-03-20 18:39:44.064898664 │ 2023-03-20 18:39:44.066019830 │
│ 00009bdf67123e6d50877205680f14bf │ 2023-03-21 07:56:30.185195776 │ 2023-03-21 07:56:30.503208045 │
│ 0000c8e1e9f5f910c02a9a98aded04bd │ 2023-03-20 18:31:35.967373056 │ 2023-03-20 18:31:35.968602368 │
│ 0000c8e1e9f5f910c02a9a98aded04bd │ 2023-03-20 18:31:36.032750972 │ 2023-03-20 18:31:36.032750972 │
│ 0000dc7a6d15c638355b33b3c6a8aaa2 │ 2023-03-21 00:31:37.075681536 │ 2023-03-21 00:31:37.247680719 │
└──────────────────────────────────┴───────────────────────────────┴───────────────────────────────┘
5 rows in set. Elapsed: 0.009 sec.
CREATE TABLE otel_traces_trace_id_ts
(
`TraceId` String CODEC(ZSTD(1)),
`Start` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
`End` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
INDEX idx_trace_id TraceId TYPE bloom_filter(0.01) GRANULARITY 1
)
ENGINE = MergeTree
ORDER BY (TraceId, toUnixTimestamp(Start))
TTL toDateTime(Start) + toIntervalDay(3)
如所示,目标表 otel_traces_trace_id_ts
使用 (TraceId, toUnixTimestamp(Start))
作为其 ORDER BY
键。这样就可以让用户快速确定特定追踪的时间范围。
我们在下面的 "查询追踪" 部分探讨了这个 materialized view 的价值,但我们发现它在加速执行 TraceId 查找的更广泛的查询方面的作用有限。然而,它确实提供了一个很好的入门示例供用户借鉴。
用户可能希望扩展或修改这个 materialized view。例如,可以在 materialized view 的聚合和目标表中添加一个 ServiceName
数组,以便快速识别追踪的服务。可以通过在部署收集器之前预创建表和 materialized view 来实现这一点,或者 在创建后修改视图和表。用户还可以向主表附加新的 materialized view 以满足其他访问模式需求。请参阅 我们最近的博客 获取更多详细信息。
- https://clickhouse.com/docs/en/sql-reference/statements/alter/view#alter-live-view-statement
- https://clickhouse.com/blog/using-materialized-views-in-clickhouse
最后,上述功能也可以通过 projections 实现。尽管它们没有提供所有 Materialized view 的功能,但它们直接包含在表定义中。与 Materialized Views 不同,projections 会被原子性地更新,并与主表保持一致,ClickHouse 会在查询时自动选择最优版本。
查询追踪数据
导出器文档 提供了一些优秀的入门查询示例。需要更多灵感的用户可以参考我们在下面展示的仪表板中的这些查询。在查询追踪数据时有几个重要的概念:
https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/exporter/clickhouseexporter/README.md#traces
https://gist.github.com/gingerwizard/dbc250063933d76462faf117b4d56b9a
- 在主
otel_traces
表上进行 TraceId 查找可能会比较耗时,尽管有布隆过滤器的存在。如果你需要深入查看某个特定的追踪记录,可以利用otel_traces_trace_id_ts
表来确定追踪记录的时间范围 ------ 如上所述。这个时间范围随后可以作为附加过滤条件应用于otel_traces
表,该表包含了时间戳作为 ORDER BY 键的一部分。如果在查询中应用服务名称作为过滤条件,则查询可以进一步优化(尽管这会限制到特定服务的跨度)。考虑以下两种查询变体及其各自的时间,这两种查询都会返回与追踪相关的跨度。
只使用 otel_traces
表:
sql
SELECT
Timestamp,
TraceId,
SpanId,
SpanName
FROM otel_traces
WHERE TraceId = '0f8a2c02d77d65da6b2c4d676985b3ab'
ORDER BY Timestamp ASC
50 rows in set. Elapsed: 0.197 sec. Processed 298.77 thousand rows, 17.39 MB (1.51 million rows/s., 88.06 MB/s.)
利用我们的 otel_traces_trace_id_ts
表,并使用得到的时间来作为过滤条件:
sql
WITH '0f8a2c02d77d65da6b2c4d676985b3ab' AS trace_id,
(
SELECT min(Start)
FROM otel_traces_trace_id_ts
WHERE TraceId = trace_id
) AS start,
(
SELECT max(End) + 1
FROM otel_traces_trace_id_ts
WHERE TraceId = trace_id
) AS end
SELECT Timestamp,
TraceId,
SpanId,
SpanName
FROM otel_traces
WHERE (TraceId = trace_id) AND (Timestamp >= start) AND (Timestamp <= end)
ORDER BY Timestamp ASC
50 rows in set. Elapsed: 0.110 sec. Processed 225.05 thousand rows, 12.78 MB (2.05 million rows/s., 116.52 MB/s.)
- 我们这里的数据量较小(大约
2
亿个跨度和125GB
的数据),因此绝对时间和差异都很低。虽然我们预计在更大的数据集上这些差异会更明显,但是我们的测试表明这种物化视图只能提供适度的速度提升(注意读取的行数差异很小)------ 这并不令人惊讶,因为时间戳列是otel_traces
表 ORDER BY 中的第三个键,因此最多只能用于通用排除搜索。otel_traces
表也已经从布隆过滤器中获得了显著的好处。这些查询之间的完整EXPLAIN
差异显示了过滤后读取的粒度数量上的小差异。因此,在大多数情况下,我们认为使用这种物化视图是一种不必要的优化,特别是考虑到它增加了查询的复杂性,尽管一些用户可能在性能关键场景中发现它有用。在后续的文章中,我们将探讨使用投影来加速 TraceId 查找的可能性。 - 收集器使用 Map 数据类型来表示属性。用户可以使用Map 符号来访问嵌套的键,同时还可以使用 ClickHouse 的Map 函数,如果需要对这些列进行过滤或选择的话。如前所述,如果你经常访问这些键,我们建议将它们物化为表上的显式列。下面我们查询来自特定主机的跨度,按小时和服务的语言分组。我们计算每个桶中的跨度持续时间的百分位数 ------ 这对于诊断任何问题都非常有用。
sql
SELECT
toStartOfHour(Timestamp) AS hour,
count(*),
lang,
avg(Duration) AS avg,
quantile(0.9)(Duration) AS p90,
quantile(0.95)(Duration) AS p95,
quantile(0.99)(Duration) AS p99
FROM otel_traces
WHERE (ResourceAttributes['host.name']) = 'bcea43b12a77'
GROUP BY
hour,
ResourceAttributes['telemetry.sdk.language'] AS lang
ORDER BY hour ASC
确定可用于查询的 Map 键可能很具挑战性 ------ 特别是如果应用程序开发人员添加了自定义元数据。使用一个聚合组合函数,下面的查询可以识别 ResourceAttributes
列中的键。根据需要调整以适应其他列,例如 SpanAttributes
。
sql
SELECT groupArrayDistinctArray(mapKeys(ResourceAttributes)) AS `Resource Keys`
FROM otel_traces
FORMAT Vertical
Row 1:
──────
Resource Keys: ['telemetry.sdk.name','telemetry.sdk.language','container.id','os.name','os.description','process.pid','process.executable.name','service.namespace','telemetry.auto.version','os.type','process.runtime.description','process.executable.path','host.arch','process.runtime.version','process.runtime.name','process.command_args','process.owner','host.name','service.instance.id','telemetry.sdk.version','process.command_line','service.name','process.command','os.version']
1 row in set. Elapsed: 0.330 sec. Processed 1.52 million rows, 459.89 MB (4.59 million rows/s., 1.39 GB/s.)
使用 Grafana 进行可视化与诊断
我们推荐使用 Grafana 结合官方 ClickHouse 插件来可视化和分析追踪数据。之前的文章与视频教程已深入解析了此插件的使用方法。近期,我们对该插件进行了升级,新增了利用追踪面板展示追踪数据的功能,这不仅作为一种可视化方式,还能够融入到 Grafana 的 Explore 界面中。追踪面板对列有严格的命名和类型要求,遗憾的是,这些要求当前与 OTEL 规范不完全一致。以下查询能产生适合在追踪可视化中展示的结果:
sql
WITH
'ec4cff3e68be6b24f35b4eef7e1659cb' AS trace_id,
(
SELECT min(Start)
FROM otel_traces_trace_id_ts
WHERE TraceId = trace_id
) AS start,
(
SELECT max(End) + 1
FROM otel_traces_trace_id_ts
WHERE TraceId = trace_id
) AS end
SELECT
TraceId AS traceID,
SpanId AS spanID,
SpanName AS operationName,
ParentSpanId AS parentSpanID,
ServiceName AS serviceName,
Duration / 1000000 AS duration,
Timestamp AS startTime,
arrayMap(key -> map('key', key, 'value', SpanAttributes[key]), mapKeys(SpanAttributes)) AS tags,
arrayMap(key -> map('key', key, 'value', ResourceAttributes[key]), mapKeys(ResourceAttributes)) AS serviceTags
FROM otel_traces
WHERE (TraceId = trace_id) AND (Timestamp >= start) AND (Timestamp <= end)
ORDER BY startTime ASC
通过 Grafana 中的变量和数据链接功能,用户可以创建复杂的交互工作流,实现图表的动态过滤。下面的仪表板包含了多种可视化元素:
https://grafana.com/docs/grafana/latest/dashboards/variables/
https://grafana.com/docs/grafana/latest/panels-visualizations/configure-data-links/
- 以堆叠柱状图展示服务请求量概览
- 多线图展示各服务的 99% 延迟
- 条形图展示每个服务的错误率
- 按 traceId 聚合的追踪列表------服务在此作为跨度链中的首项
- 当筛选到特定追踪时填充的追踪面板
此仪表板为我们提供了关于错误和性能的基本诊断能力。OpenTelemetry demo 中包含了一些现有场景,用户可以在其中启用特定服务问题。其中一个场景涉及推荐服务的内存泄漏。虽然没有指标数据无法完成整个问题解决流程,但我们仍能识别出有问题的追踪记录。示例如下:
- https://opentelemetry.io/docs/demo/scenarios/
- https://opentelemetry.io/docs/demo/scenarios/recommendation-cache/
此仪表板现已随插件一起打包提供,并且可通过示例 demo获取。
- https://github.com/grafana/clickhouse-datasource/pull/336
- https://github.com/ClickHouse/opentelemetry-demo/blob/main/src/grafana/provisioning/dashboards/general/otel-traces-clickhouse.json
使用 Parameterized Views(参数化视图)
上述查询可能相当复杂。例如,我们不得不使用 arrayMap
函数来确保属性结构正确。我们可以将此工作推迟到查询时的物化或默认列处理,从而简化查询。然而,这仍然需要编写较多的SQL代码。在Explore视图中可视化追踪时,这一点尤其繁琐。
为了简化查询语法,ClickHouse 提供了参数化视图功能。参数化视图类似于普通视图,但可以在创建时带有不立即解析的参数。这些视图可以通过表函数来使用,表函数将视图名称作为函数名,参数值作为其参数。这能极大地减少 Grafana 用户所需的 ClickHouse 查询语法。下面创建了一个接受 traceId 作为参数并返回追踪面板所需结果的视图。尽管参数化视图中最近新增了对 CTE 的支持,但下面示例中我们还是使用了较早的简单查询:
sql
CREATE VIEW trace_view AS
SELECT
TraceId AS traceID,
SpanId AS spanID,
SpanName AS operationName,
ParentSpanId AS parentSpanID,
ServiceName AS serviceName,
Duration / 1000000 AS duration,
Timestamp AS startTime,
arrayMap(key -> map('key', key, 'value', SpanAttributes[key]), mapKeys(SpanAttributes)) AS tags,
arrayMap(key -> map('key', key, 'value', ResourceAttributes[key]), mapKeys(ResourceAttributes)) AS serviceTags
FROM otel_traces
WHERE TraceId = {trace_id:String}
运行此视图时,我们只需传入一个 traceId
,例如,
sql
SELECT *
FROM trace_view(trace_id = '1f12a198ac3dd502d5201ccccad52967')
这样可以显著降低查询追踪记录的复杂度。接下来,我们使用 Grafana 的 Explore 视图来查询特定追踪。请注意,需将 Format
值设为 Trace
才能呈现追踪图:
参数化视图最适合于那些用户执行常规任务,需要即席分析的常见工作负载场景,比如检查特定追踪记录。
压缩
ClickHouse 用于存储追踪数据的一个优势在于其高压缩比。使用下面的查询,我们可以看到对于这个演示生成的追踪数据,我们实现了大约 9 到 10 倍的压缩率。这个数据集是在使用提供的 负载生成器服务 对演示进行压力测试时生成的,测试条件为 2000 个虚拟用户持续 24 小时。我们已经将这个数据集公开供大众使用。可以按照 这里的步骤 将数据插入到 ClickHouse 中。为了托管这些数据,我们建议使用 ClickHouse Cloud 的开发服务(16GB 内存,两核),这对于这种规模的数据集来说已经足够。
- https://opentelemetry.io/docs/demo/services/load-generator/
- https://gist.github.com/gingerwizard/1b8755a86621fd492bbd28cfab84603c
- https://clickhouse.cloud/signUp?glxid=fdae4a52-2711-4e88-9f04-45e566edbb1c&pagePath=%2Fblog%2Fstoring-traces-and-spans-open-telemetry-in-clickhouse&origPath=%2Fcloud&experiments=mktg-website-nav-cta-btn%3A1%2Cmktg-website-rockset-eyebrow%3A0
sh
SELECT
formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE table = 'otel_traces'
ORDER BY sum(data_compressed_bytes) DESC
┌─compressed_size─┬─uncompressed_size─┬─ratio─┐
│ 13.68 GiB │ 132.98 GiB │ 9.72 │
└─────────────────┴───────────────────┴───────┘
1 row in set. Elapsed: 0.003 sec.
SELECT
name,
formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE table = 'otel_traces'
GROUP BY name
ORDER BY sum(data_compressed_bytes) DESC
┌─name───────────────┬─compressed_size─┬─uncompressed_size─┬───ratio─┐
│ ResourceAttributes │ 2.97 GiB │ 78.49 GiB │ 26.43 │
│ TraceId │ 2.75 GiB │ 6.31 GiB │ 2.29 │
│ SpanAttributes │ 1.99 GiB │ 22.90 GiB │ 11.52 │
│ SpanId │ 1.68 GiB │ 3.25 GiB │ 1.94 │
│ ParentSpanId │ 1.35 GiB │ 2.74 GiB │ 2.02 │
│ Events.Timestamp │ 1.02 GiB │ 3.47 GiB │ 3.4 │
│ Timestamp │ 955.77 MiB │ 1.53 GiB │ 1.64 │
│ Duration │ 619.43 MiB │ 1.53 GiB │ 2.53 │
│ Events.Attributes │ 301.09 MiB │ 5.12 GiB │ 17.42 │
│ Links.TraceId │ 36.52 MiB │ 1.60 GiB │ 44.76 │
│ Events.Name │ 22.92 MiB │ 248.91 MiB │ 10.86 │
│ Links.SpanId │ 17.77 MiB │ 34.49 MiB │ 1.94 │
│ HostName │ 8.32 MiB │ 4.56 GiB │ 561.18 │
│ StatusCode │ 1.11 MiB │ 196.80 MiB │ 177.18 │
│ StatusMessage │ 1.09 MiB │ 219.08 MiB │ 201.86 │
│ SpanName │ 538.55 KiB │ 196.82 MiB │ 374.23 │
│ SpanKind │ 529.98 KiB │ 196.80 MiB │ 380.25 │
│ ServiceName │ 529.09 KiB │ 196.81 MiB │ 380.9 │
│ TraceState │ 138.05 KiB │ 195.93 MiB │ 1453.35 │
│ Links.Attributes │ 11.10 KiB │ 16.23 MiB │ 1496.99 │
│ Links.TraceState │ 1.71 KiB │ 2.03 MiB │ 1218.44 │
└────────────────────┴─────────────────┴───────────────────┴─────────┘
20 rows in set. Elapsed: 0.003 sec.
我们将在本系列的后续博客中探讨如何优化模式以及如何进一步提高压缩率。
后续工作
当前的 ClickHouse 导出器处于 Alpha 阶段。这一状态反映了它发布的相对较新以及成熟度。虽然我们计划在这个导出器上投入资源,但我们已经识别出一些挑战和可能的改进方向:
- Schema(模式) - 当前 schema 包含了一些可能过早的优化措施。例如,使用布隆过滤器可能对于某些工作负载来说并不必要。如下面所示,这些过滤器会占用一定的空间(大约占总数据大小的 1%)。
sh
SELECT
formatReadableSize(sum(secondary_indices_compressed_bytes)) AS compressed_size,
formatReadableSize(sum(secondary_indices_uncompressed_bytes)) AS uncompressed_size
FROM system.parts
WHERE (table = 'otel_traces') AND active
┌─compressed_size─┬─uncompressed_size─┐
│ 425.54 MiB │ 1.36 GiB │
└─────────────────┴───────────────────┘
我们发现 TraceId
列上的过滤器非常有效,值得加入到 schema
中。这似乎抵消了大部分物化视图带来的好处,后者增加了额外的查询和维护复杂性,其价值令人怀疑。不过,它提供了一个很好的例子,说明了物化视图如何可以用来加速查询。我们没有足够的证据证明其他布隆过滤器的价值,因此我们建议用户自行尝试。
- High memory(高内存消耗) - 我们发现 OTEL 收集器非常耗内存。在早期配置中,我们使用 batch processor 在 5 秒后或者当批处理达到 100,000 行时向 ClickHouse 发送数据。虽然这优化了 ClickHouse 的插入操作并且遵循了 最佳实践,但在高负载下可能会非常耗内存 ------ 特别是在 收集日志 时。这个问题可以通过减少刷新时间和/或批处理大小来缓解。需要注意的是,这样做需要进行调优,否则可能导致 ClickHouse 中积累过多的分区。另外,用户也可以选择使用 异步插入 来发送数据。这会减少批处理大小,但在导出器中仅支持通过 HTTP 协议。要激活此功能,请在导出器配置中使用
connection_params
,例如,
yaml
connection_params:
async_insert: 1
wait_for_async_insert: 0
需要注意的是,这种方式对于 ClickHouse 的插入操作来说效率较低。
-
End-to-end delivery(端到端交付) - 目前我们没有看到对端到端交付保证的支持。也就是说,应用程序 SDK 会在收集器接收到追踪数据后就认为数据已经发送成功。如果 OTEL 收集器崩溃,内存中的批次数据将会丢失。这个问题可以通过减少批处理大小(见上文)来缓解。但是,如果需要更高的交付保证,用户还可以考虑使用 Kafka(参见 接收器 和 导出器)或其他等效的持久队列。我们尚未探索 最近发布的持久队列功能,该功能虽然处于 Alpha 阶段,但承诺会提高系统的韧性。
- https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver/kafkareceiver
- https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/exporter/kafkaexporter
- https://github.com/open-telemetry/opentelemetry-collector/tree/main/exporter/exporterhelper#persistent-queue
-
扩展性 - 上述部署仅使用了一个收集器。在一个高流量的生产环境中,用户可能需要在负载均衡器后面部署多个收集器。OTEL 收集器支持使用 基于 Trace ID/服务名称的负载均衡导出器 来确保来自同一追踪的数据转发到同一个收集器。需要注意的是,我们还没有努力去调整代理或测量它们的资源开销 ------ 这是我们建议用户在生产部署之前研究或做的事情。我们计划在后续的博文中探讨这些话题。
-
采样 - 我们当前的实现会导致所有数据都被存储到 ClickHouse 中。虽然 ClickHouse 提供了出色的压缩能力,但我们理解用户可能希望采用 采样技术。这样只有一部分追踪数据会被存储,从而减少了硬件需求。需要注意的是,这会 增加扩展性的复杂性。我们将在后续的博文中讨论这个问题。
结论
这篇博客展示了如何使用 OpenTelemetry 轻松地收集和存储追踪数据到 ClickHouse。我们已经为 ClickHouse 分叉了 OpenTelemetry 的 demo 项目,探讨了使用 Grafana 查询和可视化技术的方法,并指出了减少查询复杂性和未来项目工作的几个方向。对于更进一步的阅读,我们鼓励用户探索本文之外的主题,例如如何大规模部署 OTEL 收集器、如何处理背压以及如何保证交付。我们将在后续的博文中探讨这些主题,并在我们的 ClickHouse 实例中添加指标数据,然后再探讨如何优化 Schema 以及如何管理数据生命周期。