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