简介
在 ClickHouse,我们认为可观测性只是另一个实时分析问题。作为一款高性能实时分析数据库,ClickHouse 被用于许多用例,包括 时间序列 数据的实时分析。其用例的多样性推动了大量 分析函数 的发展,这些函数有助于查询大多数数据类型。这些查询功能和高压缩率越来越多地促使用户利用 ClickHouse 来存储可观测性数据。这些数据通常有三种形式:日志、指标和追踪。在本篇博客中,这是 可观测性系列的第二篇,我们将探讨如何在 ClickHouse 中收集、存储和查询追踪数据。
本文重点介绍使用 OpenTelemetry 收集追踪数据以存储在 ClickHouse 中。当与 Grafana 以及 ClickHouse 插件 的最新发展结合使用时,追踪可以轻松可视化,并可以与日志和指标结合使用,以便在检测和诊断问题时深入了解您的系统行为和性能。
我们已尝试确保任何示例都可以重现。虽然本文重点介绍数据收集和可视化的基础知识,但我们包含了一些关于模式优化的技巧。为了示例目的,我们 fork 了 官方 OpenTelemetry Demo,添加了对 ClickHouse 的支持,并包含了一个开箱即用的 Grafana 仪表板,用于可视化追踪。
什么是追踪 (Traces)?
遥测数据是从系统发出的关于其行为的数据。此数据可以采用日志、指标和追踪的形式。追踪记录请求(由应用程序或最终用户发出)在通过多服务架构(如微服务和无服务器应用程序)传播时所采用的路径。单个追踪由多个 span 组成,每个 span 都是一个工作单元或操作。span 提供操作的详细信息,主要是操作所花费的时间,以及 其他元数据 和相关的日志消息。这些 span 以树状结构分层关联,第一个 span 关联根,并涵盖从开始到结束的整个追踪。在此根 span 和每个后续 span 下,捕获子操作。当我们浏览树时,我们可以看到构成更高级别的子操作和步骤。这使我们对原始请求执行的工作有了不断增加的上下文。可视化如下所示
这些追踪与指标和日志结合使用时,对于深入了解系统行为以检测和解决问题至关重要。
什么是 OpenTelemetry?
OpenTelemetry 项目是一个供应商中立的开源框架,由 SDK、API 和组件组成,允许摄取、转换和发送可观测性数据到后端。更具体地说,这包括几个主要组件
- 一套关于如何收集和存储指标、日志和追踪的 规范和约定 。这包括针对特定语言代理的建议以及基于 protobuf 的 OpenTelemetry Line Protocol (OTLP) 的完整规范。这允许通过提供客户端-服务器接口和消息格式的完整描述,在服务之间传输数据。
- 用于检测、生成、收集和导出可观测性数据的 特定于语言的库和 SDK 。这与追踪数据的收集尤为相关。
- 用 Golang 编写的 OTEL Collector 提供了接收、处理和导出可观测性数据的供应商无关的实现。OTEL Collector 通过支持多种输入格式(如 Prometheus 和 OTLP)以及广泛的导出目标(包括 ClickHouse)来提供集中式处理网关。
总而言之,OpenTelemetry 标准化了日志、指标和追踪的收集。重要的是,它不负责将这些数据存储在可观测性后端中——这正是 ClickHouse 的用武之地!
为什么选择 ClickHouse?
追踪数据通常表示为单个表,每个 span 是一行,因此可以被视为另一个实时分析问题。除了为此数据类型提供高压缩率外,ClickHouse 还提供了丰富的 SQL 接口和额外的 分析函数,这些函数简化了追踪查询。当与 Grafana 结合使用时,用户可以以高性价比的方式存储和可视化追踪。虽然其他存储可能提供类似的压缩级别,但 ClickHouse 的独特之处在于它结合了低延迟查询,成为世界上最快的分析数据库。事实上,这些特性使得 ClickHouse 成为许多商业可观测性解决方案的首选后端,例如:Signoz.io、Highlight.io、qryn、BetterStack,或自研的大规模可观测性平台,如 Uber、Cloudflare 或 Gitlab。
检测库 (Instrumentation Libraries)
为最流行的语言提供了检测库。这些库既提供了代码的自动检测,利用应用程序/服务框架来捕获最常见的指标和追踪数据,也提供了手动检测技术。虽然自动检测通常就足够了,但后者允许用户检测代码的特定部分,可能更详细地捕获特定于应用程序的指标和追踪信息。
就本博客而言,我们只对捕获追踪信息感兴趣。OpenTelemetry 演示应用程序由微服务架构组成,其中包含许多依赖服务,每个服务都使用不同的语言,为实施者提供参考。下面的简单示例展示了 Python Flask API 的检测,以收集追踪数据
# 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 Collector 接受来自可观测性源的数据,例如来自检测库的追踪数据,处理这些数据,并将其导出到目标后端。OTEL Collector 还可以通过支持多种输入格式(如 Prometheus 和 OTLP)以及广泛的导出目标(包括 ClickHouse)来提供集中式处理网关。
Collector 使用管道的概念。这些管道可以是日志、指标或追踪类型,并由接收器、处理器和导出器组成。
此架构中的 接收器 充当 OTEL 数据的输入。这可以通过拉取或推送模型完成。虽然这可以通过多种协议进行,但来自检测库的追踪数据将通过 OTLP 使用 gRPC 或 HTTP 推送。处理器 随后在此数据上运行,提供过滤、批处理和丰富功能。最后,导出器通过推送或拉取将数据发送到后端目标。在我们的例子中,我们将把数据推送到 ClickHouse。
请注意,虽然 Collector 更常用于网关/聚合器,处理批处理和重试等任务,但 Collector 也可以作为代理本身部署——这对于日志收集非常有用,如我们之前的文章中所述。OTLP 代表 OpenTelemetry 数据标准,用于网关和代理实例之间的通信,可以通过 gRPC 或 HTTP 进行。为了追踪收集的目的,Collector 只是作为网关部署,如下所示
请注意,更高级的架构可能适用于更高负载的环境。我们推荐这个 讨论可能选项的精彩视频。
ClickHouse 支持
通过 社区贡献,OTEL 导出器中支持 ClickHouse,支持日志、追踪和指标。与 ClickHouse 的通信通过优化的原生格式和协议以及官方 Go 客户端进行。在使用 OpenTelemetry Collector 之前,用户应考虑以下几点
-
代理使用的 ClickHouse 数据模型和模式是硬编码的。在撰写本文时,无法更改使用的类型或编解码器。通过在部署连接器之前创建表来缓解此问题,从而强制执行您的模式。
-
导出器未与核心 OTEL 发行版一起分发,而是作为
contrib
镜像的扩展。实际上,这意味着在任何 HELM chart 中使用正确的 Docker 镜像。对于更精简的部署,用户可以 构建自定义收集器镜像,仅包含所需的组件。 -
从 0.74 版本开始,如果未设置为
default
值(如演示 fork 中使用的那样),用户应在部署之前在 ClickHouse 中预先创建数据库。CREATE DATABASE otel
-
导出器处于 alpha 阶段,用户应遵守 OpenTelemetry 提供的建议。
示例应用程序
OpenTelemetry 提供了一个演示应用程序,给出了 OpenTelemetry 实现的实际示例。这是一个分布式微服务架构,为销售望远镜的网络商店提供支持。这种电子商务用例有助于为各种简单易懂的服务创造机会,例如,推荐、支付和货币兑换。店面受到负载生成器的影响,这会导致每个检测服务生成日志、追踪和指标。除了为从业者提供一个学习如何在他们喜欢的语言中进行检测的真实示例外,此演示还允许供应商展示其 OpenTelemetry 与其可观测性后端的集成。本着这种精神,我们 fork 了此应用程序,并进行了必要的更改以将追踪数据存储在 ClickHouse 中。
请注意上述架构中使用的语言的广泛性以及处理支付和推荐等操作的组件数量。建议用户 查看代码 以了解其首选语言的服务。由于 Collector 作为网关存在,因此未对任何检测代码进行任何更改。这种架构分离是 OpenTelemetry 的明显优势之一——只需更改 Collector 中的目标导出器即可更改后端。
本地部署
演示为每个服务使用 Docker 容器。可以使用 docker compose
和 官方文档 中概述的步骤部署演示,并将 ClickHouse fork 替换为原始存储库。
git clone https://github.com/ClickHouse/opentelemetry-demo.git
cd opentelemetry-demo/
docker compose up --no-build
我们 修改了 docker-compose 文件,以包含一个 ClickHouse 实例,数据将存储在该实例中,其他服务可以作为 clickhouse
访问。
使用 Kubernetes 部署
可以使用 官方说明 轻松地在 Kubernetes 中部署演示。我们建议复制 values 文件 并修改 collector 配置。 可以在 此处 找到一个示例 values 文件,该文件将所有 span 发送到 ClickHouse Cloud 实例。可以使用修改后的 helm 命令下载和部署它,例如,
helm install -f values.yaml my-otel-demo open-telemetry/opentelemetry-demo
集成 ClickHouse
在本文中,我们仅关注导出追踪。虽然日志和指标也可以存储在 ClickHouse 中,但为了简单起见,我们使用默认配置。默认情况下未启用日志,指标将发送到 Prometheus。
要将追踪数据发送到 ClickHouse,我们必须通过文件 otel-config-extras.yaml
添加自定义 OTEL Collector 配置。这将与 主配置 合并,覆盖任何现有声明。其他配置如下所示
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 中的数据保留。请参阅下面的“模式”部分。
我们的追踪管道利用 OTLP 接收器从检测库接收追踪数据。然后,此管道将此数据传递给两个处理器
- 批处理器 负责确保 INSERT 最多每 5 秒发生一次,或者当批大小达到 10 万时发生一次。这确保了高效地批量插入。
- spanmetrics 处理器。这从追踪数据聚合请求、错误和指标,并将其转发到指标管道。我们将在以后的关于指标的文章中利用这一点。
模式
部署后,我们可以通过在表 otel_traces
上使用简单的 SELECT 来确认追踪数据正在发送到 ClickHouse。这表示所有 span 发送到的主要数据。请注意,我们使用 clickhouse-client
访问容器(它应在主机上的默认 9000 端口上公开)。
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: []
每一行代表一个 span,其中一些也是根 span。有一些关键字段,通过对这些字段的基本了解,我们将能够构建有用的查询。此处 提供了追踪元数据的完整描述
- TraceId - Trace Id 表示 Span 所属的追踪。
- SpanId - Span 的唯一 Id
- ParentSpanId - Span 的父 span 的 span id。这允许构建追踪调用历史记录。对于根 span,这将为空。
- SpanName - 操作的名称
- SpanKind - 创建 span 时,其 Kind 为 Client、Server、Internal、Producer 或 Consumer。此 Kind 向追踪后端暗示应如何组装追踪。它有效地描述了 Span 与其子项和父项的关系。
- ServiceName - 服务的名称,例如,Span 源自的 Adservice。
- ResourceAttributes - 键值对,其中包含可用于注释 Span 的元数据,以携带有关其正在跟踪的操作的信息。例如,这可能包含 Kubernetes 信息,例如,pod 名称或有关主机的值。请注意,我们的模式强制键和值都为 String,类型为 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
的位置关联起来。
请注意,Collector 对模式有自己的看法,包括强制执行特定的编解码器。虽然这些代表了一般情况下的明智选择,但它们阻止用户通过 Collector 配置根据自己的需求调整配置。希望修改编解码器或 ORDER BY 键的用户,例如,适应 用户特定的访问模式,应提前预先创建表。
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 子句决定了我们的 数据在磁盘上的排序和存储方式。这也将控制 稀疏索引的构建,最重要的是,直接影响我们的压缩级别和查询性能。当前的子句
(ServiceName, SpanName, toUnixTimestamp(Timestamp), TraceId)
按从右到左的顺序对数据进行排序,并针对首先按 ServiceName 过滤的查询进行优化。按稍后排序的列进行的过滤限制将变得越来越无效。如果您的访问模式因诊断工作流程的差异而有所不同,则可以修改此顺序和使用的列。执行此操作时,请考虑最佳实践,以确保 键得到最佳利用。 -
PARTITION BY - 此子句会导致数据在磁盘上进行物理分离。虽然对于高效删除数据很有用(请参阅下面的 TTL),但它可能会 正面和负面地影响查询性能。基于分区表达式
toDate(Timestamp)
(按天创建分区),针对最新数据的查询,例如,最近 24 小时,将受益。对许多分区/天数的查询(仅当您将保留期扩展到默认的 3 天以上时才可能)反而可能受到负面影响。如果您将数据保留期扩展到数月或数年,或者有需要针对更广泛时间范围的访问模式,请考虑使用不同的表达式,例如,如果您的 TTL 为一年,则按周分区。 -
Map - Map 类型在上面的模式中广泛用于属性。之所以选择它,是因为此处的键是动态的且特定于应用程序的。Map 类型的灵活性在此处很有用,但也有一些代价。访问 Map 键需要读取和加载整个列。因此,访问 Map 的键将比键是根目录下的显式列产生更大的成本——尤其是在 Map 很大且有很多键的情况下。此处的性能差异将取决于 Map 的大小,但可能相当大。为了解决这个问题,用户应将 物化 频繁查询的 Map 键/值对到根目录上的列。这些 物化列 反过来将在 INSERT 时从相应的 Map 值填充,并且可用于快速访问。我们在下面展示了一个示例,其中我们将 Map 列
ResourceAttributes
中的键host.name
物化到根列Host
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)
或者,一旦数据被插入,并且您的访问模式被识别出来,就可以追溯应用此方法
ALTER TABLE otel_traces ADD COLUMN HostName String MATERIALIZED ResourceAttributes['host.name']; ALTER TABLE otel_traces MATERIALIZE COLUMN HostName;
此过程需要一个 mutation,这可能是 I/O 密集型的,应谨慎安排。此外,Map 类型还要求值是相同的类型——在我们的例子中是 String。这种类型信息的丢失可能需要在查询时进行强制转换。此外,用户应知道访问 Map 键所需的语法 - 请参阅“查询追踪”。
-
Bloom 过滤器 - 为了弥补限制性 ORDER BY 键,该模式创建了几个 数据跳过 Bloom 索引。这些索引旨在加速按追踪 id 或属性的 Map 或键进行过滤的查询。通常,当主键与目标列/表达式之间存在强相关性,或者值在数据中非常稀疏时,二级索引是有效的。这确保了在应用与此表达式匹配的过滤器时,可以跳过 磁盘上的 granule,这些 granule 有合理的可能性不包含目标值。对于我们的特定模式,我们的 TraceId 应该非常稀疏,并且与主键的 ServiceName 相关。同样,我们的属性键和值将与主键的 ServiceName 和 SpanName 列相关。通常,我们认为这些是 Bloom 过滤器的良好候选者。TraceId 索引非常有效,但其他索引尚未在真实世界的工作负载下进行测试,因此在有证据表明否则之前,这可能是一个过早的优化。我们将在以后的文章中评估此模型的可伸缩性,敬请期待!
TTL
通过 Collector 参数 ttl_days
,用户可以通过 ClickHouse 的 TTL 功能控制数据的过期时间。此值反映在表达式 TTL toDateTime(Timestamp) + toIntervalDay(3)
中,默认值为 3。早于此时间的数据将根据异步后台进程删除。有关 TTL 的更多详细信息,请参阅 此处。
上面的模式使用 PARTITION BY 来辅助 TTL。具体来说,当与参数 ttl_only_drop_parts=1
结合使用时,这允许有效删除一天的数据。如上所述,这可能 正面和负面地影响查询。
Trace Id 物化视图
除了主表之外,ClickHouse 导出器还创建了一个 物化视图。物化视图是一个特殊的触发器,用于在数据插入目标表时存储对数据执行 SELECT 查询的结果。此目标表可以汇总数据(使用聚合),并以针对特定查询优化的格式存储。在导出器的情况下,创建了以下视图
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
这个特定的物化视图正在运行 GROUP BY TraceId
,并识别每个 ID 的最大和最小时间戳。这会在插入到 otel_traces
表的每个数据块(可能数百万行)上执行。然后,此汇总数据将插入到目标表 otel_traces_trace_id_ts
中。下面我们展示这个表及其模式的几个行。
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
键。 这使得用户能够快速识别特定追踪的时间范围。
我们在下文“查询追踪”中探讨了此物化视图的价值,但发现其在加速执行 TraceId 查找的更广泛查询方面的价值有限。 然而,它确实提供了一个极好的入门示例,用户可以从中获得启发。
用户可能希望扩展或修改此物化视图。 例如,可以将 ServiceName
数组添加到物化视图的聚合和目标表中,以实现对追踪服务的快速识别。 这可以通过在部署收集器之前预先创建表和物化视图来实现,或者选择在创建后修改视图和表。 用户还可以将新的物化视图附加到主表,以满足其他访问模式需求。 有关更多详细信息,请参阅我们最近的博客。
最后,上述功能也可以使用投影来实现。 虽然投影不提供物化视图的所有功能,但它们直接包含在表定义中。 与物化视图不同,投影是原子性更新的,并与主表保持一致,ClickHouse 会在查询时自动选择最佳版本。
查询追踪
exporter 的文档提供了一些极好的入门查询。 需要更多灵感的用户可以参考我们在下面展示的仪表板中的查询。 查询追踪时需要注意的几个重要概念
-
尽管有布隆过滤器,但在主表
otel_traces
上进行 TraceId 查找可能仍然很昂贵。 如果您需要深入查看特定追踪,则可以使用otel_traces_trace_id_ts
表来识别追踪的时间范围 - 如上所述。 然后,可以将此时间范围作为附加过滤器应用于otel_traces
表,该表在 ORDER BY 键中包含时间戳。 如果将 ServiceName 应用为查询的过滤器,则可以进一步优化查询(尽管这将限制为来自特定服务的 span)。 请考虑以下两种查询变体及其各自的计时,它们都返回与追踪关联的 span。仅使用
otel_traces
表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
表并使用结果时间来应用过滤器时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 亿个 span 和 125GB 的数据),因此绝对计时和差异都很小。 虽然我们预计这些差异会在更大的数据集上扩大,但我们的测试表明,此物化视图仅提供适度的加速(请注意读取的行数差异很小) - 这并不令人意外,因为 Timestamp 列是 otel_traces 表
ORDER BY
中的第三个键,因此最多只能用于通用排除搜索。otel_traces
表也已经从布隆过滤器中获得了显著的好处。 对这些查询差异的完整EXPLAIN
显示,过滤后读取的粒度数量略有差异。 因此,我们认为在大多数情况下,权衡查询复杂性的增加,使用此物化视图是不必要的优化,尽管某些用户可能会发现它在性能关键型场景中很有用。 在后续文章中,我们将探讨使用投影来加速 TraceId 查找的可能性。 -
收集器使用 Map 数据类型来处理属性。 用户可以使用 map 表示法 来访问嵌套键,此外,如果需要过滤或选择这些列,还可以使用专门的 ClickHouse map 函数。 如前所述,如果您经常访问这些键,我们建议将它们物化为表上的显式列。 下面我们查询来自特定主机的 span,按小时和服务语言进行分组。 我们计算每个 bucket 的 span 持续时间百分位数 - 这在任何问题诊断中都很有用。
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
。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 插件来可视化和探索追踪数据。 之前的帖子和视频已经深入探讨了这个插件。 最近,我们增强了该插件,允许使用 Trace Panel 可视化追踪。 这既作为可视化效果,又作为 Explore 中的组件得到支持。 此面板对列的命名和类型有严格的要求,但不幸的是,在撰写本文时,这与 OTEL 规范不一致。 以下查询生成在 Trace 可视化中渲染追踪的适当响应。
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 中使用变量和数据链接,用户可以生成复杂的工作流程,在这些工作流程中,可视化效果可以进行交互式过滤。 以下仪表板包含多个可视化效果
- 服务请求量的概览,以堆叠条形图形式呈现
- 每个服务的延迟 99 百分位数,以多线图形式呈现
- 每个服务的错误率,以条形图形式呈现
- 按 traceId 聚合的追踪列表 - 此处的服务是 span 链中的第一个。
- 当我们过滤到特定追踪时填充的 Trace Panel。
此仪表板为我们提供了一些关于错误和性能的基本诊断功能。 OpenTelemetry 演示具有现有场景,用户可以在服务上启用特定问题。 其中一个场景涉及推荐服务中的内存泄漏。 在没有指标的情况下,我们无法完成整个问题解决流程,但可以识别有问题的追踪。 我们在下面展示了这一点
使用参数化视图
上述查询可能非常复杂。 例如,请注意我们如何被迫使用 arrayMap
函数来确保属性结构正确。 我们可以将这项工作推迟到查询时的物化列或默认列,从而简化查询。 但是,这仍然需要大量的 SQL。 当在 Explore 视图中可视化追踪时,这尤其繁琐。
为了简化查询语法,ClickHouse 提供了参数化视图。 参数化视图类似于普通视图,但可以使用不会立即解析的参数创建。 这些视图可以与表函数一起使用,表函数将视图的名称指定为函数名称,并将参数值指定为其参数。 这可以显著减少最终用户在 ClickHouse 中所需的语法。 下面我们创建一个接受追踪 id 并返回 Trace View 所需结果的视图。 尽管最近添加了对参数化视图中 CTE 的支持was recently added,但下面我们使用早期的更简单的查询
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}
要运行此视图,我们只需传递一个追踪 id,例如:
SELECT *
FROM trace_view(trace_id = '1f12a198ac3dd502d5201ccccad52967')
这可以显著降低查询追踪的复杂性。 下面我们使用 Explore 视图查询特定追踪。 请注意需要将 Format
值设置为 Trace
以导致追踪的渲染
参数化视图最适合用于常见工作负载,在这些工作负载中,用户执行需要临时分析的常见任务,例如检查特定追踪。
压缩
ClickHouse 用于存储追踪数据的好处之一是其高压缩率。 使用下面的查询,我们可以看到我们在由此演示生成的追踪数据上实现了 9 倍到 10 倍的压缩率。 此数据集是通过在使用提供的负载生成器服务的情况下,在 2000 个虚拟用户下运行演示 24 小时生成的。 我们已将此数据集提供给公众使用。 可以使用此处的步骤插入它。 为了托管此数据,我们建议使用 ClickHouse Cloud 中的开发服务(16GB,两个核心),这对于此大小的数据集来说绰绰有余。
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 Exporter 处于 alpha 阶段。 此状态反映了其发布和成熟度的相对较新。 虽然我们计划投资于此 exporter,但我们已经确定了一些挑战和可能的改进
-
模式 - 该模式包含许多可能为时过早的优化。 对于某些工作负载,使用布隆过滤器可能是不必要的。 如下所示,这些会消耗空间(约占总数据大小的 1%)。
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
列上的过滤器是有效的,并且是对模式的一个有价值的补充。 这似乎抵消了物化视图的大部分好处,物化视图对于额外的查询和维护复杂性增加了可疑的价值。 然而,它提供了一个极好的例子,说明如何潜在地应用物化视图来加速查询。 我们没有足够的证据来证明其他布隆过滤器的价值,并建议用户进行实验。 -
高内存 - 我们发现 OTEL 收集器非常占用内存。 在早期的配置中,我们使用批处理器在 5 秒后或当批次达到 100,000 行时将数据发送到 ClickHouse。 虽然这优化了 ClickHouse 插入并符合最佳实践,但在高负载下(尤其是在收集日志时)可能会非常占用内存。 这可以通过减少刷新时间和/或批次大小来缓解。 请注意,这需要调整,因为它可能导致 ClickHouse 中积累过多的 part。 或者,用户可能希望使用异步插入来发送数据。 这将减少批次大小,但仅在 exporter 中的 HTTP 上受支持。 要激活,请在 exporter 配置中使用
connection_params
,例如:connection_params: async_insert: 1 wait_for_async_insert: 0
请注意,这对于插入到 ClickHouse 而言效率不会那么高。
-
端到端交付 - 我们目前没有看到对端到端交付保证的支持。 也就是说,应用程序 SDK 将在 Collector 收到追踪后即认为已发送。 如果 OTEL Collector 崩溃,则内存中当前的批次将丢失。 这可以通过减少批次大小来缓解(见上文)。 但是,如果需要更高的交付保证,用户可能还希望考虑涉及 Kafka(参见 receiver 和 exporter)或等效持久队列的替代架构。 我们尚未探索最近的持久队列功能,该功能处于 alpha 阶段,但有望提高弹性。
-
扩展 - 上述部署仅使用单个收集器。 在高容量生产环境中,用户可能需要在负载均衡器后面部署多个收集器。 OTEL 收集器使用 Trace Id/Service Name 负载均衡 exporter 支持此功能。 这确保来自同一追踪的 span 被转发到同一收集器。 请注意,我们也没有努力调整 agent 或测量其资源开销 - 我们建议用户在生产部署之前研究或执行此操作。 我们计划在后续文章中探讨这些主题。
-
采样 - 我们当前的实现导致所有数据都存储在 ClickHouse 中。 虽然 ClickHouse 提供了出色的压缩,但我们理解用户希望采用采样技术。 这允许仅存储追踪的子集,从而减少硬件需求。 请注意,这会使扩展变得复杂。 我们将在后续文章中解决这个问题。
结论
这篇博文展示了如何使用 OpenTelemetry 轻松收集追踪并将其存储在 ClickHouse 中。 我们 fork 了 OpenTelemetry 演示以支持 ClickHouse,介绍了使用 Grafana 的查询和可视化技术,并重点介绍了一些降低查询复杂性的方法以及该项目的一些未来工作。 为了进一步阅读,我们鼓励用户探索本文以外的主题,例如如何大规模部署 OTEL 收集器、处理反压和交付保证。 我们将在后续文章中探讨这些主题,并在探索如何优化模式以及使用生命周期功能管理数据之前,将指标数据添加到我们的 ClickHouse 实例中。